@botcord/daemon 0.2.61 → 0.2.63

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.
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir, hostname, platform, release, arch } from "node:os";
3
3
  import path from "node:path";
4
4
  import { Buffer } from "node:buffer";
5
+ import { createRequire } from "node:module";
6
+ import { fileURLToPath } from "node:url";
5
7
  import { deflateRawSync } from "node:zlib";
6
8
  import {
7
9
  AUTH_EXPIRED_FLAG_PATH,
@@ -12,11 +14,14 @@ import {
12
14
  import {
13
15
  CONFIG_FILE_PATH,
14
16
  PID_PATH,
17
+ SESSIONS_PATH,
15
18
  SNAPSHOT_PATH,
16
19
  loadConfig,
20
+ saveConfig,
17
21
  type DaemonConfig,
18
22
  } from "./config.js";
19
23
  import { listDaemonLogFiles, LOG_FILE_PATH, type LogFileEntry } from "./log.js";
24
+ import { listAcpTraceLogFiles, listRuntimeLogFiles } from "./acp-logs.js";
20
25
  import {
21
26
  channelsFromDaemonConfig,
22
27
  defaultHttpFetcher,
@@ -26,16 +31,41 @@ import {
26
31
  type DoctorRuntimeEntry,
27
32
  } from "./doctor.js";
28
33
  import { detectRuntimes } from "./adapters/runtimes.js";
34
+ import { log as daemonLog } from "./log.js";
35
+ import {
36
+ discoverLocalOpenclawGateways,
37
+ mergeOpenclawGateways,
38
+ openclawDiscoveryConfigEnabled,
39
+ } from "./openclaw-discovery.js";
29
40
 
30
41
  const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
31
42
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
32
43
  const DEFAULT_ROTATED_LOGS_IN_BUNDLE = 5;
44
+ const require = createRequire(import.meta.url);
45
+ const MODULE_PATH = fileURLToPath(import.meta.url);
46
+ const ENV_ALLOWLIST = new Set([
47
+ "NODE_ENV",
48
+ "PATH",
49
+ "BOTCORD_HUB",
50
+ "BOTCORD_DAEMON_HOME",
51
+ "BOTCORD_DAEMON_CONFIG",
52
+ "BOTCORD_DAEMON_LOG",
53
+ "BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS",
54
+ "BOTCORD_HERMES_AGENT_BIN",
55
+ "BOTCORD_CLAUDE_CODE_BIN",
56
+ "BOTCORD_CODEX_BIN",
57
+ "BOTCORD_GEMINI_BIN",
58
+ "BOTCORD_DEEPSEEK_TUI_BIN",
59
+ "BOTCORD_KIMI_CLI_BIN",
60
+ "OPENCLAW_ACP_URL",
61
+ ]);
33
62
 
34
63
  export interface CreateDiagnosticBundleOptions {
35
64
  diagnosticsDir?: string;
36
65
  logFile?: string;
37
66
  configFile?: string;
38
67
  snapshotFile?: string;
68
+ sessionsFile?: string;
39
69
  doctor?: { text: string; json: unknown };
40
70
  includeAllLogs?: boolean;
41
71
  }
@@ -81,6 +111,81 @@ function safeReadText(file: string): string | null {
81
111
  }
82
112
  }
83
113
 
114
+ function readJsonFile(file: string): Record<string, unknown> | null {
115
+ try {
116
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
117
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
118
+ ? parsed as Record<string, unknown>
119
+ : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function findDaemonPackageJson(startFile: string): Record<string, unknown> | null {
126
+ let dir = path.dirname(startFile);
127
+ for (let i = 0; i < 6; i += 1) {
128
+ const candidate = path.join(dir, "package.json");
129
+ const parsed = readJsonFile(candidate);
130
+ if (parsed?.name === "@botcord/daemon") return parsed;
131
+ const next = path.dirname(dir);
132
+ if (next === dir) break;
133
+ dir = next;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function readInstalledPackageVersion(packageJsonSpecifier: string): string | null {
139
+ try {
140
+ const pkgPath = require.resolve(packageJsonSpecifier);
141
+ const parsed = readJsonFile(pkgPath);
142
+ return typeof parsed?.version === "string" ? parsed.version : null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function daemonRuntimeSummary(): Record<string, unknown> {
149
+ const pkg = findDaemonPackageJson(MODULE_PATH);
150
+ const version = typeof pkg?.version === "string" ? pkg.version : null;
151
+ const startedAtMs = Date.now() - Math.round(process.uptime() * 1000);
152
+ return {
153
+ packageName: typeof pkg?.name === "string" ? pkg.name : "@botcord/daemon",
154
+ version,
155
+ modulePath: MODULE_PATH,
156
+ entrypoint: process.argv[1] ?? null,
157
+ execPath: process.execPath,
158
+ argv: process.argv.map((arg) => redact(arg)),
159
+ execArgv: process.execArgv.map((arg) => redact(arg)),
160
+ cwd: process.cwd(),
161
+ pid: process.pid,
162
+ ppid: process.ppid,
163
+ uptimeSec: Math.round(process.uptime()),
164
+ startedAt: new Date(startedAtMs).toISOString(),
165
+ versions: {
166
+ node: process.version,
167
+ v8: process.versions.v8,
168
+ uv: process.versions.uv,
169
+ openssl: process.versions.openssl,
170
+ },
171
+ packages: {
172
+ "@botcord/daemon": version,
173
+ "@botcord/cli": readInstalledPackageVersion("@botcord/cli/package.json"),
174
+ "@botcord/protocol-core": readInstalledPackageVersion("@botcord/protocol-core/package.json"),
175
+ },
176
+ };
177
+ }
178
+
179
+ function safeEnvironmentSummary(): Record<string, string> {
180
+ const out: Record<string, string> = {};
181
+ for (const [key, value] of Object.entries(process.env)) {
182
+ if (!value) continue;
183
+ if (!ENV_ALLOWLIST.has(key) && !key.startsWith("BOTCORD_DAEMON_")) continue;
184
+ out[key] = redact(value);
185
+ }
186
+ return out;
187
+ }
188
+
84
189
  function readUserAuthSummary(): Record<string, unknown> | null {
85
190
  const raw = safeReadText(USER_AUTH_PATH);
86
191
  if (!raw) return null;
@@ -124,6 +229,7 @@ async function buildDoctorEntries(): Promise<{
124
229
  let cfgForEndpoints: DaemonConfig | null = null;
125
230
  try {
126
231
  cfgForEndpoints = loadConfig();
232
+ cfgForEndpoints = await refreshDiscoveredOpenclawGateways(cfgForEndpoints);
127
233
  channels = channelsFromDaemonConfig(cfgForEndpoints);
128
234
  } catch {
129
235
  channels = [];
@@ -147,6 +253,31 @@ async function buildDoctorEntries(): Promise<{
147
253
  return { text: renderDoctor(input), json: input };
148
254
  }
149
255
 
256
+ async function refreshDiscoveredOpenclawGateways(cfg: DaemonConfig): Promise<DaemonConfig> {
257
+ if (!openclawDiscoveryConfigEnabled(cfg)) return cfg;
258
+ try {
259
+ const found = await discoverLocalOpenclawGateways({
260
+ searchPaths: cfg.openclawDiscovery?.searchPaths,
261
+ defaultPorts: cfg.openclawDiscovery?.defaultPorts,
262
+ timeoutMs: 500,
263
+ });
264
+ const merged = mergeOpenclawGateways(cfg, found);
265
+ if (!merged.changed) return cfg;
266
+ saveConfig(merged.cfg);
267
+ daemonLog.info("openclaw discovery: gateways merged", {
268
+ source: "diagnostics",
269
+ added: merged.added.map((g) => ({ name: g.name, url: g.url })),
270
+ });
271
+ return merged.cfg;
272
+ } catch (err) {
273
+ daemonLog.warn("openclaw discovery failed; continuing", {
274
+ source: "diagnostics",
275
+ error: err instanceof Error ? err.message : String(err),
276
+ });
277
+ return cfg;
278
+ }
279
+ }
280
+
150
281
  function crc32(buf: Buffer): number {
151
282
  let crc = 0xffffffff;
152
283
  for (const b of buf) {
@@ -295,8 +426,11 @@ export async function createDiagnosticBundle(
295
426
  const logFile = opts.logFile ?? LOG_FILE_PATH;
296
427
  const configFile = opts.configFile ?? CONFIG_FILE_PATH;
297
428
  const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
429
+ const sessionsFile = opts.sessionsFile ?? SESSIONS_PATH;
298
430
  const includeAllLogs = opts.includeAllLogs === true;
299
431
  const logs = bundledLogs(logFile, includeAllLogs);
432
+ const acpLogs = listAcpTraceLogFiles(includeAllLogs);
433
+ const runtimeLogs = listRuntimeLogFiles(includeAllLogs);
300
434
  mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
301
435
 
302
436
  const doctor = opts.doctor ?? await buildDoctorEntries();
@@ -307,9 +441,12 @@ export async function createDiagnosticBundle(
307
441
  release: release(),
308
442
  arch: arch(),
309
443
  node: process.version,
444
+ daemon: daemonRuntimeSummary(),
445
+ environment: safeEnvironmentSummary(),
310
446
  pidPath: PID_PATH,
311
447
  pid: process.pid,
312
448
  configPath: configFile,
449
+ sessionsPath: sessionsFile,
313
450
  snapshotPath: snapshotFile,
314
451
  logPath: logFile,
315
452
  logsBundled: logs.map((entry) => ({
@@ -318,6 +455,16 @@ export async function createDiagnosticBundle(
318
455
  sizeBytes: entry.sizeBytes,
319
456
  active: entry.active,
320
457
  })),
458
+ acpLogsBundled: acpLogs.map((entry) => ({
459
+ name: entry.name,
460
+ path: entry.path,
461
+ sizeBytes: entry.sizeBytes,
462
+ })),
463
+ runtimeLogsBundled: runtimeLogs.map((entry) => ({
464
+ name: entry.bundleName,
465
+ path: entry.path,
466
+ sizeBytes: entry.sizeBytes,
467
+ })),
321
468
  logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
322
469
  diagnosticsDir,
323
470
  userAuth: readUserAuthSummary(),
@@ -343,6 +490,20 @@ export async function createDiagnosticBundle(
343
490
  });
344
491
  }
345
492
  }
493
+ for (const entry of acpLogs) {
494
+ const log = safeReadText(entry.path);
495
+ entries.push({
496
+ name: `acp-logs/${entry.name.split(path.sep).join("/")}`,
497
+ data: log ?? `no ACP log file at ${entry.path}\n`,
498
+ });
499
+ }
500
+ for (const entry of runtimeLogs) {
501
+ const log = safeReadText(entry.path);
502
+ entries.push({
503
+ name: entry.bundleName,
504
+ data: log ?? `no runtime log file at ${entry.path}\n`,
505
+ });
506
+ }
346
507
  const config = safeReadText(configFile);
347
508
  entries.push({
348
509
  name: "config.json.redacted",
@@ -353,6 +514,11 @@ export async function createDiagnosticBundle(
353
514
  name: "snapshot.json",
354
515
  data: snapshot ?? `no snapshot file at ${snapshotFile}\n`,
355
516
  });
517
+ const sessions = safeReadText(sessionsFile);
518
+ entries.push({
519
+ name: "sessions.json.redacted",
520
+ data: sessions ?? `no sessions file at ${sessionsFile}\n`,
521
+ });
356
522
 
357
523
  const zip = createZip(entries);
358
524
  const out = path.join(diagnosticsDir, filename);
@@ -344,6 +344,32 @@ describe("Dispatcher", () => {
344
344
  expect(store.all().length).toBe(0);
345
345
  });
346
346
 
347
+ it("drops the stored session when a resumed turn errors without text even if the adapter returns the same id", async () => {
348
+ let callNo = 0;
349
+ const runtimeFactory: RuntimeFactory = () => {
350
+ callNo += 1;
351
+ if (callNo === 1) return new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
352
+ return new FakeRuntime({
353
+ reply: "",
354
+ newSessionId: "sid-1",
355
+ errorText: "acp error -32603: Internal error",
356
+ });
357
+ };
358
+ const { dispatcher, store, channel } = await scaffold({ runtimeFactory });
359
+
360
+ await dispatcher.handle(
361
+ makeEnvelope({ id: "msg_1", conversation: { id: "rm_x", kind: "direct" } }),
362
+ );
363
+ expect(store.all()[0].runtimeSessionId).toBe("sid-1");
364
+
365
+ await dispatcher.handle(
366
+ makeEnvelope({ id: "msg_2", conversation: { id: "rm_x", kind: "direct" } }),
367
+ );
368
+
369
+ expect(store.all().length).toBe(0);
370
+ expect(channel.sends[0].message.type).toBe("error");
371
+ });
372
+
347
373
  it("applies composeUserTurn before handing text to the runtime", async () => {
348
374
  const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
349
375
  const { store, dir } = await makeStore();
@@ -183,6 +183,33 @@ describe("HermesAgentAdapter", () => {
183
183
  expect(res.error).toBeUndefined();
184
184
  });
185
185
 
186
+ it("drains late assistant text after a prompt RPC error before closing stdin", async () => {
187
+ const script = makeAcpServer(
188
+ "late-after-error.js",
189
+ `
190
+ if (msg.method === "initialize") {
191
+ reply(msg, { protocolVersion: 1 });
192
+ } else if (msg.method === "session/new") {
193
+ reply(msg, { sessionId: "sess-late-error" });
194
+ } else if (msg.method === "session/prompt") {
195
+ err(msg, -32603, "Internal error");
196
+ setTimeout(() => {
197
+ notify("session/update", {
198
+ sessionId: msg.params.sessionId,
199
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "late but valid" } }
200
+ });
201
+ process.exit(0);
202
+ }, 25);
203
+ }
204
+ `,
205
+ );
206
+
207
+ const res = await runAdapter(script);
208
+ expect(res.newSessionId).toBe("sess-late-error");
209
+ expect(res.text).toBe("late but valid");
210
+ expect(res.error).toContain("acp error -32603");
211
+ });
212
+
186
213
  it("owner trust → request_permission selects an allow_* option", async () => {
187
214
  const script = makeAcpServer(
188
215
  "perm-allow.js",
@@ -1200,6 +1200,14 @@ export class Dispatcher {
1200
1200
  systemContext,
1201
1201
  onBlock,
1202
1202
  onStatus,
1203
+ context: {
1204
+ turnId,
1205
+ messageId: msg.id,
1206
+ roomId: msg.conversation.id,
1207
+ topicId: msg.conversation.threadId ?? null,
1208
+ channel: msg.channel,
1209
+ conversationKind: msg.conversation.kind,
1210
+ },
1203
1211
  gateway: route.gateway,
1204
1212
  ...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
1205
1213
  });
@@ -1329,16 +1337,34 @@ export class Dispatcher {
1329
1337
 
1330
1338
  if (!result) return;
1331
1339
 
1340
+ const replyText = (result.text || "").trim();
1341
+ const finalTextField = truncateTextField(result.text || "");
1342
+
1332
1343
  // Persist session before reply so next turn sees the new id even if send fails.
1333
1344
  //
1334
1345
  // Adapter contract:
1335
- // result.newSessionId truthy → upsert the entry
1336
- // result.newSessionId empty + had-inbound-sessionId + result.error
1337
- // → the prior session is dead (e.g. Claude Code
1338
- // "--resume <missing-uuid>"); delete the entry so
1346
+ // had-inbound-sessionId + result.error + no reply text
1347
+ // the prior session is suspect/dead; delete it so
1339
1348
  // we don't keep resuming a stale id every turn
1349
+ // even when the adapter echoes that id back
1350
+ // result.newSessionId truthy → upsert the entry
1340
1351
  // otherwise → no-op (e.g. codex intentionally never persists)
1341
- if (result.newSessionId) {
1352
+ if (sessionId && result.error && !replyText) {
1353
+ try {
1354
+ await this.sessionStore.delete(key);
1355
+ this.log.info("dispatcher: dropped stale runtime session", {
1356
+ key,
1357
+ prevRuntimeSessionId: sessionId,
1358
+ nextRuntimeSessionId: result.newSessionId || null,
1359
+ error: result.error,
1360
+ });
1361
+ } catch (err) {
1362
+ this.log.warn("dispatcher: session-store.delete failed", {
1363
+ key,
1364
+ error: err instanceof Error ? err.message : String(err),
1365
+ });
1366
+ }
1367
+ } else if (result.newSessionId) {
1342
1368
  const session: GatewaySessionEntry = {
1343
1369
  key,
1344
1370
  runtime: route.runtime,
@@ -1381,9 +1407,6 @@ export class Dispatcher {
1381
1407
  }
1382
1408
  }
1383
1409
 
1384
- const replyText = (result.text || "").trim();
1385
- const finalTextField = truncateTextField(result.text || "");
1386
-
1387
1410
  if (!replyText) {
1388
1411
  if (result.error) {
1389
1412
  this.log.warn("dispatcher: runtime returned error without reply text", {
@@ -1,4 +1,5 @@
1
1
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { createAcpTraceLogger, type AcpTraceLogger } from "../../acp-logs.js";
2
3
  import { consoleLogger } from "../log.js";
3
4
  import type {
4
5
  RuntimeAdapter,
@@ -33,9 +34,17 @@ const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
33
34
  const KILL_GRACE_MS = 5_000;
34
35
  /** Deadline for the initial `initialize` handshake. */
35
36
  const INITIALIZE_TIMEOUT_MS = 30_000;
37
+ /** Short drain window for late `session/update` chunks after a prompt RPC error. */
38
+ const PROMPT_ERROR_DRAIN_MS = 750;
36
39
  /** ACP protocol version this client targets. */
37
40
  export const ACP_PROTOCOL_VERSION = 1;
38
41
 
42
+ function stringField(obj: unknown, key: string): string | undefined {
43
+ if (!obj || typeof obj !== "object") return undefined;
44
+ const value = (obj as Record<string, unknown>)[key];
45
+ return typeof value === "string" && value.length > 0 ? value : undefined;
46
+ }
47
+
39
48
  export interface AcpInitializeResult {
40
49
  protocolVersion?: number;
41
50
  agentInfo?: { name?: string; version?: string };
@@ -119,6 +128,7 @@ class AcpConnection {
119
128
  ): Promise<unknown> | unknown;
120
129
  },
121
130
  private readonly logId: string,
131
+ private readonly trace: AcpTraceLogger | null = null,
122
132
  ) {
123
133
  child.stdout.setEncoding("utf8");
124
134
  child.stdout.on("data", (chunk: string) => this.onStdout(chunk));
@@ -142,9 +152,11 @@ class AcpConnection {
142
152
 
143
153
  private dispatchLine(line: string): void {
144
154
  let msg: any;
155
+
145
156
  try {
146
157
  msg = JSON.parse(line);
147
158
  } catch {
159
+ this.trace?.write({ stream: "stdout_non_json", chunk: line });
148
160
  log.warn(`${this.logId} non-json acp line`, { line: line.slice(0, 200) });
149
161
  return;
150
162
  }
@@ -155,11 +167,26 @@ class AcpConnection {
155
167
  if (!pending) return;
156
168
  this.pending.delete(msg.id);
157
169
  if (msg.error) {
170
+ this.trace?.write({
171
+ stream: "rpc_in",
172
+ direction: "in",
173
+ id: msg.id,
174
+ status: "error",
175
+ code: typeof msg.error.code === "number" ? msg.error.code : undefined,
176
+ error: msg.error.message ?? "(no message)",
177
+ });
158
178
  const err = new Error(
159
179
  `acp error ${msg.error.code ?? "?"}: ${msg.error.message ?? "(no message)"}`,
160
180
  );
161
181
  pending.reject(err);
162
182
  } else {
183
+ this.trace?.write({
184
+ stream: "rpc_in",
185
+ direction: "in",
186
+ id: msg.id,
187
+ status: "response",
188
+ result: msg.result ?? null,
189
+ });
163
190
  pending.resolve(msg.result ?? null);
164
191
  }
165
192
  return;
@@ -167,8 +194,23 @@ class AcpConnection {
167
194
  if (typeof msg.method === "string") {
168
195
  // Server→client request (has `id`) or notification (no `id`)
169
196
  if (msg.id !== undefined) {
197
+ this.trace?.write({
198
+ stream: "rpc_in",
199
+ direction: "in",
200
+ id: msg.id,
201
+ method: msg.method,
202
+ status: "request",
203
+ params: msg.params,
204
+ });
170
205
  void this.handleServerRequest(msg.id, msg.method, msg.params);
171
206
  } else {
207
+ this.trace?.write({
208
+ stream: "rpc_in",
209
+ direction: "in",
210
+ method: msg.method,
211
+ status: "notification",
212
+ params: msg.params,
213
+ });
172
214
  try {
173
215
  this.handlers.onNotification(msg.method, msg.params);
174
216
  } catch (err) {
@@ -199,6 +241,15 @@ class AcpConnection {
199
241
  const reply = error
200
242
  ? { jsonrpc: "2.0", id, error }
201
243
  : { jsonrpc: "2.0", id, result: result ?? null };
244
+ this.trace?.write({
245
+ stream: "rpc_out",
246
+ direction: "out",
247
+ id,
248
+ status: error ? "error" : "response",
249
+ code: error?.code,
250
+ error: error?.message,
251
+ result: error ? undefined : result ?? null,
252
+ });
202
253
  this.writeMessage(reply);
203
254
  }
204
255
 
@@ -221,11 +272,26 @@ class AcpConnection {
221
272
  resolve: (v) => resolve(v as T),
222
273
  reject,
223
274
  });
275
+ this.trace?.write({
276
+ stream: "rpc_out",
277
+ direction: "out",
278
+ id,
279
+ method,
280
+ status: "request",
281
+ params,
282
+ });
224
283
  this.writeMessage({ jsonrpc: "2.0", id, method, params });
225
284
  });
226
285
  }
227
286
 
228
287
  notify(method: string, params: unknown): void {
288
+ this.trace?.write({
289
+ stream: "rpc_out",
290
+ direction: "out",
291
+ method,
292
+ status: "notification",
293
+ params,
294
+ });
229
295
  this.writeMessage({ jsonrpc: "2.0", method, params });
230
296
  }
231
297
 
@@ -323,6 +389,20 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
323
389
  env: this.spawnEnv(opts),
324
390
  stdio: ["pipe", "pipe", "pipe"],
325
391
  }) as ChildProcessWithoutNullStreams;
392
+ const trace = createAcpTraceLogger({
393
+ runtime: this.id,
394
+ accountId: opts.accountId,
395
+ turnId: stringField(opts.context, "turnId"),
396
+ roomId: stringField(opts.context, "roomId"),
397
+ topicId: stringField(opts.context, "topicId") ?? null,
398
+ hermesProfile: opts.hermesProfile,
399
+ sessionId: opts.sessionId,
400
+ });
401
+ trace?.write({
402
+ stream: "child_start",
403
+ pid: child.pid,
404
+ params: { command: binary, args, cwd: opts.cwd },
405
+ });
326
406
 
327
407
  let killTimer: ReturnType<typeof setTimeout> | null = null;
328
408
  const onAbort = () => {
@@ -351,6 +431,7 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
351
431
  child.stderr.setEncoding("utf8");
352
432
  child.stderr.on("data", (chunk: string) => {
353
433
  stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP);
434
+ trace?.write({ stream: "stderr", pid: child.pid, chunk });
354
435
  });
355
436
 
356
437
  const state: AcpRunState = {
@@ -414,13 +495,25 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
414
495
  },
415
496
  },
416
497
  this.id,
498
+ trace,
417
499
  );
418
500
 
419
501
  const childExit = new Promise<number>((resolve) => {
420
- child.on("close", (code) => resolve(code ?? 0));
502
+ child.on("close", (code, signal) => {
503
+ trace?.write({ stream: "child_exit", pid: child.pid, code, signal });
504
+ resolve(code ?? 0);
505
+ });
506
+ child.on("error", (err) => {
507
+ trace?.write({
508
+ stream: "child_error",
509
+ pid: child.pid,
510
+ error: err instanceof Error ? err.message : String(err),
511
+ });
512
+ });
421
513
  });
422
514
 
423
515
  let newSessionId = opts.sessionId ?? "";
516
+ let promptStarted = false;
424
517
 
425
518
  try {
426
519
  // 1) initialize
@@ -471,6 +564,7 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
471
564
  newSessionId = sessionId;
472
565
 
473
566
  // 3) session/prompt
567
+ promptStarted = true;
474
568
  const promptResult = (await conn.request<unknown>("session/prompt", {
475
569
  sessionId,
476
570
  prompt: [{ type: "text", text: opts.text }],
@@ -508,6 +602,9 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
508
602
  const tail = stderrTail.slice(-STDERR_ERROR_SNIPPET).trim();
509
603
  state.errorText =
510
604
  state.errorText ?? (tail ? `${baseMsg}; stderr: ${tail}` : baseMsg);
605
+ if (promptStarted && !opts.signal.aborted) {
606
+ await sleepUnlessAborted(PROMPT_ERROR_DRAIN_MS, opts.signal);
607
+ }
511
608
  try {
512
609
  child.stdin.end();
513
610
  } catch {
@@ -563,3 +660,17 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
563
660
  });
564
661
  }
565
662
  }
663
+
664
+ function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
665
+ if (signal.aborted) return Promise.resolve();
666
+ return new Promise((resolve) => {
667
+ const t = setTimeout(done, ms);
668
+ if (typeof t.unref === "function") t.unref();
669
+ function done(): void {
670
+ signal.removeEventListener("abort", done);
671
+ clearTimeout(t);
672
+ resolve();
673
+ }
674
+ signal.addEventListener("abort", done, { once: true });
675
+ });
676
+ }