@botcord/daemon 0.2.20 → 0.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-workspace.d.ts +10 -4
- package/dist/agent-workspace.js +57 -19
- package/dist/daemon.js +28 -1
- package/dist/gateway/channels/botcord.js +25 -10
- 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/channels/botcord.ts +41 -16
- 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,
|
|
@@ -225,7 +225,10 @@ export function createBotCordChannel(options) {
|
|
|
225
225
|
const eligible = [];
|
|
226
226
|
if (msgs.length === 0) {
|
|
227
227
|
logDrain();
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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", {
|
|
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
|
}
|