@buihongduc132/pi-acp-agents 0.3.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +359 -0
  4. package/index.ts +1521 -0
  5. package/package.json +103 -0
  6. package/skills/pi-acp-agents/SKILL.md +112 -0
  7. package/src/acp-widget.ts +379 -0
  8. package/src/adapter-factory.ts +55 -0
  9. package/src/adapters/acpx.ts +215 -0
  10. package/src/adapters/base.ts +117 -0
  11. package/src/adapters/codex.ts +77 -0
  12. package/src/adapters/custom.ts +14 -0
  13. package/src/adapters/gemini.ts +66 -0
  14. package/src/adapters/opencode.ts +101 -0
  15. package/src/config/config.ts +312 -0
  16. package/src/config/types.ts +203 -0
  17. package/src/coordination/alias-resolver.ts +208 -0
  18. package/src/coordination/coordinator.ts +266 -0
  19. package/src/coordination/worker-dispatcher.ts +191 -0
  20. package/src/core/async-executor.ts +149 -0
  21. package/src/core/circuit-breaker.ts +254 -0
  22. package/src/core/client.ts +661 -0
  23. package/src/core/health-monitor.ts +200 -0
  24. package/src/core/protocol-validator.ts +259 -0
  25. package/src/core/session-lifecycle.ts +46 -0
  26. package/src/core/session-manager.ts +64 -0
  27. package/src/extension-safety.ts +200 -0
  28. package/src/logger.ts +92 -0
  29. package/src/management/event-log.ts +31 -0
  30. package/src/management/governance-store.ts +123 -0
  31. package/src/management/heartbeat-parser.ts +92 -0
  32. package/src/management/mailbox-manager.ts +95 -0
  33. package/src/management/runtime-paths.ts +34 -0
  34. package/src/management/safe-mkdir.ts +78 -0
  35. package/src/management/session-archive-store.ts +136 -0
  36. package/src/management/session-name-store.ts +88 -0
  37. package/src/management/task-store.ts +260 -0
  38. package/src/management/worker-store.ts +164 -0
  39. package/src/public-api.ts +72 -0
  40. package/src/settings/agent-config-tui.ts +456 -0
  41. package/src/settings/agents-command.ts +138 -0
  42. package/src/settings/config.ts +201 -0
  43. package/src/settings/configure-tui.ts +135 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * ACP Client — wraps ClientSideConnection from @agentclientprotocol/sdk.
3
+ *
4
+ * Manages the lifecycle of a single ACP client connection to one agent subprocess.
5
+ * Maintains one persistent connection; collects text per-prompt via an accumulator.
6
+ */
7
+ import { type ChildProcess, spawn } from "node:child_process";
8
+ import { platform } from "node:os";
9
+ import { Readable, Writable } from "node:stream";
10
+
11
+ /**
12
+ * Creates a filtered ReadableStream that strips non-JSON lines from agent stdout.
13
+ *
14
+ * Gemini CLI (and other ACP agents) may write stack traces, MCP error messages,
15
+ * or other diagnostics to stdout. The ACP SDK's ndJsonStream tries JSON.parse on
16
+ * every line, producing noisy "Failed to parse JSON message" console.errors.
17
+ *
18
+ * This filter intercepts stdout before ndJsonStream sees it, dropping lines that
19
+ * don't start with '{' or '[' (valid JSON object/array starts).
20
+ */
21
+ function createFilteredStdoutStream(rawStdout: ReadableStream<Uint8Array>, logger?: Logger): ReadableStream<Uint8Array> {
22
+ const textDecoder = new TextDecoder();
23
+ const textEncoder = new TextEncoder();
24
+ let buffer = "";
25
+
26
+ function isJsonLine(line: string): boolean {
27
+ const trimmed = line.trim();
28
+ if (!trimmed) return false;
29
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
30
+ try {
31
+ JSON.parse(trimmed);
32
+ return true;
33
+ } catch {
34
+ // Not valid JSON — expected for non-JSON stdout lines
35
+ return false;
36
+ }
37
+ }
38
+
39
+ return new ReadableStream<Uint8Array>({
40
+ async start(controller) {
41
+ const reader = rawStdout.getReader();
42
+ try {
43
+ while (true) {
44
+ const { value, done } = await reader.read();
45
+ if (done) {
46
+ // flush remaining
47
+ if (buffer.trim()) {
48
+ const line = buffer.trim();
49
+ if (isJsonLine(line)) {
50
+ controller.enqueue(textEncoder.encode(line + "\n"));
51
+ } else {
52
+ logger?.debug("filtered non-JSON stdout (flush)", line.slice(0, 200));
53
+ }
54
+ }
55
+ break;
56
+ }
57
+ if (!value) continue;
58
+ buffer += textDecoder.decode(value, { stream: true });
59
+ const lines = buffer.split("\n");
60
+ buffer = lines.pop() || "";
61
+ for (const line of lines) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed) continue;
64
+ if (isJsonLine(trimmed)) {
65
+ controller.enqueue(textEncoder.encode(line + "\n"));
66
+ } else {
67
+ logger?.debug("filtered non-JSON stdout", trimmed.slice(0, 200));
68
+ }
69
+ }
70
+ }
71
+ } catch (err) {
72
+ controller.error(err);
73
+ return;
74
+ } finally {
75
+ reader.releaseLock();
76
+ }
77
+ controller.close();
78
+ },
79
+ });
80
+ }
81
+ import type {
82
+ InitializeResponse,
83
+ NewSessionResponse,
84
+ PromptResponse,
85
+ RequestPermissionRequest,
86
+ RequestPermissionResponse,
87
+ SessionNotification,
88
+ } from "@agentclientprotocol/sdk";
89
+ import {
90
+ ClientSideConnection,
91
+ ndJsonStream,
92
+ PROTOCOL_VERSION,
93
+ } from "@agentclientprotocol/sdk";
94
+ import type { AcpAgentConfig, AcpPromptResult } from "../config/types.js";
95
+ import type { Logger } from "../logger.js";
96
+ import { createFileLogger } from "../logger.js";
97
+ import { killWithEscalation } from "./circuit-breaker.js";
98
+ import {
99
+ AcpProtocolError,
100
+ classifyConnectionError,
101
+ validateInitializeResponse,
102
+ validateNewSessionResponse,
103
+ validatePromptResponse,
104
+ } from "./protocol-validator.js";
105
+
106
+ export interface AcpClientOptions {
107
+ agentName: string;
108
+ config: AcpAgentConfig;
109
+ cwd?: string;
110
+ clientInfo?: { name: string; version: string };
111
+ logger?: Logger;
112
+ logsDir?: string;
113
+ onActivity?: (sessionId: string) => void;
114
+ onSessionUpdate?: (sessionId: string, update: import("@agentclientprotocol/sdk").SessionUpdate) => void;
115
+ }
116
+
117
+ /**
118
+ * AcpClient — manages a single ACP connection to one agent process.
119
+ *
120
+ * Flow: spawn → connect (creates ClientSideConnection) → initialize → newSession → prompt*
121
+ */
122
+ export class AcpClient {
123
+ private proc: ChildProcess | null = null;
124
+ private conn: ClientSideConnection | null = null;
125
+ private _sessionId: string | null = null;
126
+ private _agentInfo: InitializeResponse | null = null;
127
+ private collectedText = "";
128
+ private agentName: string;
129
+ private config: AcpAgentConfig;
130
+ private cwd: string;
131
+ private clientInfo: { name: string; version: string };
132
+ private logger?: Logger;
133
+ private sessionLogger?: Logger;
134
+ private logsDir?: string;
135
+ private lastStderr = "";
136
+ private onActivity?: (sessionId: string) => void;
137
+ private onSessionUpdate?: (sessionId: string, update: import("@agentclientprotocol/sdk").SessionUpdate) => void;
138
+ /**
139
+ * Deferred spawn error. Node's child_process.spawn() does NOT throw
140
+ * synchronously when the binary is missing (ENOENT) — it emits the error
141
+ * asynchronously via the process 'error' event. We capture it here so
142
+ * connect() (and every subsequent RPC) can reject cleanly instead of
143
+ * crashing the host with an unhandled 'error' event -> uncaughtException.
144
+ */
145
+ private spawnError: Error | null = null;
146
+ /**
147
+ * Process-exit-before-handshake error. A binary that EXISTS but exits
148
+ * (any code) before the ACP initialize handshake completes is broken; we
149
+ * capture it here so initialize()/newSession() reject fast instead of
150
+ * hanging to a RPC timeout. (Does NOT fire proc 'error' — only 'exit'.)
151
+ */
152
+ private processExitError: Error | null = null;
153
+ private spawnErrorListeners: Array<(err: Error) => void> = [];
154
+ /**
155
+ * GAP-4: when true, the persistent proc.on('error')/on('exit') callbacks
156
+ * become no-ops so a late event from a killed process cannot mutate state
157
+ * of an already-disposed client. Reset to false at the top of connect().
158
+ */
159
+ private disposed = false;
160
+
161
+ constructor(opts: AcpClientOptions) {
162
+ this.agentName = opts.agentName;
163
+ this.config = opts.config;
164
+ this.cwd = opts.cwd ?? process.cwd();
165
+ this.clientInfo = opts.clientInfo ?? {
166
+ name: "pi-acp-agents",
167
+ version: "0.1.0",
168
+ };
169
+ this.logger = opts.logger;
170
+ this.logsDir = opts.logsDir;
171
+ this.onActivity = opts.onActivity;
172
+ this.onSessionUpdate = opts.onSessionUpdate;
173
+ }
174
+
175
+ get sessionId(): string | null {
176
+ return this._sessionId;
177
+ }
178
+
179
+ get agentInfo(): InitializeResponse | null {
180
+ return this._agentInfo;
181
+ }
182
+
183
+ get connected(): boolean {
184
+ return this.conn !== null && this.proc !== null && !this.proc.killed;
185
+ }
186
+
187
+ /**
188
+ * Spawn the agent process and establish ACP connection.
189
+ *
190
+ * IMPORTANT: Node's child_process.spawn() does NOT throw synchronously
191
+ * when the binary is missing (ENOENT). It returns a ChildProcess and emits
192
+ * the error asynchronously via the 'error' event on the next tick. If no
193
+ * 'error' listener is attached, Node throws on that tick ->
194
+ * uncaughtException -> host (pi) crashes. Likewise, a binary that exists
195
+ * but exits before the handshake only fires 'exit' (not 'error').
196
+ *
197
+ * We therefore:
198
+ * 1. Attach proc.on('error') + proc.on('exit') IMMEDIATELY after spawn
199
+ * returns (before any await), so the error/exit is captured, never
200
+ * leaked. Both callbacks are inert once `disposed` is true (GAP-4).
201
+ * 2. Race the rest of connect() against a spawn-error promise, so an
202
+ * ENOENT or early-exit surfaces as a clean rejection.
203
+ */
204
+ async connect(): Promise<void> {
205
+ // Reset lifecycle state so a fresh connect() after dispose() does not
206
+ // resurrect stale spawn/exit errors from a previous process (GAP-4).
207
+ this.disposed = false;
208
+ this.spawnError = null;
209
+ this.processExitError = null;
210
+ this.spawnErrorListeners = [];
211
+
212
+ const cmd = this.config.command;
213
+ if (!cmd) throw new Error(`Agent "${this.agentName}" has no command configured for direct mode`);
214
+ const args = this.config.args ?? [];
215
+
216
+ try {
217
+ this.proc = spawn(cmd, args, {
218
+ cwd: this.cwd,
219
+ env: { ...process.env, ...this.config.env },
220
+ stdio: ["pipe", "pipe", "pipe"],
221
+ shell: platform() === "win32",
222
+ });
223
+ } catch (err: unknown) {
224
+ throw classifyConnectionError(err, this.agentName, cmd);
225
+ }
226
+
227
+ // Attach process-level listeners IMMEDIATELY — before any await and
228
+ // before the stdin/stdout null check. This is the safety net for async
229
+ // spawn errors (ENOENT, EACCES, EAGAIN) delivered on the next tick, and
230
+ // for early process exit (binary exists but crashes / wrong args).
231
+ // Without proc.on('error'), an unhandled 'error' event on a
232
+ // ChildProcess throws synchronously -> uncaughtException -> pi dies.
233
+ this.proc!.on("error", (err: NodeJS.ErrnoException) => {
234
+ this.logger?.debug("process error event", err);
235
+ this.captureFatalSpawnError("spawnError", err);
236
+ });
237
+ this.proc!.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
238
+ this.logger?.debug("process exit event", { code, signal });
239
+ // Only fatal if the process died BEFORE the ACP initialize handshake
240
+ // completed. Post-handshake exits are normal session termination.
241
+ if (this._agentInfo !== null) return;
242
+ const exitErr = new AcpProtocolError({
243
+ agentName: this.agentName,
244
+ command: cmd,
245
+ phase: "spawn",
246
+ message:
247
+ `Command "${cmd}" exited immediately` +
248
+ (code !== null ? ` with non-zero status ${code}` : "") +
249
+ (signal ? ` (signal ${signal})` : "") + `.`,
250
+ cause:
251
+ "The process started but exited before completing the ACP " +
252
+ "handshake. Check the command/args; the binary may be missing " +
253
+ "the ACP flag (e.g. '--acp' or 'acp') or crashed on startup." +
254
+ (this.lastStderr ? `\nStderr: ${this.lastStderr.slice(0, 500)}` : ""),
255
+ });
256
+ this.captureFatalSpawnError("processExitError", exitErr);
257
+ });
258
+
259
+ // If the spawn already failed async (race window), reject now.
260
+ if (this.spawnError || this.processExitError) {
261
+ throw classifyConnectionError(
262
+ (this.spawnError ?? this.processExitError)!,
263
+ this.agentName,
264
+ cmd,
265
+ this.lastStderr,
266
+ );
267
+ }
268
+
269
+ if (!this.proc!.stdin || !this.proc!.stdout) {
270
+ throw new AcpProtocolError({
271
+ agentName: this.agentName,
272
+ command: cmd,
273
+ phase: "spawn",
274
+ message: "Failed to create stdio pipes.",
275
+ cause: `The process was created but stdin/stdout are not available. ` +
276
+ `This can happen if the command is not a real process or doesn't support piped I/O.`,
277
+ });
278
+ }
279
+
280
+ // Prevent EPIPE crashes
281
+ this.proc!.stdin.on("error", (err) => {
282
+ this.logger?.debug("stdin error", err);
283
+ });
284
+ this.proc!.stdout.on("error", (err) => {
285
+ this.logger?.debug("stdout error", err);
286
+ });
287
+ this.proc!.stderr?.on("error", (err) => {
288
+ this.logger?.debug("stderr error", err);
289
+ });
290
+ this.proc!.stderr?.on("data", (chunk: Buffer) => {
291
+ const text = chunk.toString();
292
+ this.lastStderr += text;
293
+ if (this.lastStderr.length > 2048)
294
+ this.lastStderr = this.lastStderr.slice(-2048);
295
+ this.logger?.debug("stderr", text);
296
+ });
297
+
298
+ const rawStdout = Readable.toWeb(
299
+ this.proc!.stdout,
300
+ ) as ReadableStream<Uint8Array>;
301
+ const webStdin = Writable.toWeb(
302
+ this.proc!.stdin,
303
+ ) as WritableStream<Uint8Array>;
304
+
305
+ // Filter non-JSON lines before passing to ndJsonStream to avoid
306
+ // "Failed to parse JSON message" noise from stack traces / MCP errors
307
+ const filteredStdout = createFilteredStdoutStream(rawStdout, this.logger);
308
+ const stream = ndJsonStream(webStdin, filteredStdout);
309
+
310
+ this.conn = new ClientSideConnection(
311
+ () => ({
312
+ sessionUpdate: (params: SessionNotification) =>
313
+ this.handleSessionUpdate(params),
314
+ requestPermission: () =>
315
+ Promise.resolve({
316
+ outcome: "approved",
317
+ } as unknown as RequestPermissionResponse),
318
+ }),
319
+ stream,
320
+ );
321
+
322
+ // Final guard: race any deferred spawn error / early exit (ENOENT and
323
+ // process-exit both fire on later ticks) against successful return.
324
+ await this.guardAgainstSpawnError(cmd);
325
+ }
326
+
327
+ /**
328
+ * Capture a fatal pre-handshake error (async spawn 'error' or early
329
+ * 'exit') into the appropriate field and notify any in-flight
330
+ * connect()/initialize()/newSession()/prompt() caller. Inert once the
331
+ * client is disposed (GAP-4).
332
+ */
333
+ private captureFatalSpawnError(
334
+ kind: "spawnError" | "processExitError",
335
+ err: Error,
336
+ ): void {
337
+ if (this.disposed) return; // GAP-4: late events on a killed proc are ignored
338
+ if (kind === "spawnError") {
339
+ if (!this.spawnError) this.spawnError = err;
340
+ } else {
341
+ if (!this.processExitError) this.processExitError = err;
342
+ }
343
+ const listeners = this.spawnErrorListeners.splice(0);
344
+ for (const fn of listeners) {
345
+ try { fn(err); } catch { /* listener errors must not propagate */ }
346
+ }
347
+ }
348
+
349
+ /**
350
+ * The current fatal pre-handshake error, if any (spawn 'error' takes
351
+ * precedence over early 'exit').
352
+ */
353
+ private get fatalSpawnError(): Error | null {
354
+ return this.spawnError ?? this.processExitError;
355
+ }
356
+
357
+ /**
358
+ * If a spawn error / early exit has fired (or fires within one event-loop
359
+ * turn), reject with a classified, stderr-enriched error. Otherwise resolve.
360
+ *
361
+ * Note on timing: yielding one setImmediate turn is empirically sufficient
362
+ * for libuv to deliver a pending ENOENT on POSIX. It is NOT a hard
363
+ * contract (Windows libuv timing is less deterministic; under heavy
364
+ * event-loop load delivery can slip). The persistent proc.on('error') /
365
+ * on('exit') listeners are the real safety net — they reject this promise
366
+ * synchronously when the event arrives. We additionally RE-READ
367
+ * this.fatalSpawnError after the await so a slowly-delivered error that
368
+ * set the field without yet draining listeners still surfaces here.
369
+ */
370
+ private guardAgainstSpawnError(cmd: string): Promise<void> {
371
+ const immediate = this.fatalSpawnError;
372
+ if (immediate) {
373
+ return Promise.reject(
374
+ classifyConnectionError(immediate, this.agentName, cmd, this.lastStderr),
375
+ );
376
+ }
377
+ return new Promise<void>((resolve, reject) => {
378
+ let settled = false;
379
+ const onErr = (err: Error) => {
380
+ if (settled) return;
381
+ settled = true;
382
+ reject(classifyConnectionError(err, this.agentName, cmd, this.lastStderr));
383
+ };
384
+ this.spawnErrorListeners.push(onErr);
385
+ setImmediate(() => {
386
+ if (settled) return; // error/exit already rejected via listener
387
+ settled = true;
388
+ const idx = this.spawnErrorListeners.indexOf(onErr);
389
+ if (idx >= 0) this.spawnErrorListeners.splice(idx, 1);
390
+ // Defensive re-check: a slow-delivered event may have set the field
391
+ // without the listener draining yet.
392
+ const late = this.fatalSpawnError;
393
+ if (late) {
394
+ reject(classifyConnectionError(late, this.agentName, cmd, this.lastStderr));
395
+ return;
396
+ }
397
+ resolve();
398
+ });
399
+ });
400
+ }
401
+
402
+ /** ACP initialize + auto-authenticate + protocol validation */
403
+ async initialize(): Promise<InitializeResponse> {
404
+ // GAP-1: surface a deferred spawn error / early exit as a classified
405
+ // rejection instead of awaiting this.conn.*() against a dead process.
406
+ if (this.fatalSpawnError) {
407
+ throw classifyConnectionError(
408
+ this.fatalSpawnError, this.agentName, this.config.command!, this.lastStderr,
409
+ );
410
+ }
411
+ if (!this.conn) throw new Error("Not connected");
412
+
413
+ let resp: InitializeResponse;
414
+ try {
415
+ resp = await this.conn.initialize({
416
+ protocolVersion: PROTOCOL_VERSION,
417
+ clientCapabilities: {},
418
+ clientInfo: this.clientInfo,
419
+ });
420
+ } catch (err: unknown) {
421
+ throw classifyConnectionError(err, this.agentName, this.config.command!, this.lastStderr);
422
+ }
423
+
424
+ // Behavior-based validation: does the response look like ACP?
425
+ validateInitializeResponse(resp, this.agentName, this.config.command!);
426
+
427
+ this._agentInfo = resp;
428
+
429
+ // Auto-authenticate with first available method
430
+ if (resp.authMethods && resp.authMethods.length > 0) {
431
+ try {
432
+ await this.conn.authenticate({ methodId: resp.authMethods[0]!.id });
433
+ } catch (err) {
434
+ // Auth is best-effort — may fail if no auth needed
435
+ this.logger?.debug("Auth skipped or failed", err);
436
+ }
437
+ }
438
+
439
+ return resp;
440
+ }
441
+
442
+ /** Create a new session */
443
+ async newSession(): Promise<string> {
444
+ // GAP-1: surface deferred spawn error / early exit fast.
445
+ if (this.fatalSpawnError) {
446
+ throw classifyConnectionError(
447
+ this.fatalSpawnError, this.agentName, this.config.command!, this.lastStderr,
448
+ );
449
+ }
450
+ if (!this.conn) throw new Error("Not connected");
451
+
452
+ let resp: NewSessionResponse;
453
+ try {
454
+ resp = await this.conn.newSession({
455
+ cwd: this.cwd,
456
+ mcpServers: [],
457
+ });
458
+ } catch (err: unknown) {
459
+ throw classifyConnectionError(err, this.agentName, this.config.command!, this.lastStderr);
460
+ }
461
+
462
+ // Behavior-based validation
463
+ validateNewSessionResponse(resp, this.agentName, this.config.command!);
464
+
465
+ this._sessionId = resp.sessionId;
466
+
467
+ // PH-15: Ensure session-specific log file exists for JSON-RPC traces
468
+ this.ensureSessionLog(resp.sessionId);
469
+
470
+ // Set default model if configured (best-effort, Zed-style default_model)
471
+ if (this.config.default_model) {
472
+ try {
473
+ await this.conn.unstable_setSessionModel({
474
+ sessionId: resp.sessionId,
475
+ modelId: this.config.default_model,
476
+ });
477
+ } catch (err) {
478
+ // Setting model is best-effort
479
+ this.logger?.debug("Set model failed (best-effort)", err);
480
+ }
481
+ }
482
+
483
+ // Set default mode if configured (best-effort, Zed-style default_mode)
484
+ if (this.config.default_mode) {
485
+ try {
486
+ await this.conn.setSessionMode({
487
+ sessionId: resp.sessionId,
488
+ modeId: this.config.default_mode,
489
+ });
490
+ } catch (err) {
491
+ // Setting mode is best-effort
492
+ this.logger?.debug("Set mode failed (best-effort)", err);
493
+ }
494
+ }
495
+
496
+ return resp.sessionId;
497
+ }
498
+
499
+ /** Send a prompt and collect the full response */
500
+ async prompt(message: string): Promise<{ text: string; stopReason: string }> {
501
+ // GAP-1: surface deferred spawn error / early exit fast.
502
+ if (this.fatalSpawnError) {
503
+ throw classifyConnectionError(
504
+ this.fatalSpawnError, this.agentName, this.config.command!, this.lastStderr,
505
+ );
506
+ }
507
+ if (!this.conn || !this._sessionId) {
508
+ throw new Error("No active session");
509
+ }
510
+
511
+ this.collectedText = "";
512
+ const stderrBefore = this.lastStderr;
513
+
514
+ let resp: PromptResponse;
515
+ try {
516
+ resp = await this.conn.prompt({
517
+ sessionId: this._sessionId,
518
+ prompt: [{ type: "text", text: message }],
519
+ });
520
+ } catch (err: unknown) {
521
+ const classified = classifyConnectionError(err, this.agentName, this.config.command!, this.lastStderr);
522
+ if (classified instanceof AcpProtocolError) throw classified;
523
+ const msg = err instanceof Error ? err.message : String(err);
524
+ const stderrDelta = this.lastStderr.slice(stderrBefore.length).trim();
525
+ throw new Error(
526
+ `Prompt RPC failed: ${msg}` +
527
+ (stderrDelta ? `\nAgent stderr:\n${stderrDelta}` : ""),
528
+ );
529
+ }
530
+
531
+ // Behavior-based validation
532
+ validatePromptResponse(resp, this.agentName, this.config.command!);
533
+
534
+ // Surface stopReason=error with stderr context
535
+ if ((resp.stopReason as string) === "error") {
536
+ const stderrDelta = this.lastStderr.slice(stderrBefore.length).trim();
537
+ throw new Error(
538
+ `Agent returned stopReason=error.\n` +
539
+ `Collected text: ${this.collectedText || "(none)"}\n` +
540
+ (stderrDelta ? `Agent stderr:\n${stderrDelta}` : "(no stderr)"),
541
+ );
542
+ }
543
+
544
+ return { text: this.collectedText, stopReason: resp.stopReason };
545
+ }
546
+
547
+ /** Full lifecycle: connect → initialize → newSession → prompt */
548
+ async quickPrompt(message: string): Promise<AcpPromptResult> {
549
+ if (!this.connected) {
550
+ await this.connect();
551
+ await this.initialize();
552
+ }
553
+ if (!this._sessionId) {
554
+ await this.newSession();
555
+ }
556
+ const result = await this.prompt(message);
557
+ return {
558
+ text: result.text,
559
+ stopReason: result.stopReason === "cancelled" ? "cancelled" : "end_turn",
560
+ sessionId: this._sessionId!,
561
+ };
562
+ }
563
+
564
+ /** Cancel an ongoing prompt */
565
+ async cancel(): Promise<void> {
566
+ if (this.conn && this._sessionId) {
567
+ await this.conn.cancel({ sessionId: this._sessionId });
568
+ }
569
+ }
570
+
571
+ /** Load an existing session by ID. Returns the sessionId. */
572
+ async loadSession(sessionId: string): Promise<string> {
573
+ if (!this.conn) throw new Error("Not connected");
574
+
575
+ const resp = await this.conn.loadSession({
576
+ sessionId,
577
+ cwd: this.cwd,
578
+ mcpServers: [],
579
+ });
580
+
581
+ // Use the loaded session as current
582
+ this._sessionId = sessionId;
583
+ return sessionId;
584
+ }
585
+
586
+ /** Set the model for the current session */
587
+ async setModel(modelId: string): Promise<void> {
588
+ if (!this.conn || !this._sessionId) throw new Error("No active session");
589
+ await this.conn.unstable_setSessionModel({
590
+ sessionId: this._sessionId,
591
+ modelId,
592
+ });
593
+ }
594
+
595
+ /** Set the mode (thinking level) for the current session */
596
+ async setMode(modeId: string): Promise<void> {
597
+ if (!this.conn || !this._sessionId) throw new Error("No active session");
598
+ await this.conn.setSessionMode({
599
+ sessionId: this._sessionId,
600
+ modeId,
601
+ });
602
+ }
603
+
604
+ /** PH-15: Ensure session-specific log file exists for JSON-RPC traces */
605
+ private ensureSessionLog(sessionId: string): void {
606
+ if (!this.logsDir) return;
607
+ // Create session-specific logger that writes to logsDir/sessions/{sessionId}.jsonl
608
+ this.sessionLogger = createFileLogger(this.logsDir, sessionId);
609
+ this.sessionLogger.info("session created", {
610
+ sessionId,
611
+ agentName: this.agentName,
612
+ });
613
+ }
614
+
615
+ /** Kill the agent process and clean up */
616
+ async dispose(): Promise<void> {
617
+ // GAP-4: mark disposed FIRST so any late 'error'/'exit' event emitted
618
+ // by killWithEscalation / OS cleanup becomes inert (captureFatalSpawnError
619
+ // returns immediately) and cannot mutate state of this dead client.
620
+ this.disposed = true;
621
+ // Drop pending listeners so a late event doesn't reject a promise nobody awaits.
622
+ this.spawnErrorListeners = [];
623
+ this.spawnError = null;
624
+ this.processExitError = null;
625
+ if (this.proc && !this.proc.killed) {
626
+ killWithEscalation(this.proc);
627
+ }
628
+ this.conn = null;
629
+ this.proc = null;
630
+ this._sessionId = null;
631
+ }
632
+
633
+ /** Handle session/update notifications — accumulate text chunks */
634
+ private async handleSessionUpdate(
635
+ params: SessionNotification,
636
+ ): Promise<void> {
637
+ const update = params.update as Record<string, unknown>;
638
+ const updateType = update.sessionUpdate;
639
+
640
+ // Log all updates for debugging
641
+ this.logger?.debug("session update", { updateType: String(updateType), keys: Object.keys(update) });
642
+
643
+ // Fire activity callback for ALL update types (stall detection)
644
+ if (this._sessionId) this.onActivity?.(this._sessionId);
645
+
646
+ // Forward the raw update to the onSessionUpdate callback (heartbeat consumer,
647
+ // defensive parsing, zero-delta fallback, AcpEventLog logging, etc.)
648
+ if (this._sessionId) {
649
+ this.onSessionUpdate?.(this._sessionId, params.update);
650
+ }
651
+
652
+ if (updateType === "agent_message_chunk" || updateType === "agent_thought_chunk") {
653
+ const content = update.content as
654
+ | { type?: string; text?: string }
655
+ | undefined;
656
+ if (content?.type === "text" && content.text) {
657
+ this.collectedText += content.text;
658
+ }
659
+ }
660
+ }
661
+ }