agentgui 1.0.1118 → 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/.gm/prd.yml CHANGED
@@ -1,15 +1,10 @@
1
1
  - id: aaa-consolidate-acp-http-routing
2
2
  subject: Route chat.sendMessage's ACP prompt through acp-sdk-manager's already-running HTTP server instead of claude-runner-acp.js spawning a fresh stdio subprocess per turn
3
- witness: 'Deep investigation this session established: (1) opencode acp --port serves REST+SSE (POST /session, POST /session/{id}/message, GET /event) -- LIVE confirmed via Explore agent''s GET /doc fetch. (2) This is a STRUCTURALLY DIFFERENT event shape than the stdio ACP JSON-RPC ''session/update'' notifications lib/acp-protocol.js''s acpProtocolHandler parses (opencode SSE emits ''session.updated''/''message.part.updated'' REST-shaped events, not ACP method=''session/update'' RPC notifications) -- consolidating requires a NEW event-normalizer for the SSE shape, not a reuse of acpProtocolHandler, and acpProtocolHandler is shared live-critical code for 14 other registered agents (gemini, goose, openhands, augment, cline, kimi, qwen, mistral, kiro, fast-agent, hermes, etc) so a wrong rewrite risks breaking all of them. (3) kilo''s exact SSE/event shape was NOT live-verified in this session (Bash tool backgrounding could not keep a kilo acp --port process alive across tool-call boundaries in this environment -- two attempts, both times the process was gone with zero log output on the next read).'
4
- description: 'This row requires a dedicated focused session with a live, running opencode instance to iterate the SSE event-normalizer against real traffic (not guessable from static analysis) -- do NOT attempt a blind rewrite of claude-runner-acp.js''s runACP against unverified event-shape assumptions. Concrete next steps for that session: (1) spawn opencode acp --port in a way that survives across dispatches (e.g. via the gm skill''s exec_js/background-convert primitives, or a supervised child in server.js itself, rather than raw shell backgrounding), (2) POST /session + /session/{id}/message with a real prompt, (3) capture actual GET /event SSE frames, (4) write a NEW lib/acp-http-protocol.js normalizer mapping those frames to the same internal event shape acpProtocolHandler produces (assistant/tool_use/tool_result/result), (5) add an opt-in per-agent transport flag (e.g. tool.transport:''http-sse'') so opencode/kilo can migrate independently of the 12 other stdio-based ACP agents which must keep working unchanged, (6) only delete stdio spawn code for a given agent after its HTTP path is proven equivalent via side-by-side witness (same prompt, same final assistant text, over both transports).'
5
- status: pending
6
- - id: aaa-codex-app-server-client
7
- subject: Build a WebSocket app-server client for codex so Codex CLI is a genuinely working ACP-class agent again
8
- witness: 'codex --help / codex app-server --help live output (this session): app-server exposes --remote ws://host:port style connections and its own --ws-auth capability-token/signed-bearer-token modes -- structurally different from the stdio JSON-RPC (claude-runner-acp.js) and HTTP-polling (acp-sdk-manager.js) transports this codebase already has clients for'
9
- description: 'Implement lib/claude-runner-codex-appserver.js: spawn `codex app-server daemon` (or the correct non-daemon invocation per `codex app-server --help`), speak its WebSocket protocol (JSON-RPC-over-WS per the --remote ws:// contract, generate-ts/generate-json-schema subcommands can emit the exact schema), wire it into claude-runner-agents.js''s codex registration in place of the disabled acp stdio path, and into acp-sdk-manager.js if health/model discovery is meaningful for it. Read `codex app-server generate-json-schema` output first to get the real RPC method/param shapes rather than guessing.'
3
+ witness: 'MAJOR PROGRESS this session, sha f5e57db253: root-caused opencode''s HTTP server appearing unreachable (same stdin-EOF disposal bug as acp-sdk-manager.js, fixed by stdio:[''pipe'',''pipe'',''pipe'']); captured a full real POST /session -> POST /session/{id}/message -> GET /global/event round trip via a temporary in-process diagnostic route (LLM genuinely replied ''OK''); built and unit-verified lib/acp-http-protocol.js''s createACPHttpProtocolHandler(), which correctly normalizes the captured event shape into the same internal shape acp-protocol.js''s stdio handler produces, including correctly filtering the user''s own prompt echo via message-id-to-role tracking (verified against the exact real captured sequence).'
4
+ description: 'Remaining step: wire the verified normalizer into the live chat.sendMessage path. Concretely: (1) in lib/claude-runner-acp.js, add an HTTP-transport branch to runACP (or a new runACPHttp) that: ensures the opencode/kilo server is running via acp-sdk-manager.ensureRunning(agentId) [already exists], POSTs /session then /session/{id}/message against that port, opens GET /global/event with an AbortController, feeds each SSE frame through acpHttpProtocolHandler, and calls onEvent for each normalized result exactly like the stdio path does; (2) add a per-agent opt-in flag (e.g. registry entries for opencode/kilo get transport:''http'') so this only activates for the two live-verified agents, leaving the other 12 stdio-based ACP agents (gemini, goose, openhands, augment, cline, kimi, qwen, mistral, kiro, fast-agent, hermes) completely untouched; (3) side-by-side witness: send the SAME prompt through both the old stdio path and the new HTTP path for opencode, confirm identical final assistant text, before deleting any stdio code; (4) only after (3) passes, delete the stdio spawn/handshake for the converted agents and update AGENTS.md''s ACP CRITICAL rule to be literally true. This is a live chat-path change -- do not skip the side-by-side witness step even under time pressure.'
10
5
  status: pending
11
6
  - id: aaa-split-app-js-god-file
12
7
  subject: Extract site/app/js/app.js's mixed concerns into per-concern modules
13
- witness: 'Slice 1/4 DONE and committed (sha d211f32cbd): chat-persistence extracted to site/app/js/chat-persistence.js via createChatPersistence(state, helpers) factory, live-witnessed via browser verb round-tripping a seeded localStorage chat through reload with zero page errors. Established working pattern for remaining slices: factory function closing over the shared `state` object + helper functions, called once at app.js module load, destructured to the SAME local names so every existing call site is untouched.'
14
- description: '3 slices remain, cut in this order (per the original extraction-contract row): (2) keyboard shortcuts (SHORTCUTS array line ~628 renumbered after slice 1''s ~70-line removal, + global keydown handler, closes over render()/navTo()/state.showShortcuts/gPending/arm-timers -- more coupled than persistence, verify the escape-ladder order documented in AGENTS.md still holds after the cut), (3) hash-routing (HASH_KEYS/readHash/buildHash/writeHash/navTo, closes over render()), (4) settings/history (largest remaining, ~900 lines, most view()-tree coupling -- do last). After each cut: node --check both files, restart live server, browser-witness hasApp:true + zero page/console errors + the specific behavior moved (e.g. shortcuts overlay opens on ''?'', hash round-trips on navTo), commit that ONE slice, then start the next -- never batch.'
8
+ witness: 'Slice 1/4 DONE (chat-persistence, sha d211f32cbd, live-witnessed). Slice 2 (keyboard shortcuts, app.js:4649-4758 as of this commit) was investigated concretely: the global keydown handler references ~10 app.js-local functions across every feature area (navTo, closeFileDialog, filesMarked, clearFileSelection, cancelChat, newChat, previewNeighbours, openPreview, announce, render) plus state fields spanning chat/files/live/settings -- the highest cross-cutting coupling in the file. This is the exact code AGENTS.md flags as historically crash-prone under webjsx keying mistakes, and a rushed extraction threading 10 dependencies through a factory is a real correctness risk, not a mechanical move.'
9
+ description: 'Slice 2 (shortcuts) needs a dedicated pass: enumerate the full dependency list precisely (grep every identifier referenced inside the keydown handler + SHORTCUTS array + focusComposer/focusSearch/focusFilter), decide whether those ~10 functions move WITH the shortcuts module (turning it into a larger cut than ''just shortcuts'') or stay in app.js and get passed in (a large helpers object) -- this decision should be made deliberately, not improvised mid-edit. Slices 3 (hash-routing) and 4 (settings/history) still queued after. Do not attempt slice 2 without first re-reading the live current line numbers (they shift after every extraction) and re-running the same live-witness discipline (node --check, live browser reload+interaction witness, single-slice commit) slice 1 used.'
15
10
  status: pending
@@ -0,0 +1,95 @@
1
+ // Normalizes opencode's REST+SSE event shape (POST /session, POST
2
+ // /session/{id}/message, GET /global/event) into the SAME internal event
3
+ // shape lib/acp-protocol.js's acpProtocolHandler produces for the stdio ACP
4
+ // JSON-RPC transport (assistant/tool_use/tool_result/result), so a caller can
5
+ // switch transports without caring which one produced the event.
6
+ //
7
+ // Live-captured event shapes this maps (opencode 1.2.15, acp --port, SSE
8
+ // frames from GET /global/event during a real session/message round trip):
9
+ // {payload:{type:'message.part.updated', properties:{part:{type:'text',text,...}}}}
10
+ // {payload:{type:'message.part.updated', properties:{part:{type:'reasoning',text,...}}}}
11
+ // {payload:{type:'message.updated', properties:{info:{role,finish,...}}}}
12
+ // {payload:{type:'session.status', properties:{status:{type:'busy'|'idle'}}}}
13
+ // {payload:{type:'session.idle', properties:{...}}}
14
+
15
+ function normalizePart(part, sessionId) {
16
+ if (!part || typeof part !== 'object') return null;
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) {
22
+ return { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: part.text }] }, session_id: sessionId };
23
+ }
24
+ if (part.type === 'tool' || part.type === 'tool-invocation') {
25
+ return {
26
+ type: 'assistant',
27
+ message: { role: 'assistant', content: [{ type: 'tool_use', id: part.id, name: part.tool || part.name || 'tool', input: part.input || part.args || {} }] },
28
+ session_id: sessionId,
29
+ };
30
+ }
31
+ // reasoning/step-start/step-finish parts carry no user-facing content in
32
+ // agentgui's chat surface today - dropped (return null), same as the stdio
33
+ // path drops ACP updates it doesn't recognize.
34
+ return null;
35
+ }
36
+
37
+ // A `message.part.updated` frame's `part` carries NO role of its own - live
38
+ // capture confirmed the part's role lives on the earlier `message.updated`
39
+ // frame for the SAME messageID (the prompt echo arrives as a role:"user"
40
+ // message.updated immediately before its own message.part.updated). Each
41
+ // createACPHttpProtocolHandler() call keeps its own messageId->role map so a
42
+ // caller with one handler per session never leaks state across sessions.
43
+ export function createACPHttpProtocolHandler() {
44
+ const roleByMessageId = new Map();
45
+
46
+ return function (frame, context) {
47
+ if (!frame || typeof frame !== 'object') return null;
48
+ const payload = frame.payload;
49
+ if (!payload || typeof payload !== 'object') return null;
50
+ const sid = context?.sessionId;
51
+
52
+ if (payload.type === 'message.updated') {
53
+ const info = payload.properties?.info;
54
+ if (!info) return null;
55
+ if (info.id) roleByMessageId.set(info.id, info.role);
56
+ if (info.role === 'assistant' && info.finish) {
57
+ return { type: 'result', result: '', stopReason: info.finish, usage: info.tokens ? { used: info.tokens.total } : undefined, session_id: info.sessionID || sid };
58
+ }
59
+ return null;
60
+ }
61
+
62
+ if (payload.type === 'message.part.updated') {
63
+ const part = payload.properties?.part;
64
+ if (!part) return null;
65
+ // The user's own prompt echoes back as a part on their own message -
66
+ // only forward parts belonging to an assistant-role message.
67
+ const role = part.messageID ? roleByMessageId.get(part.messageID) : undefined;
68
+ if (role === 'user') return null;
69
+ return normalizePart(part, sid);
70
+ }
71
+
72
+ if (payload.type === 'session.status' && payload.properties?.status?.type === 'error') {
73
+ return { type: 'error', error: payload.properties.status };
74
+ }
75
+
76
+ return null;
77
+ };
78
+ }
79
+
80
+ export const acpHttpProtocolHandler = createACPHttpProtocolHandler();
81
+
82
+ // Parse one SSE "data: {...}\n\n" frame (or a raw chunk containing several)
83
+ // into an array of parsed JSON payloads. opencode's /global/event stream
84
+ // uses the standard `data: <json>\n\n` SSE framing (live-confirmed).
85
+ export function parseSSEChunk(chunk) {
86
+ const out = [];
87
+ for (const block of chunk.split('\n\n')) {
88
+ const line = block.trim();
89
+ if (!line.startsWith('data:')) continue;
90
+ const jsonText = line.slice(5).trim();
91
+ if (!jsonText) continue;
92
+ try { out.push(JSON.parse(jsonText)); } catch { /* partial frame, skip */ }
93
+ }
94
+ return out;
95
+ }
@@ -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
- async function checkHealth(toolId, port) {
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
- else acpMachine.send(toolId, { type: 'UNHEALTHY' });
133
- } catch (_) { acpMachine.send(toolId, { type: 'UNHEALTHY' }); }
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
- for (let i = 0; i < 20; i++) {
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; }
@@ -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') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1118",
3
+ "version": "1.0.1120",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",