@botcord/daemon 0.2.20 → 0.2.22

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,11 +12,13 @@ import os from "node:os";
12
12
  import path from "node:path";
13
13
 
14
14
  import {
15
+ agentCodexHomeDir,
15
16
  agentHermesHomeDir,
16
17
  agentHomeDir,
17
18
  agentStateDir,
18
19
  agentWorkspaceDir,
19
20
  applyAgentIdentity,
21
+ ensureAgentCodexHome,
20
22
  ensureAgentHermesWorkspace,
21
23
  ensureAgentWorkspace,
22
24
  } from "../agent-workspace.js";
@@ -105,6 +107,49 @@ describe("ensureAgentWorkspace", () => {
105
107
  expect(reseeded).toContain("name: botcord");
106
108
  });
107
109
 
110
+ it("seeds bundled skills under codex-home/skills/ so per-agent CODEX_HOME sees them", () => {
111
+ ensureAgentWorkspace("ag_codex_skills", {});
112
+ const skillsDir = path.join(agentCodexHomeDir("ag_codex_skills"), "skills");
113
+ expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
114
+ expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
115
+ });
116
+
117
+ it("re-seeds codex skills on subsequent ensureAgentCodexHome calls", () => {
118
+ ensureAgentCodexHome("ag_codex_reseed");
119
+ const skillFile = path.join(
120
+ agentCodexHomeDir("ag_codex_reseed"),
121
+ "skills",
122
+ "botcord",
123
+ "SKILL.md",
124
+ );
125
+ writeFileSync(skillFile, "stale content from a prior daemon version\n");
126
+
127
+ ensureAgentCodexHome("ag_codex_reseed");
128
+
129
+ const reseeded = readFileSync(skillFile, "utf8");
130
+ expect(reseeded).not.toBe("stale content from a prior daemon version\n");
131
+ expect(reseeded).toContain("name: botcord");
132
+ });
133
+
134
+ it("seeds bundled skills under hermes-home/skills/ so per-agent HERMES_HOME sees them", () => {
135
+ const { hermesHome } = ensureAgentHermesWorkspace("ag_hermes_skills");
136
+ const skillsDir = path.join(hermesHome, "skills");
137
+ expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
138
+ expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
139
+ });
140
+
141
+ it("re-seeds hermes skills on subsequent ensureAgentHermesWorkspace calls", () => {
142
+ const { hermesHome } = ensureAgentHermesWorkspace("ag_hermes_reseed");
143
+ const skillFile = path.join(hermesHome, "skills", "botcord", "SKILL.md");
144
+ writeFileSync(skillFile, "stale content from a prior daemon version\n");
145
+
146
+ ensureAgentHermesWorkspace("ag_hermes_reseed");
147
+
148
+ const reseeded = readFileSync(skillFile, "utf8");
149
+ expect(reseeded).not.toBe("stale content from a prior daemon version\n");
150
+ expect(reseeded).toContain("name: botcord");
151
+ });
152
+
108
153
  it("does not overwrite a user-modified memory.md on a second call", () => {
109
154
  ensureAgentWorkspace("ag_keep", {});
110
155
  const memoryPath = path.join(agentWorkspaceDir("ag_keep"), "memory.md");
@@ -778,6 +778,86 @@ describe("provision_agent seeds workspace + hot-adds managed route", () => {
778
778
  expect(gw.listManagedRoutes()).toHaveLength(0);
779
779
  });
780
780
  });
781
+
782
+ // Regression: the daemon's per-agent caches (credentialPathByAgentId,
783
+ // hubUrlByAgentId, displayNameByAgent) used to be seeded only at boot.
784
+ // Hot-provisioning then left those caches missing the new agent until the
785
+ // next restart, and `room-context-fetcher` logged
786
+ // `daemon.room-context.no-credentials` on every turn. This contract test
787
+ // pins the install path: a successful provision MUST fire the hook with
788
+ // the credential file + hub URL + display name.
789
+ it("fires onAgentInstalled after a successful install so daemon caches stay warm", async () => {
790
+ await withSandboxHome(async ({ tmp, path: nodePath }) => {
791
+ const gw = makeFakeGateway();
792
+ const installed: Array<Record<string, unknown>> = [];
793
+ const provisioner = createProvisioner({
794
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
795
+ onAgentInstalled: (info) => {
796
+ installed.push({ ...info });
797
+ },
798
+ });
799
+ const privateKey = Buffer.alloc(32, 41).toString("base64");
800
+ const ack = await provisioner({
801
+ id: "req_hook",
802
+ type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
803
+ params: {
804
+ // `name` only flows into `displayName` on the slow (daemon-register)
805
+ // path. Hub's fast path carries it via `credentials.displayName`.
806
+ runtime: "claude-code",
807
+ credentials: {
808
+ agentId: "ag_hook",
809
+ keyId: "k_hook",
810
+ privateKey,
811
+ hubUrl: "https://hub.example",
812
+ displayName: "zhejian's cc",
813
+ },
814
+ },
815
+ });
816
+ expect(ack.ok).toBe(true);
817
+ expect(installed).toHaveLength(1);
818
+ const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_hook.json");
819
+ expect(installed[0]).toMatchObject({
820
+ agentId: "ag_hook",
821
+ credentialsFile: credFile,
822
+ hubUrl: "https://hub.example",
823
+ displayName: "zhejian's cc",
824
+ runtime: "claude-code",
825
+ });
826
+ });
827
+ });
828
+
829
+ // The hook is best-effort wiring, not part of the install transaction.
830
+ // A throwing hook must not roll back the install (the agent is already
831
+ // on disk and in the gateway), and must not flip the control-frame ack
832
+ // to failure — the operator only sees a loud error log.
833
+ it("does not roll back the install when onAgentInstalled throws", async () => {
834
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
835
+ const gw = makeFakeGateway();
836
+ const provisioner = createProvisioner({
837
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
838
+ onAgentInstalled: () => {
839
+ throw new Error("hook boom");
840
+ },
841
+ });
842
+ const privateKey = Buffer.alloc(32, 43).toString("base64");
843
+ const ack = await provisioner({
844
+ id: "req_hook_throws",
845
+ type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
846
+ params: {
847
+ credentials: {
848
+ agentId: "ag_hookboom",
849
+ keyId: "k_hb",
850
+ privateKey,
851
+ hubUrl: "https://hub.example",
852
+ },
853
+ },
854
+ });
855
+ expect(ack.ok).toBe(true);
856
+ const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_hookboom.json");
857
+ expect(fs.existsSync(credFile)).toBe(true);
858
+ expect(gw.listManagedRoutes()).toHaveLength(1);
859
+ });
860
+ });
781
861
  });
782
862
 
783
863
  describe("adoptDiscoveredOpenclawAgents", () => {
@@ -333,14 +333,18 @@ function isSymlink(p: string): boolean {
333
333
  }
334
334
 
335
335
  /**
336
- * Idempotently create the per-agent CODEX_HOME directory and link the
337
- * user's codex `auth.json` into it. Does NOT write an initial `AGENTS.md`
338
- * the codex adapter writes it fresh per turn from `systemContext`.
336
+ * Idempotently create the per-agent CODEX_HOME directory, link the
337
+ * user's codex `auth.json` into it, and seed the bundled BotCord skills
338
+ * under `<dir>/skills/` so the codex runtime (which sees this as
339
+ * `CODEX_HOME`, not the user's `~/.codex`) can discover them. Does NOT
340
+ * write an initial `AGENTS.md` — the codex adapter writes it fresh per
341
+ * turn from `systemContext`.
339
342
  */
340
343
  export function ensureAgentCodexHome(agentId: string): string {
341
344
  const dir = agentCodexHomeDir(agentId);
342
345
  mkdirTolerant(dir);
343
346
  linkCodexAuth(dir);
347
+ seedCodexSkills(dir);
344
348
  return dir;
345
349
  }
346
350
 
@@ -348,7 +352,10 @@ export function ensureAgentCodexHome(agentId: string): string {
348
352
  * Idempotently create the per-agent HERMES_HOME and HERMES workspace
349
353
  * directories. Writes a stub `.env` inside HERMES_HOME so hermes-acp's
350
354
  * `_load_env` does not log "No .env found" on every spawn; users can edit
351
- * this file to add API keys / model overrides.
355
+ * this file to add API keys / model overrides. Also seeds the bundled
356
+ * BotCord skills under `<hermes-home>/skills/` so hermes-acp's skill
357
+ * loader (which only sees this isolated HERMES_HOME, not `~/.hermes`)
358
+ * can discover them.
352
359
  */
353
360
  export function ensureAgentHermesWorkspace(agentId: string): {
354
361
  hermesHome: string;
@@ -365,16 +372,21 @@ export function ensureAgentHermesWorkspace(agentId: string): {
365
372
  );
366
373
  seedHermesConfig(hermesHome);
367
374
  mergeHermesProviderEnv(path.join(hermesHome, ".env"));
375
+ seedHermesAgentSkills(hermesHome);
368
376
  return { hermesHome, hermesWorkspace };
369
377
  }
370
378
 
371
379
  /**
372
- * Bundled Claude Code skills shipped inside `@botcord/cli/skills/`. Seeded
373
- * into every agent workspace so the spawned `claude` runtime (which loads
374
- * `.claude/` via `--setting-sources project`) can discover the BotCord CLI
375
- * skill without any manual setup.
380
+ * Bundled BotCord skills shipped inside `@botcord/cli/skills/`. Skill
381
+ * content (SKILL.md + helper scripts) is runtime-agnostic; only the
382
+ * discovery path differs:
383
+ * - Claude Code: `<workspace>/.claude/skills/<name>/`
384
+ * - Codex: `<codex-home>/skills/<name>/`
385
+ * - Hermes: `<hermes-home>/skills/<name>/`
386
+ * Seeded fresh per `ensureAgent*` call (force-overwrite) so daemon
387
+ * upgrades propagate.
376
388
  */
377
- const BUNDLED_CC_SKILLS = ["botcord", "botcord-user-guide"] as const;
389
+ const BUNDLED_SKILLS = ["botcord", "botcord-user-guide"] as const;
378
390
 
379
391
  function resolveBundledCliSkillsRoot(): string | null {
380
392
  try {
@@ -387,21 +399,19 @@ function resolveBundledCliSkillsRoot(): string | null {
387
399
  }
388
400
 
389
401
  /**
390
- * Copy daemon-owned Claude Code skills into the workspace. Re-copied on every
391
- * `ensureAgentWorkspace` call (force-overwrite) so daemon upgrades propagate;
392
- * users wanting custom skills should pick a different directory name under
393
- * `.claude/skills/` those are not touched here.
402
+ * Copy bundled skill directories into `destSkillsDir`, force-overwriting
403
+ * any prior copy of each named skill. Other entries in `destSkillsDir`
404
+ * are left alone so user-authored skills survive. Best-effort: silently
405
+ * skips on copy failure or when the bundled CLI isn't resolvable.
394
406
  */
395
- function seedClaudeCodeSkills(workspace: string): void {
407
+ function copyBundledSkills(destSkillsDir: string): void {
396
408
  const sourceRoot = resolveBundledCliSkillsRoot();
397
409
  if (!sourceRoot) return;
398
- const skillsDir = path.join(workspace, ".claude", "skills");
399
- mkdirTolerant(path.join(workspace, ".claude"));
400
- mkdirTolerant(skillsDir);
401
- for (const name of BUNDLED_CC_SKILLS) {
410
+ mkdirTolerant(destSkillsDir);
411
+ for (const name of BUNDLED_SKILLS) {
402
412
  const src = path.join(sourceRoot, name);
403
413
  if (!existsSync(src)) continue;
404
- const dst = path.join(skillsDir, name);
414
+ const dst = path.join(destSkillsDir, name);
405
415
  try {
406
416
  cpSync(src, dst, { recursive: true, force: true, dereference: true });
407
417
  } catch {
@@ -410,6 +420,37 @@ function seedClaudeCodeSkills(workspace: string): void {
410
420
  }
411
421
  }
412
422
 
423
+ /**
424
+ * Seed Claude Code's `.claude/skills/` discovery dir under the agent
425
+ * workspace. The `claude` adapter spawns with `--setting-sources project`
426
+ * so this dir is auto-discovered.
427
+ */
428
+ function seedClaudeCodeSkills(workspace: string): void {
429
+ mkdirTolerant(path.join(workspace, ".claude"));
430
+ copyBundledSkills(path.join(workspace, ".claude", "skills"));
431
+ }
432
+
433
+ /**
434
+ * Seed Codex's `<CODEX_HOME>/skills/` discovery dir. The codex adapter
435
+ * sets `CODEX_HOME=<agent>/codex-home/`, isolating per-agent skills from
436
+ * the user's global `~/.codex/skills/` — so skills must be seeded here
437
+ * for Codex agents to discover them.
438
+ */
439
+ function seedCodexSkills(codexHome: string): void {
440
+ copyBundledSkills(path.join(codexHome, "skills"));
441
+ }
442
+
443
+ /**
444
+ * Seed Hermes's `<HERMES_HOME>/skills/` discovery dir. hermes-acp's
445
+ * skill loader scans `$HERMES_HOME/skills/` (primary) plus any
446
+ * `skills.external_dirs` from config; the daemon points hermes-acp at
447
+ * the per-agent `<hermes-home>/`, so the user's global
448
+ * `~/.hermes/skills/` is invisible — bundled skills must be seeded here.
449
+ */
450
+ function seedHermesAgentSkills(hermesHome: string): void {
451
+ copyBundledSkills(path.join(hermesHome, "skills"));
452
+ }
453
+
413
454
  /**
414
455
  * Idempotently create the agent's home / workspace / state directories and
415
456
  * seed the workspace Markdown files. Existing files are never overwritten —
package/src/daemon.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  adoptDiscoveredOpenclawAgents,
28
28
  collectRuntimeSnapshot,
29
29
  createProvisioner,
30
+ type OnAgentInstalledHook,
30
31
  } from "./provision.js";
31
32
  import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
32
33
  import { SnapshotWriter } from "./snapshot-writer.js";
@@ -382,6 +383,34 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
382
383
  });
383
384
  };
384
385
 
386
+ // Boot-seeded per-agent caches (`credentialPathByAgentId`,
387
+ // `hubUrlByAgentId`, `displayNameByAgent`, `scBuilders`) are scoped to
388
+ // the agents present at startup. Without this hook, agents added later
389
+ // via `provision_agent` or openclaw-adoption stay missing from those
390
+ // caches until the next daemon restart — `room-context-fetcher` then
391
+ // logs `daemon.room-context.no-credentials` on every turn for the new
392
+ // agent and the system context loses its `[BotCord Room]` block (member
393
+ // names, rule, role).
394
+ const onAgentInstalled: OnAgentInstalledHook = (info) => {
395
+ // Re-provision (e.g. credential rotation) overwrites in place so the
396
+ // next room-context fetch re-loads the BotCordClient against the new
397
+ // credential file.
398
+ credentialPathByAgentId.set(info.agentId, info.credentialsFile);
399
+ if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
400
+ if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
401
+ if (!scBuilders.has(info.agentId)) {
402
+ scBuilders.set(
403
+ info.agentId,
404
+ createDaemonSystemContextBuilder({
405
+ agentId: info.agentId,
406
+ activityTracker,
407
+ roomContextBuilder,
408
+ loopRiskBuilder,
409
+ }),
410
+ );
411
+ }
412
+ };
413
+
385
414
  const gateway = new Gateway({
386
415
  config: gwConfig,
387
416
  sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
@@ -437,6 +466,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
437
466
  const adopted = await adoptDiscoveredOpenclawAgents({
438
467
  gateway,
439
468
  cfg: opts.config,
469
+ onAgentInstalled,
440
470
  });
441
471
  if (
442
472
  adopted.adopted.length > 0 ||
@@ -465,7 +495,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
465
495
  userId: userAuth.current.userId,
466
496
  hubUrl: userAuth.current.hubUrl,
467
497
  });
468
- const provisioner = createProvisioner({ gateway, policyResolver });
498
+ const provisioner = createProvisioner({ gateway, policyResolver, onAgentInstalled });
469
499
  controlChannel = new ControlChannel({
470
500
  auth: userAuth,
471
501
  handle: provisioner,
@@ -30,7 +30,11 @@ const OWNER_CHAT_PREFIX = "rm_oc_";
30
30
  const DM_ROOM_PREFIX = "rm_dm_";
31
31
  const INBOX_POLL_LIMIT = 50;
32
32
 
33
- type InboxDrainTrigger = "ws_auth_ok" | "ws_inbox_update" | "coalesced_inbox_update";
33
+ type InboxDrainTrigger =
34
+ | "ws_auth_ok"
35
+ | "ws_inbox_update"
36
+ | "coalesced_inbox_update"
37
+ | "has_more_continue";
34
38
 
35
39
  /** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
36
40
  export interface BotCordChannelClient {
@@ -309,7 +313,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
309
313
  emit: (env: GatewayInboundEnvelope) => Promise<void>,
310
314
  log: GatewayLogger,
311
315
  trigger: InboxDrainTrigger,
312
- ): Promise<void> {
316
+ ): Promise<{ hasMore: boolean }> {
313
317
  const startedAt = Date.now();
314
318
  const resp = await client.pollInbox({ limit: INBOX_POLL_LIMIT, ack: false });
315
319
  const msgs = resp.messages ?? [];
@@ -334,7 +338,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
334
338
  const eligible: InboxMessage[] = [];
335
339
  if (msgs.length === 0) {
336
340
  logDrain();
337
- return;
341
+ // Defensive: if Hub returns 0 messages, refuse to honor has_more=true.
342
+ // A stuck cursor on the Hub side could otherwise produce an unbounded
343
+ // poll loop here (count=0 with has_more=true on every iteration).
344
+ return { hasMore: false };
338
345
  }
339
346
 
340
347
  // First pass: ack duplicates/skipped messages so Hub stops requeueing,
@@ -370,7 +377,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
370
377
 
371
378
  if (eligible.length === 0) {
372
379
  logDrain();
373
- return;
380
+ return { hasMore: Boolean(resp.has_more) };
374
381
  }
375
382
 
376
383
  // Group by `(room_id, topic)`. Insertion order is the poll order, so
@@ -384,6 +391,13 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
384
391
  else groups.set(key, [msg]);
385
392
  }
386
393
 
394
+ // Emit groups in parallel: each `(room_id, topic)` group is an independent
395
+ // conversation thread, and the dispatcher already keys its per-turn queue
396
+ // by `(channel, accountId, roomId, threadId)` (see `buildQueueKey` in
397
+ // dispatcher.ts). Awaiting groups serially here forced a slow turn in
398
+ // room A to block room B's turn from starting; running them concurrently
399
+ // lets the dispatcher's per-room queues actually run in parallel.
400
+ const emitTasks: Promise<void>[] = [];
387
401
  for (const group of groups.values()) {
388
402
  const normalized = normalizeInboxBatch(group, {
389
403
  channelId: options.id,
@@ -409,17 +423,23 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
409
423
  },
410
424
  },
411
425
  };
412
- try {
413
- await emit(envelope);
414
- emittedGroups += 1;
415
- } catch (err) {
416
- log.error("botcord emit threw", {
417
- hubMsgIds: hubIds,
418
- err: String(err),
419
- });
420
- }
426
+ emitTasks.push(
427
+ emit(envelope).then(
428
+ () => {
429
+ emittedGroups += 1;
430
+ },
431
+ (err) => {
432
+ log.error("botcord emit threw", {
433
+ hubMsgIds: hubIds,
434
+ err: String(err),
435
+ });
436
+ },
437
+ ),
438
+ );
421
439
  }
440
+ await Promise.all(emitTasks);
422
441
  logDrain();
442
+ return { hasMore: Boolean(resp.has_more) };
423
443
  }
424
444
 
425
445
  function startWsLoop(
@@ -470,11 +490,16 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
470
490
  processing = true;
471
491
  try {
472
492
  let currentTrigger = trigger;
493
+ let hasMore = false;
473
494
  do {
474
495
  pendingUpdate = false;
475
- await drainInbox(client, emit, log, currentTrigger);
476
- currentTrigger = "coalesced_inbox_update";
477
- } while (pendingUpdate && running);
496
+ const result = await drainInbox(client, emit, log, currentTrigger);
497
+ hasMore = result.hasMore;
498
+ // Prefer `has_more_continue` when this iteration is chained because
499
+ // the previous poll capped at INBOX_POLL_LIMIT — distinguishes a
500
+ // backlog drain from a coalesced ws_inbox_update drain in logs.
501
+ currentTrigger = hasMore ? "has_more_continue" : "coalesced_inbox_update";
502
+ } while ((pendingUpdate || hasMore) && running);
478
503
  } catch (err) {
479
504
  log.error("botcord inbox drain failed", { err: String(err) });
480
505
  } finally {
@@ -324,6 +324,18 @@ export class Dispatcher {
324
324
  // grounded turnId for any downstream attention_skipped / dropped / etc.
325
325
  this.emitInbound(turnId, msg);
326
326
 
327
+ this.log.info("dispatcher: inbound received", {
328
+ agentId: msg.accountId,
329
+ roomId: msg.conversation.id,
330
+ topicId: msg.conversation.threadId ?? null,
331
+ turnId,
332
+ messageId: msg.id,
333
+ senderId: msg.sender.id,
334
+ senderKind: msg.sender.kind,
335
+ mode,
336
+ textPreview: logPreview(rawText),
337
+ });
338
+
327
339
  // Notify the optional observer (activity tracking, metrics, etc.) as soon
328
340
  // as the dispatcher owns the message. Errors must not abort the turn.
329
341
  if (this.onInbound) {
@@ -448,7 +460,14 @@ export class Dispatcher {
448
460
  const myGen = q.cancelGen;
449
461
  const prev = q.current;
450
462
  if (prev) {
451
- this.log.info("dispatcher: cancelling previous turn", { queueKey });
463
+ this.log.info("dispatcher: cancelling previous turn", {
464
+ agentId: msg.accountId,
465
+ roomId: msg.conversation.id,
466
+ topicId: msg.conversation.threadId ?? null,
467
+ turnId,
468
+ prevTurnId: prev.turnId,
469
+ queueKey,
470
+ });
452
471
  // Record the supersede BEFORE aborting so the prev turn's finalize sees
453
472
  // the abort reason (TurnSupersededError) and skips writing turn_error.
454
473
  this.transcript.write({
@@ -469,7 +488,13 @@ export class Dispatcher {
469
488
  // already fired its own abort + runTurn, or be mid-await itself. If so,
470
489
  // drop out silently — the newest turn is the only one that should run.
471
490
  if (myGen !== q.cancelGen) {
472
- this.log.info("dispatcher: cancel-previous superseded", { queueKey });
491
+ this.log.info("dispatcher: cancel-previous superseded", {
492
+ agentId: msg.accountId,
493
+ roomId: msg.conversation.id,
494
+ topicId: msg.conversation.threadId ?? null,
495
+ turnId,
496
+ queueKey,
497
+ });
473
498
  // We didn't run the turn; emit dropped so the caller's inbound has a
474
499
  // matching path record. supersededBy is unknown at this layer (newer
475
500
  // arrival owns its own bump) — leave null.
@@ -738,10 +763,25 @@ export class Dispatcher {
738
763
  this.transcript.write(dispatched);
739
764
  }
740
765
 
766
+ this.log.info("dispatcher: dispatched to runtime", {
767
+ agentId: msg.accountId,
768
+ roomId: msg.conversation.id,
769
+ topicId: msg.conversation.threadId ?? null,
770
+ turnId,
771
+ runtime: route.runtime,
772
+ cwd: route.cwd,
773
+ ...(mergedFromTurnIds.length > 0 ? { mergedFromTurns: mergedFromTurnIds.length } : {}),
774
+ composedPreview: logPreview(text),
775
+ });
776
+
741
777
  // Hard-cap turn with a timeout.
742
778
  const timer = setTimeout(() => {
743
779
  slot.timedOut = true;
744
780
  this.log.warn("dispatcher: turn timed out", {
781
+ agentId: msg.accountId,
782
+ roomId: msg.conversation.id,
783
+ topicId: msg.conversation.threadId ?? null,
784
+ turnId,
745
785
  queueKey,
746
786
  timeoutMs: this.turnTimeoutMs,
747
787
  });
@@ -1072,11 +1112,14 @@ export class Dispatcher {
1072
1112
  text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
1073
1113
  replyTo: msg.id,
1074
1114
  traceId: msg.trace?.id ?? null,
1075
- });
1115
+ }, turnId);
1076
1116
  } else {
1077
1117
  this.log.warn("dispatcher: timeout in non-owner-chat room — error reply suppressed", {
1118
+ agentId: msg.accountId,
1119
+ roomId: msg.conversation.id,
1120
+ topicId: msg.conversation.threadId ?? null,
1121
+ turnId,
1078
1122
  queueKey,
1079
- conversationId: msg.conversation.id,
1080
1123
  timeoutMs: this.turnTimeoutMs,
1081
1124
  });
1082
1125
  }
@@ -1086,6 +1129,10 @@ export class Dispatcher {
1086
1129
  if (threw) {
1087
1130
  const errMsg = threw instanceof Error ? threw.message : String(threw);
1088
1131
  this.log.error("dispatcher: runtime threw", {
1132
+ agentId: msg.accountId,
1133
+ roomId: msg.conversation.id,
1134
+ topicId: msg.conversation.threadId ?? null,
1135
+ turnId,
1089
1136
  queueKey,
1090
1137
  runtime: route.runtime,
1091
1138
  error: errMsg,
@@ -1110,11 +1157,14 @@ export class Dispatcher {
1110
1157
  text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
1111
1158
  replyTo: msg.id,
1112
1159
  traceId: msg.trace?.id ?? null,
1113
- });
1160
+ }, turnId);
1114
1161
  } else {
1115
1162
  this.log.warn("dispatcher: runtime error in non-owner-chat room — error reply suppressed", {
1163
+ agentId: msg.accountId,
1164
+ roomId: msg.conversation.id,
1165
+ topicId: msg.conversation.threadId ?? null,
1166
+ turnId,
1116
1167
  queueKey,
1117
- conversationId: msg.conversation.id,
1118
1168
  });
1119
1169
  }
1120
1170
  return;
@@ -1201,8 +1251,11 @@ export class Dispatcher {
1201
1251
  this.log.debug(
1202
1252
  "dispatcher: non-owner-chat — discarding result.text (agent must use botcord_send)",
1203
1253
  {
1254
+ agentId: msg.accountId,
1255
+ roomId: msg.conversation.id,
1256
+ topicId: msg.conversation.threadId ?? null,
1257
+ turnId,
1204
1258
  queueKey,
1205
- conversationId: msg.conversation.id,
1206
1259
  replyTextLen: replyText.length,
1207
1260
  },
1208
1261
  );
@@ -1236,7 +1289,7 @@ export class Dispatcher {
1236
1289
  text: replyText,
1237
1290
  replyTo: msg.id,
1238
1291
  traceId: msg.trace?.id ?? null,
1239
- });
1292
+ }, turnId);
1240
1293
  this.emitOutbound({
1241
1294
  turnId,
1242
1295
  msg,
@@ -1268,14 +1321,18 @@ export class Dispatcher {
1268
1321
  private async sendReply(
1269
1322
  channel: ChannelAdapter,
1270
1323
  outbound: GatewayOutboundMessage,
1324
+ turnId?: string,
1271
1325
  ): Promise<{ ok: true } | { ok: false; error: string }> {
1272
1326
  try {
1273
1327
  await channel.send({ message: outbound, log: this.log });
1274
1328
  } catch (err) {
1275
1329
  const error = err instanceof Error ? err.message : String(err);
1276
1330
  this.log.warn("dispatcher: channel.send failed", {
1331
+ agentId: outbound.accountId,
1332
+ roomId: outbound.conversationId,
1333
+ topicId: outbound.threadId ?? null,
1334
+ ...(turnId ? { turnId } : {}),
1277
1335
  channel: outbound.channel,
1278
- conversationId: outbound.conversationId,
1279
1336
  error,
1280
1337
  });
1281
1338
  return { ok: false, error };
@@ -1285,7 +1342,10 @@ export class Dispatcher {
1285
1342
  await this.onOutbound(outbound);
1286
1343
  } catch (err) {
1287
1344
  this.log.warn("dispatcher: onOutbound threw — continuing", {
1288
- conversationId: outbound.conversationId,
1345
+ agentId: outbound.accountId,
1346
+ roomId: outbound.conversationId,
1347
+ topicId: outbound.threadId ?? null,
1348
+ ...(turnId ? { turnId } : {}),
1289
1349
  error: err instanceof Error ? err.message : String(err),
1290
1350
  });
1291
1351
  }
@@ -1333,6 +1393,19 @@ export class Dispatcher {
1333
1393
  deliveryReason: string | null;
1334
1394
  blocks: TranscriptBlockSummary[];
1335
1395
  }): void {
1396
+ const durationMs = Date.now() - args.startedAt;
1397
+ this.log.info("dispatcher: outbound emitted", {
1398
+ agentId: args.msg.accountId,
1399
+ roomId: args.msg.conversation.id,
1400
+ topicId: args.msg.conversation.threadId ?? null,
1401
+ turnId: args.turnId,
1402
+ runtime: args.runtime,
1403
+ deliveryStatus: args.deliveryStatus,
1404
+ ...(args.deliveryReason ? { deliveryReason: args.deliveryReason } : {}),
1405
+ durationMs,
1406
+ replyPreview: logPreview(args.finalText.text),
1407
+ ...(typeof args.costUsd === "number" ? { costUsd: args.costUsd } : {}),
1408
+ });
1336
1409
  if (!this.transcript.enabled) return;
1337
1410
  const rec: import("./transcript.js").OutboundTranscriptRecord = {
1338
1411
  ts: nowIso(),
@@ -1343,7 +1416,7 @@ export class Dispatcher {
1343
1416
  topicId: args.msg.conversation.threadId ?? null,
1344
1417
  runtime: args.runtime,
1345
1418
  runtimeSessionId: args.runtimeSessionId,
1346
- durationMs: Date.now() - args.startedAt,
1419
+ durationMs,
1347
1420
  finalText: args.finalText.text,
1348
1421
  deliveryStatus: args.deliveryStatus,
1349
1422
  deliveryReason: args.deliveryReason,
@@ -1397,3 +1470,13 @@ function resolveQueueMode(
1397
1470
  function truncate(s: string, max: number): string {
1398
1471
  return s.length <= max ? s : s.slice(0, max) + "…";
1399
1472
  }
1473
+
1474
+ /**
1475
+ * Single-line preview of a multi-line user/agent text, capped at `max` chars.
1476
+ * Used to embed message/reply previews in daemon.log lines without bloating
1477
+ * each line into multi-line JSON. Full text lives in transcripts.
1478
+ */
1479
+ function logPreview(s: string, max: number = 120): string {
1480
+ const flat = s.replace(/\s+/g, " ").trim();
1481
+ return flat.length <= max ? flat : flat.slice(0, max) + "…";
1482
+ }