@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.
@@ -202,6 +202,45 @@ describe("OpenclawAcpAdapter.run", () => {
202
202
  expect(res.error).toContain("Missing env var FOO_API_KEY");
203
203
  });
204
204
 
205
+ it("treats end_turn with warning-only stdout as an empty reply, not an error", async () => {
206
+ const child = new FakeChild();
207
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
208
+ const gateway: ResolvedOpenclawGateway = {
209
+ name: "local",
210
+ url: "ws://127.0.0.1:1",
211
+ openclawAgent: "main",
212
+ };
213
+
214
+ child.stdin.on("data", (chunk: Buffer) => {
215
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
216
+ const frame = JSON.parse(line);
217
+ if (frame.method === "initialize") {
218
+ child.stdout.write("◇ Config warnings ─────────────────────╮\n");
219
+ child.stdout.write("│ - plugins.allow: plugin not installed: brave │\n");
220
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
221
+ } else if (frame.method === "session/new") {
222
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-warn-end" } }) + "\n");
223
+ } else if (frame.method === "session/prompt") {
224
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { stopReason: "end_turn" } }) + "\n");
225
+ }
226
+ }
227
+ });
228
+
229
+ const res = await adapter.run({
230
+ text: "hi",
231
+ sessionId: null,
232
+ cwd: "/tmp",
233
+ accountId: "ag_alice",
234
+ signal: new AbortController().signal,
235
+ trustLevel: "owner",
236
+ gateway,
237
+ });
238
+
239
+ expect(res.text).toBe("");
240
+ expect(res.newSessionId).toBe("sid-warn-end");
241
+ expect(res.error).toBeUndefined();
242
+ });
243
+
205
244
  it("streams only final text when OpenClaw sends reasoning before a final block", async () => {
206
245
  const child = new FakeChild();
207
246
  const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
@@ -156,6 +156,7 @@ describe("discoverLocalOpenclawGateways", () => {
156
156
 
157
157
  expect(found).toEqual([
158
158
  expect.objectContaining({
159
+ name: "qclaw-127-0-0-1-28789",
159
160
  url: "ws://127.0.0.1:28789",
160
161
  token: "qclaw-token",
161
162
  source: "config-file",
@@ -0,0 +1,382 @@
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
+ import type { LogFileEntry } from "./log.js";
5
+
6
+ const ACP_LOG_DIR = path.join(homedir(), ".botcord", "logs", "acp");
7
+ const ACP_LOG_MAX_BYTES = 2 * 1024 * 1024;
8
+ const ACP_LOG_KEEP = 20;
9
+ const ACP_LOG_DIAGNOSTICS_DEFAULT = 10;
10
+ const ACP_LOG_DIAGNOSTICS_ALL = 50;
11
+ const RUNTIME_LOG_DEFAULT_PER_ROOT = 5;
12
+ const RUNTIME_LOG_ALL_PER_ROOT = 25;
13
+ const RUNTIME_LOG_MAX_FILE_BYTES = 2 * 1024 * 1024;
14
+
15
+ const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
16
+
17
+ export type AcpTraceStream =
18
+ | "child_start"
19
+ | "child_exit"
20
+ | "child_error"
21
+ | "stderr"
22
+ | "stdout_non_json"
23
+ | "rpc_in"
24
+ | "rpc_out";
25
+
26
+ export interface AcpTraceMeta {
27
+ runtime: string;
28
+ accountId?: string;
29
+ turnId?: string;
30
+ roomId?: string;
31
+ topicId?: string | null;
32
+ gatewayName?: string;
33
+ gatewayUrl?: string;
34
+ hermesProfile?: string;
35
+ sessionId?: string | null;
36
+ }
37
+
38
+ export interface AcpTraceEvent {
39
+ stream: AcpTraceStream;
40
+ direction?: "in" | "out";
41
+ pid?: number;
42
+ id?: number | string;
43
+ method?: string;
44
+ status?: "request" | "notification" | "response" | "error";
45
+ code?: number | null;
46
+ signal?: NodeJS.Signals | null;
47
+ error?: string;
48
+ chunk?: string;
49
+ params?: unknown;
50
+ result?: unknown;
51
+ }
52
+
53
+ export interface AcpTraceLogger {
54
+ path: string;
55
+ verbose: boolean;
56
+ write(event: AcpTraceEvent): void;
57
+ }
58
+
59
+ interface RuntimeLogRoot {
60
+ label: string;
61
+ dir: string;
62
+ }
63
+
64
+ interface RuntimeLogFile extends LogFileEntry {
65
+ bundleName: string;
66
+ }
67
+
68
+ export function createAcpTraceLogger(meta: AcpTraceMeta): AcpTraceLogger | null {
69
+ if (process.env.BOTCORD_ACP_LOGS === "0") return null;
70
+ const runtime = safePathSegment(meta.runtime || "acp");
71
+ const key = safePathSegment(
72
+ [
73
+ meta.accountId,
74
+ meta.gatewayName,
75
+ meta.hermesProfile,
76
+ meta.roomId,
77
+ ].filter(Boolean).join("_") || "default",
78
+ );
79
+ const dir = path.join(ACP_LOG_DIR, runtime);
80
+ const file = path.join(dir, `${key}.jsonl`);
81
+ const verbose = process.env.BOTCORD_ACP_TRACE === "verbose";
82
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
83
+ return {
84
+ path: file,
85
+ verbose,
86
+ write(event) {
87
+ writeAcpTrace(file, meta, event, verbose);
88
+ },
89
+ };
90
+ }
91
+
92
+ export function listAcpTraceLogFiles(includeAll = false): LogFileEntry[] {
93
+ const limit = includeAll ? ACP_LOG_DIAGNOSTICS_ALL : ACP_LOG_DIAGNOSTICS_DEFAULT;
94
+ const out: LogFileEntry[] = [];
95
+ collectFiles(ACP_LOG_DIR, out, (name) => name.endsWith(".jsonl") || name.includes(".jsonl."));
96
+ return out
97
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
98
+ .slice(0, limit);
99
+ }
100
+
101
+ export function listRuntimeLogFiles(includeAll = false): RuntimeLogFile[] {
102
+ const limit = includeAll ? RUNTIME_LOG_ALL_PER_ROOT : RUNTIME_LOG_DEFAULT_PER_ROOT;
103
+ const out: RuntimeLogFile[] = [];
104
+ for (const root of runtimeLogRoots()) {
105
+ const files: LogFileEntry[] = [];
106
+ collectFiles(root.dir, files, looksLikeLogFile, 4);
107
+ for (const entry of files
108
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
109
+ .slice(0, limit)) {
110
+ out.push({
111
+ ...entry,
112
+ bundleName: path.posix.join(
113
+ "runtime-logs",
114
+ root.label,
115
+ relativeBundlePath(root.dir, entry.path),
116
+ ),
117
+ });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function writeAcpTrace(
124
+ file: string,
125
+ meta: AcpTraceMeta,
126
+ event: AcpTraceEvent,
127
+ verbose: boolean,
128
+ ): void {
129
+ try {
130
+ rotateIfNeeded(file);
131
+ const record = {
132
+ ts: new Date().toISOString(),
133
+ runtime: meta.runtime,
134
+ accountId: meta.accountId,
135
+ turnId: meta.turnId,
136
+ roomId: meta.roomId,
137
+ topicId: meta.topicId ?? undefined,
138
+ gatewayName: meta.gatewayName,
139
+ gatewayUrl: meta.gatewayUrl,
140
+ hermesProfile: meta.hermesProfile,
141
+ sessionId: event.params && typeof event.params === "object"
142
+ ? pickString(event.params as Record<string, unknown>, "sessionId") ?? meta.sessionId ?? undefined
143
+ : meta.sessionId ?? undefined,
144
+ ...summarizeEvent(event, verbose),
145
+ };
146
+ appendFileSync(file, JSON.stringify(record) + "\n", { mode: 0o600 });
147
+ } catch {
148
+ // ACP trace logging must never affect runtime execution.
149
+ }
150
+ }
151
+
152
+ function summarizeEvent(event: AcpTraceEvent, verbose: boolean): Record<string, unknown> {
153
+ const out: Record<string, unknown> = {
154
+ stream: event.stream,
155
+ };
156
+ if (event.direction) out.direction = event.direction;
157
+ if (event.pid !== undefined) out.pid = event.pid;
158
+ if (event.id !== undefined) out.id = event.id;
159
+ if (event.method) out.method = event.method;
160
+ if (event.status) out.status = event.status;
161
+ if (event.code !== undefined) out.code = event.code;
162
+ if (event.signal !== undefined) out.signal = event.signal;
163
+ if (event.error) out.error = truncate(event.error, 1000);
164
+ if (event.chunk) out.chunk = truncate(redactSecretString(event.chunk), 2000);
165
+ if (event.params !== undefined) out.params = summarizePayload(event.params, verbose);
166
+ if (event.result !== undefined) out.result = summarizePayload(event.result, verbose);
167
+ return out;
168
+ }
169
+
170
+ function summarizePayload(value: unknown, verbose: boolean): unknown {
171
+ const redacted = redactSecrets(value);
172
+ if (verbose) return capPayload(redacted);
173
+ if (Array.isArray(redacted)) return { type: "array", length: redacted.length };
174
+ if (!redacted || typeof redacted !== "object") return redacted;
175
+ const obj = redacted as Record<string, unknown>;
176
+ const out: Record<string, unknown> = {};
177
+ for (const [key, v] of Object.entries(obj)) {
178
+ if (key === "prompt") {
179
+ out.prompt = summarizePrompt(v);
180
+ } else if (key === "cwd" || key === "sessionId") {
181
+ out[key] = v;
182
+ } else if (key === "_meta") {
183
+ out[key] = summarizePayload(v, false);
184
+ } else if (key === "update") {
185
+ out.update = summarizeUpdate(v);
186
+ } else if (key === "toolCall") {
187
+ out.toolCall = summarizeToolCall(v);
188
+ } else if (typeof v === "string") {
189
+ out[key] = stringSummary(v);
190
+ } else if (Array.isArray(v)) {
191
+ out[key] = { type: "array", length: v.length };
192
+ } else if (v && typeof v === "object") {
193
+ out[key] = { type: "object", keys: Object.keys(v as Record<string, unknown>).slice(0, 20) };
194
+ } else {
195
+ out[key] = v;
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+
201
+ function summarizePrompt(value: unknown): unknown {
202
+ if (!Array.isArray(value)) return summarizePayload(value, false);
203
+ return value.map((item) => {
204
+ if (!item || typeof item !== "object") return summarizePayload(item, false);
205
+ const obj = item as Record<string, unknown>;
206
+ const text = typeof obj.text === "string" ? obj.text : "";
207
+ return {
208
+ type: obj.type,
209
+ textBytes: text ? Buffer.byteLength(text, "utf8") : undefined,
210
+ textPreview: text ? truncate(text.replace(/\s+/g, " "), 120) : undefined,
211
+ };
212
+ });
213
+ }
214
+
215
+ function summarizeUpdate(value: unknown): unknown {
216
+ if (!value || typeof value !== "object") return summarizePayload(value, false);
217
+ const obj = value as Record<string, unknown>;
218
+ const out: Record<string, unknown> = {};
219
+ if (typeof obj.sessionUpdate === "string") out.sessionUpdate = obj.sessionUpdate;
220
+ if (typeof obj.title === "string") out.title = stringSummary(obj.title);
221
+ if (obj.content !== undefined) out.content = summarizePayload(obj.content, false);
222
+ return Object.keys(out).length > 0 ? out : { type: "object", keys: Object.keys(obj).slice(0, 20) };
223
+ }
224
+
225
+ function summarizeToolCall(value: unknown): unknown {
226
+ if (!value || typeof value !== "object") return summarizePayload(value, false);
227
+ const obj = value as Record<string, unknown>;
228
+ return {
229
+ name: typeof obj.name === "string" ? obj.name : undefined,
230
+ rawInput: obj.rawInput === undefined ? undefined : summarizePayload(obj.rawInput, false),
231
+ };
232
+ }
233
+
234
+ function redactSecrets(value: unknown): unknown {
235
+ if (Array.isArray(value)) return value.map(redactSecrets);
236
+ if (!value || typeof value !== "object") {
237
+ return typeof value === "string" ? redactSecretString(value) : value;
238
+ }
239
+ const out: Record<string, unknown> = {};
240
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
241
+ out[key] = SECRET_KEY_RE.test(key) ? "[REDACTED]" : redactSecrets(v);
242
+ }
243
+ return out;
244
+ }
245
+
246
+ function redactSecretString(value: string): string {
247
+ return value
248
+ .replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]")
249
+ .replace(/\b(token=)[^\s"']+/gi, "$1[REDACTED]")
250
+ .replace(/\b(drt_|dit_|gho_)[A-Za-z0-9_-]+/g, "$1[REDACTED]");
251
+ }
252
+
253
+ function capPayload(value: unknown): unknown {
254
+ if (typeof value === "string") return truncate(value, 2000);
255
+ if (Array.isArray(value)) return value.slice(0, 50).map(capPayload);
256
+ if (!value || typeof value !== "object") return value;
257
+ const out: Record<string, unknown> = {};
258
+ for (const [key, v] of Object.entries(value as Record<string, unknown>).slice(0, 50)) {
259
+ out[key] = capPayload(v);
260
+ }
261
+ return out;
262
+ }
263
+
264
+ function stringSummary(value: string): Record<string, unknown> {
265
+ return {
266
+ bytes: Buffer.byteLength(value, "utf8"),
267
+ preview: truncate(value.replace(/\s+/g, " "), 160),
268
+ };
269
+ }
270
+
271
+ function truncate(value: string, max: number): string {
272
+ return value.length > max ? `${value.slice(0, max)}…` : value;
273
+ }
274
+
275
+ function pickString(obj: Record<string, unknown>, key: string): string | undefined {
276
+ const value = obj[key];
277
+ return typeof value === "string" && value.length > 0 ? value : undefined;
278
+ }
279
+
280
+ function rotateIfNeeded(file: string): void {
281
+ try {
282
+ const st = statSync(file);
283
+ if (!st.isFile() || st.size <= ACP_LOG_MAX_BYTES) return;
284
+ renameSync(file, `${file}.${new Date().toISOString().replace(/[:.]/g, "-")}.${process.pid}`);
285
+ const dir = path.dirname(file);
286
+ const base = path.basename(file);
287
+ const rotated = readdirSync(dir)
288
+ .filter((name) => name.startsWith(`${base}.`))
289
+ .map((name) => {
290
+ const p = path.join(dir, name);
291
+ const st = statSync(p);
292
+ return { p, mtimeMs: st.mtimeMs };
293
+ })
294
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
295
+ for (const entry of rotated.slice(ACP_LOG_KEEP)) unlinkSync(entry.p);
296
+ } catch {
297
+ // best-effort
298
+ }
299
+ }
300
+
301
+ function collectFiles(
302
+ dir: string,
303
+ out: LogFileEntry[],
304
+ accept: (name: string, file: string) => boolean,
305
+ maxDepth = 3,
306
+ ): void {
307
+ if (maxDepth < 0) return;
308
+ let names: string[];
309
+ try {
310
+ names = readdirSync(dir);
311
+ } catch {
312
+ return;
313
+ }
314
+ for (const name of names) {
315
+ const file = path.join(dir, name);
316
+ try {
317
+ const st = statSync(file);
318
+ if (st.isDirectory()) {
319
+ collectFiles(file, out, accept, maxDepth - 1);
320
+ } else if (st.isFile() && st.size <= RUNTIME_LOG_MAX_FILE_BYTES && accept(name, file)) {
321
+ out.push({
322
+ path: file,
323
+ name: path.relative(ACP_LOG_DIR, file) || name,
324
+ sizeBytes: st.size,
325
+ mtimeMs: st.mtimeMs,
326
+ active: true,
327
+ });
328
+ }
329
+ } catch {
330
+ // ignore disappearing files
331
+ }
332
+ }
333
+ }
334
+
335
+ function runtimeLogRoots(): RuntimeLogRoot[] {
336
+ const roots: RuntimeLogRoot[] = [
337
+ { label: "openclaw", dir: path.join(homedir(), ".openclaw", "logs") },
338
+ { label: "qclaw", dir: path.join(homedir(), ".qclaw", "logs") },
339
+ { label: "hermes", dir: path.join(homedir(), ".hermes", "logs") },
340
+ ];
341
+ const hermesProfiles = path.join(homedir(), ".hermes", "profiles");
342
+ try {
343
+ for (const name of readdirSync(hermesProfiles)) {
344
+ roots.push({
345
+ label: path.posix.join("hermes-profiles", safePathSegment(name)),
346
+ dir: path.join(hermesProfiles, name, "logs"),
347
+ });
348
+ }
349
+ } catch {
350
+ // no profiles
351
+ }
352
+ const botcordAgents = path.join(homedir(), ".botcord", "agents");
353
+ try {
354
+ for (const agent of readdirSync(botcordAgents)) {
355
+ roots.push({
356
+ label: path.posix.join("botcord-hermes", safePathSegment(agent)),
357
+ dir: path.join(botcordAgents, agent, "hermes-home", "logs"),
358
+ });
359
+ }
360
+ } catch {
361
+ // no botcord agent homes
362
+ }
363
+ return roots.filter((root) => existsSync(root.dir));
364
+ }
365
+
366
+ function looksLikeLogFile(name: string): boolean {
367
+ const lower = name.toLowerCase();
368
+ return (
369
+ lower.endsWith(".log") ||
370
+ lower.endsWith(".jsonl") ||
371
+ lower.endsWith(".txt") ||
372
+ lower.includes("log")
373
+ );
374
+ }
375
+
376
+ function relativeBundlePath(root: string, file: string): string {
377
+ return path.relative(root, file).split(path.sep).map(safePathSegment).join("/");
378
+ }
379
+
380
+ function safePathSegment(raw: string): string {
381
+ return raw.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 120) || "unknown";
382
+ }
@@ -14,9 +14,11 @@ import {
14
14
  PID_PATH,
15
15
  SNAPSHOT_PATH,
16
16
  loadConfig,
17
+ saveConfig,
17
18
  type DaemonConfig,
18
19
  } from "./config.js";
19
20
  import { listDaemonLogFiles, LOG_FILE_PATH, type LogFileEntry } from "./log.js";
21
+ import { listAcpTraceLogFiles, listRuntimeLogFiles } from "./acp-logs.js";
20
22
  import {
21
23
  channelsFromDaemonConfig,
22
24
  defaultHttpFetcher,
@@ -26,6 +28,12 @@ import {
26
28
  type DoctorRuntimeEntry,
27
29
  } from "./doctor.js";
28
30
  import { detectRuntimes } from "./adapters/runtimes.js";
31
+ import { log as daemonLog } from "./log.js";
32
+ import {
33
+ discoverLocalOpenclawGateways,
34
+ mergeOpenclawGateways,
35
+ openclawDiscoveryConfigEnabled,
36
+ } from "./openclaw-discovery.js";
29
37
 
30
38
  const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
31
39
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
@@ -124,6 +132,7 @@ async function buildDoctorEntries(): Promise<{
124
132
  let cfgForEndpoints: DaemonConfig | null = null;
125
133
  try {
126
134
  cfgForEndpoints = loadConfig();
135
+ cfgForEndpoints = await refreshDiscoveredOpenclawGateways(cfgForEndpoints);
127
136
  channels = channelsFromDaemonConfig(cfgForEndpoints);
128
137
  } catch {
129
138
  channels = [];
@@ -147,6 +156,31 @@ async function buildDoctorEntries(): Promise<{
147
156
  return { text: renderDoctor(input), json: input };
148
157
  }
149
158
 
159
+ async function refreshDiscoveredOpenclawGateways(cfg: DaemonConfig): Promise<DaemonConfig> {
160
+ if (!openclawDiscoveryConfigEnabled(cfg)) return cfg;
161
+ try {
162
+ const found = await discoverLocalOpenclawGateways({
163
+ searchPaths: cfg.openclawDiscovery?.searchPaths,
164
+ defaultPorts: cfg.openclawDiscovery?.defaultPorts,
165
+ timeoutMs: 500,
166
+ });
167
+ const merged = mergeOpenclawGateways(cfg, found);
168
+ if (!merged.changed) return cfg;
169
+ saveConfig(merged.cfg);
170
+ daemonLog.info("openclaw discovery: gateways merged", {
171
+ source: "diagnostics",
172
+ added: merged.added.map((g) => ({ name: g.name, url: g.url })),
173
+ });
174
+ return merged.cfg;
175
+ } catch (err) {
176
+ daemonLog.warn("openclaw discovery failed; continuing", {
177
+ source: "diagnostics",
178
+ error: err instanceof Error ? err.message : String(err),
179
+ });
180
+ return cfg;
181
+ }
182
+ }
183
+
150
184
  function crc32(buf: Buffer): number {
151
185
  let crc = 0xffffffff;
152
186
  for (const b of buf) {
@@ -297,6 +331,8 @@ export async function createDiagnosticBundle(
297
331
  const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
298
332
  const includeAllLogs = opts.includeAllLogs === true;
299
333
  const logs = bundledLogs(logFile, includeAllLogs);
334
+ const acpLogs = listAcpTraceLogFiles(includeAllLogs);
335
+ const runtimeLogs = listRuntimeLogFiles(includeAllLogs);
300
336
  mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
301
337
 
302
338
  const doctor = opts.doctor ?? await buildDoctorEntries();
@@ -318,6 +354,16 @@ export async function createDiagnosticBundle(
318
354
  sizeBytes: entry.sizeBytes,
319
355
  active: entry.active,
320
356
  })),
357
+ acpLogsBundled: acpLogs.map((entry) => ({
358
+ name: entry.name,
359
+ path: entry.path,
360
+ sizeBytes: entry.sizeBytes,
361
+ })),
362
+ runtimeLogsBundled: runtimeLogs.map((entry) => ({
363
+ name: entry.bundleName,
364
+ path: entry.path,
365
+ sizeBytes: entry.sizeBytes,
366
+ })),
321
367
  logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
322
368
  diagnosticsDir,
323
369
  userAuth: readUserAuthSummary(),
@@ -343,6 +389,20 @@ export async function createDiagnosticBundle(
343
389
  });
344
390
  }
345
391
  }
392
+ for (const entry of acpLogs) {
393
+ const log = safeReadText(entry.path);
394
+ entries.push({
395
+ name: `acp-logs/${entry.name.split(path.sep).join("/")}`,
396
+ data: log ?? `no ACP log file at ${entry.path}\n`,
397
+ });
398
+ }
399
+ for (const entry of runtimeLogs) {
400
+ const log = safeReadText(entry.path);
401
+ entries.push({
402
+ name: entry.bundleName,
403
+ data: log ?? `no runtime log file at ${entry.path}\n`,
404
+ });
405
+ }
346
406
  const config = safeReadText(configFile);
347
407
  entries.push({
348
408
  name: "config.json.redacted",
@@ -27,6 +27,9 @@ function makeClient(overrides: Partial<BotCordChannelClient> = {}): BotCordChann
27
27
  sendMessage: vi
28
28
  .fn()
29
29
  .mockResolvedValue({ hub_msg_id: "m_provider", queued: true, status: "queued" }),
30
+ sendTypedMessage: vi
31
+ .fn()
32
+ .mockResolvedValue({ hub_msg_id: "m_provider_typed", queued: true, status: "queued" }),
30
33
  getHubUrl: vi.fn().mockReturnValue("http://127.0.0.1:1"),
31
34
  ...overrides,
32
35
  };
@@ -138,6 +141,34 @@ describe("createBotCordChannel — send()", () => {
138
141
  expect(client.sendMessage).toHaveBeenCalledWith("rm_dm_1", "hey", {});
139
142
  expect(result.providerMessageId).toBeNull();
140
143
  });
144
+
145
+ it("sends runtime diagnostics as BotCord error envelopes", async () => {
146
+ const client = makeClient();
147
+ const channel = createBotCordChannel({
148
+ id: "botcord-main",
149
+ accountId: "ag_self",
150
+ agentId: "ag_self",
151
+ client,
152
+ });
153
+ const result = await channel.send({
154
+ message: {
155
+ channel: "botcord",
156
+ accountId: "ag_self",
157
+ conversationId: "rm_group_a",
158
+ threadId: "tp_42",
159
+ replyTo: "env_source",
160
+ type: "error",
161
+ text: "Runtime error: boom",
162
+ },
163
+ log: silentLog,
164
+ });
165
+ expect(client.sendTypedMessage).toHaveBeenCalledWith("rm_group_a", "error", "Runtime error: boom", {
166
+ topic: "tp_42",
167
+ replyTo: "env_source",
168
+ });
169
+ expect(client.sendMessage).not.toHaveBeenCalled();
170
+ expect(result.providerMessageId).toBe("m_provider_typed");
171
+ });
141
172
  });
142
173
 
143
174
  // ---------------------------------------------------------------------------
@@ -263,6 +294,52 @@ describe("createBotCordChannel — inbox normalization", () => {
263
294
  }
264
295
  });
265
296
 
297
+ it("acks error InboxMessages without dispatching them", async () => {
298
+ const server = await startAuthOkServer();
299
+ const errorMessage = makeInbox({
300
+ hub_msg_id: "m_error_1",
301
+ text: undefined,
302
+ envelope: {
303
+ type: "error",
304
+ from: "ag_peer",
305
+ payload: { error: { code: "agent_error", message: "Runtime error: boom" } },
306
+ } as InboxMessage["envelope"],
307
+ });
308
+ const client = makeClient({
309
+ pollInbox: vi.fn().mockResolvedValue({ messages: [errorMessage], count: 1, has_more: false }),
310
+ getHubUrl: vi.fn().mockReturnValue(server.url),
311
+ });
312
+ const channel = createBotCordChannel({
313
+ id: "botcord-main",
314
+ accountId: "ag_self",
315
+ agentId: "ag_self",
316
+ client,
317
+ hubBaseUrl: server.url,
318
+ });
319
+ const abort = new AbortController();
320
+ const emits: GatewayInboundEnvelope[] = [];
321
+ const startPromise = channel.start({
322
+ config: stubConfig,
323
+ accountId: "ag_self",
324
+ abortSignal: abort.signal,
325
+ log: silentLog,
326
+ emit: async (env) => {
327
+ emits.push(env);
328
+ },
329
+ setStatus: () => {},
330
+ });
331
+ try {
332
+ await vi.waitFor(() => {
333
+ expect(client.ackMessages).toHaveBeenCalledWith(["m_error_1"]);
334
+ });
335
+ expect(emits).toHaveLength(0);
336
+ } finally {
337
+ abort.abort();
338
+ await startPromise;
339
+ await server.close();
340
+ }
341
+ });
342
+
266
343
  it("marks rm_dm_ and rm_oc_ rooms as direct; rm_oc_ also sets streamable + user-kind", async () => {
267
344
  const { emits, server } = await startWithInbox([
268
345
  makeInbox({