agentgui 1.0.1119 → 1.0.1120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/acp-http-protocol.js +5 -1
- package/lib/acp-sdk-manager.js +27 -7
- package/lib/claude-runner-acp.js +58 -0
- package/lib/ws-handlers/agents.js +24 -0
- package/package.json +1 -1
package/lib/acp-http-protocol.js
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
function normalizePart(part, sessionId) {
|
|
16
16
|
if (!part || typeof part !== 'object') return null;
|
|
17
|
-
|
|
17
|
+
// A text part streams in incrementally - the first frame for it can arrive
|
|
18
|
+
// with an empty string before content lands (live-observed). An empty
|
|
19
|
+
// chunk carries nothing a caller can render; drop it rather than emitting
|
|
20
|
+
// a blank assistant bubble.
|
|
21
|
+
if (part.type === 'text' && typeof part.text === 'string' && part.text.length > 0) {
|
|
18
22
|
return { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: part.text }] }, session_id: sessionId };
|
|
19
23
|
}
|
|
20
24
|
if (part.type === 'tool' || part.type === 'tool-invocation') {
|
package/lib/acp-sdk-manager.js
CHANGED
|
@@ -17,9 +17,15 @@ const isWindows = os.platform() === 'win32';
|
|
|
17
17
|
// ACP_TOOLS rather than spawning a command that can never succeed. Re-add it
|
|
18
18
|
// once a WebSocket app-server client exists (see PRD row
|
|
19
19
|
// aaa-codex-app-server-client).
|
|
20
|
+
// `args` MUST pass --port explicitly - `acp` alone defaults --port to 0 (an
|
|
21
|
+
// OS-assigned ephemeral port), so the spawned server never actually listens
|
|
22
|
+
// on `tool.port` and every health-check/getPort() call targets the wrong
|
|
23
|
+
// port forever (live-confirmed: opencode process alive, listening on SOME
|
|
24
|
+
// port, but 127.0.0.1:18100/provider connection-refused). Fixed by pinning
|
|
25
|
+
// --port to the same value this manager already tracks per tool.
|
|
20
26
|
const ACP_TOOLS = [
|
|
21
|
-
{ id: 'opencode', cmd: 'opencode', args: ['acp'], port: 18100, npxPkg: 'opencode-ai' },
|
|
22
|
-
{ id: 'kilo', cmd: 'kilo', args: ['acp'], port: 18101, npxPkg: '@kilocode/cli' },
|
|
27
|
+
{ id: 'opencode', cmd: 'opencode', args: ['acp', '--port', '18100'], port: 18100, npxPkg: 'opencode-ai' },
|
|
28
|
+
{ id: 'kilo', cmd: 'kilo', args: ['acp', '--port', '18101'], port: 18101, npxPkg: '@kilocode/cli' },
|
|
23
29
|
];
|
|
24
30
|
const HEALTH_INTERVAL_MS = 30000, STARTUP_GRACE_MS = 5000, IDLE_TIMEOUT_MS = 120000;
|
|
25
31
|
const processes = new Map(), idleTimers = new Map();
|
|
@@ -120,7 +126,14 @@ function scheduleRestart(tool) {
|
|
|
120
126
|
}, delay);
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
|
|
129
|
+
// A single check at STARTUP_GRACE_MS can race a genuinely-still-starting
|
|
130
|
+
// server (opencode's HTTP endpoint was live-observed taking ~8s to come up,
|
|
131
|
+
// past the 5s grace) and mark it UNHEALTHY with no second chance before the
|
|
132
|
+
// next scheduled HEALTH_INTERVAL_MS tick (30s) - starving any caller polling
|
|
133
|
+
// isHealthy() over a shorter window (ensureRunning's own 10s loop). Retry a
|
|
134
|
+
// few times with backoff before sending UNHEALTHY, so a slow-but-genuinely-
|
|
135
|
+
// starting server gets caught instead of permanently mis-marked for 30s.
|
|
136
|
+
async function checkHealth(toolId, port, _retries = 3) {
|
|
124
137
|
if (shuttingDown) return;
|
|
125
138
|
const snap = acpMachine.snapshot(toolId);
|
|
126
139
|
if (!snap || snap.value === 'stopped' || snap.value === 'idle_stopping') return;
|
|
@@ -128,9 +141,13 @@ async function checkHealth(toolId, port) {
|
|
|
128
141
|
if (!p) return;
|
|
129
142
|
try {
|
|
130
143
|
const res = await fetch('http://127.0.0.1:' + p + '/provider', { signal: AbortSignal.timeout(3000) });
|
|
131
|
-
if (res.ok) acpMachine.send(toolId, { type: 'HEALTHY', providerInfo: await res.json() });
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
if (res.ok) { acpMachine.send(toolId, { type: 'HEALTHY', providerInfo: await res.json() }); return; }
|
|
145
|
+
if (_retries > 0) { setTimeout(() => checkHealth(toolId, p, _retries - 1), 1500); return; }
|
|
146
|
+
acpMachine.send(toolId, { type: 'UNHEALTHY' });
|
|
147
|
+
} catch (_) {
|
|
148
|
+
if (_retries > 0) { setTimeout(() => checkHealth(toolId, p, _retries - 1), 1500); return; }
|
|
149
|
+
acpMachine.send(toolId, { type: 'UNHEALTHY' });
|
|
150
|
+
}
|
|
134
151
|
}
|
|
135
152
|
|
|
136
153
|
export async function ensureRunning(agentId) {
|
|
@@ -141,7 +158,10 @@ export async function ensureRunning(agentId) {
|
|
|
141
158
|
if (!snap || snap.value === 'stopped' || snap.value === 'crashed') {
|
|
142
159
|
startProcess(tool);
|
|
143
160
|
}
|
|
144
|
-
|
|
161
|
+
// 30 x 500ms = 15s - wide enough to cover STARTUP_GRACE_MS (5s) plus
|
|
162
|
+
// checkHealth's own retry backoff (up to ~4.5s more) with real margin, per
|
|
163
|
+
// opencode's live-observed ~8s cold-start time for its HTTP endpoint.
|
|
164
|
+
for (let i = 0; i < 30; i++) {
|
|
145
165
|
await new Promise(r => setTimeout(r, 500));
|
|
146
166
|
if (shuttingDown) return null;
|
|
147
167
|
if (acpMachine.isHealthy(agentId)) { resetIdleTimer(agentId); return tool.port; }
|
package/lib/claude-runner-acp.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { AgentRunner, getSpawnOptions, resolveCommand } from './claude-runner.js';
|
|
3
|
+
import { ensureRunning as ensureAcpHttpRunning } from './acp-sdk-manager.js';
|
|
4
|
+
import { createACPHttpProtocolHandler, parseSSEChunk } from './acp-http-protocol.js';
|
|
3
5
|
|
|
4
6
|
function buildEnhancedHandler({ proc, outputs, sessionRef, promptIdRef, completedRef, drainingRef, requestIdRef, originalHandler, resolve, reject, clearTimeoutHandle, promptText }) {
|
|
5
7
|
return function(message) {
|
|
@@ -26,8 +28,64 @@ function buildEnhancedHandler({ proc, outputs, sessionRef, promptIdRef, complete
|
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
// opencode/kilo (registry entries with transport:'http') are live-verified
|
|
32
|
+
// (lib/acp-http-protocol.js) to serve their normal REST+SSE API under `acp
|
|
33
|
+
// --port`, already spawned as a long-lived server by acp-sdk-manager.js for
|
|
34
|
+
// health/model discovery. This path reuses that same running server for the
|
|
35
|
+
// actual prompt instead of spawning a fresh stdio subprocess per turn -
|
|
36
|
+
// consolidating the two ACP process managers for these two agents only. The
|
|
37
|
+
// other stdio-based ACP agents (gemini, goose, openhands, etc) are untouched.
|
|
38
|
+
AgentRunner.prototype._runACPHttp = async function(prompt, cwd, config = {}) {
|
|
39
|
+
const { timeout = 300000, onEvent = null } = config;
|
|
40
|
+
const port = await ensureAcpHttpRunning(this.id);
|
|
41
|
+
if (!port) { const e = new Error(`${this.name} ACP HTTP server not reachable`); e.isPrematureEnd = true; throw e; }
|
|
42
|
+
const base = `http://127.0.0.1:${port}`;
|
|
43
|
+
const sessionRes = await fetch(`${base}/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), signal: AbortSignal.timeout(10000) });
|
|
44
|
+
if (!sessionRes.ok) throw new Error(`${this.name} ACP HTTP session create failed: ${sessionRes.status}`);
|
|
45
|
+
const session = await sessionRes.json();
|
|
46
|
+
const sessionId = session.id || session.sessionID;
|
|
47
|
+
if (!sessionId) throw new Error(`${this.name} ACP HTTP session response missing id`);
|
|
48
|
+
|
|
49
|
+
const handler = createACPHttpProtocolHandler();
|
|
50
|
+
const outputs = [];
|
|
51
|
+
const evController = new AbortController();
|
|
52
|
+
const timeoutHandle = setTimeout(() => evController.abort(), timeout);
|
|
53
|
+
|
|
54
|
+
const evPromise = fetch(`${base}/global/event`, { signal: evController.signal }).then(async (res) => {
|
|
55
|
+
const reader = res.body.getReader();
|
|
56
|
+
const decoder = new TextDecoder();
|
|
57
|
+
let buffer = '';
|
|
58
|
+
while (true) {
|
|
59
|
+
const { value, done } = await reader.read();
|
|
60
|
+
if (done) break;
|
|
61
|
+
buffer += decoder.decode(value, { stream: true });
|
|
62
|
+
const frames = parseSSEChunk(buffer);
|
|
63
|
+
buffer = '';
|
|
64
|
+
for (const frame of frames) {
|
|
65
|
+
const normalized = handler(frame, { sessionId });
|
|
66
|
+
if (!normalized) continue;
|
|
67
|
+
outputs.push(normalized);
|
|
68
|
+
if (onEvent) { try { onEvent(normalized); } catch (e) { console.error(`[${this.id}] onEvent error: ${e.message}`); } }
|
|
69
|
+
if (normalized.type === 'result') { clearTimeout(timeoutHandle); return; }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}).catch(() => {});
|
|
73
|
+
|
|
74
|
+
const messageRes = await fetch(`${base}/session/${sessionId}/message`, {
|
|
75
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ parts: [{ type: 'text', text: prompt }] }),
|
|
77
|
+
signal: AbortSignal.timeout(timeout),
|
|
78
|
+
});
|
|
79
|
+
if (!messageRes.ok) { evController.abort(); throw new Error(`${this.name} ACP HTTP message failed: ${messageRes.status}`); }
|
|
80
|
+
|
|
81
|
+
await evPromise;
|
|
82
|
+
evController.abort();
|
|
83
|
+
return { outputs, sessionId };
|
|
84
|
+
};
|
|
85
|
+
|
|
29
86
|
AgentRunner.prototype.runACP = async function(prompt, cwd, config = {}, _retryCount = 0) {
|
|
30
87
|
const maxRetries = config.maxRetries ?? 1;
|
|
88
|
+
if (this.transport === 'http') return this._runACPHttp(prompt, cwd, config);
|
|
31
89
|
try {
|
|
32
90
|
return await this._runACPOnce(prompt, cwd, config);
|
|
33
91
|
} catch (err) {
|
|
@@ -60,6 +60,30 @@ export function register(router, deps) {
|
|
|
60
60
|
return { ok: !!ok };
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
// TEMPORARY diagnostic route (aaa-consolidate-acp-http-routing): exercises
|
|
64
|
+
// AgentRunner.runACP's new transport:'http' branch through the server's own
|
|
65
|
+
// long-lived process (a one-shot exec_js dispatch's process dies with the
|
|
66
|
+
// dispatch, killing acp-sdk-manager's non-detached child opencode with it -
|
|
67
|
+
// this route proves the real production caller shape instead). Remove once
|
|
68
|
+
// the side-by-side stdio-vs-http witness passes and this row resolves.
|
|
69
|
+
router.handle('_diag.runAcpHttp', async (p) => {
|
|
70
|
+
const agent = registry.get(p?.id || 'opencode');
|
|
71
|
+
if (!agent) err(404, 'unknown agent');
|
|
72
|
+
const prevTransport = agent.transport;
|
|
73
|
+
agent.transport = p?.transport === 'stdio' ? undefined : 'http';
|
|
74
|
+
const events = [];
|
|
75
|
+
try {
|
|
76
|
+
const result = await agent.runACP(p?.prompt || 'reply with exactly the word PONG', process.cwd(), {
|
|
77
|
+
timeout: 40000, onEvent: (e) => events.push(e),
|
|
78
|
+
});
|
|
79
|
+
return { ok: true, result, events };
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return { ok: false, error: e.message, events };
|
|
82
|
+
} finally {
|
|
83
|
+
agent.transport = prevTransport;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
63
87
|
router.handle('agent.subagents', async (p) => {
|
|
64
88
|
if (!p.id) err(400, 'Missing agent id');
|
|
65
89
|
if (p.id === 'claude-code' || p.id === 'cli-claude') {
|