@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.
@@ -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,
@@ -225,7 +225,10 @@ export function createBotCordChannel(options) {
225
225
  const eligible = [];
226
226
  if (msgs.length === 0) {
227
227
  logDrain();
228
- return;
228
+ // Defensive: if Hub returns 0 messages, refuse to honor has_more=true.
229
+ // A stuck cursor on the Hub side could otherwise produce an unbounded
230
+ // poll loop here (count=0 with has_more=true on every iteration).
231
+ return { hasMore: false };
229
232
  }
230
233
  // First pass: ack duplicates/skipped messages so Hub stops requeueing,
231
234
  // and collect eligible messages preserving poll order. Grouping by
@@ -261,7 +264,7 @@ export function createBotCordChannel(options) {
261
264
  }
262
265
  if (eligible.length === 0) {
263
266
  logDrain();
264
- return;
267
+ return { hasMore: Boolean(resp.has_more) };
265
268
  }
266
269
  // Group by `(room_id, topic)`. Insertion order is the poll order, so
267
270
  // iterating the map yields groups with the same external chronology.
@@ -275,6 +278,13 @@ export function createBotCordChannel(options) {
275
278
  else
276
279
  groups.set(key, [msg]);
277
280
  }
281
+ // Emit groups in parallel: each `(room_id, topic)` group is an independent
282
+ // conversation thread, and the dispatcher already keys its per-turn queue
283
+ // by `(channel, accountId, roomId, threadId)` (see `buildQueueKey` in
284
+ // dispatcher.ts). Awaiting groups serially here forced a slow turn in
285
+ // room A to block room B's turn from starting; running them concurrently
286
+ // lets the dispatcher's per-room queues actually run in parallel.
287
+ const emitTasks = [];
278
288
  for (const group of groups.values()) {
279
289
  const normalized = normalizeInboxBatch(group, {
280
290
  channelId: options.id,
@@ -301,18 +311,18 @@ export function createBotCordChannel(options) {
301
311
  },
302
312
  },
303
313
  };
304
- try {
305
- await emit(envelope);
314
+ emitTasks.push(emit(envelope).then(() => {
306
315
  emittedGroups += 1;
307
- }
308
- catch (err) {
316
+ }, (err) => {
309
317
  log.error("botcord emit threw", {
310
318
  hubMsgIds: hubIds,
311
319
  err: String(err),
312
320
  });
313
- }
321
+ }));
314
322
  }
323
+ await Promise.all(emitTasks);
315
324
  logDrain();
325
+ return { hasMore: Boolean(resp.has_more) };
316
326
  }
317
327
  function startWsLoop(client, ctx) {
318
328
  const { abortSignal, log, emit, setStatus } = ctx;
@@ -354,11 +364,16 @@ export function createBotCordChannel(options) {
354
364
  processing = true;
355
365
  try {
356
366
  let currentTrigger = trigger;
367
+ let hasMore = false;
357
368
  do {
358
369
  pendingUpdate = false;
359
- await drainInbox(client, emit, log, currentTrigger);
360
- currentTrigger = "coalesced_inbox_update";
361
- } while (pendingUpdate && running);
370
+ const result = await drainInbox(client, emit, log, currentTrigger);
371
+ hasMore = result.hasMore;
372
+ // Prefer `has_more_continue` when this iteration is chained because
373
+ // the previous poll capped at INBOX_POLL_LIMIT — distinguishes a
374
+ // backlog drain from a coalesced ws_inbox_update drain in logs.
375
+ currentTrigger = hasMore ? "has_more_continue" : "coalesced_inbox_update";
376
+ } while ((pendingUpdate || hasMore) && running);
362
377
  }
363
378
  catch (err) {
364
379
  log.error("botcord inbox drain failed", { err: String(err) });
@@ -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
  }