@botcord/daemon 0.2.60 → 0.2.62

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.
@@ -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();
@@ -492,6 +518,7 @@ describe("Dispatcher", () => {
492
518
  await dispatcher.handle(makeEnvelope({ id: "msg_error" }));
493
519
 
494
520
  expect(channel.sends.length).toBe(1);
521
+ expect(channel.sends[0].message.type).toBe("error");
495
522
  expect(channel.sends[0].message.text).toContain("Runtime error");
496
523
  expect(channel.sends[0].message.text).toContain("missing openclawAgent");
497
524
  });
@@ -1256,6 +1283,7 @@ describe("Dispatcher", () => {
1256
1283
 
1257
1284
  await dispatcher.handle(makeEnvelope({ id: "m1" }));
1258
1285
  expect(channel.sends.length).toBe(1);
1286
+ expect(channel.sends[0].message.type).toBe("error");
1259
1287
  expect(channel.sends[0].message.text).toContain("Runtime error");
1260
1288
  expect(channel.sends[0].message.text).toContain("boom");
1261
1289
  expect(store.all().length).toBe(0);
@@ -1921,6 +1949,7 @@ describe("Dispatcher", () => {
1921
1949
  await p;
1922
1950
  expect(runtime.calls[0].signal.aborted).toBe(true);
1923
1951
  expect(channel.sends.length).toBe(1);
1952
+ expect(channel.sends[0].message.type).toBe("error");
1924
1953
  expect(channel.sends[0].message.text).toMatch(/Runtime timeout/);
1925
1954
  expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
1926
1955
  expect(channel.sends[0].message.replyTo).toBe("m_to");
@@ -1941,6 +1970,7 @@ describe("Dispatcher", () => {
1941
1970
  }),
1942
1971
  );
1943
1972
  expect(channel.sends.length).toBe(1);
1973
+ expect(channel.sends[0].message.type).toBe("error");
1944
1974
  expect(channel.sends[0].message.text).toContain("Runtime error: boom");
1945
1975
  expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
1946
1976
  expect(channel.sends[0].message.replyTo).toBe("m_err");
@@ -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",
@@ -55,6 +55,12 @@ export interface BotCordChannelClient {
55
55
  text: string,
56
56
  options?: { replyTo?: string; topic?: string },
57
57
  ): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
58
+ sendTypedMessage?(
59
+ to: string,
60
+ type: "result" | "error",
61
+ text: string,
62
+ options?: { replyTo?: string; topic?: string },
63
+ ): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
58
64
  getHubUrl(): string;
59
65
  onTokenRefresh?: (token: string, expiresAt: number) => void;
60
66
  }
@@ -804,7 +810,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
804
810
  const options: { replyTo?: string; topic?: string } = {};
805
811
  if (message.replyTo) options.replyTo = message.replyTo;
806
812
  if (message.threadId) options.topic = message.threadId;
807
- const resp = await client.sendMessage(message.conversationId, message.text, options);
813
+ const resp =
814
+ message.type === "error" && client.sendTypedMessage
815
+ ? await client.sendTypedMessage(message.conversationId, "error", message.text, options)
816
+ : await client.sendMessage(message.conversationId, message.text, options);
808
817
  const providerMessageId =
809
818
  (resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
810
819
  (resp && typeof (resp as { message_id?: unknown }).message_id === "string"
@@ -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
  });
@@ -1264,6 +1272,7 @@ export class Dispatcher {
1264
1272
  accountId: msg.accountId,
1265
1273
  conversationId: msg.conversation.id,
1266
1274
  threadId: msg.conversation.threadId ?? null,
1275
+ type: "error",
1267
1276
  text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
1268
1277
  replyTo: msg.id,
1269
1278
  traceId: msg.trace?.id ?? null,
@@ -1309,6 +1318,7 @@ export class Dispatcher {
1309
1318
  accountId: msg.accountId,
1310
1319
  conversationId: msg.conversation.id,
1311
1320
  threadId: msg.conversation.threadId ?? null,
1321
+ type: "error",
1312
1322
  text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
1313
1323
  replyTo: msg.id,
1314
1324
  traceId: msg.trace?.id ?? null,
@@ -1327,16 +1337,34 @@ export class Dispatcher {
1327
1337
 
1328
1338
  if (!result) return;
1329
1339
 
1340
+ const replyText = (result.text || "").trim();
1341
+ const finalTextField = truncateTextField(result.text || "");
1342
+
1330
1343
  // Persist session before reply so next turn sees the new id even if send fails.
1331
1344
  //
1332
1345
  // Adapter contract:
1333
- // result.newSessionId truthy → upsert the entry
1334
- // result.newSessionId empty + had-inbound-sessionId + result.error
1335
- // → the prior session is dead (e.g. Claude Code
1336
- // "--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
1337
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
1338
1351
  // otherwise → no-op (e.g. codex intentionally never persists)
1339
- 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) {
1340
1368
  const session: GatewaySessionEntry = {
1341
1369
  key,
1342
1370
  runtime: route.runtime,
@@ -1379,9 +1407,6 @@ export class Dispatcher {
1379
1407
  }
1380
1408
  }
1381
1409
 
1382
- const replyText = (result.text || "").trim();
1383
- const finalTextField = truncateTextField(result.text || "");
1384
-
1385
1410
  if (!replyText) {
1386
1411
  if (result.error) {
1387
1412
  this.log.warn("dispatcher: runtime returned error without reply text", {
@@ -1398,6 +1423,7 @@ export class Dispatcher {
1398
1423
  accountId: msg.accountId,
1399
1424
  conversationId: msg.conversation.id,
1400
1425
  threadId: msg.conversation.threadId ?? null,
1426
+ type: "error",
1401
1427
  text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
1402
1428
  replyTo: msg.id,
1403
1429
  traceId: msg.trace?.id ?? null,
@@ -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
+ }
@@ -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 {
3
4
  readCommandVersion,
4
5
  resolveCommandOnPath,
@@ -51,6 +52,7 @@ interface AcpProcessHandle {
51
52
  */
52
53
  spawnedUrl: string;
53
54
  spawnedToken: string | undefined;
55
+ trace: AcpTraceLogger | null;
54
56
  }
55
57
 
56
58
  interface PendingCall {
@@ -358,6 +360,12 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
358
360
 
359
361
  if (!finalText) {
360
362
  const stopReason = pickStopReason(promptResult);
363
+ if (!stopReason || stopReason === "end_turn") {
364
+ return {
365
+ text: "",
366
+ newSessionId: acpSessionId,
367
+ };
368
+ }
361
369
  const warningTail = handle.nonJsonStdoutTail.slice(-8).join("\n").trim();
362
370
  const detail = warningTail ? `; stdout: ${truncateDetail(warningTail, 1000)}` : "";
363
371
  const reason = stopReason ? `prompt stopped: ${stopReason}` : "empty assistant response";
@@ -444,11 +452,23 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
444
452
  const command = resolveOpenclawCommand() ?? "openclaw";
445
453
  const args = ["acp", "--url", gateway.url];
446
454
  if (gateway.token) args.push("--token", gateway.token);
455
+ const accountId = key.split("::", 1)[0];
456
+ const trace = createAcpTraceLogger({
457
+ runtime: acpRuntimeLogName(gateway),
458
+ accountId,
459
+ gatewayName: gateway.name,
460
+ gatewayUrl: gateway.url,
461
+ });
447
462
 
448
463
  const child = this.spawnFn(command, args, {
449
464
  stdio: ["pipe", "pipe", "pipe"],
450
465
  env: { ...process.env },
451
466
  }) as ChildProcessWithoutNullStreams;
467
+ trace?.write({
468
+ stream: "child_start",
469
+ pid: child.pid,
470
+ params: { command, args, gateway: gateway.name },
471
+ });
452
472
 
453
473
  const handle: AcpProcessHandle = {
454
474
  child,
@@ -462,19 +482,27 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
462
482
  closed: false,
463
483
  spawnedUrl: gateway.url,
464
484
  spawnedToken: gateway.token,
485
+ trace,
465
486
  };
466
487
 
467
488
  child.stdout.setEncoding("utf8");
468
489
  child.stdout.on("data", (chunk: string) => onStdoutChunk(handle, chunk));
469
490
  child.stderr.setEncoding("utf8");
470
491
  child.stderr.on("data", (chunk: string) => {
492
+ trace?.write({ stream: "stderr", pid: child.pid, chunk });
471
493
  log.debug("openclaw-acp.stderr", { key, chunk: chunk.slice(0, 500) });
472
494
  });
473
495
  child.on("exit", (code, signal) => {
496
+ trace?.write({ stream: "child_exit", pid: child.pid, code, signal });
474
497
  shutdownHandle(handle, `exit code=${code ?? "null"} signal=${signal ?? "null"}`);
475
498
  ACP_POOL.delete(key);
476
499
  });
477
500
  child.on("error", (err) => {
501
+ trace?.write({
502
+ stream: "child_error",
503
+ pid: child.pid,
504
+ error: err instanceof Error ? err.message : String(err),
505
+ });
478
506
  log.warn("openclaw-acp.child-error", {
479
507
  key,
480
508
  error: err instanceof Error ? err.message : String(err),
@@ -532,6 +560,16 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
532
560
  // JSON-RPC stdio plumbing
533
561
  // ---------------------------------------------------------------------------
534
562
 
563
+ function acpRuntimeLogName(gateway: NonNullable<RuntimeRunOptions["gateway"]>): string {
564
+ if (gateway.name.toLowerCase().includes("qclaw")) return "qclaw-acp";
565
+ try {
566
+ if (new URL(gateway.url).port === "28789") return "qclaw-acp";
567
+ } catch {
568
+ // Fall back to OpenClaw.
569
+ }
570
+ return "openclaw-acp";
571
+ }
572
+
535
573
  function onStdoutChunk(handle: AcpProcessHandle, chunk: string): void {
536
574
  handle.buffer += chunk;
537
575
  let idx: number;
@@ -551,6 +589,7 @@ function onStdoutChunk(handle: AcpProcessHandle, chunk: string): void {
551
589
  error: err instanceof Error ? err.message : String(err),
552
590
  line: line.slice(0, 200),
553
591
  });
592
+ handle.trace?.write({ stream: "stdout_non_json", chunk: line });
554
593
  continue;
555
594
  }
556
595
  routeMessage(handle, msg);
@@ -565,14 +604,36 @@ function routeMessage(handle: AcpProcessHandle, msg: any): void {
565
604
  handle.pending.delete(id);
566
605
  if (msg.error) {
567
606
  const message = formatRpcError(msg.error);
607
+ handle.trace?.write({
608
+ stream: "rpc_in",
609
+ direction: "in",
610
+ id,
611
+ status: "error",
612
+ code: typeof msg.error.code === "number" ? msg.error.code : undefined,
613
+ error: message,
614
+ });
568
615
  pending.reject(new Error(message));
569
616
  } else {
617
+ handle.trace?.write({
618
+ stream: "rpc_in",
619
+ direction: "in",
620
+ id,
621
+ status: "response",
622
+ result: msg.result ?? null,
623
+ });
570
624
  pending.resolve(msg.result);
571
625
  }
572
626
  return;
573
627
  }
574
628
  // Notification.
575
629
  if (msg?.method && msg?.params) {
630
+ handle.trace?.write({
631
+ stream: "rpc_in",
632
+ direction: "in",
633
+ method: msg.method,
634
+ status: "notification",
635
+ params: msg.params,
636
+ });
576
637
  const sid = msg.params?.sessionId;
577
638
  if (typeof sid === "string") {
578
639
  const sub = handle.subscribers.get(sid);
@@ -598,6 +659,14 @@ function sendRequest(
598
659
  return new Promise((resolve, reject) => {
599
660
  const id = handle.nextId++;
600
661
  handle.pending.set(id, { resolve, reject, method });
662
+ handle.trace?.write({
663
+ stream: "rpc_out",
664
+ direction: "out",
665
+ id,
666
+ method,
667
+ status: "request",
668
+ params,
669
+ });
601
670
  const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
602
671
  try {
603
672
  handle.child.stdin.write(frame);
@@ -614,6 +683,13 @@ function sendNotification(
614
683
  params: any,
615
684
  ): void {
616
685
  if (handle.closed) return;
686
+ handle.trace?.write({
687
+ stream: "rpc_out",
688
+ direction: "out",
689
+ method,
690
+ status: "notification",
691
+ params,
692
+ });
617
693
  const frame = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
618
694
  try {
619
695
  handle.child.stdin.write(frame);
@@ -187,6 +187,7 @@ export interface GatewayOutboundMessage {
187
187
  accountId: string;
188
188
  conversationId: string;
189
189
  threadId?: string | null;
190
+ type?: "message" | "error";
190
191
  text: string;
191
192
  attachments?: GatewayOutboundAttachment[];
192
193
  replyTo?: string | null;
package/src/index.ts CHANGED
@@ -307,6 +307,34 @@ function loadOrInitConfig(args: ParsedArgs): DaemonConfig {
307
307
  }
308
308
  }
309
309
 
310
+ async function refreshDiscoveredOpenclawGateways(
311
+ cfg: DaemonConfig,
312
+ source: string,
313
+ ): Promise<DaemonConfig> {
314
+ if (!openclawDiscoveryConfigEnabled(cfg)) return cfg;
315
+ try {
316
+ const found = await discoverLocalOpenclawGateways({
317
+ searchPaths: cfg.openclawDiscovery?.searchPaths,
318
+ defaultPorts: cfg.openclawDiscovery?.defaultPorts,
319
+ timeoutMs: 500,
320
+ });
321
+ const merged = mergeOpenclawGateways(cfg, found);
322
+ if (!merged.changed) return cfg;
323
+ saveConfig(merged.cfg);
324
+ log.info("openclaw discovery: gateways merged", {
325
+ source,
326
+ added: merged.added.map((g) => ({ name: g.name, url: g.url })),
327
+ });
328
+ return merged.cfg;
329
+ } catch (err) {
330
+ log.warn("openclaw discovery failed; continuing", {
331
+ source,
332
+ error: err instanceof Error ? err.message : String(err),
333
+ });
334
+ return cfg;
335
+ }
336
+ }
337
+
310
338
  /**
311
339
  * Read the current user-auth record without throwing on parse / permission
312
340
  * errors — those are returned as `null` so the caller treats them like a
@@ -566,27 +594,7 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
566
594
 
567
595
  async function cmdStart(args: ParsedArgs): Promise<void> {
568
596
  let cfg = loadOrInitConfig(args);
569
- if (openclawDiscoveryConfigEnabled(cfg)) {
570
- try {
571
- const found = await discoverLocalOpenclawGateways({
572
- searchPaths: cfg.openclawDiscovery?.searchPaths,
573
- defaultPorts: cfg.openclawDiscovery?.defaultPorts,
574
- timeoutMs: 500,
575
- });
576
- const merged = mergeOpenclawGateways(cfg, found);
577
- if (merged.changed) {
578
- cfg = merged.cfg;
579
- saveConfig(cfg);
580
- log.info("openclaw discovery: gateways merged", {
581
- added: merged.added.map((g) => ({ name: g.name, url: g.url })),
582
- });
583
- }
584
- } catch (err) {
585
- log.warn("openclaw discovery failed; continuing", {
586
- error: err instanceof Error ? err.message : String(err),
587
- });
588
- }
589
- }
597
+ cfg = await refreshDiscoveredOpenclawGateways(cfg, "start");
590
598
  // Foreground is now the default. --background (alias -d) detaches.
591
599
  // --foreground is still accepted (no-op) for backwards compatibility and
592
600
  // is also what the detached child re-execs itself with.
@@ -1373,8 +1381,8 @@ async function cmdDoctor(args: ParsedArgs): Promise<void> {
1373
1381
  let cfgForEndpoints: import("./config.js").DaemonConfig | null = null;
1374
1382
  try {
1375
1383
  const cfg = loadConfig();
1376
- cfgForEndpoints = cfg;
1377
- channels = channelsFromDaemonConfig(cfg);
1384
+ cfgForEndpoints = await refreshDiscoveredOpenclawGateways(cfg, "doctor");
1385
+ channels = channelsFromDaemonConfig(cfgForEndpoints);
1378
1386
  } catch {
1379
1387
  channels = [];
1380
1388
  }