@botcord/daemon 0.2.21 → 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/dist/agent-workspace.d.ts +10 -4
- package/dist/agent-workspace.js +57 -19
- package/dist/daemon.js +28 -1
- package/dist/gateway/dispatcher.js +91 -12
- package/dist/index.js +11 -0
- package/dist/provision.d.ts +32 -0
- package/dist/provision.js +52 -1
- package/package.json +1 -1
- package/src/__tests__/agent-workspace.test.ts +45 -0
- package/src/__tests__/provision.test.ts +80 -0
- package/src/agent-workspace.ts +60 -19
- package/src/daemon.ts +31 -1
- package/src/gateway/dispatcher.ts +94 -11
- package/src/index.ts +15 -0
- package/src/provision.ts +86 -1
|
@@ -31,16 +31,22 @@ export interface WorkspaceSeed {
|
|
|
31
31
|
savedAt?: string;
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
* Idempotently create the per-agent CODEX_HOME directory
|
|
35
|
-
* user's codex `auth.json` into it
|
|
36
|
-
*
|
|
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;
|
package/dist/agent-workspace.js
CHANGED
|
@@ -304,21 +304,28 @@ function isSymlink(p) {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
/**
|
|
307
|
-
* Idempotently create the per-agent CODEX_HOME directory
|
|
308
|
-
* user's codex `auth.json` into it
|
|
309
|
-
*
|
|
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
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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
|
|
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
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
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
|
|
369
|
+
function copyBundledSkills(destSkillsDir) {
|
|
358
370
|
const sourceRoot = resolveBundledCliSkillsRoot();
|
|
359
371
|
if (!sourceRoot)
|
|
360
372
|
return;
|
|
361
|
-
|
|
362
|
-
|
|
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(
|
|
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", {
|
|
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", {
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/provision.d.ts
CHANGED
|
@@ -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
|
@@ -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", () => {
|
package/src/agent-workspace.ts
CHANGED
|
@@ -333,14 +333,18 @@ function isSymlink(p: string): boolean {
|
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
/**
|
|
336
|
-
* Idempotently create the per-agent CODEX_HOME directory
|
|
337
|
-
* user's codex `auth.json` into it
|
|
338
|
-
*
|
|
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
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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
|
|
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
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
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
|
|
407
|
+
function copyBundledSkills(destSkillsDir: string): void {
|
|
396
408
|
const sourceRoot = resolveBundledCliSkillsRoot();
|
|
397
409
|
if (!sourceRoot) return;
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
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", {
|
|
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", {
|
|
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
|
-
|
|
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
|
|
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) {
|