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

View File

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

View File

@ -1,46 +1,44 @@
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { ProcessManager } from './manager.js'
import { createApp } from './routes.js'
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { ProcessManager } from "./manager.js";
import { createApp } from "./routes.js";
export async function startManager(port: number): Promise<void> {
const manager = new ProcessManager()
const app = createApp(manager)
const manager = new ProcessManager();
const app = createApp(manager);
// 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 () => {
if (shuttingDown) return
shuttingDown = true
console.log('Shutting down...')
await manager.shutdownAll()
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
if (shuttingDown) return;
shuttingDown = true;
console.log("Shutting down...");
await manager.shutdownAll();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
const server = serve({ fetch: app.fetch, port })
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(
`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`,
)
const server = serve({ fetch: app.fetch, port });
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.error(`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`);
} 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(` 🖥️ ACP Manager`)
console.log()
console.log(` URL: http://localhost:${port}`)
console.log()
console.log(` Press Ctrl+C to stop`)
console.log()
console.log();
console.log(` 🖥️ ACP Manager`);
console.log();
console.log(` URL: http://localhost:${port}`);
console.log();
console.log(` Press Ctrl+C to stop`);
console.log();
// 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) {
const ts = new Date().toISOString()
console.log(`[${ts}] [${tag}] ${msg}`)
const ts = new Date().toISOString();
console.log(`[${ts}] [${tag}] ${msg}`);
}
const MAX_LOG_LINES = 2000
const SHUTDOWN_TIMEOUT_MS = 5000
const MAX_LOG_LINES = 2000;
const SHUTDOWN_TIMEOUT_MS = 5000;
export class ProcessManager {
private instances = new Map<string, AcpInstance>()
private instances = new Map<string, AcpInstance>();
// 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 {
const id = crypto.randomUUID()
const id = crypto.randomUUID();
const instance: AcpInstance = {
id,
group,
command,
status: 'running',
status: "running",
pid: undefined,
startTime: Date.now(),
exitCode: null,
logs: [],
subscribers: new Set(),
}
};
const args = this.parseCommand(command)
const fullArgs = ['--group', group, ...args]
const args = this.parseCommand(command);
const fullArgs = ["--group", group, ...args];
const proc = Bun.spawn(['acp-link', ...fullArgs], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...Bun.env, ACP_CHILD: '1' },
})
const proc = Bun.spawn(["acp-link", ...fullArgs], {
stdout: "pipe",
stderr: "pipe",
env: { ...Bun.env, ACP_CHILD: "1" },
});
instance.pid = proc.pid
this.instances.set(id, instance)
this.processes.set(id, proc)
log(
'manager',
`created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(' ')}"`,
)
instance.pid = proc.pid;
this.instances.set(id, instance);
this.processes.set(id, proc);
log("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.stderr, id, 'stderr')
this.pipeStream(proc.stdout, id, "stdout");
this.pipeStream(proc.stderr, id, "stderr");
proc.exited.then(code => {
instance.status = code === 0 ? 'stopped' : 'failed'
instance.exitCode = code
instance.pid = undefined
this.processes.delete(id)
log(
'manager',
`instance ${id.slice(0, 8)} ${instance.status} exit=${code}`,
)
this.notifyStatus(instance)
})
proc.exited.then((code) => {
instance.status = code === 0 ? "stopped" : "failed";
instance.exitCode = code;
instance.pid = undefined;
this.processes.delete(id);
log("manager", `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`);
this.notifyStatus(instance);
});
return instance
return instance;
}
stop(id: string): boolean {
const proc = this.processes.get(id)
if (!proc) return false
const inst = this.instances.get(id)
log('manager', `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`)
proc.kill('SIGTERM')
const proc = this.processes.get(id);
if (!proc) return false;
const inst = this.instances.get(id);
log("manager", `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`);
proc.kill("SIGTERM");
// Immediately mark as stopped to prevent stale state
if (inst) {
inst.status = 'stopped'
inst.status = "stopped";
}
return true
return true;
}
remove(id: string): boolean {
const instance = this.instances.get(id)
if (!instance) return false
if (instance.status === 'running') return false
instance.subscribers.clear()
this.instances.delete(id)
log('manager', `removed instance ${id.slice(0, 8)} group=${instance.group}`)
return true
const instance = this.instances.get(id);
if (!instance) return false;
if (instance.status === "running") return false;
instance.subscribers.clear();
this.instances.delete(id);
log("manager", `removed instance ${id.slice(0, 8)} group=${instance.group}`);
return true;
}
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 {
return this.instances.get(id)
return this.instances.get(id);
}
subscribe(id: string, callback: (entry: LogEntry) => void): () => void {
const instance = this.instances.get(id)
if (!instance) return () => {}
instance.subscribers.add(callback)
return () => instance.subscribers.delete(callback)
const instance = this.instances.get(id);
if (!instance) return () => {};
instance.subscribers.add(callback);
return () => instance.subscribers.delete(callback);
}
async shutdownAll(): Promise<void> {
const running = Array.from(this.processes.entries())
if (running.length === 0) return
const running = Array.from(this.processes.entries());
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) {
try {
proc.kill('SIGTERM')
log('manager', `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`)
proc.kill("SIGTERM");
log("manager", `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`);
} catch {
// already dead
}
}
const timeout = new Promise<void>(resolve =>
setTimeout(resolve, SHUTDOWN_TIMEOUT_MS),
)
const timeout = new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS));
await Promise.race([
Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))),
timeout,
])
]);
for (const [id, proc] of running) {
try {
proc.kill('SIGKILL')
log('manager', `sent SIGKILL to ${id.slice(0, 8)}`)
proc.kill("SIGKILL");
log("manager", `sent SIGKILL to ${id.slice(0, 8)}`);
} catch {
// already dead
}
}
log('manager', 'all instances shut down')
log("manager", "all instances shut down");
}
private parseCommand(command: string): string[] {
const args: string[] = []
let current = ''
let inQuote: string | null = null
const args: string[] = [];
let current = "";
let inQuote: string | null = null;
for (const ch of command) {
if (inQuote) {
if (ch === inQuote) {
inQuote = null
inQuote = null;
} else {
current += ch
current += ch;
}
} else if (ch === '"' || ch === "'") {
inQuote = ch
} else if (ch === ' ' || ch === '\t') {
inQuote = ch;
} else if (ch === " " || ch === "\t") {
if (current) {
args.push(current)
current = ''
args.push(current);
current = "";
}
} else {
current += ch
current += ch;
}
}
if (current) args.push(current)
return args
if (current) args.push(current);
return args;
}
private pipeStream(
readable: ReadableStream<Uint8Array>,
instanceId: string,
stream: 'stdout' | 'stderr',
stream: "stdout" | "stderr",
) {
const reader = readable.getReader()
const decoder = new TextDecoder()
let buffer = ''
const reader = readable.getReader();
const decoder = new TextDecoder();
let buffer = "";
const processChunk = () => {
reader
.read()
.then(({ done, value }) => {
if (done) {
if (buffer) this.appendLog(instanceId, buffer, stream)
return
if (buffer) this.appendLog(instanceId, buffer, stream);
return;
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line) this.appendLog(instanceId, line, stream)
if (line) this.appendLog(instanceId, line, stream);
}
processChunk()
processChunk();
})
.catch(() => {
// stream ended or error
})
}
processChunk()
});
};
processChunk();
}
private appendLog(
instanceId: string,
text: string,
stream: 'stdout' | 'stderr',
) {
const instance = this.instances.get(instanceId)
if (!instance) return
private appendLog(instanceId: string, text: string, stream: "stdout" | "stderr") {
const instance = this.instances.get(instanceId);
if (!instance) return;
const entry: LogEntry = { timestamp: Date.now(), stream, text }
instance.logs.push(entry)
const entry: LogEntry = { timestamp: Date.now(), stream, text };
instance.logs.push(entry);
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) {
try {
sub(entry)
sub(entry);
} catch {
// subscriber error, remove it
instance.subscribers.delete(sub)
instance.subscribers.delete(sub);
}
}
}
@ -219,14 +207,14 @@ export class ProcessManager {
private notifyStatus(instance: AcpInstance) {
const statusEntry: LogEntry = {
timestamp: Date.now(),
stream: 'stderr',
stream: "stderr",
text: `[${instance.status}] exit code: ${instance.exitCode}`,
}
};
for (const sub of instance.subscribers) {
try {
sub(statusEntry)
sub(statusEntry);
} catch {
instance.subscribers.delete(sub)
instance.subscribers.delete(sub);
}
}
}
@ -240,6 +228,6 @@ export class ProcessManager {
pid: inst.pid,
startTime: inst.startTime,
exitCode: inst.exitCode,
}
};
}
}

View File

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

View File

@ -1282,6 +1282,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
process.on('SIGINT', 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
await new Promise(() => {})
}

View File

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