@botcord/daemon 0.2.88 → 0.2.90

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.
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import type { GatewayInboundMessage } from "./gateway/index.js";
28
28
  import type { ActivityTracker } from "./activity-tracker.js";
29
+ import type { SkillIndexOptions } from "./skill-index.js";
29
30
  /**
30
31
  * Async per-turn room-context builder (see `room-context.ts`). Returns the
31
32
  * rendered `[BotCord Room Context]` block, or `null` when there is nothing
@@ -59,6 +60,11 @@ export interface SystemContextDeps {
59
60
  * dirs each turn. Return null to suppress the block.
60
61
  */
61
62
  skillIndexBuilder?: (message: GatewayInboundMessage) => string | null;
63
+ /**
64
+ * Runtime/profile options for the default soft skill scanner. Kept lazy so
65
+ * hot-provisioned runtime changes are visible without rebuilding this closure.
66
+ */
67
+ skillIndexOptions?: (message: GatewayInboundMessage) => SkillIndexOptions;
62
68
  }
63
69
  /**
64
70
  * Build a {@link SystemContextBuilder} for the gateway dispatcher.
@@ -112,7 +112,7 @@ export function createDaemonSystemContextBuilder(deps) {
112
112
  try {
113
113
  if (deps.skillIndexBuilder)
114
114
  return deps.skillIndexBuilder(message);
115
- return buildSoftSkillIndexPrompt(deps.agentId);
115
+ return buildSoftSkillIndexPrompt(deps.agentId, deps.skillIndexOptions?.(message) ?? {});
116
116
  }
117
117
  catch (err) {
118
118
  log.warn("system-context: skill index build failed — skipping skill block", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.88",
3
+ "version": "0.2.90",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,8 +23,8 @@
23
23
  "dependencies": {
24
24
  "@larksuiteoapi/node-sdk": "^1.63.1",
25
25
  "ws": "^8.20.1",
26
- "@botcord/cli": "^0.1.18",
27
- "@botcord/protocol-core": "^0.2.13"
26
+ "@botcord/protocol-core": "^0.2.13",
27
+ "@botcord/cli": "^0.1.18"
28
28
  },
29
29
  "overrides": {
30
30
  "axios": "^1.15.2"
@@ -147,11 +147,14 @@ describe("collectRuntimeSnapshot", () => {
147
147
  });
148
148
 
149
149
  it("omits optional fields rather than emitting explicit undefineds", () => {
150
+ // Use a synthetic runtime id with no catalog strategy so the snapshot
151
+ // doesn't pick up a `models` field. Switching to "gemini" here would
152
+ // attach the built-in gemini model list.
150
153
  setRuntimes([
151
154
  {
152
- id: "gemini",
153
- displayName: "Gemini",
154
- binary: "gemini",
155
+ id: "unknown-runtime",
156
+ displayName: "Unknown",
157
+ binary: "unknown",
155
158
  supportsRun: true,
156
159
  result: { available: true },
157
160
  },
@@ -380,4 +380,57 @@ describe("runtime model discovery parsers", () => {
380
380
  },
381
381
  ]);
382
382
  });
383
+
384
+ it("returns the built-in Gemini catalog and caches it under gemini.json", () => {
385
+ const tmp = mkdtempSync(path.join(tmpdir(), "daemon-gemini-catalog-"));
386
+ const prevHome = process.env.HOME;
387
+ const prevCacheDir = process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
388
+ try {
389
+ const home = path.join(tmp, "home");
390
+ const cacheDir = path.join(tmp, "catalog-cache");
391
+ mkdirSync(home, { recursive: true });
392
+ process.env.HOME = home;
393
+ process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = cacheDir;
394
+
395
+ const catalog = discoverRuntimeModelCatalog({
396
+ id: "gemini",
397
+ displayName: "Gemini CLI",
398
+ binary: "gemini",
399
+ supportsRun: true,
400
+ result: { available: true, path: path.join(tmp, "missing-gemini") },
401
+ });
402
+
403
+ const ids = catalog.models?.map((m) => m.id) ?? [];
404
+ // `auto` is the default — wizard should pre-select it.
405
+ expect(catalog.models?.find((m) => m.isDefault)?.id).toBe("auto");
406
+ // Spot-check the documented family is present.
407
+ expect(ids).toEqual(
408
+ expect.arrayContaining([
409
+ "auto",
410
+ "pro",
411
+ "flash",
412
+ "flash-lite",
413
+ "gemini-2.5-pro",
414
+ "gemini-2.5-flash",
415
+ "gemini-3-pro-preview",
416
+ "gemini-3.1-pro-preview",
417
+ ]),
418
+ );
419
+ // No per-turn parameters yet (thinkingLevel / thinkingBudget aren't
420
+ // exposed as CLI flags; we can't safely route them through `extraArgs`).
421
+ expect(catalog.parameters ?? []).toEqual([]);
422
+
423
+ // Cache persisted so subsequent calls don't re-touch settings.json.
424
+ expect(readdirSync(cacheDir)).toEqual(["gemini.json"]);
425
+ const payload = JSON.parse(readFileSync(path.join(cacheDir, "gemini.json"), "utf8"));
426
+ expect(payload.runtimeId).toBe("gemini");
427
+ expect(payload.catalog.models.map((m: { id: string }) => m.id)).toContain("gemini-2.5-flash");
428
+ } finally {
429
+ if (prevHome === undefined) delete process.env.HOME;
430
+ else process.env.HOME = prevHome;
431
+ if (prevCacheDir === undefined) delete process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
432
+ else process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = prevCacheDir;
433
+ rmSync(tmp, { recursive: true, force: true });
434
+ }
435
+ });
383
436
  });
@@ -4,8 +4,10 @@ import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import {
6
6
  agentCodexHomeDir,
7
+ agentHermesHomeDir,
7
8
  agentWorkspaceDir,
8
9
  } from "../agent-workspace.js";
10
+ import { hermesProfileHomeDir } from "../gateway/runtimes/hermes-agent.js";
9
11
  import {
10
12
  buildSoftSkillIndexPrompt,
11
13
  collectAgentSkillSnapshot,
@@ -37,27 +39,40 @@ afterEach(() => {
37
39
  });
38
40
 
39
41
  describe("skill snapshots", () => {
40
- it("scans agent workspace/runtime-global skills and maps UI source buckets", () => {
42
+ it("scopes scans to the selected runtime and maps UI source buckets", () => {
41
43
  const agentId = "ag_skilltest";
42
- writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "workspace-skill", "Workspace skill");
43
- writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-skill", "Codex skill");
44
- writeSkill(path.join(tmpDir, ".codex", "skills"), "global-skill", "Global skill");
44
+ const claudePath = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
45
+ const codexPath = path.join(agentCodexHomeDir(agentId), "skills");
46
+ writeSkill(claudePath, "claude-skill", "Claude skill");
47
+ writeSkill(codexPath, "codex-skill", "Codex skill");
48
+ writeSkill(path.join(tmpDir, ".claude", "skills"), "global-claude", "Global Claude");
49
+ writeSkill(path.join(tmpDir, ".codex", "skills"), "global-codex", "Global Codex");
45
50
 
46
- const scanned = scanSoftSkills(agentId);
47
- expect(scanned.map((s) => s.name).sort()).toEqual([
51
+ const claudeScanned = scanSoftSkills(agentId, { runtime: "claude-code" });
52
+ expect(claudeScanned.map((s) => s.name).sort()).toEqual([
53
+ "claude-skill",
54
+ "global-claude",
55
+ ]);
56
+ expect(claudeScanned.every((s) => s.runtime === "claude-code")).toBe(true);
57
+
58
+ const codexScanned = scanSoftSkills(agentId, { runtime: "codex" });
59
+ expect(codexScanned.map((s) => s.name).sort()).toEqual([
48
60
  "codex-skill",
49
- "global-skill",
50
- "workspace-skill",
61
+ "global-codex",
51
62
  ]);
63
+ expect(codexScanned.every((s) => s.runtime === "codex")).toBe(true);
52
64
 
53
- const snapshot = collectAgentSkillSnapshot(agentId);
65
+ const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "codex" });
54
66
  expect(snapshot.agentId).toBe(agentId);
55
- expect(snapshot.skills).toHaveLength(3);
56
- expect(snapshot.skills.find((s) => s.name === "workspace-skill")?.source)
57
- .toBe("workspace");
67
+ expect(snapshot.runtime).toBe("codex");
68
+ expect(snapshot.skills).toHaveLength(2);
58
69
  expect(snapshot.skills.find((s) => s.name === "codex-skill")?.source)
59
70
  .toBe("workspace");
60
- expect(snapshot.skills.find((s) => s.name === "global-skill")?.source)
71
+ expect(snapshot.skills.find((s) => s.name === "codex-skill")?.sourceDetail)
72
+ .toBe("agent-codex");
73
+ expect(snapshot.skills.find((s) => s.name === "codex-skill")?.path)
74
+ .toBe(path.join(codexPath, "codex-skill", "SKILL.md"));
75
+ expect(snapshot.skills.find((s) => s.name === "global-codex")?.source)
61
76
  .toBe("runtime-global");
62
77
  expect(snapshot.probedAt).toBeGreaterThan(0);
63
78
  });
@@ -101,6 +116,67 @@ describe("skill snapshots", () => {
101
116
  .toBe("runtime-global");
102
117
  });
103
118
 
119
+ it("scans Hermes home/profile skills without mixing Claude or Codex dirs", () => {
120
+ const agentId = "ag_hermes_skills";
121
+ writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "claude-only", "Claude only");
122
+ writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-only", "Codex only");
123
+ writeSkill(path.join(agentHermesHomeDir(agentId), "skills"), "hermes-only", "Hermes only");
124
+
125
+ const isolated = scanSoftSkills(agentId, { runtime: "hermes-agent" });
126
+ expect(isolated.map((s) => s.name)).toEqual(["hermes-only"]);
127
+ expect(isolated[0]).toMatchObject({
128
+ source: "agent-hermes",
129
+ runtime: "hermes-agent",
130
+ });
131
+
132
+ const profileAgentId = "ag_hermes_profile";
133
+ writeSkill(
134
+ path.join(hermesProfileHomeDir("writer"), "skills"),
135
+ "profile-skill",
136
+ "Hermes profile skill",
137
+ );
138
+ const profile = collectAgentSkillSnapshot(profileAgentId, {
139
+ runtime: "hermes-agent",
140
+ hermesProfile: "writer",
141
+ });
142
+ expect(profile.skills).toHaveLength(1);
143
+ expect(profile.skills[0]).toMatchObject({
144
+ name: "profile-skill",
145
+ source: "workspace",
146
+ sourceDetail: "agent-hermes-profile",
147
+ runtime: "hermes-agent",
148
+ profile: "writer",
149
+ });
150
+ });
151
+
152
+ it("keeps same-device workspace skills scoped by agent id", () => {
153
+ writeSkill(
154
+ path.join(agentWorkspaceDir("ag_workspace_a"), ".claude", "skills"),
155
+ "agent-local",
156
+ "Skill for agent A",
157
+ );
158
+ writeSkill(
159
+ path.join(agentWorkspaceDir("ag_workspace_b"), ".claude", "skills"),
160
+ "agent-local",
161
+ "Skill for agent B",
162
+ );
163
+
164
+ expect(scanSoftSkills("ag_workspace_a", { runtime: "claude-code" })).toEqual([
165
+ expect.objectContaining({
166
+ name: "agent-local",
167
+ description: "Skill for agent A",
168
+ source: "agent-claude",
169
+ }),
170
+ ]);
171
+ expect(scanSoftSkills("ag_workspace_b", { runtime: "claude-code" })).toEqual([
172
+ expect.objectContaining({
173
+ name: "agent-local",
174
+ description: "Skill for agent B",
175
+ source: "agent-claude",
176
+ }),
177
+ ]);
178
+ });
179
+
104
180
  it("returns complete snapshots while keeping the prompt soft index capped", () => {
105
181
  const agentId = "ag_manyskills";
106
182
  const workspaceSkills = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
@@ -0,0 +1,224 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ symlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+ import {
14
+ agentCodexHomeDir,
15
+ agentWorkspaceDir,
16
+ } from "../agent-workspace.js";
17
+ import {
18
+ buildVercelSkillsArgs,
19
+ installAgentSkillManifest,
20
+ installBotLearnArchiveManifest,
21
+ installVercelSkillsForAgent,
22
+ } from "../skill-installer.js";
23
+
24
+ let tmpHome = "";
25
+ let prevHome: string | undefined;
26
+
27
+ beforeEach(() => {
28
+ tmpHome = mkdtempSync(path.join(tmpdir(), "skill-installer-"));
29
+ prevHome = process.env.HOME;
30
+ process.env.HOME = tmpHome;
31
+ });
32
+
33
+ afterEach(() => {
34
+ if (prevHome === undefined) delete process.env.HOME;
35
+ else process.env.HOME = prevHome;
36
+ rmSync(tmpHome, { recursive: true, force: true });
37
+ });
38
+
39
+ describe("skill installer", () => {
40
+ it("installs a manifest into the loaded agent runtime path and returns a refreshed snapshot", () => {
41
+ const result = installAgentSkillManifest(
42
+ "ag_manifest",
43
+ {
44
+ name: "review-helper",
45
+ description: "Review pull requests",
46
+ files: [{ path: "references/checklist.md", content: "Check tests\n" }],
47
+ },
48
+ { runtime: "codex" },
49
+ );
50
+
51
+ const skillDir = path.join(agentCodexHomeDir("ag_manifest"), "skills", "review-helper");
52
+ expect(readFileSync(path.join(skillDir, "SKILL.md"), "utf8")).toContain("name: review-helper");
53
+ expect(readFileSync(path.join(skillDir, "references", "checklist.md"), "utf8")).toBe("Check tests\n");
54
+ expect(result.installed).toEqual([
55
+ {
56
+ name: "review-helper",
57
+ targets: ["codex"],
58
+ paths: [skillDir],
59
+ },
60
+ ]);
61
+ expect(result.snapshot.skills.map((s) => s.name)).toContain("review-helper");
62
+ });
63
+
64
+ it("installs BotLearn archive-style manifests containing multiple skills", () => {
65
+ const result = installBotLearnArchiveManifest(
66
+ "ag_archive",
67
+ {
68
+ targetRuntimes: ["claude-code"],
69
+ skills: [
70
+ {
71
+ id: "planner",
72
+ description: "Plan work",
73
+ skillMd: "---\nname: planner\ndescription: Plan work\n---\n\n# Planner\n",
74
+ },
75
+ {
76
+ name: "tester",
77
+ markdown: "---\nname: tester\n---\n\n# Tester\n",
78
+ },
79
+ ],
80
+ },
81
+ { runtime: "claude-code" },
82
+ );
83
+
84
+ const skillsRoot = path.join(agentWorkspaceDir("ag_archive"), ".claude", "skills");
85
+ expect(existsSync(path.join(skillsRoot, "planner", "SKILL.md"))).toBe(true);
86
+ expect(existsSync(path.join(skillsRoot, "tester", "SKILL.md"))).toBe(true);
87
+ expect(result.installed.map((skill) => skill.name)).toEqual(["planner", "tester"]);
88
+ expect(result.snapshot.skills.map((skill) => skill.name).sort()).toEqual(["planner", "tester"]);
89
+ });
90
+
91
+ it("rejects unsafe names and file paths before writing", () => {
92
+ expect(() => installAgentSkillManifest("ag_unsafe_name", {
93
+ name: "../bad",
94
+ })).toThrow(/unsafe skill name/);
95
+
96
+ expect(() => installAgentSkillManifest("ag_unsafe_file", {
97
+ name: "safe",
98
+ files: [{ path: "../escape.txt", content: "no" }],
99
+ })).toThrow(/unsafe skill file path/);
100
+ });
101
+
102
+ it("does not leave a partial skill dir when source file validation fails", () => {
103
+ const sourceRoot = mkdtempSync(path.join(tmpdir(), "skill-source-"));
104
+ try {
105
+ expect(() => installAgentSkillManifest("ag_missing_source", {
106
+ name: "missing-source",
107
+ files: [{ path: "references/missing.md", sourcePath: "missing.md" }],
108
+ }, {
109
+ runtime: "codex",
110
+ sourceRoot,
111
+ })).toThrow();
112
+
113
+ const skillDir = path.join(agentCodexHomeDir("ag_missing_source"), "skills", "missing-source");
114
+ expect(existsSync(skillDir)).toBe(false);
115
+ } finally {
116
+ rmSync(sourceRoot, { recursive: true, force: true });
117
+ }
118
+ });
119
+
120
+ it("does not leave a partial skill dir when an inline file is oversized", () => {
121
+ const oversized = "x".repeat((256 * 1024) + 1);
122
+
123
+ expect(() => installAgentSkillManifest("ag_oversized", {
124
+ name: "oversized",
125
+ files: [{ path: "references/large.md", content: oversized }],
126
+ }, {
127
+ runtime: "codex",
128
+ })).toThrow(/skill file too large/);
129
+
130
+ const skillDir = path.join(agentCodexHomeDir("ag_oversized"), "skills", "oversized");
131
+ expect(existsSync(skillDir)).toBe(false);
132
+ });
133
+
134
+ it("builds a non-interactive vercel-labs/skills command for injected execution", () => {
135
+ expect(buildVercelSkillsArgs("https://github.com/vercel-labs/skills", ["frontend-design"], ["codex"]))
136
+ .toEqual([
137
+ "--yes",
138
+ "skills",
139
+ "add",
140
+ "https://github.com/vercel-labs/skills",
141
+ "--global",
142
+ "--copy",
143
+ "--yes",
144
+ "--skill",
145
+ "frontend-design",
146
+ "--agent",
147
+ "codex",
148
+ ]);
149
+ });
150
+
151
+ it("imports skills produced by an injected vercel-labs/skills executor without network", async () => {
152
+ const executor = vi.fn(async (_command, _args, options) => {
153
+ const home = options.env?.HOME as string;
154
+ const namespaced = path.join(home, ".codex", "skills", "vercel-labs", "find-skills");
155
+ mkdirSync(namespaced, { recursive: true });
156
+ writeFileSync(
157
+ path.join(namespaced, "SKILL.md"),
158
+ "---\nname: find-skills\ndescription: Find skills\n---\n\n# Find Skills\n",
159
+ );
160
+ });
161
+
162
+ const result = await installVercelSkillsForAgent({
163
+ agentId: "ag_vercel",
164
+ packageSpec: "https://github.com/vercel-labs/skills",
165
+ skills: ["find-skills"],
166
+ runtime: "codex",
167
+ executor,
168
+ });
169
+
170
+ const installed = path.join(agentCodexHomeDir("ag_vercel"), "skills", "find-skills", "SKILL.md");
171
+ expect(existsSync(installed)).toBe(true);
172
+ expect(result.installed).toEqual([
173
+ {
174
+ name: "find-skills",
175
+ targets: ["codex"],
176
+ paths: [path.dirname(installed)],
177
+ },
178
+ ]);
179
+ expect(result.snapshot.skills.map((skill) => skill.name)).toContain("find-skills");
180
+ expect(executor).toHaveBeenCalledWith(
181
+ "npx",
182
+ expect.arrayContaining(["skills", "add", "https://github.com/vercel-labs/skills"]),
183
+ expect.objectContaining({ cwd: agentWorkspaceDir("ag_vercel") }),
184
+ );
185
+ });
186
+
187
+ it("rejects untrusted vercel package specs", async () => {
188
+ await expect(installVercelSkillsForAgent({
189
+ agentId: "ag_untrusted_vercel",
190
+ packageSpec: "attacker/skills",
191
+ runtime: "codex",
192
+ executor: vi.fn(),
193
+ })).rejects.toThrow(/unsupported vercel skills packageSpec/);
194
+ });
195
+
196
+ it("rejects symlinked files from vercel skill import without installing a skill dir", async () => {
197
+ const outside = mkdtempSync(path.join(tmpdir(), "skill-outside-"));
198
+ try {
199
+ writeFileSync(path.join(outside, "secret.txt"), "do not copy");
200
+ const executor = vi.fn(async (_command, _args, options) => {
201
+ const home = options.env?.HOME as string;
202
+ const skillDir = path.join(home, ".codex", "skills", "vercel-labs", "linked-skill");
203
+ mkdirSync(skillDir, { recursive: true });
204
+ writeFileSync(
205
+ path.join(skillDir, "SKILL.md"),
206
+ "---\nname: linked-skill\n---\n\n# Linked Skill\n",
207
+ );
208
+ symlinkSync(path.join(outside, "secret.txt"), path.join(skillDir, "secret.txt"));
209
+ });
210
+
211
+ await expect(installVercelSkillsForAgent({
212
+ agentId: "ag_vercel_symlink",
213
+ packageSpec: "https://github.com/vercel-labs/skills",
214
+ runtime: "codex",
215
+ executor,
216
+ })).rejects.toThrow(/rejects symlink/);
217
+
218
+ const skillDir = path.join(agentCodexHomeDir("ag_vercel_symlink"), "skills", "linked-skill");
219
+ expect(existsSync(skillDir)).toBe(false);
220
+ } finally {
221
+ rmSync(outside, { recursive: true, force: true });
222
+ }
223
+ });
224
+ });
@@ -42,6 +42,7 @@ import { CloudAuthManager, asUserAuthManager } from "./cloud-auth.js";
42
42
  import type { CloudModeConfig } from "./cloud-mode.js";
43
43
  import { buildCloudRunSettleHook } from "./cloud-settle.js";
44
44
  import type { InstalledAgentInfo, OnAgentInstalledHook } from "./provision.js";
45
+ import type { SkillIndexOptions } from "./skill-index.js";
45
46
 
46
47
  // Cloud daemons follow the same cadence as local — keeps dashboard
47
48
  // "runtimes last detected" behavior identical across both kinds.
@@ -125,6 +126,15 @@ export async function startCloudDaemon(
125
126
  const credentialPathByAgentId = new Map<string, string>();
126
127
  const hubUrlByAgentId = new Map<string, string>();
127
128
  const displayNameByAgent = new Map<string, string>();
129
+ const runtimeByAgentId = new Map<string, string>();
130
+ const hermesProfileByAgentId = new Map<string, string>();
131
+ const skillIndexOptionsForAgent = (agentId: string): SkillIndexOptions => {
132
+ const hermesProfile = hermesProfileByAgentId.get(agentId);
133
+ return {
134
+ runtime: runtimeByAgentId.get(agentId) ?? opts.config.defaultRoute.adapter,
135
+ ...(hermesProfile ? { hermesProfile } : {}),
136
+ };
137
+ };
128
138
  // Seed each per-agent hub URL with the cloud-mode value so that even
129
139
  // before the first credential file is written the room-context fetcher
130
140
  // has somewhere sensible to point.
@@ -235,15 +245,15 @@ export async function startCloudDaemon(
235
245
  };
236
246
 
237
247
  const installedAgentIds = new Set<string>();
238
- const runtimeByAgentId = new Map<string, string>();
239
248
  let controlChannel: ControlChannel | null = null;
240
249
  const pushInstalledAgentSkillSnapshot = (agentId: string, reason: string): void => {
241
250
  if (!controlChannel) return;
242
- const runtime = runtimeByAgentId.get(agentId) ?? opts.config.defaultRoute.adapter;
243
- const pushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
251
+ const skillIndexOptions = skillIndexOptionsForAgent(agentId);
252
+ const pushed = pushAgentSkillSnapshot(controlChannel, agentId, skillIndexOptions);
244
253
  logger.info("cloud control-channel: agent_skill_snapshot pushed", {
245
254
  agentId,
246
- runtime,
255
+ runtime: skillIndexOptions.runtime,
256
+ hermesProfile: skillIndexOptions.hermesProfile ?? null,
247
257
  reason,
248
258
  ok: pushed,
249
259
  });
@@ -252,6 +262,8 @@ export async function startCloudDaemon(
252
262
  const onAgentInstalled: OnAgentInstalledHook = (info: InstalledAgentInfo) => {
253
263
  installedAgentIds.add(info.agentId);
254
264
  if (info.runtime) runtimeByAgentId.set(info.agentId, info.runtime);
265
+ if (info.hermesProfile) hermesProfileByAgentId.set(info.agentId, info.hermesProfile);
266
+ else if (info.runtime) hermesProfileByAgentId.delete(info.agentId);
255
267
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
256
268
  if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
257
269
  if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
@@ -262,6 +274,7 @@ export async function startCloudDaemon(
262
274
  agentId: info.agentId,
263
275
  activityTracker,
264
276
  roomContextBuilder,
277
+ skillIndexOptions: () => skillIndexOptionsForAgent(info.agentId),
265
278
  // Cloud daemons run isolated — no loop-risk guard wired in PR1;
266
279
  // the runtime adapter's wall-time budget enforces the equivalent.
267
280
  loopRiskBuilder: () => null,
package/src/daemon.ts CHANGED
@@ -52,7 +52,7 @@ import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-res
52
52
  import { scanMention } from "./mention-scan.js";
53
53
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
54
54
  import { createAttentionPolicyFetcher } from "./attention-policy-fetcher.js";
55
- import { collectAgentSkillSnapshot } from "./skill-index.js";
55
+ import { collectAgentSkillSnapshot, type SkillIndexOptions } from "./skill-index.js";
56
56
 
57
57
  /**
58
58
  * Default hard cap for a single runtime turn. Long-running coding/research
@@ -249,7 +249,7 @@ export function pushRuntimeSnapshot(
249
249
  export function pushAgentSkillSnapshot(
250
250
  sink: RuntimeSnapshotSink,
251
251
  agentId: string,
252
- opts: { runtime?: string } = {},
252
+ opts: SkillIndexOptions = {},
253
253
  ): boolean {
254
254
  const snap = collectAgentSkillSnapshot(agentId, opts);
255
255
  const ok = sink.send({
@@ -354,6 +354,13 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
354
354
  );
355
355
 
356
356
  const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
357
+ const skillIndexOptionsForAgent = (agentId: string): SkillIndexOptions => {
358
+ const meta = agentRuntimes[agentId];
359
+ return {
360
+ runtime: meta?.runtime ?? opts.config.defaultRoute.adapter,
361
+ ...(meta?.hermesProfile ? { hermesProfile: meta.hermesProfile } : {}),
362
+ };
363
+ };
357
364
 
358
365
  // Per-agent hub URL — read from each credential file at boot. Used to
359
366
  // populate `BOTCORD_HUB` for runtime CLI subprocesses so the bundled
@@ -420,6 +427,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
420
427
  activityTracker,
421
428
  roomContextBuilder,
422
429
  loopRiskBuilder,
430
+ skillIndexOptions: () => skillIndexOptionsForAgent(aid),
423
431
  }),
424
432
  );
425
433
  }
@@ -530,11 +538,16 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
530
538
  // next room-context fetch re-loads the BotCordClient against the new
531
539
  // credential file.
532
540
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
533
- if (info.runtime) {
534
- agentRuntimes[info.agentId] = {
541
+ if (info.runtime || info.hermesProfile) {
542
+ const next = {
535
543
  ...(agentRuntimes[info.agentId] ?? {}),
536
- runtime: info.runtime,
544
+ ...(info.runtime ? { runtime: info.runtime } : {}),
545
+ ...(info.hermesProfile ? { hermesProfile: info.hermesProfile } : {}),
537
546
  };
547
+ if (info.runtime && !info.hermesProfile) {
548
+ delete next.hermesProfile;
549
+ }
550
+ agentRuntimes[info.agentId] = next;
538
551
  }
539
552
  if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
540
553
  if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
@@ -546,6 +559,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
546
559
  activityTracker,
547
560
  roomContextBuilder,
548
561
  loopRiskBuilder,
562
+ skillIndexOptions: () => skillIndexOptionsForAgent(info.agentId),
549
563
  }),
550
564
  );
551
565
  }
@@ -677,11 +691,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
677
691
  ok: pushed,
678
692
  });
679
693
  for (const agentId of agentIds) {
680
- const runtime = agentRuntimes[agentId]?.runtime ?? opts.config.defaultRoute.adapter;
681
- const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
694
+ const skillIndexOptions = skillIndexOptionsForAgent(agentId);
695
+ const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, skillIndexOptions);
682
696
  logger.info("control-channel: initial agent_skill_snapshot push", {
683
697
  agentId,
684
- runtime,
698
+ runtime: skillIndexOptions.runtime,
699
+ hermesProfile: skillIndexOptions.hermesProfile ?? null,
685
700
  ok: skillsPushed,
686
701
  });
687
702
  }