@botcord/daemon 0.2.21 → 0.2.23

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.
@@ -31,16 +31,22 @@ export interface WorkspaceSeed {
31
31
  savedAt?: string;
32
32
  }
33
33
  /**
34
- * Idempotently create the per-agent CODEX_HOME directory and link the
35
- * user's codex `auth.json` into it. Does NOT write an initial `AGENTS.md`
36
- * the codex adapter writes it fresh per turn from `systemContext`.
34
+ * Idempotently create the per-agent CODEX_HOME directory, link the
35
+ * user's codex `auth.json` into it, and seed the bundled BotCord skills
36
+ * under `<dir>/skills/` so the codex runtime (which sees this as
37
+ * `CODEX_HOME`, not the user's `~/.codex`) can discover them. Does NOT
38
+ * write an initial `AGENTS.md` — the codex adapter writes it fresh per
39
+ * turn from `systemContext`.
37
40
  */
38
41
  export declare function ensureAgentCodexHome(agentId: string): string;
39
42
  /**
40
43
  * Idempotently create the per-agent HERMES_HOME and HERMES workspace
41
44
  * directories. Writes a stub `.env` inside HERMES_HOME so hermes-acp's
42
45
  * `_load_env` does not log "No .env found" on every spawn; users can edit
43
- * this file to add API keys / model overrides.
46
+ * this file to add API keys / model overrides. Also seeds the bundled
47
+ * BotCord skills under `<hermes-home>/skills/` so hermes-acp's skill
48
+ * loader (which only sees this isolated HERMES_HOME, not `~/.hermes`)
49
+ * can discover them.
44
50
  */
45
51
  export declare function ensureAgentHermesWorkspace(agentId: string): {
46
52
  hermesHome: string;
@@ -304,21 +304,28 @@ function isSymlink(p) {
304
304
  }
305
305
  }
306
306
  /**
307
- * Idempotently create the per-agent CODEX_HOME directory and link the
308
- * user's codex `auth.json` into it. Does NOT write an initial `AGENTS.md`
309
- * the codex adapter writes it fresh per turn from `systemContext`.
307
+ * Idempotently create the per-agent CODEX_HOME directory, link the
308
+ * user's codex `auth.json` into it, and seed the bundled BotCord skills
309
+ * under `<dir>/skills/` so the codex runtime (which sees this as
310
+ * `CODEX_HOME`, not the user's `~/.codex`) can discover them. Does NOT
311
+ * write an initial `AGENTS.md` — the codex adapter writes it fresh per
312
+ * turn from `systemContext`.
310
313
  */
311
314
  export function ensureAgentCodexHome(agentId) {
312
315
  const dir = agentCodexHomeDir(agentId);
313
316
  mkdirTolerant(dir);
314
317
  linkCodexAuth(dir);
318
+ seedCodexSkills(dir);
315
319
  return dir;
316
320
  }
317
321
  /**
318
322
  * Idempotently create the per-agent HERMES_HOME and HERMES workspace
319
323
  * directories. Writes a stub `.env` inside HERMES_HOME so hermes-acp's
320
324
  * `_load_env` does not log "No .env found" on every spawn; users can edit
321
- * this file to add API keys / model overrides.
325
+ * this file to add API keys / model overrides. Also seeds the bundled
326
+ * BotCord skills under `<hermes-home>/skills/` so hermes-acp's skill
327
+ * loader (which only sees this isolated HERMES_HOME, not `~/.hermes`)
328
+ * can discover them.
322
329
  */
323
330
  export function ensureAgentHermesWorkspace(agentId) {
324
331
  const hermesHome = agentHermesHomeDir(agentId);
@@ -329,15 +336,20 @@ export function ensureAgentHermesWorkspace(agentId) {
329
336
  "# Add e.g. HERMES_INFERENCE_PROVIDER=openrouter, OPENROUTER_API_KEY=...\n");
330
337
  seedHermesConfig(hermesHome);
331
338
  mergeHermesProviderEnv(path.join(hermesHome, ".env"));
339
+ seedHermesAgentSkills(hermesHome);
332
340
  return { hermesHome, hermesWorkspace };
333
341
  }
334
342
  /**
335
- * Bundled Claude Code skills shipped inside `@botcord/cli/skills/`. Seeded
336
- * into every agent workspace so the spawned `claude` runtime (which loads
337
- * `.claude/` via `--setting-sources project`) can discover the BotCord CLI
338
- * skill without any manual setup.
343
+ * Bundled BotCord skills shipped inside `@botcord/cli/skills/`. Skill
344
+ * content (SKILL.md + helper scripts) is runtime-agnostic; only the
345
+ * discovery path differs:
346
+ * - Claude Code: `<workspace>/.claude/skills/<name>/`
347
+ * - Codex: `<codex-home>/skills/<name>/`
348
+ * - Hermes: `<hermes-home>/skills/<name>/`
349
+ * Seeded fresh per `ensureAgent*` call (force-overwrite) so daemon
350
+ * upgrades propagate.
339
351
  */
340
- const BUNDLED_CC_SKILLS = ["botcord", "botcord-user-guide"];
352
+ const BUNDLED_SKILLS = ["botcord", "botcord-user-guide"];
341
353
  function resolveBundledCliSkillsRoot() {
342
354
  try {
343
355
  const pkgJsonPath = require.resolve("@botcord/cli/package.json");
@@ -349,23 +361,21 @@ function resolveBundledCliSkillsRoot() {
349
361
  }
350
362
  }
351
363
  /**
352
- * Copy daemon-owned Claude Code skills into the workspace. Re-copied on every
353
- * `ensureAgentWorkspace` call (force-overwrite) so daemon upgrades propagate;
354
- * users wanting custom skills should pick a different directory name under
355
- * `.claude/skills/` those are not touched here.
364
+ * Copy bundled skill directories into `destSkillsDir`, force-overwriting
365
+ * any prior copy of each named skill. Other entries in `destSkillsDir`
366
+ * are left alone so user-authored skills survive. Best-effort: silently
367
+ * skips on copy failure or when the bundled CLI isn't resolvable.
356
368
  */
357
- function seedClaudeCodeSkills(workspace) {
369
+ function copyBundledSkills(destSkillsDir) {
358
370
  const sourceRoot = resolveBundledCliSkillsRoot();
359
371
  if (!sourceRoot)
360
372
  return;
361
- const skillsDir = path.join(workspace, ".claude", "skills");
362
- mkdirTolerant(path.join(workspace, ".claude"));
363
- mkdirTolerant(skillsDir);
364
- for (const name of BUNDLED_CC_SKILLS) {
373
+ mkdirTolerant(destSkillsDir);
374
+ for (const name of BUNDLED_SKILLS) {
365
375
  const src = path.join(sourceRoot, name);
366
376
  if (!existsSync(src))
367
377
  continue;
368
- const dst = path.join(skillsDir, name);
378
+ const dst = path.join(destSkillsDir, name);
369
379
  try {
370
380
  cpSync(src, dst, { recursive: true, force: true, dereference: true });
371
381
  }
@@ -374,6 +384,34 @@ function seedClaudeCodeSkills(workspace) {
374
384
  }
375
385
  }
376
386
  }
387
+ /**
388
+ * Seed Claude Code's `.claude/skills/` discovery dir under the agent
389
+ * workspace. The `claude` adapter spawns with `--setting-sources project`
390
+ * so this dir is auto-discovered.
391
+ */
392
+ function seedClaudeCodeSkills(workspace) {
393
+ mkdirTolerant(path.join(workspace, ".claude"));
394
+ copyBundledSkills(path.join(workspace, ".claude", "skills"));
395
+ }
396
+ /**
397
+ * Seed Codex's `<CODEX_HOME>/skills/` discovery dir. The codex adapter
398
+ * sets `CODEX_HOME=<agent>/codex-home/`, isolating per-agent skills from
399
+ * the user's global `~/.codex/skills/` — so skills must be seeded here
400
+ * for Codex agents to discover them.
401
+ */
402
+ function seedCodexSkills(codexHome) {
403
+ copyBundledSkills(path.join(codexHome, "skills"));
404
+ }
405
+ /**
406
+ * Seed Hermes's `<HERMES_HOME>/skills/` discovery dir. hermes-acp's
407
+ * skill loader scans `$HERMES_HOME/skills/` (primary) plus any
408
+ * `skills.external_dirs` from config; the daemon points hermes-acp at
409
+ * the per-agent `<hermes-home>/`, so the user's global
410
+ * `~/.hermes/skills/` is invisible — bundled skills must be seeded here.
411
+ */
412
+ function seedHermesAgentSkills(hermesHome) {
413
+ copyBundledSkills(path.join(hermesHome, "skills"));
414
+ }
377
415
  /**
378
416
  * Idempotently create the agent's home / workspace / state directories and
379
417
  * seed the workspace Markdown files. Existing files are never overwritten —
package/dist/daemon.js CHANGED
@@ -253,6 +253,32 @@ export async function startDaemon(opts) {
253
253
  text: msg.text,
254
254
  });
255
255
  };
256
+ // Boot-seeded per-agent caches (`credentialPathByAgentId`,
257
+ // `hubUrlByAgentId`, `displayNameByAgent`, `scBuilders`) are scoped to
258
+ // the agents present at startup. Without this hook, agents added later
259
+ // via `provision_agent` or openclaw-adoption stay missing from those
260
+ // caches until the next daemon restart — `room-context-fetcher` then
261
+ // logs `daemon.room-context.no-credentials` on every turn for the new
262
+ // agent and the system context loses its `[BotCord Room]` block (member
263
+ // names, rule, role).
264
+ const onAgentInstalled = (info) => {
265
+ // Re-provision (e.g. credential rotation) overwrites in place so the
266
+ // next room-context fetch re-loads the BotCordClient against the new
267
+ // credential file.
268
+ credentialPathByAgentId.set(info.agentId, info.credentialsFile);
269
+ if (info.hubUrl)
270
+ hubUrlByAgentId.set(info.agentId, info.hubUrl);
271
+ if (info.displayName)
272
+ displayNameByAgent.set(info.agentId, info.displayName);
273
+ if (!scBuilders.has(info.agentId)) {
274
+ scBuilders.set(info.agentId, createDaemonSystemContextBuilder({
275
+ agentId: info.agentId,
276
+ activityTracker,
277
+ roomContextBuilder,
278
+ loopRiskBuilder,
279
+ }));
280
+ }
281
+ };
256
282
  const gateway = new Gateway({
257
283
  config: gwConfig,
258
284
  sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
@@ -299,6 +325,7 @@ export async function startDaemon(opts) {
299
325
  const adopted = await adoptDiscoveredOpenclawAgents({
300
326
  gateway,
301
327
  cfg: opts.config,
328
+ onAgentInstalled,
302
329
  });
303
330
  if (adopted.adopted.length > 0 ||
304
331
  adopted.failed.length > 0 ||
@@ -325,7 +352,7 @@ export async function startDaemon(opts) {
325
352
  userId: userAuth.current.userId,
326
353
  hubUrl: userAuth.current.hubUrl,
327
354
  });
328
- const provisioner = createProvisioner({ gateway, policyResolver });
355
+ const provisioner = createProvisioner({ gateway, policyResolver, onAgentInstalled });
329
356
  controlChannel = new ControlChannel({
330
357
  auth: userAuth,
331
358
  handle: provisioner,
@@ -159,6 +159,17 @@ export class Dispatcher {
159
159
  // Inbound transcript record — always before observers / gates so we have a
160
160
  // grounded turnId for any downstream attention_skipped / dropped / etc.
161
161
  this.emitInbound(turnId, msg);
162
+ this.log.info("dispatcher: inbound received", {
163
+ agentId: msg.accountId,
164
+ roomId: msg.conversation.id,
165
+ topicId: msg.conversation.threadId ?? null,
166
+ turnId,
167
+ messageId: msg.id,
168
+ senderId: msg.sender.id,
169
+ senderKind: msg.sender.kind,
170
+ mode,
171
+ textPreview: logPreview(rawText),
172
+ });
162
173
  // Notify the optional observer (activity tracking, metrics, etc.) as soon
163
174
  // as the dispatcher owns the message. Errors must not abort the turn.
164
175
  if (this.onInbound) {
@@ -274,7 +285,14 @@ export class Dispatcher {
274
285
  const myGen = q.cancelGen;
275
286
  const prev = q.current;
276
287
  if (prev) {
277
- this.log.info("dispatcher: cancelling previous turn", { queueKey });
288
+ this.log.info("dispatcher: cancelling previous turn", {
289
+ agentId: msg.accountId,
290
+ roomId: msg.conversation.id,
291
+ topicId: msg.conversation.threadId ?? null,
292
+ turnId,
293
+ prevTurnId: prev.turnId,
294
+ queueKey,
295
+ });
278
296
  // Record the supersede BEFORE aborting so the prev turn's finalize sees
279
297
  // the abort reason (TurnSupersededError) and skips writing turn_error.
280
298
  this.transcript.write({
@@ -295,7 +313,13 @@ export class Dispatcher {
295
313
  // already fired its own abort + runTurn, or be mid-await itself. If so,
296
314
  // drop out silently — the newest turn is the only one that should run.
297
315
  if (myGen !== q.cancelGen) {
298
- this.log.info("dispatcher: cancel-previous superseded", { queueKey });
316
+ this.log.info("dispatcher: cancel-previous superseded", {
317
+ agentId: msg.accountId,
318
+ roomId: msg.conversation.id,
319
+ topicId: msg.conversation.threadId ?? null,
320
+ turnId,
321
+ queueKey,
322
+ });
299
323
  // We didn't run the turn; emit dropped so the caller's inbound has a
300
324
  // matching path record. supersededBy is unknown at this layer (newer
301
325
  // arrival owns its own bump) — leave null.
@@ -529,10 +553,24 @@ export class Dispatcher {
529
553
  dispatched.truncated = { composedText: true };
530
554
  this.transcript.write(dispatched);
531
555
  }
556
+ this.log.info("dispatcher: dispatched to runtime", {
557
+ agentId: msg.accountId,
558
+ roomId: msg.conversation.id,
559
+ topicId: msg.conversation.threadId ?? null,
560
+ turnId,
561
+ runtime: route.runtime,
562
+ cwd: route.cwd,
563
+ ...(mergedFromTurnIds.length > 0 ? { mergedFromTurns: mergedFromTurnIds.length } : {}),
564
+ composedPreview: logPreview(text),
565
+ });
532
566
  // Hard-cap turn with a timeout.
533
567
  const timer = setTimeout(() => {
534
568
  slot.timedOut = true;
535
569
  this.log.warn("dispatcher: turn timed out", {
570
+ agentId: msg.accountId,
571
+ roomId: msg.conversation.id,
572
+ topicId: msg.conversation.threadId ?? null,
573
+ turnId,
536
574
  queueKey,
537
575
  timeoutMs: this.turnTimeoutMs,
538
576
  });
@@ -860,12 +898,15 @@ export class Dispatcher {
860
898
  text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
861
899
  replyTo: msg.id,
862
900
  traceId: msg.trace?.id ?? null,
863
- });
901
+ }, turnId);
864
902
  }
865
903
  else {
866
904
  this.log.warn("dispatcher: timeout in non-owner-chat room — error reply suppressed", {
905
+ agentId: msg.accountId,
906
+ roomId: msg.conversation.id,
907
+ topicId: msg.conversation.threadId ?? null,
908
+ turnId,
867
909
  queueKey,
868
- conversationId: msg.conversation.id,
869
910
  timeoutMs: this.turnTimeoutMs,
870
911
  });
871
912
  }
@@ -874,6 +915,10 @@ export class Dispatcher {
874
915
  if (threw) {
875
916
  const errMsg = threw instanceof Error ? threw.message : String(threw);
876
917
  this.log.error("dispatcher: runtime threw", {
918
+ agentId: msg.accountId,
919
+ roomId: msg.conversation.id,
920
+ topicId: msg.conversation.threadId ?? null,
921
+ turnId,
877
922
  queueKey,
878
923
  runtime: route.runtime,
879
924
  error: errMsg,
@@ -898,12 +943,15 @@ export class Dispatcher {
898
943
  text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
899
944
  replyTo: msg.id,
900
945
  traceId: msg.trace?.id ?? null,
901
- });
946
+ }, turnId);
902
947
  }
903
948
  else {
904
949
  this.log.warn("dispatcher: runtime error in non-owner-chat room — error reply suppressed", {
950
+ agentId: msg.accountId,
951
+ roomId: msg.conversation.id,
952
+ topicId: msg.conversation.threadId ?? null,
953
+ turnId,
905
954
  queueKey,
906
- conversationId: msg.conversation.id,
907
955
  });
908
956
  }
909
957
  return;
@@ -987,8 +1035,11 @@ export class Dispatcher {
987
1035
  // already; whatever it left in the runtime's final assistant text is
988
1036
  // discarded so it doesn't leak into the room.
989
1037
  this.log.debug("dispatcher: non-owner-chat — discarding result.text (agent must use botcord_send)", {
1038
+ agentId: msg.accountId,
1039
+ roomId: msg.conversation.id,
1040
+ topicId: msg.conversation.threadId ?? null,
1041
+ turnId,
990
1042
  queueKey,
991
- conversationId: msg.conversation.id,
992
1043
  replyTextLen: replyText.length,
993
1044
  });
994
1045
  this.emitOutbound({
@@ -1019,7 +1070,7 @@ export class Dispatcher {
1019
1070
  text: replyText,
1020
1071
  replyTo: msg.id,
1021
1072
  traceId: msg.trace?.id ?? null,
1022
- });
1073
+ }, turnId);
1023
1074
  this.emitOutbound({
1024
1075
  turnId,
1025
1076
  msg,
@@ -1049,15 +1100,18 @@ export class Dispatcher {
1049
1100
  resolveDone();
1050
1101
  }
1051
1102
  }
1052
- async sendReply(channel, outbound) {
1103
+ async sendReply(channel, outbound, turnId) {
1053
1104
  try {
1054
1105
  await channel.send({ message: outbound, log: this.log });
1055
1106
  }
1056
1107
  catch (err) {
1057
1108
  const error = err instanceof Error ? err.message : String(err);
1058
1109
  this.log.warn("dispatcher: channel.send failed", {
1110
+ agentId: outbound.accountId,
1111
+ roomId: outbound.conversationId,
1112
+ topicId: outbound.threadId ?? null,
1113
+ ...(turnId ? { turnId } : {}),
1059
1114
  channel: outbound.channel,
1060
- conversationId: outbound.conversationId,
1061
1115
  error,
1062
1116
  });
1063
1117
  return { ok: false, error };
@@ -1068,7 +1122,10 @@ export class Dispatcher {
1068
1122
  }
1069
1123
  catch (err) {
1070
1124
  this.log.warn("dispatcher: onOutbound threw — continuing", {
1071
- conversationId: outbound.conversationId,
1125
+ agentId: outbound.accountId,
1126
+ roomId: outbound.conversationId,
1127
+ topicId: outbound.threadId ?? null,
1128
+ ...(turnId ? { turnId } : {}),
1072
1129
  error: err instanceof Error ? err.message : String(err),
1073
1130
  });
1074
1131
  }
@@ -1105,6 +1162,19 @@ export class Dispatcher {
1105
1162
  this.transcript.write(rec);
1106
1163
  }
1107
1164
  emitOutbound(args) {
1165
+ const durationMs = Date.now() - args.startedAt;
1166
+ this.log.info("dispatcher: outbound emitted", {
1167
+ agentId: args.msg.accountId,
1168
+ roomId: args.msg.conversation.id,
1169
+ topicId: args.msg.conversation.threadId ?? null,
1170
+ turnId: args.turnId,
1171
+ runtime: args.runtime,
1172
+ deliveryStatus: args.deliveryStatus,
1173
+ ...(args.deliveryReason ? { deliveryReason: args.deliveryReason } : {}),
1174
+ durationMs,
1175
+ replyPreview: logPreview(args.finalText.text),
1176
+ ...(typeof args.costUsd === "number" ? { costUsd: args.costUsd } : {}),
1177
+ });
1108
1178
  if (!this.transcript.enabled)
1109
1179
  return;
1110
1180
  const rec = {
@@ -1116,7 +1186,7 @@ export class Dispatcher {
1116
1186
  topicId: args.msg.conversation.threadId ?? null,
1117
1187
  runtime: args.runtime,
1118
1188
  runtimeSessionId: args.runtimeSessionId,
1119
- durationMs: Date.now() - args.startedAt,
1189
+ durationMs,
1120
1190
  finalText: args.finalText.text,
1121
1191
  deliveryStatus: args.deliveryStatus,
1122
1192
  deliveryReason: args.deliveryReason,
@@ -1168,3 +1238,12 @@ function resolveQueueMode(route, kind) {
1168
1238
  function truncate(s, max) {
1169
1239
  return s.length <= max ? s : s.slice(0, max) + "…";
1170
1240
  }
1241
+ /**
1242
+ * Single-line preview of a multi-line user/agent text, capped at `max` chars.
1243
+ * Used to embed message/reply previews in daemon.log lines without bloating
1244
+ * each line into multi-line JSON. Full text lives in transcripts.
1245
+ */
1246
+ function logPreview(s, max = 120) {
1247
+ const flat = s.replace(/\s+/g, " ").trim();
1248
+ return flat.length <= max ? flat : flat.slice(0, max) + "…";
1249
+ }
package/dist/index.js CHANGED
@@ -735,6 +735,17 @@ function cmdTranscriptTail(args) {
735
735
  const file = transcriptFilePath(defaultTranscriptRoot(), agent, room, topic);
736
736
  if (!existsSync(file)) {
737
737
  console.error(`no transcript at ${file}`);
738
+ let cfg = null;
739
+ try {
740
+ cfg = loadConfig();
741
+ }
742
+ catch {
743
+ // ignore — config may simply not exist yet
744
+ }
745
+ const enabled = resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, cfg?.transcript?.enabled === true);
746
+ if (!enabled) {
747
+ console.error("hint: transcripts are disabled (default-off). Run `botcord-daemon transcript enable` and restart the daemon, then send a new message.");
748
+ }
738
749
  process.exit(1);
739
750
  }
740
751
  const follow = args.flags.f === true || args.flags.follow === true;
@@ -2,6 +2,28 @@ import { BotCordClient, type AgentIdentitySnapshot, type ControlAck, type Contro
2
2
  import type { Gateway } from "./gateway/index.js";
3
3
  import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
4
4
  import { type DaemonConfig } from "./config.js";
5
+ /**
6
+ * Information passed to {@link OnAgentInstalledHook} after a successful
7
+ * provision. Mirrors the credential fields the daemon's per-agent caches
8
+ * (`credentialPathByAgentId`, `hubUrlByAgentId`, `displayNameByAgent`) read at
9
+ * boot — fired again here so hot-provisioned agents land in those caches
10
+ * without waiting for a daemon restart.
11
+ */
12
+ export interface InstalledAgentInfo {
13
+ agentId: string;
14
+ credentialsFile: string;
15
+ hubUrl: string;
16
+ displayName?: string;
17
+ runtime?: string;
18
+ }
19
+ /**
20
+ * Hook fired after `installLocalAgent` / `installExistingOpenclawBinding`
21
+ * finish successfully. Synchronous on purpose — the caller updates a few
22
+ * in-memory maps and we don't want a slow hook to delay the control-frame
23
+ * ack. Throwing from the hook is treated as a programmer error and
24
+ * surfaced; rollback of the install is the caller's responsibility.
25
+ */
26
+ export type OnAgentInstalledHook = (info: InstalledAgentInfo) => void;
5
27
  /** Options accepted by {@link createProvisioner}. */
6
28
  export interface ProvisionerOptions {
7
29
  /** Live gateway handle used to hot-plug channels. */
@@ -19,6 +41,15 @@ export interface ProvisionerOptions {
19
41
  * extra round-trip.
20
42
  */
21
43
  policyResolver?: PolicyResolverLike;
44
+ /**
45
+ * Optional hook called after each successful agent install (whether via
46
+ * `provision_agent` or `installExistingOpenclawBinding`). The daemon
47
+ * wires this to write the new agent into its boot-seeded caches —
48
+ * without it, `room-context-fetcher` keeps emitting
49
+ * `daemon.room-context.no-credentials` for hot-provisioned agents until
50
+ * the next restart.
51
+ */
52
+ onAgentInstalled?: OnAgentInstalledHook;
22
53
  }
23
54
  /** The value a frame handler returns (minus the `id` which the channel fills in). */
24
55
  type AckBody = Omit<ControlAck, "id">;
@@ -47,6 +78,7 @@ export declare function adoptDiscoveredOpenclawAgents(ctx: {
47
78
  cfg?: DaemonConfig;
48
79
  timeoutMs?: number;
49
80
  probe?: WsEndpointProbeFn;
81
+ onAgentInstalled?: OnAgentInstalledHook;
50
82
  }): Promise<AdoptDiscoveredOpenclawAgentsResult>;
51
83
  /**
52
84
  * Append `agentId` to the daemon config if not already present. Returns a
package/dist/provision.js CHANGED
@@ -23,6 +23,7 @@ export function createProvisioner(opts) {
23
23
  const gateway = opts.gateway;
24
24
  const register = opts.register ?? BotCordClient.register;
25
25
  const policyResolver = opts.policyResolver;
26
+ const onAgentInstalled = opts.onAgentInstalled;
26
27
  return async (frame) => {
27
28
  daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
28
29
  switch (frame.type) {
@@ -66,7 +67,7 @@ export function createProvisioner(opts) {
66
67
  runtime: pickRuntime(params) ?? null,
67
68
  name: params.name ?? null,
68
69
  });
69
- const agent = await provisionAgent(params, { gateway, register });
70
+ const agent = await provisionAgent(params, { gateway, register, onAgentInstalled });
70
71
  // Seed the policy resolver from the optional `defaultAttention` /
71
72
  // `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
72
73
  // don't yet emit these stay backwards-compatible — the resolver just
@@ -331,6 +332,32 @@ async function installLocalAgent(credentials, ctx) {
331
332
  }
332
333
  throw err;
333
334
  }
335
+ // Update the daemon's boot-seeded per-agent caches in place. Without this
336
+ // a hot-provisioned agent keeps missing `credentialPathByAgentId` /
337
+ // `hubUrlByAgentId` / `displayNameByAgent` until the next daemon restart,
338
+ // and `room-context-fetcher` logs `daemon.room-context.no-credentials` on
339
+ // every turn for it (so the system-context loses the room block — member
340
+ // names, rule, role).
341
+ if (ctx.onAgentInstalled) {
342
+ try {
343
+ ctx.onAgentInstalled({
344
+ agentId: credentials.agentId,
345
+ credentialsFile,
346
+ hubUrl: credentials.hubUrl,
347
+ ...(credentials.displayName ? { displayName: credentials.displayName } : {}),
348
+ ...(credentials.runtime ? { runtime: credentials.runtime } : {}),
349
+ });
350
+ }
351
+ catch (err) {
352
+ // Hook misbehavior must not fail the install — the agent is already
353
+ // on disk + in the gateway. Surface it loudly so the daemon owner
354
+ // notices the cache is out of sync.
355
+ daemonLog.error("provision.onAgentInstalled threw — caches may be stale", {
356
+ agentId: credentials.agentId,
357
+ error: err instanceof Error ? err.message : String(err),
358
+ });
359
+ }
360
+ }
334
361
  daemonLog.info("agent provisioned", {
335
362
  agentId: credentials.agentId,
336
363
  credentialsFile,
@@ -382,6 +409,27 @@ async function installExistingOpenclawBinding(agentId, ctx) {
382
409
  });
383
410
  }
384
411
  upsertManagedRouteForCredentials(credentials, cfg, ctx.gateway);
412
+ // Same cache-warmup as `installLocalAgent` — re-binding an existing
413
+ // openclaw agent at runtime should also land it in the daemon's
414
+ // per-agent maps, otherwise room-context lookups stay broken until
415
+ // restart.
416
+ if (ctx.onAgentInstalled) {
417
+ try {
418
+ ctx.onAgentInstalled({
419
+ agentId: credentials.agentId,
420
+ credentialsFile,
421
+ hubUrl: credentials.hubUrl,
422
+ ...(credentials.displayName ? { displayName: credentials.displayName } : {}),
423
+ ...(credentials.runtime ? { runtime: credentials.runtime } : {}),
424
+ });
425
+ }
426
+ catch (err) {
427
+ daemonLog.error("provision.onAgentInstalled threw — caches may be stale", {
428
+ agentId: credentials.agentId,
429
+ error: err instanceof Error ? err.message : String(err),
430
+ });
431
+ }
432
+ }
385
433
  return {
386
434
  agentId: credentials.agentId,
387
435
  hubUrl: credentials.hubUrl,
@@ -528,6 +576,7 @@ function findCredentialsByOpenclaw(gateway, openclawAgent) {
528
576
  export async function adoptDiscoveredOpenclawAgents(ctx) {
529
577
  const register = ctx.register ?? BotCordClient.register;
530
578
  const cfg = ctx.cfg ?? loadConfig();
579
+ const onAgentInstalled = ctx.onAgentInstalled;
531
580
  const result = {
532
581
  adopted: [],
533
582
  skipped: [],
@@ -601,12 +650,14 @@ export async function adoptDiscoveredOpenclawAgents(ctx) {
601
650
  const credentials = await materializeCredentials(params, freshCfg, {
602
651
  gateway: ctx.gateway,
603
652
  register,
653
+ ...(onAgentInstalled ? { onAgentInstalled } : {}),
604
654
  }, undefined);
605
655
  const installed = await installLocalAgent(credentials, {
606
656
  gateway: ctx.gateway,
607
657
  register,
608
658
  cfg: freshCfg,
609
659
  source: "adopted-openclaw",
660
+ ...(onAgentInstalled ? { onAgentInstalled } : {}),
610
661
  });
611
662
  result.adopted.push(installed.agentId);
612
663
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
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,
@@ -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
+ }
package/src/index.ts CHANGED
@@ -851,6 +851,21 @@ function cmdTranscriptTail(args: ParsedArgs): Promise<void> | void {
851
851
  const file = transcriptFilePath(defaultTranscriptRoot(), agent, room, topic);
852
852
  if (!existsSync(file)) {
853
853
  console.error(`no transcript at ${file}`);
854
+ let cfg: DaemonConfig | null = null;
855
+ try {
856
+ cfg = loadConfig();
857
+ } catch {
858
+ // ignore — config may simply not exist yet
859
+ }
860
+ const enabled = resolveTranscriptEnabled(
861
+ process.env.BOTCORD_TRANSCRIPT,
862
+ cfg?.transcript?.enabled === true,
863
+ );
864
+ if (!enabled) {
865
+ console.error(
866
+ "hint: transcripts are disabled (default-off). Run `botcord-daemon transcript enable` and restart the daemon, then send a new message.",
867
+ );
868
+ }
854
869
  process.exit(1);
855
870
  }
856
871
  const follow = args.flags.f === true || args.flags.follow === true;
package/src/provision.ts CHANGED
@@ -57,6 +57,30 @@ import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
57
57
  import { log as daemonLog } from "./log.js";
58
58
  import { discoverAgentCredentials } from "./agent-discovery.js";
59
59
 
60
+ /**
61
+ * Information passed to {@link OnAgentInstalledHook} after a successful
62
+ * provision. Mirrors the credential fields the daemon's per-agent caches
63
+ * (`credentialPathByAgentId`, `hubUrlByAgentId`, `displayNameByAgent`) read at
64
+ * boot — fired again here so hot-provisioned agents land in those caches
65
+ * without waiting for a daemon restart.
66
+ */
67
+ export interface InstalledAgentInfo {
68
+ agentId: string;
69
+ credentialsFile: string;
70
+ hubUrl: string;
71
+ displayName?: string;
72
+ runtime?: string;
73
+ }
74
+
75
+ /**
76
+ * Hook fired after `installLocalAgent` / `installExistingOpenclawBinding`
77
+ * finish successfully. Synchronous on purpose — the caller updates a few
78
+ * in-memory maps and we don't want a slow hook to delay the control-frame
79
+ * ack. Throwing from the hook is treated as a programmer error and
80
+ * surfaced; rollback of the install is the caller's responsibility.
81
+ */
82
+ export type OnAgentInstalledHook = (info: InstalledAgentInfo) => void;
83
+
60
84
  /** Options accepted by {@link createProvisioner}. */
61
85
  export interface ProvisionerOptions {
62
86
  /** Live gateway handle used to hot-plug channels. */
@@ -74,6 +98,15 @@ export interface ProvisionerOptions {
74
98
  * extra round-trip.
75
99
  */
76
100
  policyResolver?: PolicyResolverLike;
101
+ /**
102
+ * Optional hook called after each successful agent install (whether via
103
+ * `provision_agent` or `installExistingOpenclawBinding`). The daemon
104
+ * wires this to write the new agent into its boot-seeded caches —
105
+ * without it, `room-context-fetcher` keeps emitting
106
+ * `daemon.room-context.no-credentials` for hot-provisioned agents until
107
+ * the next restart.
108
+ */
109
+ onAgentInstalled?: OnAgentInstalledHook;
77
110
  }
78
111
 
79
112
  /** The value a frame handler returns (minus the `id` which the channel fills in). */
@@ -90,6 +123,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
90
123
  const gateway = opts.gateway;
91
124
  const register = opts.register ?? BotCordClient.register;
92
125
  const policyResolver = opts.policyResolver;
126
+ const onAgentInstalled = opts.onAgentInstalled;
93
127
 
94
128
  return async (frame: ControlFrame): Promise<AckBody> => {
95
129
  daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
@@ -137,7 +171,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
137
171
  runtime: pickRuntime(params) ?? null,
138
172
  name: params.name ?? null,
139
173
  });
140
- const agent = await provisionAgent(params, { gateway, register });
174
+ const agent = await provisionAgent(params, { gateway, register, onAgentInstalled });
141
175
  // Seed the policy resolver from the optional `defaultAttention` /
142
176
  // `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
143
177
  // don't yet emit these stay backwards-compatible — the resolver just
@@ -266,6 +300,7 @@ interface ProvisionedAgent {
266
300
  interface ProvisionCtx {
267
301
  gateway: Gateway;
268
302
  register: typeof BotCordClient.register;
303
+ onAgentInstalled?: OnAgentInstalledHook;
269
304
  }
270
305
 
271
306
  const openclawProvisionLocks = new Map<string, Promise<unknown>>();
@@ -428,6 +463,32 @@ async function installLocalAgent(
428
463
  throw err;
429
464
  }
430
465
 
466
+ // Update the daemon's boot-seeded per-agent caches in place. Without this
467
+ // a hot-provisioned agent keeps missing `credentialPathByAgentId` /
468
+ // `hubUrlByAgentId` / `displayNameByAgent` until the next daemon restart,
469
+ // and `room-context-fetcher` logs `daemon.room-context.no-credentials` on
470
+ // every turn for it (so the system-context loses the room block — member
471
+ // names, rule, role).
472
+ if (ctx.onAgentInstalled) {
473
+ try {
474
+ ctx.onAgentInstalled({
475
+ agentId: credentials.agentId,
476
+ credentialsFile,
477
+ hubUrl: credentials.hubUrl,
478
+ ...(credentials.displayName ? { displayName: credentials.displayName } : {}),
479
+ ...(credentials.runtime ? { runtime: credentials.runtime } : {}),
480
+ });
481
+ } catch (err) {
482
+ // Hook misbehavior must not fail the install — the agent is already
483
+ // on disk + in the gateway. Surface it loudly so the daemon owner
484
+ // notices the cache is out of sync.
485
+ daemonLog.error("provision.onAgentInstalled threw — caches may be stale", {
486
+ agentId: credentials.agentId,
487
+ error: err instanceof Error ? err.message : String(err),
488
+ });
489
+ }
490
+ }
491
+
431
492
  daemonLog.info("agent provisioned", {
432
493
  agentId: credentials.agentId,
433
494
  credentialsFile,
@@ -490,6 +551,26 @@ async function installExistingOpenclawBinding(
490
551
  });
491
552
  }
492
553
  upsertManagedRouteForCredentials(credentials, cfg, ctx.gateway);
554
+ // Same cache-warmup as `installLocalAgent` — re-binding an existing
555
+ // openclaw agent at runtime should also land it in the daemon's
556
+ // per-agent maps, otherwise room-context lookups stay broken until
557
+ // restart.
558
+ if (ctx.onAgentInstalled) {
559
+ try {
560
+ ctx.onAgentInstalled({
561
+ agentId: credentials.agentId,
562
+ credentialsFile,
563
+ hubUrl: credentials.hubUrl,
564
+ ...(credentials.displayName ? { displayName: credentials.displayName } : {}),
565
+ ...(credentials.runtime ? { runtime: credentials.runtime } : {}),
566
+ });
567
+ } catch (err) {
568
+ daemonLog.error("provision.onAgentInstalled threw — caches may be stale", {
569
+ agentId: credentials.agentId,
570
+ error: err instanceof Error ? err.message : String(err),
571
+ });
572
+ }
573
+ }
493
574
  return {
494
575
  agentId: credentials.agentId,
495
576
  hubUrl: credentials.hubUrl,
@@ -658,9 +739,11 @@ export async function adoptDiscoveredOpenclawAgents(ctx: {
658
739
  cfg?: DaemonConfig;
659
740
  timeoutMs?: number;
660
741
  probe?: WsEndpointProbeFn;
742
+ onAgentInstalled?: OnAgentInstalledHook;
661
743
  }): Promise<AdoptDiscoveredOpenclawAgentsResult> {
662
744
  const register = ctx.register ?? BotCordClient.register;
663
745
  const cfg = ctx.cfg ?? loadConfig();
746
+ const onAgentInstalled = ctx.onAgentInstalled;
664
747
  const result: AdoptDiscoveredOpenclawAgentsResult = {
665
748
  adopted: [],
666
749
  skipped: [],
@@ -733,12 +816,14 @@ export async function adoptDiscoveredOpenclawAgents(ctx: {
733
816
  const credentials = await materializeCredentials(params, freshCfg, {
734
817
  gateway: ctx.gateway,
735
818
  register,
819
+ ...(onAgentInstalled ? { onAgentInstalled } : {}),
736
820
  }, undefined);
737
821
  const installed = await installLocalAgent(credentials, {
738
822
  gateway: ctx.gateway,
739
823
  register,
740
824
  cfg: freshCfg,
741
825
  source: "adopted-openclaw",
826
+ ...(onAgentInstalled ? { onAgentInstalled } : {}),
742
827
  });
743
828
  result.adopted.push(installed.agentId);
744
829
  } catch (err) {