feat: acp manager (#304)

* feat: acp 控制器第一版

* feat: acp-link 命令二合一
This commit is contained in:
claude-code-best
2026-04-19 21:18:18 +08:00
committed by GitHub
parent c7bc8c8636
commit 66d2671c98
11 changed files with 897 additions and 49 deletions

View File

@@ -17,20 +17,20 @@
"@ant/computer-use-swift": "workspace:*",
"@ant/model-provider": "workspace:*",
"@anthropic-ai/bedrock-sdk": "^0.26.4",
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
"@anthropic-ai/foundry-sdk": "^0.2.3",
"@anthropic-ai/mcpb": "^2.1.2",
"@anthropic-ai/sandbox-runtime": "^0.0.44",
"@anthropic-ai/sdk": "^0.80.0",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@anthropic/ink": "workspace:*",
"@aws-sdk/client-bedrock": "^3.1020.0",
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
"@aws-sdk/client-sts": "^3.1020.0",
"@aws-sdk/credential-provider-node": "^3.972.28",
"@aws-sdk/credential-providers": "^3.1020.0",
"@aws-sdk/client-bedrock": "^3.1032.0",
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
"@aws-sdk/client-sts": "^3.1032.0",
"@aws-sdk/credential-provider-node": "^3.972.32",
"@aws-sdk/credential-providers": "^3.1032.0",
"@azure/identity": "^4.13.1",
"@biomejs/biome": "^2.4.10",
"@biomejs/biome": "^2.4.12",
"@claude-code-best/agent-tools": "workspace:*",
"@claude-code-best/builtin-tools": "workspace:*",
"@claude-code-best/mcp-client": "workspace:*",
@@ -41,7 +41,7 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/core": "^2.6.1",
"@opentelemetry/core": "^2.7.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
@@ -52,14 +52,14 @@
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/resources": "^2.7.0",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.7.0",
"@opentelemetry/sdk-trace-base": "^2.7.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@sentry/node": "^10.47.0",
"@smithy/core": "^3.23.13",
"@smithy/node-http-handler": "^4.5.1",
"@sentry/node": "^10.49.0",
"@smithy/core": "^3.23.15",
"@smithy/node-http-handler": "^4.5.3",
"@types/bun": "^1.3.12",
"@types/cacache": "^20.0.1",
"@types/he": "^1.2.3",
@@ -81,7 +81,7 @@
"asciichart": "^1.5.25",
"audio-capture-napi": "workspace:*",
"auto-bind": "^5.0.1",
"axios": "^1.14.0",
"axios": "^1.15.0",
"bidi-js": "^1.0.3",
"cacache": "^20.0.4",
"chalk": "^5.6.2",
@@ -96,7 +96,7 @@
"execa": "^9.6.1",
"fflate": "^0.8.2",
"figures": "^6.1.0",
"fuse.js": "^7.1.0",
"fuse.js": "^7.3.0",
"get-east-asian-width": "^1.5.0",
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
@@ -106,21 +106,21 @@
"image-processor-napi": "workspace:*",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",
"knip": "^6.1.1",
"lodash-es": "^4.17.23",
"lru-cache": "^11.2.7",
"marked": "^17.0.5",
"knip": "^6.4.1",
"lodash-es": "^4.18.1",
"lru-cache": "^11.3.5",
"marked": "^17.0.6",
"modifiers-napi": "workspace:*",
"openai": "^6.33.0",
"openai": "^6.34.0",
"p-map": "^7.0.4",
"picomatch": "^4.0.4",
"plist": "^3.1.0",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react": "^19.2.5",
"react-compiler-runtime": "^1.0.0",
"react-reconciler": "^0.33.0",
"rollup": "^4.60.1",
"rollup": "^4.60.2",
"semver": "^7.7.4",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
@@ -129,10 +129,10 @@
"strip-ansi": "^7.2.0",
"supports-hyperlinks": "^4.4.0",
"tree-kill": "^1.2.2",
"turndown": "^7.2.2",
"type-fest": "^5.5.0",
"typescript": "^6.0.2",
"undici": "^7.24.6",
"turndown": "^7.2.4",
"type-fest": "^5.6.0",
"typescript": "^6.0.3",
"undici": "^7.25.0",
"url-handler-napi": "workspace:*",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.8",
@@ -194,9 +194,10 @@
},
"packages/acp-link": {
"name": "acp-link",
"version": "1.0.1",
"version": "1.1.0",
"bin": {
"acp-link": "dist/cli/bin.js",
"acp-manager": "dist/manager/bin.js",
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
@@ -210,6 +211,7 @@
"selfsigned": "^5.5.0",
},
"devDependencies": {
"@types/bun": "^1.3.12",
"@types/selfsigned": "^2.0.4",
"@types/ws": "^8.18.1",
},

View File

@@ -100,6 +100,22 @@ acp-link can register to a Remote Control Server (RCS) for remote access. Set th
You can also use `--group <id>` on the CLI. The CLI flag takes priority over the env var.
## Manager UI
通过 `--manager` flag 启动独立的管理服务(不启动代理):
```bash
# 启动 Manager默认端口 9315
acp-link --manager
# 指定端口
acp-link --manager --port 3210
```
在浏览器打开 `http://localhost:<port>` 即可访问管理界面,创建、停止、删除多个 acp-link 子进程实例并实时查看日志。
通过 Manager UI 创建的子进程会自动跳过 Manager UI。
## License
MIT

View File

@@ -1,6 +1,6 @@
{
"name": "acp-link",
"version": "1.1.0",
"version": "2.0.0",
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
"author": "claude-code-best",
"type": "module",
@@ -15,11 +15,14 @@
"scripts": {
"build": "tsc",
"dev": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
"dev:remote": "ACP_RCS_URL=https://remote-control.claude-code-best.win/ ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
"dev:manager": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts --manager",
"prepublishOnly": "bun run build"
},
"devDependencies": {
"@types/selfsigned": "^2.0.4",
"@types/ws": "^8.18.1"
"@types/ws": "^8.18.1",
"@types/bun": "^1.3.12"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",

View File

@@ -9,6 +9,8 @@ export const command = buildCommand({
"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: {
@@ -40,6 +42,11 @@ export const command = buildCommand({
brief: "Enable HTTPS with auto-generated self-signed certificate",
default: false,
},
manager: {
kind: "boolean",
brief: "Start Manager Web UI (no proxy)",
default: false,
},
group: {
kind: "parsed",
parse: (value: string) => {
@@ -59,12 +66,12 @@ export const command = buildCommand({
parse: String,
placeholder: "command",
},
minimum: 1,
minimum: 0,
},
},
func: async function (
this: LocalContext,
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: 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;
@@ -72,7 +79,21 @@ export const command = buildCommand({
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;
}
// Proxy mode: agent command is required
if (args.length === 0) {
console.error("Error: agent command is required (or use --manager)");
process.exit(1);
}
const [command, ...agentArgs] = args;
const cwd = process.cwd();

View File

@@ -0,0 +1,345 @@
export const MANAGER_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ACP Manager</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f8f7f5;
color: #1a1a1a;
padding: 24px;
min-height: 100vh;
}
h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #1a1a1a; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.create-form {
background: #fff;
border: 1px solid #e5e2de;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
display: flex;
gap: 10px;
align-items: flex-end;
}
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 12px; color: #888; }
.form-group input {
background: #fff;
border: 1px solid #d5d2ce;
border-radius: 4px;
padding: 8px 12px;
color: #1a1a1a;
font-size: 14px;
width: 200px;
}
.form-group input.wide { width: 400px; }
button {
background: #d77757;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
button:hover { background: #c4694b; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.danger { background: #a63d3d; }
button.danger:hover { background: #c44a4a; }
button.small { padding: 4px 10px; font-size: 12px; }
.instances { display: flex; flex-direction: column; gap: 8px; }
.instance-card {
background: #fff;
border: 1px solid #e5e2de;
border-radius: 8px;
overflow: hidden;
}
.instance-header {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 12px;
cursor: pointer;
user-select: none;
}
.instance-header:hover { background: #f5f3f0; }
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.running { background: #4ade80; box-shadow: 0 0 6px #4ade8066; }
.status-dot.stopped { background: #aaa; }
.status-dot.failed { background: #f87171; box-shadow: 0 0 6px #f8717166; }
.instance-info { flex: 1; display: flex; gap: 16px; align-items: center; font-size: 13px; }
.instance-info .group { font-weight: 600; color: #d77757; }
.instance-info .cmd { color: #888; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.instance-info .pid { color: #999; font-size: 12px; }
.instance-info .uptime { color: #999; font-size: 12px; }
.instance-actions { display: flex; gap: 6px; }
.expand-icon { color: #999; font-size: 12px; transition: transform 0.2s; }
.expand-icon.open { transform: rotate(90deg); }
.log-panel {
display: none;
border-top: 1px solid #e5e2de;
background: #faf9f7;
max-height: 300px;
overflow-y: auto;
padding: 12px 16px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-panel.visible { display: block; }
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-line.stdout { color: #333; }
.log-line.stderr { color: #d94040; }
.empty { color: #999; text-align: center; padding: 40px; font-size: 14px; }
@media (max-width: 640px) {
body { padding: 12px; }
.create-form { flex-wrap: wrap; }
.form-group input, .form-group input.wide { width: 100%; }
.form-group { flex: 1 1 120px; min-width: 0; }
.instance-header { flex-wrap: wrap; padding: 10px 12px; gap: 8px; }
.instance-info { flex-wrap: wrap; gap: 6px; font-size: 12px; }
.instance-info .cmd { max-width: 100%; }
button.small { padding: 8px 14px; min-height: 44px; font-size: 13px; }
.log-panel { max-height: 50vh; }
}
</style>
</head>
<body>
<div class="header">
<h1>ACP Manager</h1>
</div>
<div class="create-form">
<div class="form-group">
<label>Group</label>
<input type="text" id="inp-group" placeholder="my-group" />
</div>
<div class="form-group">
<label>ACP Command</label>
<input type="text" id="inp-command" class="wide" placeholder="/path/to/agent --verbose" />
</div>
<button id="btn-create">Create</button>
</div>
<div class="instances" id="instance-list"></div>
<script>
var listEl = document.getElementById('instance-list');
var esMap = {};
var instances = [];
var inpGroup = document.getElementById('inp-group');
var inpCommand = document.getElementById('inp-command');
var btnCreate = document.getElementById('btn-create');
// localStorage persistence
function loadForm() {
try {
inpGroup.value = localStorage.getItem('acp-mgr-group') || '';
inpCommand.value = localStorage.getItem('acp-mgr-command') || '';
} catch(e) {}
}
function saveForm() {
try {
localStorage.setItem('acp-mgr-group', inpGroup.value);
localStorage.setItem('acp-mgr-command', inpCommand.value);
} catch(e) {}
}
inpGroup.addEventListener('input', saveForm);
inpCommand.addEventListener('input', saveForm);
loadForm();
btnCreate.addEventListener('click', function() {
var group = inpGroup.value.trim();
var command = inpCommand.value.trim();
if (!group || !command) return alert('Both fields required');
btnCreate.disabled = true;
fetch('/api/instances', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ group: group, command: command }),
}).then(function() { fetchInstances(); })
.finally(function() { btnCreate.disabled = false; });
});
// event delegation for instance actions
listEl.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (btn) {
e.stopPropagation();
var id = btn.getAttribute('data-id');
var action = btn.getAttribute('data-action');
if (action === 'stop') stopInstance(id);
else if (action === 'delete') deleteInstance(id);
return;
}
var header = e.target.closest('.instance-header');
if (header) {
var cardId = header.closest('.instance-card').getAttribute('data-id');
toggleLog(cardId);
}
});
async function fetchInstances() {
var res = await fetch('/api/instances');
instances = await res.json();
render();
}
function uptime(start) {
var s = Math.floor((Date.now() - start) / 1000);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
}
function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function render() {
if (instances.length === 0) {
listEl.innerHTML = '<div class="empty">No instances. Create one above.</div>';
return;
}
// Diff-based update: only rebuild cards whose status changed
var existingCards = {};
listEl.querySelectorAll('.instance-card').forEach(function(card) {
existingCards[card.getAttribute('data-id')] = card;
});
var newIds = new Set(instances.map(function(i) { return i.id; }));
// Remove cards that no longer exist
for (var eid in existingCards) {
if (!newIds.has(eid)) {
closeLog(eid);
existingCards[eid].remove();
delete existingCards[eid];
}
}
// Update or create cards in order
instances.forEach(function(inst) {
var card = existingCards[inst.id];
if (!card) {
// New instance — create card
card = document.createElement('div');
card.className = 'instance-card';
card.setAttribute('data-id', inst.id);
card.innerHTML =
'<div class="instance-header">' +
'<span class="expand-icon">&#9654;</span>' +
'<span class="status-dot"></span>' +
'<div class="instance-info">' +
'<span class="group"></span>' +
'<span class="cmd"></span>' +
'<span class="pid"></span>' +
'<span class="uptime"></span>' +
'</div>' +
'<div class="instance-actions"></div>' +
'</div>' +
'<div class="log-panel" id="log-' + inst.id + '"></div>';
listEl.appendChild(card);
}
// Update card content
card.querySelector('.status-dot').className = 'status-dot ' + inst.status;
card.querySelector('.group').textContent = inst.group;
card.querySelector('.cmd').textContent = inst.command;
card.querySelector('.pid').textContent = inst.pid ? 'PID ' + inst.pid : '';
card.querySelector('.uptime').textContent = inst.status === 'running' ? uptime(inst.startTime) : '';
// Update action buttons
var actions = card.querySelector('.instance-actions');
var prevStatus = card.getAttribute('data-status');
if (prevStatus !== inst.status) {
card.setAttribute('data-status', inst.status);
actions.innerHTML = inst.status === 'running'
? '<button class="small danger" data-action="stop" data-id="' + inst.id + '">Stop</button>'
: '<button class="small danger" data-action="delete" data-id="' + inst.id + '">Delete</button>';
}
});
}
async function stopInstance(id) {
var btn = listEl.querySelector('[data-action="stop"][data-id="' + id + '"]');
if (btn) btn.disabled = true;
await fetch('/api/instances/' + id + '/stop', { method: 'POST' });
await fetchInstances();
}
async function deleteInstance(id) {
var btn = listEl.querySelector('[data-action="delete"][data-id="' + id + '"]');
if (btn) btn.disabled = true;
await fetch('/api/instances/' + id, { method: 'DELETE' });
closeLog(id);
await fetchInstances();
}
function toggleLog(id) {
var panel = document.getElementById('log-' + id);
if (!panel) return;
if (panel.classList.contains('visible')) {
closeLog(id);
} else {
openLog(id);
}
var icon = listEl.querySelector('[data-id="' + id + '"] .expand-icon');
if (icon) icon.classList.toggle('open', panel.classList.contains('visible'));
}
function openLog(id) {
var panel = document.getElementById('log-' + id);
if (!panel) return;
panel.classList.add('visible');
panel.innerHTML = '';
var es = new EventSource('/api/instances/' + id + '/logs');
esMap[id] = es;
var scrollPending = false;
es.onmessage = function(e) {
try {
var entry = JSON.parse(e.data);
var line = document.createElement('div');
line.className = 'log-line ' + entry.stream;
var time = new Date(entry.timestamp).toLocaleTimeString();
line.textContent = '[' + time + '] ' + entry.text;
panel.appendChild(line);
if (panel.children.length > 500) panel.removeChild(panel.firstChild);
if (!scrollPending) {
scrollPending = true;
requestAnimationFrame(function() {
panel.scrollTop = panel.scrollHeight;
scrollPending = false;
});
}
} catch(err) {}
};
es.onerror = function() {
es.close();
delete esMap[id];
};
}
function closeLog(id) {
if (esMap[id]) {
esMap[id].close();
delete esMap[id];
}
var panel = document.getElementById('log-' + id);
if (panel) panel.classList.remove('visible');
}
fetchInstances();
setInterval(fetchInstances, 3000);
</script>
</body>
</html>`;

View File

@@ -0,0 +1,44 @@
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);
// Health check
app.get("/health", (c) => c.json({ status: "ok" }));
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);
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`);
}
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();
// Keep running
await new Promise(() => {});
}

View File

@@ -0,0 +1,233 @@
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 MAX_LOG_LINES = 2000;
const SHUTDOWN_TIMEOUT_MS = 5000;
export class ProcessManager {
private instances = new Map<string, AcpInstance>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private processes = new Map<string, any>();
create(group: string, command: string): AcpInstance {
const id = crypto.randomUUID();
const instance: AcpInstance = {
id,
group,
command,
status: "running",
pid: undefined,
startTime: Date.now(),
exitCode: null,
logs: [],
subscribers: new Set(),
};
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" },
});
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");
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;
}
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");
// Immediately mark as stopped to prevent stale state
if (inst) {
inst.status = "stopped";
}
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;
}
list(): InstanceSummary[] {
return Array.from(this.instances.values()).map(this.toSummary);
}
get(id: string): AcpInstance | undefined {
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);
}
async shutdownAll(): Promise<void> {
const running = Array.from(this.processes.entries());
if (running.length === 0) return;
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}`);
} catch {
// already dead
}
}
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)}`);
} catch {
// already dead
}
}
log("manager", "all instances shut down");
}
private parseCommand(command: string): string[] {
const args: string[] = [];
let current = "";
let inQuote: string | null = null;
for (const ch of command) {
if (inQuote) {
if (ch === inQuote) {
inQuote = null;
} else {
current += ch;
}
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === " " || ch === "\t") {
if (current) {
args.push(current);
current = "";
}
} else {
current += ch;
}
}
if (current) args.push(current);
return args;
}
private pipeStream(
readable: ReadableStream<Uint8Array>,
instanceId: string,
stream: "stdout" | "stderr",
) {
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;
}
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);
}
processChunk();
})
.catch(() => {
// stream ended or error
});
};
processChunk();
}
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);
if (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);
} catch {
// subscriber error, remove it
instance.subscribers.delete(sub);
}
}
}
private notifyStatus(instance: AcpInstance) {
const statusEntry: LogEntry = {
timestamp: Date.now(),
stream: "stderr",
text: `[${instance.status}] exit code: ${instance.exitCode}`,
};
for (const sub of instance.subscribers) {
try {
sub(statusEntry);
} catch {
instance.subscribers.delete(sub);
}
}
}
private toSummary(inst: AcpInstance): InstanceSummary {
return {
id: inst.id,
group: inst.group,
command: inst.command,
status: inst.status,
pid: inst.pid,
startTime: inst.startTime,
exitCode: inst.exitCode,
};
}
}

View File

@@ -0,0 +1,153 @@
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}`);
}
export function createApp(manager: ProcessManager): Hono {
const app = new Hono();
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.post("/api/instances", async (c) => {
let body: { group?: string; command?: string };
try {
body = await c.req.json<{ group?: string; command?: string }>();
} catch {
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);
}
const instance = manager.create(body.group.trim(), body.command.trim());
logReq("POST", `/api/instances group=${body.group}`, 201);
return c.json(
{
id: instance.id,
group: instance.group,
command: instance.command,
status: instance.status,
pid: instance.pid,
startTime: instance.startTime,
exitCode: instance.exitCode,
},
201,
);
});
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);
}
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 });
});
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);
}
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 });
});
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 SSE`);
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const send = (data: string) => {
try {
controller.enqueue(encoder.encode(data));
} catch {
// stream closed
}
};
// send historical logs
for (const log of inst.logs) {
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`);
});
// keepalive every 15s
const keepalive = setInterval(() => {
send(": keepalive\n\n");
}, 15000);
const cleanup = () => {
unsub();
clearInterval(keepalive);
logReq("SSE", `/api/instances/${id.slice(0, 8)}/logs closed`);
try {
controller.close();
} catch {
// already closed
}
};
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",
},
});
});
// 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);
});
return app;
}

View File

@@ -0,0 +1,34 @@
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>;
}
export interface LogEntry {
timestamp: number;
stream: "stdout" | "stderr";
text: string;
}
export interface CreateInstanceRequest {
group: string;
command: string;
}
export interface InstanceSummary {
id: string;
group: string;
command: string;
status: InstanceStatus;
pid: number | undefined;
startTime: number;
exitCode: number | null;
}

View File

@@ -883,20 +883,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
authEnabled: !!AUTH_TOKEN,
}, "started");
// 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(() => {});
}
// Graceful shutdown — close RCS upstream on process exit
process.on("SIGINT", async () => {
if (rcsUpstream) {
await rcsUpstream.close();
}
process.exit(0);
});
process.on("SIGTERM", async () => {
if (rcsUpstream) {
await rcsUpstream.close();
}
process.exit(0);
});

View File

@@ -3,12 +3,12 @@
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ES2022",
"module": "NodeNext",
"module": "esnext",
"moduleDetection": "force",
"allowJs": true,
// Node.js module resolution
"moduleResolution": "NodeNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
// Output
@@ -30,7 +30,8 @@
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
"noPropertyAccessFromIndexSignature": false,
"types": ["bun"],
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/__tests__"]