@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.
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +359 -0
- package/index.ts +1521 -0
- package/package.json +103 -0
- package/skills/pi-acp-agents/SKILL.md +112 -0
- package/src/acp-widget.ts +379 -0
- package/src/adapter-factory.ts +55 -0
- package/src/adapters/acpx.ts +215 -0
- package/src/adapters/base.ts +117 -0
- package/src/adapters/codex.ts +77 -0
- package/src/adapters/custom.ts +14 -0
- package/src/adapters/gemini.ts +66 -0
- package/src/adapters/opencode.ts +101 -0
- package/src/config/config.ts +312 -0
- package/src/config/types.ts +203 -0
- package/src/coordination/alias-resolver.ts +208 -0
- package/src/coordination/coordinator.ts +266 -0
- package/src/coordination/worker-dispatcher.ts +191 -0
- package/src/core/async-executor.ts +149 -0
- package/src/core/circuit-breaker.ts +254 -0
- package/src/core/client.ts +661 -0
- package/src/core/health-monitor.ts +200 -0
- package/src/core/protocol-validator.ts +259 -0
- package/src/core/session-lifecycle.ts +46 -0
- package/src/core/session-manager.ts +64 -0
- package/src/extension-safety.ts +200 -0
- package/src/logger.ts +92 -0
- package/src/management/event-log.ts +31 -0
- package/src/management/governance-store.ts +123 -0
- package/src/management/heartbeat-parser.ts +92 -0
- package/src/management/mailbox-manager.ts +95 -0
- package/src/management/runtime-paths.ts +34 -0
- package/src/management/safe-mkdir.ts +78 -0
- package/src/management/session-archive-store.ts +136 -0
- package/src/management/session-name-store.ts +88 -0
- package/src/management/task-store.ts +260 -0
- package/src/management/worker-store.ts +164 -0
- package/src/public-api.ts +72 -0
- package/src/settings/agent-config-tui.ts +456 -0
- package/src/settings/agents-command.ts +138 -0
- package/src/settings/config.ts +201 -0
- 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
|
+
}
|