@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.
@@ -0,0 +1,39 @@
1
+ import type { LogFileEntry } from "./log.js";
2
+ export type AcpTraceStream = "child_start" | "child_exit" | "child_error" | "stderr" | "stdout_non_json" | "rpc_in" | "rpc_out";
3
+ export interface AcpTraceMeta {
4
+ runtime: string;
5
+ accountId?: string;
6
+ turnId?: string;
7
+ roomId?: string;
8
+ topicId?: string | null;
9
+ gatewayName?: string;
10
+ gatewayUrl?: string;
11
+ hermesProfile?: string;
12
+ sessionId?: string | null;
13
+ }
14
+ export interface AcpTraceEvent {
15
+ stream: AcpTraceStream;
16
+ direction?: "in" | "out";
17
+ pid?: number;
18
+ id?: number | string;
19
+ method?: string;
20
+ status?: "request" | "notification" | "response" | "error";
21
+ code?: number | null;
22
+ signal?: NodeJS.Signals | null;
23
+ error?: string;
24
+ chunk?: string;
25
+ params?: unknown;
26
+ result?: unknown;
27
+ }
28
+ export interface AcpTraceLogger {
29
+ path: string;
30
+ verbose: boolean;
31
+ write(event: AcpTraceEvent): void;
32
+ }
33
+ interface RuntimeLogFile extends LogFileEntry {
34
+ bundleName: string;
35
+ }
36
+ export declare function createAcpTraceLogger(meta: AcpTraceMeta): AcpTraceLogger | null;
37
+ export declare function listAcpTraceLogFiles(includeAll?: boolean): LogFileEntry[];
38
+ export declare function listRuntimeLogFiles(includeAll?: boolean): RuntimeLogFile[];
39
+ export {};
@@ -0,0 +1,333 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ const ACP_LOG_DIR = path.join(homedir(), ".botcord", "logs", "acp");
5
+ const ACP_LOG_MAX_BYTES = 2 * 1024 * 1024;
6
+ const ACP_LOG_KEEP = 20;
7
+ const ACP_LOG_DIAGNOSTICS_DEFAULT = 10;
8
+ const ACP_LOG_DIAGNOSTICS_ALL = 50;
9
+ const RUNTIME_LOG_DEFAULT_PER_ROOT = 5;
10
+ const RUNTIME_LOG_ALL_PER_ROOT = 25;
11
+ const RUNTIME_LOG_MAX_FILE_BYTES = 2 * 1024 * 1024;
12
+ const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
13
+ export function createAcpTraceLogger(meta) {
14
+ if (process.env.BOTCORD_ACP_LOGS === "0")
15
+ return null;
16
+ const runtime = safePathSegment(meta.runtime || "acp");
17
+ const key = safePathSegment([
18
+ meta.accountId,
19
+ meta.gatewayName,
20
+ meta.hermesProfile,
21
+ meta.roomId,
22
+ ].filter(Boolean).join("_") || "default");
23
+ const dir = path.join(ACP_LOG_DIR, runtime);
24
+ const file = path.join(dir, `${key}.jsonl`);
25
+ const verbose = process.env.BOTCORD_ACP_TRACE === "verbose";
26
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
27
+ return {
28
+ path: file,
29
+ verbose,
30
+ write(event) {
31
+ writeAcpTrace(file, meta, event, verbose);
32
+ },
33
+ };
34
+ }
35
+ export function listAcpTraceLogFiles(includeAll = false) {
36
+ const limit = includeAll ? ACP_LOG_DIAGNOSTICS_ALL : ACP_LOG_DIAGNOSTICS_DEFAULT;
37
+ const out = [];
38
+ collectFiles(ACP_LOG_DIR, out, (name) => name.endsWith(".jsonl") || name.includes(".jsonl."));
39
+ return out
40
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
41
+ .slice(0, limit);
42
+ }
43
+ export function listRuntimeLogFiles(includeAll = false) {
44
+ const limit = includeAll ? RUNTIME_LOG_ALL_PER_ROOT : RUNTIME_LOG_DEFAULT_PER_ROOT;
45
+ const out = [];
46
+ for (const root of runtimeLogRoots()) {
47
+ const files = [];
48
+ collectFiles(root.dir, files, looksLikeLogFile, 4);
49
+ for (const entry of files
50
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
51
+ .slice(0, limit)) {
52
+ out.push({
53
+ ...entry,
54
+ bundleName: path.posix.join("runtime-logs", root.label, relativeBundlePath(root.dir, entry.path)),
55
+ });
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+ function writeAcpTrace(file, meta, event, verbose) {
61
+ try {
62
+ rotateIfNeeded(file);
63
+ const record = {
64
+ ts: new Date().toISOString(),
65
+ runtime: meta.runtime,
66
+ accountId: meta.accountId,
67
+ turnId: meta.turnId,
68
+ roomId: meta.roomId,
69
+ topicId: meta.topicId ?? undefined,
70
+ gatewayName: meta.gatewayName,
71
+ gatewayUrl: meta.gatewayUrl,
72
+ hermesProfile: meta.hermesProfile,
73
+ sessionId: event.params && typeof event.params === "object"
74
+ ? pickString(event.params, "sessionId") ?? meta.sessionId ?? undefined
75
+ : meta.sessionId ?? undefined,
76
+ ...summarizeEvent(event, verbose),
77
+ };
78
+ appendFileSync(file, JSON.stringify(record) + "\n", { mode: 0o600 });
79
+ }
80
+ catch {
81
+ // ACP trace logging must never affect runtime execution.
82
+ }
83
+ }
84
+ function summarizeEvent(event, verbose) {
85
+ const out = {
86
+ stream: event.stream,
87
+ };
88
+ if (event.direction)
89
+ out.direction = event.direction;
90
+ if (event.pid !== undefined)
91
+ out.pid = event.pid;
92
+ if (event.id !== undefined)
93
+ out.id = event.id;
94
+ if (event.method)
95
+ out.method = event.method;
96
+ if (event.status)
97
+ out.status = event.status;
98
+ if (event.code !== undefined)
99
+ out.code = event.code;
100
+ if (event.signal !== undefined)
101
+ out.signal = event.signal;
102
+ if (event.error)
103
+ out.error = truncate(event.error, 1000);
104
+ if (event.chunk)
105
+ out.chunk = truncate(redactSecretString(event.chunk), 2000);
106
+ if (event.params !== undefined)
107
+ out.params = summarizePayload(event.params, verbose);
108
+ if (event.result !== undefined)
109
+ out.result = summarizePayload(event.result, verbose);
110
+ return out;
111
+ }
112
+ function summarizePayload(value, verbose) {
113
+ const redacted = redactSecrets(value);
114
+ if (verbose)
115
+ return capPayload(redacted);
116
+ if (Array.isArray(redacted))
117
+ return { type: "array", length: redacted.length };
118
+ if (!redacted || typeof redacted !== "object")
119
+ return redacted;
120
+ const obj = redacted;
121
+ const out = {};
122
+ for (const [key, v] of Object.entries(obj)) {
123
+ if (key === "prompt") {
124
+ out.prompt = summarizePrompt(v);
125
+ }
126
+ else if (key === "cwd" || key === "sessionId") {
127
+ out[key] = v;
128
+ }
129
+ else if (key === "_meta") {
130
+ out[key] = summarizePayload(v, false);
131
+ }
132
+ else if (key === "update") {
133
+ out.update = summarizeUpdate(v);
134
+ }
135
+ else if (key === "toolCall") {
136
+ out.toolCall = summarizeToolCall(v);
137
+ }
138
+ else if (typeof v === "string") {
139
+ out[key] = stringSummary(v);
140
+ }
141
+ else if (Array.isArray(v)) {
142
+ out[key] = { type: "array", length: v.length };
143
+ }
144
+ else if (v && typeof v === "object") {
145
+ out[key] = { type: "object", keys: Object.keys(v).slice(0, 20) };
146
+ }
147
+ else {
148
+ out[key] = v;
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+ function summarizePrompt(value) {
154
+ if (!Array.isArray(value))
155
+ return summarizePayload(value, false);
156
+ return value.map((item) => {
157
+ if (!item || typeof item !== "object")
158
+ return summarizePayload(item, false);
159
+ const obj = item;
160
+ const text = typeof obj.text === "string" ? obj.text : "";
161
+ return {
162
+ type: obj.type,
163
+ textBytes: text ? Buffer.byteLength(text, "utf8") : undefined,
164
+ textPreview: text ? truncate(text.replace(/\s+/g, " "), 120) : undefined,
165
+ };
166
+ });
167
+ }
168
+ function summarizeUpdate(value) {
169
+ if (!value || typeof value !== "object")
170
+ return summarizePayload(value, false);
171
+ const obj = value;
172
+ const out = {};
173
+ if (typeof obj.sessionUpdate === "string")
174
+ out.sessionUpdate = obj.sessionUpdate;
175
+ if (typeof obj.title === "string")
176
+ out.title = stringSummary(obj.title);
177
+ if (obj.content !== undefined)
178
+ out.content = summarizePayload(obj.content, false);
179
+ return Object.keys(out).length > 0 ? out : { type: "object", keys: Object.keys(obj).slice(0, 20) };
180
+ }
181
+ function summarizeToolCall(value) {
182
+ if (!value || typeof value !== "object")
183
+ return summarizePayload(value, false);
184
+ const obj = value;
185
+ return {
186
+ name: typeof obj.name === "string" ? obj.name : undefined,
187
+ rawInput: obj.rawInput === undefined ? undefined : summarizePayload(obj.rawInput, false),
188
+ };
189
+ }
190
+ function redactSecrets(value) {
191
+ if (Array.isArray(value))
192
+ return value.map(redactSecrets);
193
+ if (!value || typeof value !== "object") {
194
+ return typeof value === "string" ? redactSecretString(value) : value;
195
+ }
196
+ const out = {};
197
+ for (const [key, v] of Object.entries(value)) {
198
+ out[key] = SECRET_KEY_RE.test(key) ? "[REDACTED]" : redactSecrets(v);
199
+ }
200
+ return out;
201
+ }
202
+ function redactSecretString(value) {
203
+ return value
204
+ .replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]")
205
+ .replace(/\b(token=)[^\s"']+/gi, "$1[REDACTED]")
206
+ .replace(/\b(drt_|dit_|gho_)[A-Za-z0-9_-]+/g, "$1[REDACTED]");
207
+ }
208
+ function capPayload(value) {
209
+ if (typeof value === "string")
210
+ return truncate(value, 2000);
211
+ if (Array.isArray(value))
212
+ return value.slice(0, 50).map(capPayload);
213
+ if (!value || typeof value !== "object")
214
+ return value;
215
+ const out = {};
216
+ for (const [key, v] of Object.entries(value).slice(0, 50)) {
217
+ out[key] = capPayload(v);
218
+ }
219
+ return out;
220
+ }
221
+ function stringSummary(value) {
222
+ return {
223
+ bytes: Buffer.byteLength(value, "utf8"),
224
+ preview: truncate(value.replace(/\s+/g, " "), 160),
225
+ };
226
+ }
227
+ function truncate(value, max) {
228
+ return value.length > max ? `${value.slice(0, max)}…` : value;
229
+ }
230
+ function pickString(obj, key) {
231
+ const value = obj[key];
232
+ return typeof value === "string" && value.length > 0 ? value : undefined;
233
+ }
234
+ function rotateIfNeeded(file) {
235
+ try {
236
+ const st = statSync(file);
237
+ if (!st.isFile() || st.size <= ACP_LOG_MAX_BYTES)
238
+ return;
239
+ renameSync(file, `${file}.${new Date().toISOString().replace(/[:.]/g, "-")}.${process.pid}`);
240
+ const dir = path.dirname(file);
241
+ const base = path.basename(file);
242
+ const rotated = readdirSync(dir)
243
+ .filter((name) => name.startsWith(`${base}.`))
244
+ .map((name) => {
245
+ const p = path.join(dir, name);
246
+ const st = statSync(p);
247
+ return { p, mtimeMs: st.mtimeMs };
248
+ })
249
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
250
+ for (const entry of rotated.slice(ACP_LOG_KEEP))
251
+ unlinkSync(entry.p);
252
+ }
253
+ catch {
254
+ // best-effort
255
+ }
256
+ }
257
+ function collectFiles(dir, out, accept, maxDepth = 3) {
258
+ if (maxDepth < 0)
259
+ return;
260
+ let names;
261
+ try {
262
+ names = readdirSync(dir);
263
+ }
264
+ catch {
265
+ return;
266
+ }
267
+ for (const name of names) {
268
+ const file = path.join(dir, name);
269
+ try {
270
+ const st = statSync(file);
271
+ if (st.isDirectory()) {
272
+ collectFiles(file, out, accept, maxDepth - 1);
273
+ }
274
+ else if (st.isFile() && st.size <= RUNTIME_LOG_MAX_FILE_BYTES && accept(name, file)) {
275
+ out.push({
276
+ path: file,
277
+ name: path.relative(ACP_LOG_DIR, file) || name,
278
+ sizeBytes: st.size,
279
+ mtimeMs: st.mtimeMs,
280
+ active: true,
281
+ });
282
+ }
283
+ }
284
+ catch {
285
+ // ignore disappearing files
286
+ }
287
+ }
288
+ }
289
+ function runtimeLogRoots() {
290
+ const roots = [
291
+ { label: "openclaw", dir: path.join(homedir(), ".openclaw", "logs") },
292
+ { label: "qclaw", dir: path.join(homedir(), ".qclaw", "logs") },
293
+ { label: "hermes", dir: path.join(homedir(), ".hermes", "logs") },
294
+ ];
295
+ const hermesProfiles = path.join(homedir(), ".hermes", "profiles");
296
+ try {
297
+ for (const name of readdirSync(hermesProfiles)) {
298
+ roots.push({
299
+ label: path.posix.join("hermes-profiles", safePathSegment(name)),
300
+ dir: path.join(hermesProfiles, name, "logs"),
301
+ });
302
+ }
303
+ }
304
+ catch {
305
+ // no profiles
306
+ }
307
+ const botcordAgents = path.join(homedir(), ".botcord", "agents");
308
+ try {
309
+ for (const agent of readdirSync(botcordAgents)) {
310
+ roots.push({
311
+ label: path.posix.join("botcord-hermes", safePathSegment(agent)),
312
+ dir: path.join(botcordAgents, agent, "hermes-home", "logs"),
313
+ });
314
+ }
315
+ }
316
+ catch {
317
+ // no botcord agent homes
318
+ }
319
+ return roots.filter((root) => existsSync(root.dir));
320
+ }
321
+ function looksLikeLogFile(name) {
322
+ const lower = name.toLowerCase();
323
+ return (lower.endsWith(".log") ||
324
+ lower.endsWith(".jsonl") ||
325
+ lower.endsWith(".txt") ||
326
+ lower.includes("log"));
327
+ }
328
+ function relativeBundlePath(root, file) {
329
+ return path.relative(root, file).split(path.sep).map(safePathSegment).join("/");
330
+ }
331
+ function safePathSegment(raw) {
332
+ return raw.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 120) || "unknown";
333
+ }
@@ -4,10 +4,13 @@ import path from "node:path";
4
4
  import { Buffer } from "node:buffer";
5
5
  import { deflateRawSync } from "node:zlib";
6
6
  import { AUTH_EXPIRED_FLAG_PATH, USER_AUTH_PATH, } from "./user-auth.js";
7
- import { CONFIG_FILE_PATH, PID_PATH, SNAPSHOT_PATH, loadConfig, } from "./config.js";
7
+ import { CONFIG_FILE_PATH, PID_PATH, SNAPSHOT_PATH, loadConfig, saveConfig, } from "./config.js";
8
8
  import { listDaemonLogFiles, LOG_FILE_PATH } from "./log.js";
9
+ import { listAcpTraceLogFiles, listRuntimeLogFiles } from "./acp-logs.js";
9
10
  import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
10
11
  import { detectRuntimes } from "./adapters/runtimes.js";
12
+ import { log as daemonLog } from "./log.js";
13
+ import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
11
14
  const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
12
15
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
13
16
  const DEFAULT_ROTATED_LOGS_IN_BUNDLE = 5;
@@ -76,6 +79,7 @@ async function buildDoctorEntries() {
76
79
  let cfgForEndpoints = null;
77
80
  try {
78
81
  cfgForEndpoints = loadConfig();
82
+ cfgForEndpoints = await refreshDiscoveredOpenclawGateways(cfgForEndpoints);
79
83
  channels = channelsFromDaemonConfig(cfgForEndpoints);
80
84
  }
81
85
  catch {
@@ -99,6 +103,33 @@ async function buildDoctorEntries() {
99
103
  });
100
104
  return { text: renderDoctor(input), json: input };
101
105
  }
106
+ async function refreshDiscoveredOpenclawGateways(cfg) {
107
+ if (!openclawDiscoveryConfigEnabled(cfg))
108
+ return cfg;
109
+ try {
110
+ const found = await discoverLocalOpenclawGateways({
111
+ searchPaths: cfg.openclawDiscovery?.searchPaths,
112
+ defaultPorts: cfg.openclawDiscovery?.defaultPorts,
113
+ timeoutMs: 500,
114
+ });
115
+ const merged = mergeOpenclawGateways(cfg, found);
116
+ if (!merged.changed)
117
+ return cfg;
118
+ saveConfig(merged.cfg);
119
+ daemonLog.info("openclaw discovery: gateways merged", {
120
+ source: "diagnostics",
121
+ added: merged.added.map((g) => ({ name: g.name, url: g.url })),
122
+ });
123
+ return merged.cfg;
124
+ }
125
+ catch (err) {
126
+ daemonLog.warn("openclaw discovery failed; continuing", {
127
+ source: "diagnostics",
128
+ error: err instanceof Error ? err.message : String(err),
129
+ });
130
+ return cfg;
131
+ }
132
+ }
102
133
  function crc32(buf) {
103
134
  let crc = 0xffffffff;
104
135
  for (const b of buf) {
@@ -231,6 +262,8 @@ export async function createDiagnosticBundle(opts = {}) {
231
262
  const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
232
263
  const includeAllLogs = opts.includeAllLogs === true;
233
264
  const logs = bundledLogs(logFile, includeAllLogs);
265
+ const acpLogs = listAcpTraceLogFiles(includeAllLogs);
266
+ const runtimeLogs = listRuntimeLogFiles(includeAllLogs);
234
267
  mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
235
268
  const doctor = opts.doctor ?? await buildDoctorEntries();
236
269
  const status = {
@@ -251,6 +284,16 @@ export async function createDiagnosticBundle(opts = {}) {
251
284
  sizeBytes: entry.sizeBytes,
252
285
  active: entry.active,
253
286
  })),
287
+ acpLogsBundled: acpLogs.map((entry) => ({
288
+ name: entry.name,
289
+ path: entry.path,
290
+ sizeBytes: entry.sizeBytes,
291
+ })),
292
+ runtimeLogsBundled: runtimeLogs.map((entry) => ({
293
+ name: entry.bundleName,
294
+ path: entry.path,
295
+ sizeBytes: entry.sizeBytes,
296
+ })),
254
297
  logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
255
298
  diagnosticsDir,
256
299
  userAuth: readUserAuthSummary(),
@@ -276,6 +319,20 @@ export async function createDiagnosticBundle(opts = {}) {
276
319
  });
277
320
  }
278
321
  }
322
+ for (const entry of acpLogs) {
323
+ const log = safeReadText(entry.path);
324
+ entries.push({
325
+ name: `acp-logs/${entry.name.split(path.sep).join("/")}`,
326
+ data: log ?? `no ACP log file at ${entry.path}\n`,
327
+ });
328
+ }
329
+ for (const entry of runtimeLogs) {
330
+ const log = safeReadText(entry.path);
331
+ entries.push({
332
+ name: entry.bundleName,
333
+ data: log ?? `no runtime log file at ${entry.path}\n`,
334
+ });
335
+ }
279
336
  const config = safeReadText(configFile);
280
337
  entries.push({
281
338
  name: "config.json.redacted",
@@ -23,6 +23,13 @@ export interface BotCordChannelClient {
23
23
  hub_msg_id?: string;
24
24
  message_id?: string;
25
25
  } & Record<string, unknown>>;
26
+ sendTypedMessage?(to: string, type: "result" | "error", text: string, options?: {
27
+ replyTo?: string;
28
+ topic?: string;
29
+ }): Promise<{
30
+ hub_msg_id?: string;
31
+ message_id?: string;
32
+ } & Record<string, unknown>>;
26
33
  getHubUrl(): string;
27
34
  onTokenRefresh?: (token: string, expiresAt: number) => void;
28
35
  }
@@ -674,7 +674,9 @@ export function createBotCordChannel(options) {
674
674
  options.replyTo = message.replyTo;
675
675
  if (message.threadId)
676
676
  options.topic = message.threadId;
677
- const resp = await client.sendMessage(message.conversationId, message.text, options);
677
+ const resp = message.type === "error" && client.sendTypedMessage
678
+ ? await client.sendTypedMessage(message.conversationId, "error", message.text, options)
679
+ : await client.sendMessage(message.conversationId, message.text, options);
678
680
  const providerMessageId = (resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
679
681
  (resp && typeof resp.message_id === "string"
680
682
  ? resp.message_id
@@ -959,6 +959,14 @@ export class Dispatcher {
959
959
  systemContext,
960
960
  onBlock,
961
961
  onStatus,
962
+ context: {
963
+ turnId,
964
+ messageId: msg.id,
965
+ roomId: msg.conversation.id,
966
+ topicId: msg.conversation.threadId ?? null,
967
+ channel: msg.channel,
968
+ conversationKind: msg.conversation.kind,
969
+ },
962
970
  gateway: route.gateway,
963
971
  ...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
964
972
  });
@@ -1022,6 +1030,7 @@ export class Dispatcher {
1022
1030
  accountId: msg.accountId,
1023
1031
  conversationId: msg.conversation.id,
1024
1032
  threadId: msg.conversation.threadId ?? null,
1033
+ type: "error",
1025
1034
  text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
1026
1035
  replyTo: msg.id,
1027
1036
  traceId: msg.trace?.id ?? null,
@@ -1067,6 +1076,7 @@ export class Dispatcher {
1067
1076
  accountId: msg.accountId,
1068
1077
  conversationId: msg.conversation.id,
1069
1078
  threadId: msg.conversation.threadId ?? null,
1079
+ type: "error",
1070
1080
  text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
1071
1081
  replyTo: msg.id,
1072
1082
  traceId: msg.trace?.id ?? null,
@@ -1085,16 +1095,35 @@ export class Dispatcher {
1085
1095
  }
1086
1096
  if (!result)
1087
1097
  return;
1098
+ const replyText = (result.text || "").trim();
1099
+ const finalTextField = truncateTextField(result.text || "");
1088
1100
  // Persist session before reply so next turn sees the new id even if send fails.
1089
1101
  //
1090
1102
  // Adapter contract:
1091
- // result.newSessionId truthy → upsert the entry
1092
- // result.newSessionId empty + had-inbound-sessionId + result.error
1093
- // → the prior session is dead (e.g. Claude Code
1094
- // "--resume <missing-uuid>"); delete the entry so
1103
+ // had-inbound-sessionId + result.error + no reply text
1104
+ // the prior session is suspect/dead; delete it so
1095
1105
  // we don't keep resuming a stale id every turn
1106
+ // even when the adapter echoes that id back
1107
+ // result.newSessionId truthy → upsert the entry
1096
1108
  // otherwise → no-op (e.g. codex intentionally never persists)
1097
- if (result.newSessionId) {
1109
+ if (sessionId && result.error && !replyText) {
1110
+ try {
1111
+ await this.sessionStore.delete(key);
1112
+ this.log.info("dispatcher: dropped stale runtime session", {
1113
+ key,
1114
+ prevRuntimeSessionId: sessionId,
1115
+ nextRuntimeSessionId: result.newSessionId || null,
1116
+ error: result.error,
1117
+ });
1118
+ }
1119
+ catch (err) {
1120
+ this.log.warn("dispatcher: session-store.delete failed", {
1121
+ key,
1122
+ error: err instanceof Error ? err.message : String(err),
1123
+ });
1124
+ }
1125
+ }
1126
+ else if (result.newSessionId) {
1098
1127
  const session = {
1099
1128
  key,
1100
1129
  runtime: route.runtime,
@@ -1139,8 +1168,6 @@ export class Dispatcher {
1139
1168
  });
1140
1169
  }
1141
1170
  }
1142
- const replyText = (result.text || "").trim();
1143
- const finalTextField = truncateTextField(result.text || "");
1144
1171
  if (!replyText) {
1145
1172
  if (result.error) {
1146
1173
  this.log.warn("dispatcher: runtime returned error without reply text", {
@@ -1157,6 +1184,7 @@ export class Dispatcher {
1157
1184
  accountId: msg.accountId,
1158
1185
  conversationId: msg.conversation.id,
1159
1186
  threadId: msg.conversation.threadId ?? null,
1187
+ type: "error",
1160
1188
  text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
1161
1189
  replyTo: msg.id,
1162
1190
  traceId: msg.trace?.id ?? null,