@botcord/daemon 0.2.63 → 0.2.64

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.
@@ -1276,6 +1276,50 @@ describe("Dispatcher", () => {
1276
1276
  expect(blockTypes).toEqual(["system", "tool_use"]);
1277
1277
  });
1278
1278
 
1279
+ it("transcript: records runtime blocks even when channel streaming is disabled", async () => {
1280
+ const blocks: StreamBlock[] = [
1281
+ {
1282
+ raw: {
1283
+ params: {
1284
+ update: {
1285
+ sessionUpdate: "tool_call",
1286
+ toolCall: { name: "botcord_send", rawInput: { text: "hello" } },
1287
+ },
1288
+ },
1289
+ },
1290
+ kind: "tool_use",
1291
+ seq: 7,
1292
+ },
1293
+ ];
1294
+ const runtime = new FakeRuntime({ blocks, reply: "ok", newSessionId: "sid" });
1295
+ const channel = new FakeChannel();
1296
+ const records: import("../transcript.js").TranscriptRecord[] = [];
1297
+ const { store, dir } = await makeStore();
1298
+ tempDirs.push(dir);
1299
+ const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
1300
+ const dispatcher = new Dispatcher({
1301
+ config: baseConfig(),
1302
+ channels,
1303
+ runtime: () => runtime,
1304
+ sessionStore: store,
1305
+ log: silentLogger(),
1306
+ transcript: { enabled: true, rootDir: dir, write: (rec) => records.push(rec) },
1307
+ });
1308
+
1309
+ await dispatcher.handle(
1310
+ makeEnvelope({ trace: { id: "tr", streamable: false } }),
1311
+ );
1312
+
1313
+ expect(channel.streams).toHaveLength(0);
1314
+ const block = records.find((r) => r.kind === "block");
1315
+ expect(block).toMatchObject({
1316
+ kind: "block",
1317
+ blockType: "tool_use",
1318
+ seq: 7,
1319
+ summary: { type: "tool_use", name: "botcord_send" },
1320
+ });
1321
+ });
1322
+
1279
1323
  it("runtime throws: sends error reply, does not write session", async () => {
1280
1324
  const runtime = new FakeRuntime({ throwError: "boom" });
1281
1325
  const channel = new FakeChannel();
@@ -1,5 +1,5 @@
1
- import { mkdtemp, readFile, rm } from "node:fs/promises";
2
- import { existsSync, statSync } from "node:fs";
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync, statSync, utimesSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -7,7 +7,9 @@ import { Dispatcher, type RuntimeFactory } from "../dispatcher.js";
7
7
  import { SessionStore } from "../session-store.js";
8
8
  import {
9
9
  createTranscriptWriter,
10
+ cleanupTranscriptFiles,
10
11
  resolveTranscriptEnabled,
12
+ TRANSCRIPT_RETENTION_MS,
11
13
  TRANSCRIPT_TEXT_LIMIT,
12
14
  truncateTextField,
13
15
  type TranscriptRecord,
@@ -239,6 +241,10 @@ describe("resolveTranscriptEnabled", () => {
239
241
  expect(resolveTranscriptEnabled("yes", true)).toBe(true);
240
242
  expect(resolveTranscriptEnabled("yes", false)).toBe(false);
241
243
  });
244
+ it("defaults on when env and config are both unset", () => {
245
+ expect(resolveTranscriptEnabled(undefined, undefined)).toBe(true);
246
+ expect(resolveTranscriptEnabled("yes", undefined)).toBe(true);
247
+ });
242
248
  });
243
249
 
244
250
  describe("truncateTextField", () => {
@@ -460,6 +466,25 @@ describe("Dispatcher transcript integration", () => {
460
466
  expect(files).toContain("_default.jsonl");
461
467
  });
462
468
 
469
+ it("cleans transcript files older than the retention window", async () => {
470
+ const tmp = await mkdtemp(path.join(tmpdir(), "transcript-clean-"));
471
+ cleanups.push(() => rm(tmp, { recursive: true, force: true }));
472
+ const oldFile = transcriptFilePath(tmp, "ag_me", "rm_old", null);
473
+ const freshFile = transcriptFilePath(tmp, "ag_me", "rm_fresh", null);
474
+ await mkdir(path.dirname(oldFile), { recursive: true });
475
+ await mkdir(path.dirname(freshFile), { recursive: true });
476
+ await writeFile(oldFile, "{}\n", { mode: 0o600 });
477
+ await writeFile(freshFile, "{}\n", { mode: 0o600 });
478
+ const oldDate = new Date(Date.now() - TRANSCRIPT_RETENTION_MS - 60_000);
479
+ utimesSync(oldFile, oldDate, oldDate);
480
+
481
+ const removed = cleanupTranscriptFiles(tmp, Date.now() - TRANSCRIPT_RETENTION_MS);
482
+
483
+ expect(removed).toBe(1);
484
+ expect(existsSync(oldFile)).toBe(false);
485
+ expect(existsSync(freshFile)).toBe(true);
486
+ });
487
+
463
488
  it("disabled writer does not create files", async () => {
464
489
  const tmp = await mkdtemp(path.join(tmpdir(), "transcript-off-"));
465
490
  cleanups.push(() => rm(tmp, { recursive: true, force: true }));
@@ -25,6 +25,7 @@ import { sanitizeUntrustedContent } from "./sanitize.js";
25
25
  import { revokeAgent } from "../../provision.js";
26
26
 
27
27
  const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
28
+ const RECONNECT_JITTER_RATIO = 0.25;
28
29
  const KEEPALIVE_INTERVAL = 20_000;
29
30
  const MAX_AUTH_FAILURES = 5;
30
31
  const SEEN_MESSAGES_CAP = 500;
@@ -33,6 +34,11 @@ const DM_ROOM_PREFIX = "rm_dm_";
33
34
  const INBOX_POLL_LIMIT = 50;
34
35
  const CHANNEL_PERMANENT_STOP = "channel_permanent_stop";
35
36
 
37
+ function withReconnectJitter(delayMs: number): { delayMs: number; jitterMs: number } {
38
+ const jitterMs = Math.floor(Math.random() * delayMs * RECONNECT_JITTER_RATIO);
39
+ return { delayMs: delayMs + jitterMs, jitterMs };
40
+ }
41
+
36
42
  type InboxDrainTrigger =
37
43
  | "ws_auth_ok"
38
44
  | "ws_inbox_update"
@@ -477,6 +483,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
477
483
  let reconnectTimer: NodeJS.Timeout | null = null;
478
484
  let keepaliveTimer: NodeJS.Timeout | null = null;
479
485
  let reconnectAttempt = 0;
486
+ let connectionSeq = 0;
480
487
  let consecutiveAuthFailures = 0;
481
488
  let running = true;
482
489
  let permanentStopping = false;
@@ -603,23 +610,36 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
603
610
 
604
611
  function scheduleReconnect() {
605
612
  if (!running) return;
606
- const delay =
613
+ if (reconnectTimer) return;
614
+ if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
615
+ return;
616
+ }
617
+ const baseDelayMs =
607
618
  RECONNECT_BACKOFF[Math.min(reconnectAttempt, RECONNECT_BACKOFF.length - 1)];
619
+ const { delayMs, jitterMs } = withReconnectJitter(baseDelayMs);
608
620
  reconnectAttempt += 1;
609
621
  markStatus({
610
622
  connected: false,
611
623
  restartPending: true,
612
624
  reconnectAttempts: reconnectAttempt,
613
625
  });
614
- log.info("botcord ws reconnect scheduled", { delayMs: delay, attempt: reconnectAttempt });
626
+ log.info("botcord ws reconnect scheduled", {
627
+ delayMs,
628
+ baseDelayMs,
629
+ jitterMs,
630
+ attempt: reconnectAttempt,
631
+ });
615
632
  reconnectTimer = setTimeout(() => {
616
633
  reconnectTimer = null;
617
634
  void connect();
618
- }, delay);
635
+ }, delayMs);
619
636
  }
620
637
 
621
638
  async function connect() {
622
639
  if (!running) return;
640
+ if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
641
+ return;
642
+ }
623
643
  const agentId = options.agentId;
624
644
  markStatus({ connected: false, restartPending: false });
625
645
  if (pendingRefresh) {
@@ -644,8 +664,11 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
644
664
  const url = buildHubWebSocketUrl(hubUrl);
645
665
  log.info("botcord ws connecting", { url, agentId });
646
666
 
667
+ const connectionId = ++connectionSeq;
668
+ let socket: WebSocket;
647
669
  try {
648
- ws = new wsCtor(url);
670
+ socket = new wsCtor(url);
671
+ ws = socket;
649
672
  } catch (err) {
650
673
  log.error("botcord ws construct failed", { agentId, err: String(err) });
651
674
  markStatus({ lastError: String(err) });
@@ -653,11 +676,20 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
653
676
  return;
654
677
  }
655
678
 
656
- ws.on("open", () => {
657
- ws!.send(JSON.stringify({ type: "auth", token }));
679
+ socket.on("open", () => {
680
+ if (!running || ws !== socket || connectionId !== connectionSeq) {
681
+ try {
682
+ socket.close();
683
+ } catch {
684
+ // ignore
685
+ }
686
+ return;
687
+ }
688
+ socket.send(JSON.stringify({ type: "auth", token }));
658
689
  });
659
690
 
660
- ws.on("message", (data: WebSocket.RawData) => {
691
+ socket.on("message", (data: WebSocket.RawData) => {
692
+ if (ws !== socket || connectionId !== connectionSeq) return;
661
693
  let msg: { type?: string; agent_id?: string } | null = null;
662
694
  try {
663
695
  msg = JSON.parse(String(data));
@@ -677,10 +709,11 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
677
709
  });
678
710
  log.info("botcord ws authenticated", { agentId: msg.agent_id });
679
711
  void fireInbox("ws_auth_ok");
712
+ if (keepaliveTimer) clearInterval(keepaliveTimer);
680
713
  keepaliveTimer = setInterval(() => {
681
- if (ws && ws.readyState === WebSocket.OPEN) {
714
+ if (ws === socket && socket.readyState === WebSocket.OPEN) {
682
715
  try {
683
- ws.send(JSON.stringify({ type: "ping" }));
716
+ socket.send(JSON.stringify({ type: "ping" }));
684
717
  } catch {
685
718
  // ignore
686
719
  }
@@ -696,10 +729,15 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
696
729
  }
697
730
  });
698
731
 
699
- ws.on("close", (code: number, reason: Buffer) => {
732
+ socket.on("close", (code: number, reason: Buffer) => {
700
733
  const reasonStr = reason?.toString() || "";
734
+ if (ws !== socket || connectionId !== connectionSeq) {
735
+ log.debug("botcord ws stale close ignored", { agentId, code, reason: reasonStr });
736
+ return;
737
+ }
701
738
  log.info("botcord ws closed", { agentId, code, reason: reasonStr });
702
739
  clearTimers();
740
+ ws = null;
703
741
  markStatus({ connected: false });
704
742
  if (!running) {
705
743
  if (permanentStopping) return;
@@ -740,7 +778,8 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
740
778
  scheduleReconnect();
741
779
  });
742
780
 
743
- ws.on("error", (err: Error) => {
781
+ socket.on("error", (err: Error) => {
782
+ if (ws !== socket || connectionId !== connectionSeq) return;
744
783
  log.warn("botcord ws error", { agentId, err: String(err) });
745
784
  markStatus({ lastError: String(err) });
746
785
  });
@@ -37,6 +37,8 @@ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
37
37
  * (or `botcord send` CLI via Bash) to actually deliver replies.
38
38
  */
39
39
  const OWNER_CHAT_ROOM_PREFIX = "rm_oc_";
40
+ const TRANSCRIPT_BLOCK_RAW_LIMIT = 16 * 1024;
41
+ const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
40
42
 
41
43
  /** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
42
44
  const MAX_BATCH_BUFFER_ENTRIES = 40;
@@ -66,6 +68,62 @@ const TYPING_REFRESH_MS = 4000;
66
68
  /** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
67
69
  const TYPING_RECENCY_CAP = 1024;
68
70
 
71
+ function transcriptBlocksVerbose(): boolean {
72
+ return process.env.BOTCORD_TRANSCRIPT_BLOCKS === "verbose" ||
73
+ process.env.BOTCORD_TRACE_VERBOSE === "1";
74
+ }
75
+
76
+ function summarizeStreamBlock(block: StreamBlock): TranscriptBlockSummary {
77
+ const summary: TranscriptBlockSummary = { type: block.kind };
78
+ const raw = block.raw as {
79
+ text?: unknown;
80
+ name?: unknown;
81
+ update?: unknown;
82
+ params?: { update?: unknown };
83
+ } | null | undefined;
84
+ if (raw && typeof raw === "object") {
85
+ if (typeof raw.text === "string") summary.chars = raw.text.length;
86
+ if (typeof raw.name === "string") summary.name = raw.name;
87
+ const update = raw.params?.update ?? raw.update;
88
+ if (update && typeof update === "object") {
89
+ const u = update as Record<string, unknown>;
90
+ if (typeof u.sessionUpdate === "string" && !summary.name) summary.name = u.sessionUpdate;
91
+ const toolCall = u.toolCall;
92
+ if (toolCall && typeof toolCall === "object") {
93
+ const toolName = (toolCall as Record<string, unknown>).name;
94
+ if (typeof toolName === "string") summary.name = toolName;
95
+ }
96
+ }
97
+ }
98
+ return summary;
99
+ }
100
+
101
+ function redactAndCap(value: unknown, budget = TRANSCRIPT_BLOCK_RAW_LIMIT): unknown {
102
+ const seen = new WeakSet<object>();
103
+ const walk = (v: unknown): unknown => {
104
+ if (typeof v === "string") {
105
+ return redactSecretString(v.length > budget ? `${v.slice(0, budget)}…` : v);
106
+ }
107
+ if (Array.isArray(v)) return v.slice(0, 50).map(walk);
108
+ if (!v || typeof v !== "object") return v;
109
+ if (seen.has(v)) return "[Circular]";
110
+ seen.add(v);
111
+ const out: Record<string, unknown> = {};
112
+ for (const [key, child] of Object.entries(v as Record<string, unknown>).slice(0, 80)) {
113
+ out[key] = SECRET_KEY_RE.test(key) ? "[REDACTED]" : walk(child);
114
+ }
115
+ return out;
116
+ };
117
+ return walk(value);
118
+ }
119
+
120
+ function redactSecretString(value: string): string {
121
+ return value
122
+ .replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]")
123
+ .replace(/\b(token=)[^\s"']+/gi, "$1[REDACTED]")
124
+ .replace(/\b(drt_|dit_|gho_)[A-Za-z0-9_-]+/g, "$1[REDACTED]");
125
+ }
126
+
69
127
  /** Factory signature for building a runtime adapter at turn dispatch time. */
70
128
  export type RuntimeFactory = (
71
129
  runtimeId: string,
@@ -943,13 +1001,23 @@ export class Dispatcher {
943
1001
  const canStream =
944
1002
  streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
945
1003
  const recordBlock = (block: StreamBlock): void => {
946
- const summary: TranscriptBlockSummary = { type: block.kind };
947
- const raw = block.raw as { text?: unknown; name?: unknown } | null | undefined;
948
- if (raw && typeof raw === "object") {
949
- if (typeof raw.text === "string") summary.chars = raw.text.length;
950
- if (typeof raw.name === "string") summary.name = raw.name;
951
- }
1004
+ const summary = summarizeStreamBlock(block);
952
1005
  slot.blocks.push(summary);
1006
+ if (this.transcript.enabled) {
1007
+ this.transcript.write({
1008
+ ts: new Date().toISOString(),
1009
+ kind: "block",
1010
+ turnId,
1011
+ agentId: msg.accountId,
1012
+ roomId: msg.conversation.id,
1013
+ topicId: msg.conversation.threadId ?? null,
1014
+ runtime: route.runtime,
1015
+ seq: block.seq,
1016
+ blockType: block.kind,
1017
+ summary,
1018
+ ...(transcriptBlocksVerbose() ? { raw: redactAndCap(block.raw) } : {}),
1019
+ });
1020
+ }
953
1021
  };
954
1022
 
955
1023
  // Owner-chat lifecycle state for typing/thinking. The dispatcher is the
@@ -1118,13 +1186,14 @@ export class Dispatcher {
1118
1186
  }
1119
1187
  : undefined;
1120
1188
 
1121
- const onBlock = canStream
1189
+ const onBlock = (canStream || this.transcript.enabled)
1122
1190
  ? (block: StreamBlock) => {
1123
1191
  // Always record adapter-emitted blocks for transcript fidelity, even
1124
1192
  // after abort — the transcript reflects what the runtime emitted,
1125
1193
  // not what the dispatcher chose to forward.
1126
1194
  recordBlock(block);
1127
1195
  if (controller.signal.aborted) return;
1196
+ if (!canStream) return;
1128
1197
  // Synthesize thinking.started before non-assistant blocks. After
1129
1198
  // we've seen any assistant_text, only `tool_use` may re-enter
1130
1199
  // thinking — terminal markers like `system`/`other` (codex
@@ -1144,7 +1213,7 @@ export class Dispatcher {
1144
1213
  thinkingActive = false;
1145
1214
  sawAssistantText = true;
1146
1215
  }
1147
- forwardBlockToChannel!(block);
1216
+ forwardBlockToChannel?.(block);
1148
1217
  }
1149
1218
  : undefined;
1150
1219
 
@@ -78,7 +78,8 @@ export interface GatewayBootOptions {
78
78
  * Tri-state convenience: if `transcript` is not provided, the gateway
79
79
  * constructs a writer using this flag plus `transcriptRootDir`. Use
80
80
  * {@link resolveTranscriptEnabled} to combine `BOTCORD_TRANSCRIPT` env with
81
- * the persistent daemon-config flag.
81
+ * the persistent daemon-config flag. When omitted, transcripts are enabled
82
+ * by default.
82
83
  */
83
84
  transcriptEnabled?: boolean;
84
85
  /** Root directory for transcript files. Defaults to `~/.botcord/agents`. */
@@ -145,7 +146,7 @@ export class Gateway {
145
146
  opts.transcript
146
147
  ?? createTranscriptWriter({
147
148
  log: this.log,
148
- enabled: opts.transcriptEnabled === true,
149
+ enabled: opts.transcriptEnabled,
149
150
  rootDir: opts.transcriptRootDir,
150
151
  });
151
152
 
@@ -35,7 +35,7 @@ interface AcpProcessHandle {
35
35
  /** Pending JSON-RPC requests keyed by id. */
36
36
  pending: Map<number, PendingCall>;
37
37
  /** Per-ACP-sessionId notification subscribers. */
38
- subscribers: Map<string, (note: AcpNotification) => void>;
38
+ subscribers: Map<string, AcpSubscriber>;
39
39
  nextId: number;
40
40
  buffer: string;
41
41
  nonJsonStdoutTail: string[];
@@ -66,6 +66,18 @@ interface AcpNotification {
66
66
  params: any;
67
67
  }
68
68
 
69
+ interface AcpTurnTraceContext {
70
+ turnId?: string;
71
+ messageId?: string;
72
+ roomId?: string;
73
+ topicId?: string | null;
74
+ }
75
+
76
+ interface AcpSubscriber {
77
+ notify: (note: AcpNotification) => void;
78
+ traceContext: AcpTurnTraceContext;
79
+ }
80
+
69
81
  const ACP_POOL = new Map<string, AcpProcessHandle>();
70
82
 
71
83
  function poolKey(accountId: string, gatewayName: string): string {
@@ -183,6 +195,12 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
183
195
  // synthetic test calls).
184
196
  conversationKey: stringField(opts.context, "conversationKey") ?? "default",
185
197
  });
198
+ const traceContext: AcpTurnTraceContext = {
199
+ turnId: stringField(opts.context, "turnId"),
200
+ messageId: stringField(opts.context, "messageId"),
201
+ roomId: stringField(opts.context, "roomId"),
202
+ topicId: nullableStringField(opts.context, "topicId"),
203
+ };
186
204
 
187
205
  const key = poolKey(opts.accountId, gateway.name);
188
206
  let handle: AcpProcessHandle;
@@ -273,7 +291,17 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
273
291
  throw new Error(`newSession failed: ${(err as Error).message}`);
274
292
  }
275
293
  }
276
- handle.subscribers.set(acpSessionId, onNotification);
294
+ handle.subscribers.set(acpSessionId, { notify: onNotification, traceContext });
295
+ handle.trace?.write({
296
+ stream: "turn_context",
297
+ ...traceContext,
298
+ params: {
299
+ sessionId: acpSessionId,
300
+ sessionKey,
301
+ openclawAgent,
302
+ cwd: opts.cwd,
303
+ },
304
+ });
277
305
 
278
306
  if (opts.signal?.aborted) {
279
307
  return failResult(acpSessionId, "openclaw-acp: aborted before prompt");
@@ -308,7 +336,18 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
308
336
  });
309
337
  handle.subscribers.delete(acpSessionId);
310
338
  acpSessionId = fresh;
311
- handle.subscribers.set(acpSessionId, onNotification);
339
+ handle.subscribers.set(acpSessionId, { notify: onNotification, traceContext });
340
+ handle.trace?.write({
341
+ stream: "turn_context",
342
+ ...traceContext,
343
+ params: {
344
+ sessionId: acpSessionId,
345
+ sessionKey,
346
+ openclawAgent,
347
+ cwd: opts.cwd,
348
+ recreatedFrom: oldSessionId,
349
+ },
350
+ });
312
351
  log.info("openclaw-acp.session-recreated", {
313
352
  accountId: opts.accountId,
314
353
  oldSessionId,
@@ -627,19 +666,20 @@ function routeMessage(handle: AcpProcessHandle, msg: any): void {
627
666
  }
628
667
  // Notification.
629
668
  if (msg?.method && msg?.params) {
669
+ const sid = msg.params?.sessionId;
670
+ const sub = typeof sid === "string" ? handle.subscribers.get(sid) : undefined;
630
671
  handle.trace?.write({
631
672
  stream: "rpc_in",
632
673
  direction: "in",
633
674
  method: msg.method,
634
675
  status: "notification",
676
+ ...(sub?.traceContext ?? {}),
635
677
  params: msg.params,
636
678
  });
637
- const sid = msg.params?.sessionId;
638
679
  if (typeof sid === "string") {
639
- const sub = handle.subscribers.get(sid);
640
680
  if (sub) {
641
681
  try {
642
- sub({ method: msg.method, params: msg.params });
682
+ sub.notify({ method: msg.method, params: msg.params });
643
683
  } catch (err) {
644
684
  log.warn("openclaw-acp.subscriber-threw", {
645
685
  error: err instanceof Error ? err.message : String(err),
@@ -1026,6 +1066,16 @@ function stringField(bag: Record<string, unknown> | undefined, key: string): str
1026
1066
  return typeof v === "string" && v.length > 0 ? v : undefined;
1027
1067
  }
1028
1068
 
1069
+ function nullableStringField(
1070
+ bag: Record<string, unknown> | undefined,
1071
+ key: string,
1072
+ ): string | null | undefined {
1073
+ if (!bag) return undefined;
1074
+ const v = bag[key];
1075
+ if (v === null) return null;
1076
+ return typeof v === "string" && v.length > 0 ? v : undefined;
1077
+ }
1078
+
1029
1079
  /**
1030
1080
  * Build the OpenClaw ACP `sessionKey` for a daemon turn. `accountId` is
1031
1081
  * always included to prevent two daemon agents from colliding on the same