feat: acp manager (#304)

* feat: acp 控制器第一版

* feat: acp-link 命令二合一
This commit is contained in:
James Feng 2026-06-04 18:15:31 +08:00
parent 6950401c06
commit 082f91aa80
9 changed files with 2794 additions and 853 deletions

2990
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -1,152 +1,123 @@
import { buildCommand, numberParser } from '@stricli/core' import { buildCommand, numberParser } from "@stricli/core";
import type { LocalContext } from './context.js' import type { LocalContext } from "./context.js";
export const command = buildCommand({ export const command = buildCommand({
docs: { docs: {
brief: 'Start the ACP proxy server', brief: "Start the ACP proxy server",
fullDescription: fullDescription:
'Starts a WebSocket proxy server that bridges clients to ACP agents. ' + "Starts a WebSocket proxy server that bridges clients to ACP agents. " +
'The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n' + "The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
'Use -- to pass arguments to the agent:\n' + "Use -- to pass arguments to the agent:\n" +
' acp-link /path/to/agent -- --verbose --model gpt-4\n\n' + " acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
'Use --manager to start the Manager Web UI instead:\n' + "Use --manager to start the Manager Web UI instead:\n" +
' acp-link --manager\n\n' + " acp-link --manager\n\n" +
'For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.', "For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
}, },
parameters: { parameters: {
flags: { flags: {
port: { port: {
kind: 'parsed', kind: "parsed",
parse: numberParser, parse: numberParser,
brief: 'Port to listen on', brief: "Port to listen on",
default: '9315' as any, default: "9315",
optional: true as any,
}, },
host: { host: {
kind: 'parsed', kind: "parsed",
parse: String, parse: String,
brief: 'Host to bind to (use 0.0.0.0 for remote access)', brief: "Host to bind to (use 0.0.0.0 for remote access)",
default: 'localhost' as any, default: "localhost",
optional: true as any,
}, },
debug: { debug: {
kind: 'boolean', kind: "boolean",
brief: 'Enable debug logging to file', brief: "Enable debug logging to file",
default: false as any, default: false,
optional: true as any,
}, },
'no-auth': { "no-auth": {
kind: 'boolean', kind: "boolean",
brief: 'DANGEROUS: Disable authentication (not recommended)', brief: "DANGEROUS: Disable authentication (not recommended)",
default: false as any, default: false,
optional: true as any,
}, },
https: { https: {
kind: 'boolean', kind: "boolean",
brief: 'Enable HTTPS with auto-generated self-signed certificate', brief: "Enable HTTPS with auto-generated self-signed certificate",
default: false as any, default: false,
optional: true as any,
}, },
manager: { manager: {
kind: 'boolean', kind: "boolean",
brief: 'Start Manager Web UI (no proxy)', brief: "Start Manager Web UI (no proxy)",
default: false as any, default: false,
optional: true as any,
}, },
group: { group: {
kind: 'parsed', kind: "parsed",
parse: (value: string) => { parse: (value: string) => {
if (!/^[a-zA-Z0-9_-]+$/.test(value)) { if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
throw new Error( throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`);
`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`,
)
} }
return value return value;
}, },
brief: 'Channel group ID for RCS registration (env: ACP_RCS_GROUP)', brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)",
optional: true, optional: true,
}, },
}, },
positional: { positional: {
kind: 'array', kind: "array",
parameter: { parameter: {
brief: 'Agent command and arguments (use -- before agent flags)', brief: "Agent command and arguments (use -- before agent flags)",
parse: String, parse: String,
placeholder: 'command', placeholder: "command",
optional: true as any,
}, },
minimum: 0, minimum: 0,
}, },
}, },
func: async function ( func: async function (
this: LocalContext, this: LocalContext,
flags: { flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
port: number
host: string
debug: boolean
'no-auth': boolean
https: boolean
manager: boolean
group: string | undefined
},
...args: readonly string[] ...args: readonly string[]
) { ) {
const port = flags.port const port = flags.port;
const host = flags.host const host = flags.host;
const debug = flags.debug const debug = flags.debug;
const noAuth = flags['no-auth'] const noAuth = flags["no-auth"];
const https = flags.https const https = flags.https;
const manager = flags.manager const manager = flags.manager;
const group = flags.group const group = flags.group;
// Manager mode: start web UI only, no proxy // Manager mode: start web UI only, no proxy
if (manager) { if (manager) {
const { startManager } = await import('../manager/index.js') const { startManager } = await import("../manager/index.js");
await startManager(port) await startManager(port);
return return;
} }
// Proxy mode: agent command is required // Proxy mode: agent command is required
if (args.length === 0) { if (args.length === 0) {
console.error('Error: agent command is required (or use --manager)') console.error("Error: agent command is required (or use --manager)");
process.exit(1) process.exit(1);
} }
const [command, ...agentArgs] = args const [command, ...agentArgs] = args;
const cwd = process.cwd() const cwd = process.cwd();
// Determine auth token // Determine auth token
// Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth) // Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth)
let token: string | undefined let token: string | undefined;
if (noAuth) { if (noAuth) {
console.warn( console.warn("⚠️ WARNING: Authentication disabled. This is dangerous for remote access!");
'⚠️ WARNING: Authentication disabled. This is dangerous for remote access!', token = undefined;
)
token = undefined
} else { } else {
token = process.env.ACP_AUTH_TOKEN token = process.env.ACP_AUTH_TOKEN;
if (!token) { if (!token) {
// Auto-generate random token // Auto-generate random token
const { randomBytes } = await import('node:crypto') const { randomBytes } = await import("node:crypto");
token = randomBytes(32).toString('hex') token = randomBytes(32).toString("hex");
} }
} }
// Initialize logger // Initialize logger
const { initLogger } = await import('../logger.js') const { initLogger } = await import("../logger.js");
initLogger({ debug }) initLogger({ debug });
// Import and run the server // Import and run the server
const { startServer } = await import('../server.js') const { startServer } = await import("../server.js");
await startServer({ await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group });
port,
host,
command: command!,
args: [...agentArgs],
cwd,
debug,
token,
https,
group,
})
}, },
}) });

View File

@ -342,4 +342,4 @@ fetchInstances();
setInterval(fetchInstances, 3000); setInterval(fetchInstances, 3000);
</script> </script>
</body> </body>
</html>` </html>`;

View File

@ -1,46 +1,44 @@
import { Hono } from 'hono' import { Hono } from "hono";
import { serve } from '@hono/node-server' import { serve } from "@hono/node-server";
import { ProcessManager } from './manager.js' import { ProcessManager } from "./manager.js";
import { createApp } from './routes.js' import { createApp } from "./routes.js";
export async function startManager(port: number): Promise<void> { export async function startManager(port: number): Promise<void> {
const manager = new ProcessManager() const manager = new ProcessManager();
const app = createApp(manager) const app = createApp(manager);
// Health check // Health check
app.get('/health', c => c.json({ status: 'ok' })) app.get("/health", (c) => c.json({ status: "ok" }));
let shuttingDown = false let shuttingDown = false;
const shutdown = async () => { const shutdown = async () => {
if (shuttingDown) return if (shuttingDown) return;
shuttingDown = true shuttingDown = true;
console.log('Shutting down...') console.log("Shutting down...");
await manager.shutdownAll() await manager.shutdownAll();
process.exit(0) process.exit(0);
} };
process.on('SIGTERM', shutdown) process.on("SIGTERM", shutdown);
process.on('SIGINT', shutdown) process.on("SIGINT", shutdown);
const server = serve({ fetch: app.fetch, port }) const server = serve({ fetch: app.fetch, port });
server.on('error', (err: NodeJS.ErrnoException) => { server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') { if (err.code === "EADDRINUSE") {
console.error( console.error(`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`);
`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`,
)
} else { } else {
console.error(`\n Error: ${err.message}\n`) console.error(`\n Error: ${err.message}\n`);
} }
process.exit(1) process.exit(1);
}) });
console.log() console.log();
console.log(` 🖥️ ACP Manager`) console.log(` 🖥️ ACP Manager`);
console.log() console.log();
console.log(` URL: http://localhost:${port}`) console.log(` URL: http://localhost:${port}`);
console.log() console.log();
console.log(` Press Ctrl+C to stop`) console.log(` Press Ctrl+C to stop`);
console.log() console.log();
// Keep running // Keep running
await new Promise(() => {}) await new Promise(() => {});
} }

View File

@ -1,217 +1,205 @@
import type { AcpInstance, InstanceSummary, LogEntry } from './types.js' import type { AcpInstance, InstanceSummary, LogEntry } from "./types.js";
function log(tag: string, msg: string) { function log(tag: string, msg: string) {
const ts = new Date().toISOString() const ts = new Date().toISOString();
console.log(`[${ts}] [${tag}] ${msg}`) console.log(`[${ts}] [${tag}] ${msg}`);
} }
const MAX_LOG_LINES = 2000 const MAX_LOG_LINES = 2000;
const SHUTDOWN_TIMEOUT_MS = 5000 const SHUTDOWN_TIMEOUT_MS = 5000;
export class ProcessManager { export class ProcessManager {
private instances = new Map<string, AcpInstance>() private instances = new Map<string, AcpInstance>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
private processes = new Map<string, any>() private processes = new Map<string, any>();
create(group: string, command: string): AcpInstance { create(group: string, command: string): AcpInstance {
const id = crypto.randomUUID() const id = crypto.randomUUID();
const instance: AcpInstance = { const instance: AcpInstance = {
id, id,
group, group,
command, command,
status: 'running', status: "running",
pid: undefined, pid: undefined,
startTime: Date.now(), startTime: Date.now(),
exitCode: null, exitCode: null,
logs: [], logs: [],
subscribers: new Set(), subscribers: new Set(),
} };
const args = this.parseCommand(command) const args = this.parseCommand(command);
const fullArgs = ['--group', group, ...args] const fullArgs = ["--group", group, ...args];
const proc = Bun.spawn(['acp-link', ...fullArgs], { const proc = Bun.spawn(["acp-link", ...fullArgs], {
stdout: 'pipe', stdout: "pipe",
stderr: 'pipe', stderr: "pipe",
env: { ...Bun.env, ACP_CHILD: '1' }, env: { ...Bun.env, ACP_CHILD: "1" },
}) });
instance.pid = proc.pid instance.pid = proc.pid;
this.instances.set(id, instance) this.instances.set(id, instance);
this.processes.set(id, proc) this.processes.set(id, proc);
log( log("manager", `created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(" ")}"`);
'manager',
`created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(' ')}"`,
)
this.pipeStream(proc.stdout, id, 'stdout') this.pipeStream(proc.stdout, id, "stdout");
this.pipeStream(proc.stderr, id, 'stderr') this.pipeStream(proc.stderr, id, "stderr");
proc.exited.then(code => { proc.exited.then((code) => {
instance.status = code === 0 ? 'stopped' : 'failed' instance.status = code === 0 ? "stopped" : "failed";
instance.exitCode = code instance.exitCode = code;
instance.pid = undefined instance.pid = undefined;
this.processes.delete(id) this.processes.delete(id);
log( log("manager", `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`);
'manager', this.notifyStatus(instance);
`instance ${id.slice(0, 8)} ${instance.status} exit=${code}`, });
)
this.notifyStatus(instance)
})
return instance return instance;
} }
stop(id: string): boolean { stop(id: string): boolean {
const proc = this.processes.get(id) const proc = this.processes.get(id);
if (!proc) return false if (!proc) return false;
const inst = this.instances.get(id) const inst = this.instances.get(id);
log('manager', `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`) log("manager", `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`);
proc.kill('SIGTERM') proc.kill("SIGTERM");
// Immediately mark as stopped to prevent stale state // Immediately mark as stopped to prevent stale state
if (inst) { if (inst) {
inst.status = 'stopped' inst.status = "stopped";
} }
return true return true;
} }
remove(id: string): boolean { remove(id: string): boolean {
const instance = this.instances.get(id) const instance = this.instances.get(id);
if (!instance) return false if (!instance) return false;
if (instance.status === 'running') return false if (instance.status === "running") return false;
instance.subscribers.clear() instance.subscribers.clear();
this.instances.delete(id) this.instances.delete(id);
log('manager', `removed instance ${id.slice(0, 8)} group=${instance.group}`) log("manager", `removed instance ${id.slice(0, 8)} group=${instance.group}`);
return true return true;
} }
list(): InstanceSummary[] { list(): InstanceSummary[] {
return Array.from(this.instances.values()).map(this.toSummary) return Array.from(this.instances.values()).map(this.toSummary);
} }
get(id: string): AcpInstance | undefined { get(id: string): AcpInstance | undefined {
return this.instances.get(id) return this.instances.get(id);
} }
subscribe(id: string, callback: (entry: LogEntry) => void): () => void { subscribe(id: string, callback: (entry: LogEntry) => void): () => void {
const instance = this.instances.get(id) const instance = this.instances.get(id);
if (!instance) return () => {} if (!instance) return () => {};
instance.subscribers.add(callback) instance.subscribers.add(callback);
return () => instance.subscribers.delete(callback) return () => instance.subscribers.delete(callback);
} }
async shutdownAll(): Promise<void> { async shutdownAll(): Promise<void> {
const running = Array.from(this.processes.entries()) const running = Array.from(this.processes.entries());
if (running.length === 0) return if (running.length === 0) return;
log('manager', `shutting down ${running.length} running instance(s)...`) log("manager", `shutting down ${running.length} running instance(s)...`);
for (const [id, proc] of running) { for (const [id, proc] of running) {
try { try {
proc.kill('SIGTERM') proc.kill("SIGTERM");
log('manager', `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`) log("manager", `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`);
} catch { } catch {
// already dead // already dead
} }
} }
const timeout = new Promise<void>(resolve => const timeout = new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS));
setTimeout(resolve, SHUTDOWN_TIMEOUT_MS),
)
await Promise.race([ await Promise.race([
Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))), Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))),
timeout, timeout,
]) ]);
for (const [id, proc] of running) { for (const [id, proc] of running) {
try { try {
proc.kill('SIGKILL') proc.kill("SIGKILL");
log('manager', `sent SIGKILL to ${id.slice(0, 8)}`) log("manager", `sent SIGKILL to ${id.slice(0, 8)}`);
} catch { } catch {
// already dead // already dead
} }
} }
log('manager', 'all instances shut down') log("manager", "all instances shut down");
} }
private parseCommand(command: string): string[] { private parseCommand(command: string): string[] {
const args: string[] = [] const args: string[] = [];
let current = '' let current = "";
let inQuote: string | null = null let inQuote: string | null = null;
for (const ch of command) { for (const ch of command) {
if (inQuote) { if (inQuote) {
if (ch === inQuote) { if (ch === inQuote) {
inQuote = null inQuote = null;
} else { } else {
current += ch current += ch;
} }
} else if (ch === '"' || ch === "'") { } else if (ch === '"' || ch === "'") {
inQuote = ch inQuote = ch;
} else if (ch === ' ' || ch === '\t') { } else if (ch === " " || ch === "\t") {
if (current) { if (current) {
args.push(current) args.push(current);
current = '' current = "";
} }
} else { } else {
current += ch current += ch;
} }
} }
if (current) args.push(current) if (current) args.push(current);
return args return args;
} }
private pipeStream( private pipeStream(
readable: ReadableStream<Uint8Array>, readable: ReadableStream<Uint8Array>,
instanceId: string, instanceId: string,
stream: 'stdout' | 'stderr', stream: "stdout" | "stderr",
) { ) {
const reader = readable.getReader() const reader = readable.getReader();
const decoder = new TextDecoder() const decoder = new TextDecoder();
let buffer = '' let buffer = "";
const processChunk = () => { const processChunk = () => {
reader reader
.read() .read()
.then(({ done, value }) => { .then(({ done, value }) => {
if (done) { if (done) {
if (buffer) this.appendLog(instanceId, buffer, stream) if (buffer) this.appendLog(instanceId, buffer, stream);
return return;
} }
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n') const lines = buffer.split("\n");
buffer = lines.pop() ?? '' buffer = lines.pop() ?? "";
for (const line of lines) { for (const line of lines) {
if (line) this.appendLog(instanceId, line, stream) if (line) this.appendLog(instanceId, line, stream);
} }
processChunk() processChunk();
}) })
.catch(() => { .catch(() => {
// stream ended or error // stream ended or error
}) });
} };
processChunk() processChunk();
} }
private appendLog( private appendLog(instanceId: string, text: string, stream: "stdout" | "stderr") {
instanceId: string, const instance = this.instances.get(instanceId);
text: string, if (!instance) return;
stream: 'stdout' | 'stderr',
) {
const instance = this.instances.get(instanceId)
if (!instance) return
const entry: LogEntry = { timestamp: Date.now(), stream, text } const entry: LogEntry = { timestamp: Date.now(), stream, text };
instance.logs.push(entry) instance.logs.push(entry);
if (instance.logs.length > MAX_LOG_LINES) { if (instance.logs.length > MAX_LOG_LINES) {
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES) instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES);
} }
for (const sub of instance.subscribers) { for (const sub of instance.subscribers) {
try { try {
sub(entry) sub(entry);
} catch { } catch {
// subscriber error, remove it // subscriber error, remove it
instance.subscribers.delete(sub) instance.subscribers.delete(sub);
} }
} }
} }
@ -219,14 +207,14 @@ export class ProcessManager {
private notifyStatus(instance: AcpInstance) { private notifyStatus(instance: AcpInstance) {
const statusEntry: LogEntry = { const statusEntry: LogEntry = {
timestamp: Date.now(), timestamp: Date.now(),
stream: 'stderr', stream: "stderr",
text: `[${instance.status}] exit code: ${instance.exitCode}`, text: `[${instance.status}] exit code: ${instance.exitCode}`,
} };
for (const sub of instance.subscribers) { for (const sub of instance.subscribers) {
try { try {
sub(statusEntry) sub(statusEntry);
} catch { } catch {
instance.subscribers.delete(sub) instance.subscribers.delete(sub);
} }
} }
} }
@ -240,6 +228,6 @@ export class ProcessManager {
pid: inst.pid, pid: inst.pid,
startTime: inst.startTime, startTime: inst.startTime,
exitCode: inst.exitCode, exitCode: inst.exitCode,
} };
} }
} }

View File

@ -1,41 +1,41 @@
import { Hono } from 'hono' import { Hono } from "hono";
import type { ProcessManager } from './manager.js' import type { ProcessManager } from "./manager.js";
import { MANAGER_HTML } from './html.js' import { MANAGER_HTML } from "./html.js";
function logReq(method: string, path: string, status?: number) { function logReq(method: string, path: string, status?: number) {
const ts = new Date().toISOString() const ts = new Date().toISOString();
const suffix = status != null ? ` -> ${status}` : '' const suffix = status != null ? ` -> ${status}` : "";
console.log(`[${ts}] [http] ${method} ${path}${suffix}`) console.log(`[${ts}] [http] ${method} ${path}${suffix}`);
} }
export function createApp(manager: ProcessManager): Hono { export function createApp(manager: ProcessManager): Hono {
const app = new Hono() const app = new Hono();
app.get('/', c => { app.get("/", (c) => {
logReq('GET', '/', 200) logReq("GET", "/", 200);
return c.html(MANAGER_HTML) return c.html(MANAGER_HTML);
}) });
app.get('/api/instances', c => { app.get("/api/instances", (c) => {
const list = manager.list() const list = manager.list();
logReq('GET', '/api/instances', 200) logReq("GET", "/api/instances", 200);
return c.json(list) return c.json(list);
}) });
app.post('/api/instances', async c => { app.post("/api/instances", async (c) => {
let body: { group?: string; command?: string } let body: { group?: string; command?: string };
try { try {
body = await c.req.json<{ group?: string; command?: string }>() body = await c.req.json<{ group?: string; command?: string }>();
} catch { } catch {
logReq('POST', '/api/instances', 400) logReq("POST", "/api/instances", 400);
return c.json({ error: 'invalid JSON body' }, 400) return c.json({ error: "invalid JSON body" }, 400);
} }
if (!body.group?.trim() || !body.command?.trim()) { if (!body.group?.trim() || !body.command?.trim()) {
logReq('POST', '/api/instances', 400) logReq("POST", "/api/instances", 400);
return c.json({ error: 'group and command are required' }, 400) return c.json({ error: "group and command are required" }, 400);
} }
const instance = manager.create(body.group.trim(), body.command.trim()) const instance = manager.create(body.group.trim(), body.command.trim());
logReq('POST', `/api/instances group=${body.group}`, 201) logReq("POST", `/api/instances group=${body.group}`, 201);
return c.json( return c.json(
{ {
id: instance.id, id: instance.id,
@ -47,107 +47,107 @@ export function createApp(manager: ProcessManager): Hono {
exitCode: instance.exitCode, exitCode: instance.exitCode,
}, },
201, 201,
) );
}) });
app.post('/api/instances/:id/stop', c => { app.post("/api/instances/:id/stop", (c) => {
const id = c.req.param('id') const id = c.req.param("id");
const inst = manager.get(id) const inst = manager.get(id);
if (!inst) { if (!inst) {
logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 404) logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 404);
return c.json({ error: 'not found' }, 404) return c.json({ error: "not found" }, 404);
} }
if (inst.status !== 'running') { if (inst.status !== "running") {
logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 400) logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 400);
return c.json({ error: 'not running' }, 400) return c.json({ error: "not running" }, 400);
} }
manager.stop(inst.id) manager.stop(inst.id);
logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 200) logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 200);
return c.json({ ok: true }) return c.json({ ok: true });
}) });
app.delete('/api/instances/:id', c => { app.delete("/api/instances/:id", (c) => {
const id = c.req.param('id') const id = c.req.param("id");
const inst = manager.get(id) const inst = manager.get(id);
if (!inst) { if (!inst) {
logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 404) logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 404);
return c.json({ error: 'not found' }, 404) return c.json({ error: "not found" }, 404);
} }
if (inst.status === 'running') { if (inst.status === "running") {
logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 400) logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 400);
return c.json({ error: 'still running' }, 400) return c.json({ error: "still running" }, 400);
} }
manager.remove(inst.id) manager.remove(inst.id);
logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 200) logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 200);
return c.json({ ok: true }) return c.json({ ok: true });
}) });
app.get('/api/instances/:id/logs', c => { app.get("/api/instances/:id/logs", (c) => {
const id = c.req.param('id') const id = c.req.param("id");
const inst = manager.get(id) const inst = manager.get(id);
if (!inst) { if (!inst) {
logReq('GET', `/api/instances/${id.slice(0, 8)}/logs`, 404) logReq("GET", `/api/instances/${id.slice(0, 8)}/logs`, 404);
return c.json({ error: 'not found' }, 404) return c.json({ error: "not found" }, 404);
} }
logReq('GET', `/api/instances/${id.slice(0, 8)}/logs SSE`) logReq("GET", `/api/instances/${id.slice(0, 8)}/logs SSE`);
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
const encoder = new TextEncoder() const encoder = new TextEncoder();
const send = (data: string) => { const send = (data: string) => {
try { try {
controller.enqueue(encoder.encode(data)) controller.enqueue(encoder.encode(data));
} catch { } catch {
// stream closed // stream closed
} }
} };
// send historical logs // send historical logs
for (const log of inst.logs) { for (const log of inst.logs) {
send(`data: ${JSON.stringify(log)}\n\n`) send(`data: ${JSON.stringify(log)}\n\n`);
} }
// subscribe to new logs // subscribe to new logs
const unsub = manager.subscribe(inst.id, entry => { const unsub = manager.subscribe(inst.id, (entry) => {
send(`data: ${JSON.stringify(entry)}\n\n`) send(`data: ${JSON.stringify(entry)}\n\n`);
}) });
// keepalive every 15s // keepalive every 15s
const keepalive = setInterval(() => { const keepalive = setInterval(() => {
send(': keepalive\n\n') send(": keepalive\n\n");
}, 15000) }, 15000);
const cleanup = () => { const cleanup = () => {
unsub() unsub();
clearInterval(keepalive) clearInterval(keepalive);
logReq('SSE', `/api/instances/${id.slice(0, 8)}/logs closed`) logReq("SSE", `/api/instances/${id.slice(0, 8)}/logs closed`);
try { try {
controller.close() controller.close();
} catch { } catch {
// already closed // already closed
} }
} };
c.req.raw.signal.addEventListener('abort', cleanup, { once: true }) c.req.raw.signal.addEventListener("abort", cleanup, { once: true });
}, },
}) });
return new Response(stream, { return new Response(stream, {
headers: { headers: {
'Content-Type': 'text/event-stream', "Content-Type": "text/event-stream",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
Connection: 'keep-alive', Connection: "keep-alive",
'X-Accel-Buffering': 'no', "X-Accel-Buffering": "no",
}, },
}) });
}) });
// Catch-all: log unmatched routes for debugging // Catch-all: log unmatched routes for debugging
app.all('*', c => { app.all("*", (c) => {
logReq(c.req.method, c.req.path, 404) logReq(c.req.method, c.req.path, 404);
return c.json({ error: 'not found', path: c.req.path }, 404) return c.json({ error: "not found", path: c.req.path }, 404);
}) });
return app return app;
} }

View File

@ -1,34 +1,34 @@
export type InstanceStatus = 'running' | 'stopped' | 'failed' export type InstanceStatus = "running" | "stopped" | "failed";
export interface AcpInstance { export interface AcpInstance {
id: string id: string;
group: string group: string;
command: string command: string;
status: InstanceStatus status: InstanceStatus;
pid: number | undefined pid: number | undefined;
startTime: number startTime: number;
exitCode: number | null exitCode: number | null;
logs: LogEntry[] logs: LogEntry[];
subscribers: Set<(entry: LogEntry) => void> subscribers: Set<(entry: LogEntry) => void>;
} }
export interface LogEntry { export interface LogEntry {
timestamp: number timestamp: number;
stream: 'stdout' | 'stderr' stream: "stdout" | "stderr";
text: string text: string;
} }
export interface CreateInstanceRequest { export interface CreateInstanceRequest {
group: string group: string;
command: string command: string;
} }
export interface InstanceSummary { export interface InstanceSummary {
id: string id: string;
group: string group: string;
command: string command: string;
status: InstanceStatus status: InstanceStatus;
pid: number | undefined pid: number | undefined;
startTime: number startTime: number;
exitCode: number | null exitCode: number | null;
} }

View File

@ -1282,6 +1282,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
process.on('SIGINT', shutdown) process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown) process.on('SIGTERM', shutdown)
// Graceful shutdown — close RCS upstream
const shutdown = async () => {
if (rcsUpstream) {
await rcsUpstream.close();
}
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Keep the server running // Keep the server running
await new Promise(() => {}) await new Promise(() => {})
} }

View File

@ -31,7 +31,7 @@
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false,
"types": ["bun"] "types": ["bun"],
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/__tests__"] "exclude": ["node_modules", "dist", "src/__tests__"]