@botcord/daemon 0.2.27 → 0.2.29

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.
@@ -10,6 +10,14 @@ const DEFAULT_TOKEN_FILE_PATHS = [
10
10
  "/var/run/openclaw/gateway-token",
11
11
  "~/.openclaw/gateway-token",
12
12
  ];
13
+ const DEFAULT_SYSTEMD_UNIT_PATHS = [
14
+ "/etc/systemd/system/openclaw.service",
15
+ "/etc/systemd/system/openclaw-gateway.service",
16
+ "/lib/systemd/system/openclaw.service",
17
+ "/lib/systemd/system/openclaw-gateway.service",
18
+ "/usr/lib/systemd/system/openclaw.service",
19
+ "/usr/lib/systemd/system/openclaw-gateway.service",
20
+ ];
13
21
  export async function discoverLocalOpenclawGateways(opts = {}) {
14
22
  const found = [];
15
23
  for (const root of opts.searchPaths ?? DEFAULT_SEARCH_PATHS) {
@@ -17,6 +25,7 @@ export async function discoverLocalOpenclawGateways(opts = {}) {
17
25
  }
18
26
  const env = opts.env ?? process.env;
19
27
  found.push(...discoverFromEnv(env));
28
+ found.push(...discoverFromSystemdUnits(opts.systemdUnitPaths ?? DEFAULT_SYSTEMD_UNIT_PATHS));
20
29
  const envAuth = pickOpenclawEnvAuth(env) ?? pickDefaultTokenFile();
21
30
  const ports = opts.defaultPorts ?? DEFAULT_PORTS;
22
31
  if (ports.length > 0) {
@@ -38,6 +47,167 @@ export async function discoverLocalOpenclawGateways(opts = {}) {
38
47
  }
39
48
  return dedupeDiscovered(found);
40
49
  }
50
+ function discoverFromSystemdUnits(paths) {
51
+ const out = [];
52
+ for (const unitPath of paths) {
53
+ try {
54
+ if (!existsSync(unitPath))
55
+ continue;
56
+ const parsed = parseSystemdUnit(readFileSync(unitPath, "utf8"), path.dirname(unitPath));
57
+ const url = parsed.url ?? urlFromGatewayPort(parsed.env);
58
+ if (!url)
59
+ continue;
60
+ const auth = pickOpenclawEnvAuth(parsed.env);
61
+ out.push({
62
+ name: nameFromUrl(url),
63
+ url,
64
+ source: "systemd-unit",
65
+ ...auth,
66
+ });
67
+ }
68
+ catch (err) {
69
+ daemonLog.debug("openclaw discovery systemd unit skipped", {
70
+ file: unitPath,
71
+ error: err instanceof Error ? err.message : String(err),
72
+ });
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ function parseSystemdUnit(raw, unitDir) {
78
+ const env = {};
79
+ let url;
80
+ for (const line of joinedSystemdLines(raw)) {
81
+ const trimmed = line.trim();
82
+ if (!trimmed || trimmed.startsWith("#"))
83
+ continue;
84
+ const eq = trimmed.indexOf("=");
85
+ if (eq <= 0)
86
+ continue;
87
+ const key = trimmed.slice(0, eq);
88
+ const value = trimmed.slice(eq + 1).trim();
89
+ if (key === "Environment") {
90
+ Object.assign(env, parseSystemdEnvironment(value));
91
+ }
92
+ else if (key === "EnvironmentFile") {
93
+ for (const file of splitSystemdWords(value)) {
94
+ const optional = file.startsWith("-");
95
+ const resolved = path.resolve(unitDir, expandHome(optional ? file.slice(1) : file));
96
+ try {
97
+ Object.assign(env, parseEnvFile(readFileSync(resolved, "utf8")));
98
+ }
99
+ catch (err) {
100
+ if (!optional) {
101
+ daemonLog.debug("openclaw discovery environment file skipped", {
102
+ file: resolved,
103
+ error: err instanceof Error ? err.message : String(err),
104
+ });
105
+ }
106
+ }
107
+ }
108
+ }
109
+ else if (key === "ExecStart") {
110
+ url = urlFromExecStart(value) ?? url;
111
+ }
112
+ }
113
+ return { env, url };
114
+ }
115
+ function joinedSystemdLines(raw) {
116
+ const out = [];
117
+ let cur = "";
118
+ for (const line of raw.split(/\r?\n/)) {
119
+ const trimmedEnd = line.replace(/\s+$/, "");
120
+ if (trimmedEnd.endsWith("\\")) {
121
+ cur += trimmedEnd.slice(0, -1) + " ";
122
+ continue;
123
+ }
124
+ out.push(cur + line);
125
+ cur = "";
126
+ }
127
+ if (cur)
128
+ out.push(cur);
129
+ return out;
130
+ }
131
+ function parseSystemdEnvironment(raw) {
132
+ const env = {};
133
+ for (const word of splitSystemdWords(raw)) {
134
+ const eq = word.indexOf("=");
135
+ if (eq <= 0)
136
+ continue;
137
+ env[word.slice(0, eq)] = word.slice(eq + 1);
138
+ }
139
+ return env;
140
+ }
141
+ function parseEnvFile(raw) {
142
+ const env = {};
143
+ for (const line of raw.split(/\r?\n/)) {
144
+ const trimmed = line.trim();
145
+ if (!trimmed || trimmed.startsWith("#"))
146
+ continue;
147
+ const eq = trimmed.indexOf("=");
148
+ if (eq <= 0)
149
+ continue;
150
+ env[trimmed.slice(0, eq)] = unquote(trimmed.slice(eq + 1).trim());
151
+ }
152
+ return env;
153
+ }
154
+ function urlFromExecStart(raw) {
155
+ const words = splitSystemdWords(raw);
156
+ const portIdx = words.indexOf("--port");
157
+ const rawPort = portIdx >= 0 ? words[portIdx + 1] : words.find((w) => w.startsWith("--port="))?.slice(7);
158
+ if (!rawPort)
159
+ return undefined;
160
+ const port = Number(rawPort);
161
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
162
+ return undefined;
163
+ return `ws://127.0.0.1:${port}`;
164
+ }
165
+ function splitSystemdWords(raw) {
166
+ const words = [];
167
+ let cur = "";
168
+ let quote = null;
169
+ let escaped = false;
170
+ for (const ch of raw) {
171
+ if (escaped) {
172
+ cur += ch;
173
+ escaped = false;
174
+ continue;
175
+ }
176
+ if (ch === "\\") {
177
+ escaped = true;
178
+ continue;
179
+ }
180
+ if (quote) {
181
+ if (ch === quote)
182
+ quote = null;
183
+ else
184
+ cur += ch;
185
+ continue;
186
+ }
187
+ if (ch === '"' || ch === "'") {
188
+ quote = ch;
189
+ continue;
190
+ }
191
+ if (/\s/.test(ch)) {
192
+ if (cur) {
193
+ words.push(cur);
194
+ cur = "";
195
+ }
196
+ continue;
197
+ }
198
+ cur += ch;
199
+ }
200
+ if (cur)
201
+ words.push(cur);
202
+ return words.map(unquote);
203
+ }
204
+ function unquote(raw) {
205
+ if ((raw.startsWith('"') && raw.endsWith('"')) ||
206
+ (raw.startsWith("'") && raw.endsWith("'"))) {
207
+ return raw.slice(1, -1);
208
+ }
209
+ return raw;
210
+ }
41
211
  function discoverFromEnv(env) {
42
212
  const url = pickEnv(env, "OPENCLAW_ACP_URL") ??
43
213
  pickEnv(env, "OPENCLAW_GATEWAY_URL") ??
@@ -244,8 +414,9 @@ function pickString(obj, keys) {
244
414
  }
245
415
  function dedupeDiscovered(items) {
246
416
  const priority = {
247
- "config-file": 3,
248
- env: 2,
417
+ "config-file": 4,
418
+ env: 3,
419
+ "systemd-unit": 2,
249
420
  "default-port": 1,
250
421
  };
251
422
  const byUrl = new Map();
@@ -307,6 +478,9 @@ export function defaultOpenclawDiscoveryPorts() {
307
478
  export function defaultOpenclawDiscoveryTokenFilePaths() {
308
479
  return DEFAULT_TOKEN_FILE_PATHS.slice();
309
480
  }
481
+ export function defaultOpenclawDiscoverySystemdUnitPaths() {
482
+ return DEFAULT_SYSTEMD_UNIT_PATHS.slice();
483
+ }
310
484
  export function openclawDiscoveryConfigEnabled(cfg) {
311
485
  return cfg.openclawDiscovery?.enabled !== false;
312
486
  }
@@ -90,14 +90,6 @@ export declare function addAgentToConfig(cfg: DaemonConfig, agentId: string): Da
90
90
  export declare function removeAgentFromConfig(cfg: DaemonConfig, agentId: string): DaemonConfig | null;
91
91
  /** Drop the cache (e.g. before a `doctor`-style interactive re-probe). */
92
92
  export declare function clearRuntimeProbeCache(): void;
93
- /**
94
- * Probe every registered adapter and shape the result as the wire-level
95
- * {@link ListRuntimesResult} — used by both the `list_runtimes` ack path and
96
- * the daemon-side first-connect `runtime_snapshot` push in `daemon.ts`.
97
- *
98
- * Cached for {@link RUNTIME_PROBE_CACHE_TTL_MS}; pass `{ force: true }` to
99
- * bypass the cache.
100
- */
101
93
  export declare function collectRuntimeSnapshot(opts?: {
102
94
  force?: boolean;
103
95
  }): ListRuntimesResult;
@@ -200,6 +192,18 @@ interface ReloadResult {
200
192
  export declare function reloadConfig(ctx: {
201
193
  gateway: Gateway;
202
194
  }): Promise<ReloadResult>;
195
+ /**
196
+ * Scan `~/.botcord/credentials/*.json` and return a mapping from hermes
197
+ * profile name to the BotCord agent currently bound to it. Used by the
198
+ * runtime snapshot path to mark profiles as occupied in the picker, and by
199
+ * the provision path to enforce the 1 BotCord agent : 1 hermes profile
200
+ * invariant. The scan is cheap and authoritative — running daemon state may
201
+ * lag (e.g. between provision and reconcile), so we always read disk.
202
+ */
203
+ export declare function profileOccupancyMap(): Map<string, {
204
+ agentId: string;
205
+ agentName?: string;
206
+ }>;
203
207
  /**
204
208
  * Per-agent entry returned by `list_agents`. Wire shape: `{id, name, online}`.
205
209
  * `status` and `lastMessageAt` are extra daemon-only fields the dashboard
package/dist/provision.js CHANGED
@@ -4,14 +4,15 @@
4
4
  * side effects (register agent, write credentials, load route, add/remove
5
5
  * gateway channel) and return an ack payload.
6
6
  */
7
- import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
7
+ import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import path from "node:path";
10
10
  import { BotCordClient, CONTROL_FRAME_TYPES, defaultCredentialsFile, derivePublicKey, loadStoredCredentials, writeCredentialsFile, } from "@botcord/protocol-core";
11
11
  import { loadConfig, resolveConfiguredAgentIds, saveConfig, } from "./config.js";
12
12
  import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes, prepareGatewayProfile, } from "./daemon-config-map.js";
13
- import { agentHomeDir, agentStateDir, agentWorkspaceDir, applyAgentIdentity, ensureAgentWorkspace, } from "./agent-workspace.js";
13
+ import { agentHomeDir, agentStateDir, agentWorkspaceDir, applyAgentIdentity, ensureAttachedHermesProfileSkills, ensureAgentWorkspace, } from "./agent-workspace.js";
14
14
  import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
15
+ import { hermesProfileHomeDir, isValidHermesProfileName, listHermesProfiles, } from "./gateway/runtimes/hermes-agent.js";
15
16
  import { log as daemonLog } from "./log.js";
16
17
  import { discoverAgentCredentials } from "./agent-discovery.js";
17
18
  /**
@@ -67,7 +68,33 @@ export function createProvisioner(opts) {
67
68
  runtime: pickRuntime(params) ?? null,
68
69
  name: params.name ?? null,
69
70
  });
70
- const agent = await provisionAgent(params, { gateway, register, onAgentInstalled });
71
+ let agent;
72
+ try {
73
+ agent = await provisionAgent(params, {
74
+ gateway,
75
+ register,
76
+ onAgentInstalled,
77
+ });
78
+ }
79
+ catch (err) {
80
+ if (err instanceof HermesProfileError) {
81
+ daemonLog.warn("provision_agent: hermes profile rejected", {
82
+ code: err.code,
83
+ profile: err.profile,
84
+ occupiedBy: err.occupiedBy ?? null,
85
+ });
86
+ return {
87
+ ok: false,
88
+ error: {
89
+ code: err.code,
90
+ message: err.message,
91
+ ...(err.occupiedBy ? { occupiedBy: err.occupiedBy } : {}),
92
+ profile: err.profile,
93
+ },
94
+ };
95
+ }
96
+ throw err;
97
+ }
71
98
  // Seed the policy resolver from the optional `defaultAttention` /
72
99
  // `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
73
100
  // don't yet emit these stay backwards-compatible — the resolver just
@@ -213,6 +240,23 @@ async function provisionAgent(params, ctx) {
213
240
  });
214
241
  });
215
242
  }
243
+ const hermesSel = pickHermesSelection(resolvedParams);
244
+ if (hermesSel) {
245
+ return withHermesProvisionLock(hermesSel, async () => {
246
+ // Race-safe re-check inside the per-profile lock so two concurrent
247
+ // provisions for the same profile (e.g. two dashboard tabs) cannot
248
+ // both succeed.
249
+ validateHermesProfileForProvision(hermesSel, params.credentials?.agentId);
250
+ const cfg = loadConfig();
251
+ const credentials = await materializeCredentials(resolvedParams, cfg, ctx, explicitCwd);
252
+ return installLocalAgent(credentials, {
253
+ ...ctx,
254
+ cfg,
255
+ bio: params.bio,
256
+ source: params.credentials ? "hub-supplied" : "registered",
257
+ });
258
+ });
259
+ }
216
260
  const credentials = await materializeCredentials(resolvedParams, initialCfg, ctx, explicitCwd);
217
261
  return installLocalAgent(credentials, {
218
262
  ...ctx,
@@ -241,6 +285,9 @@ async function installLocalAgent(credentials, ctx) {
241
285
  keyId: credentials.keyId,
242
286
  savedAt: credentials.savedAt,
243
287
  });
288
+ if (credentials.runtime === "hermes-agent" && credentials.hermesProfile) {
289
+ ensureAttachedHermesProfileSkills(hermesProfileHomeDir(credentials.hermesProfile));
290
+ }
244
291
  }
245
292
  catch (err) {
246
293
  try {
@@ -391,6 +438,9 @@ function upsertManagedRouteForCredentials(credentials, cfg, gateway) {
391
438
  };
392
439
  }
393
440
  }
441
+ if (synthRoute.runtime === "hermes-agent" && credentials.hermesProfile) {
442
+ synthRoute.hermesProfile = credentials.hermesProfile;
443
+ }
394
444
  gateway.upsertManagedRoute(credentials.agentId, synthRoute);
395
445
  }
396
446
  async function installExistingOpenclawBinding(agentId, ctx) {
@@ -482,6 +532,9 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
482
532
  record.openclawGateway = openclawSel.gateway;
483
533
  if (openclawSel.agent)
484
534
  record.openclawAgent = openclawSel.agent;
535
+ const hermesSel = pickHermesSelection(params);
536
+ if (hermesSel)
537
+ record.hermesProfile = hermesSel;
485
538
  return record;
486
539
  }
487
540
  // Slow path: daemon registers a fresh identity against Hub. We need a
@@ -514,6 +567,9 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
514
567
  record.openclawGateway = openclawSel.gateway;
515
568
  if (openclawSel.agent)
516
569
  record.openclawAgent = openclawSel.agent;
570
+ const hermesSel = pickHermesSelection(params);
571
+ if (hermesSel)
572
+ record.hermesProfile = hermesSel;
517
573
  return record;
518
574
  }
519
575
  /**
@@ -596,6 +652,69 @@ function withResolvedOpenclawSelection(params, selection) {
596
652
  },
597
653
  };
598
654
  }
655
+ /**
656
+ * Resolve hermes profile selection from a `provision_agent` frame. Top-level
657
+ * `params.hermes.profile` (nested) wins over the flat
658
+ * `credentials.hermesProfile` mirror. Returning `undefined` is fine — the
659
+ * adapter falls back to the BotCord-isolated HERMES_HOME under
660
+ * `~/.botcord/agents/<id>/` when the agent is not attached to a profile.
661
+ */
662
+ function pickHermesSelection(params) {
663
+ const top = params.hermes;
664
+ if (top && typeof top.profile === "string" && top.profile.length > 0) {
665
+ return top.profile;
666
+ }
667
+ const flat = params.credentials;
668
+ if (flat && typeof flat.hermesProfile === "string" && flat.hermesProfile.length > 0) {
669
+ return flat.hermesProfile;
670
+ }
671
+ return undefined;
672
+ }
673
+ const hermesProvisionLocks = new Map();
674
+ async function withHermesProvisionLock(profile, fn) {
675
+ const prev = hermesProvisionLocks.get(profile) ?? Promise.resolve();
676
+ const next = prev.then(fn, fn);
677
+ hermesProvisionLocks.set(profile, next);
678
+ try {
679
+ return (await next);
680
+ }
681
+ finally {
682
+ if (hermesProvisionLocks.get(profile) === next) {
683
+ hermesProvisionLocks.delete(profile);
684
+ }
685
+ }
686
+ }
687
+ class HermesProfileError extends Error {
688
+ code;
689
+ profile;
690
+ occupiedBy;
691
+ constructor(code, message, profile, occupiedBy) {
692
+ super(message);
693
+ this.code = code;
694
+ this.profile = profile;
695
+ this.occupiedBy = occupiedBy;
696
+ this.name = "HermesProfileError";
697
+ }
698
+ }
699
+ /**
700
+ * Validate that `profile` exists on disk and is not already bound to another
701
+ * BotCord agent. Throws {@link HermesProfileError} on failure so the caller
702
+ * can surface the structured error code via the control-frame ack.
703
+ */
704
+ function validateHermesProfileForProvision(profile, selfAgentId) {
705
+ if (!isValidHermesProfileName(profile)) {
706
+ throw new HermesProfileError("hermes_profile_invalid", `Hermes profile "${profile}" is not a valid profile name.`, profile);
707
+ }
708
+ const home = hermesProfileHomeDir(profile);
709
+ if (!existsSync(home)) {
710
+ throw new HermesProfileError("hermes_profile_not_found", `Hermes profile "${profile}" does not exist at ${home}. Create it via "hermes profile create ${profile}" and retry.`, profile);
711
+ }
712
+ const occupancy = profileOccupancyMap();
713
+ const owner = occupancy.get(profile);
714
+ if (owner && owner.agentId !== selfAgentId) {
715
+ throw new HermesProfileError("hermes_profile_occupied", `Hermes profile "${profile}" is already bound to BotCord agent ${owner.agentId}.`, profile, owner.agentId);
716
+ }
717
+ }
599
718
  async function withOpenclawProvisionLock(gateway, agent, fn) {
600
719
  const key = `${gateway}\0${agent}`;
601
720
  const prev = openclawProvisionLocks.get(key) ?? Promise.resolve();
@@ -922,6 +1041,33 @@ export function clearRuntimeProbeCache() {
922
1041
  * Cached for {@link RUNTIME_PROBE_CACHE_TTL_MS}; pass `{ force: true }` to
923
1042
  * bypass the cache.
924
1043
  */
1044
+ /**
1045
+ * Build the wire-shape `HermesProfileProbe[]` for the runtime snapshot,
1046
+ * joining the on-disk profile listing with the BotCord-side occupancy map
1047
+ * so dashboards can render disabled rows without a second round trip.
1048
+ */
1049
+ function listHermesProfilesForSnapshot(occupancy) {
1050
+ return listHermesProfiles().map((p) => {
1051
+ const out = { name: p.name, home: p.home };
1052
+ if (p.isDefault)
1053
+ out.isDefault = true;
1054
+ if (p.isActive)
1055
+ out.isActive = true;
1056
+ if (p.modelName)
1057
+ out.modelName = p.modelName;
1058
+ if (typeof p.sessionsCount === "number")
1059
+ out.sessionsCount = p.sessionsCount;
1060
+ if (p.hasSoul)
1061
+ out.hasSoul = true;
1062
+ const owner = occupancy.get(p.name);
1063
+ if (owner) {
1064
+ out.occupiedBy = owner.agentId;
1065
+ if (owner.agentName)
1066
+ out.occupiedByName = owner.agentName;
1067
+ }
1068
+ return out;
1069
+ });
1070
+ }
925
1071
  export function collectRuntimeSnapshot(opts = {}) {
926
1072
  if (!opts.force &&
927
1073
  _runtimeProbeCache &&
@@ -929,6 +1075,7 @@ export function collectRuntimeSnapshot(opts = {}) {
929
1075
  return _runtimeProbeCache.value;
930
1076
  }
931
1077
  const entries = detectRuntimes();
1078
+ const occupancy = profileOccupancyMap();
932
1079
  const runtimes = entries.map((entry) => {
933
1080
  const record = {
934
1081
  id: entry.id,
@@ -945,6 +1092,9 @@ export function collectRuntimeSnapshot(opts = {}) {
945
1092
  // already swallows throws into `{available: false}`. We leave the wire
946
1093
  // field blank in that case and let callers treat `!available` as reason
947
1094
  // enough; filling a synthetic message would be misleading.
1095
+ if (entry.id === "hermes-agent" && entry.result.available) {
1096
+ record.profiles = listHermesProfilesForSnapshot(occupancy);
1097
+ }
948
1098
  return record;
949
1099
  });
950
1100
  const value = { runtimes, probedAt: Date.now() };
@@ -1457,7 +1607,9 @@ function readAgentRuntimesFromCredentials(agentIds) {
1457
1607
  entry.openclawGateway = creds.openclawGateway;
1458
1608
  if (creds.openclawAgent)
1459
1609
  entry.openclawAgent = creds.openclawAgent;
1460
- if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent)
1610
+ if (creds.hermesProfile)
1611
+ entry.hermesProfile = creds.hermesProfile;
1612
+ if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent || entry.hermesProfile)
1461
1613
  out[id] = entry;
1462
1614
  }
1463
1615
  catch {
@@ -1466,6 +1618,48 @@ function readAgentRuntimesFromCredentials(agentIds) {
1466
1618
  }
1467
1619
  return out;
1468
1620
  }
1621
+ /**
1622
+ * Scan `~/.botcord/credentials/*.json` and return a mapping from hermes
1623
+ * profile name to the BotCord agent currently bound to it. Used by the
1624
+ * runtime snapshot path to mark profiles as occupied in the picker, and by
1625
+ * the provision path to enforce the 1 BotCord agent : 1 hermes profile
1626
+ * invariant. The scan is cheap and authoritative — running daemon state may
1627
+ * lag (e.g. between provision and reconcile), so we always read disk.
1628
+ */
1629
+ export function profileOccupancyMap() {
1630
+ const out = new Map();
1631
+ const dir = path.join(homedir(), ".botcord", "credentials");
1632
+ let names = [];
1633
+ try {
1634
+ names = readdirSync(dir);
1635
+ }
1636
+ catch {
1637
+ return out;
1638
+ }
1639
+ for (const file of names) {
1640
+ if (!file.endsWith(".json"))
1641
+ continue;
1642
+ try {
1643
+ const creds = loadStoredCredentials(path.join(dir, file));
1644
+ if (creds.runtime !== "hermes-agent")
1645
+ continue;
1646
+ if (!creds.hermesProfile)
1647
+ continue;
1648
+ // First write wins — ties shouldn't happen, but if they do, the
1649
+ // provision path's race check will surface it.
1650
+ if (!out.has(creds.hermesProfile)) {
1651
+ out.set(creds.hermesProfile, {
1652
+ agentId: creds.agentId,
1653
+ agentName: creds.displayName,
1654
+ });
1655
+ }
1656
+ }
1657
+ catch {
1658
+ // skip unreadable
1659
+ }
1660
+ }
1661
+ return out;
1662
+ }
1469
1663
  function listAgentsFromGateway(gateway) {
1470
1664
  const snap = gateway.snapshot();
1471
1665
  // Include any configured agents that the gateway may not have a status for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@ import {
18
18
  agentStateDir,
19
19
  agentWorkspaceDir,
20
20
  applyAgentIdentity,
21
+ ensureAttachedHermesProfileSkills,
21
22
  ensureAgentCodexHome,
22
23
  ensureAgentHermesWorkspace,
23
24
  ensureAgentWorkspace,
@@ -150,6 +151,23 @@ describe("ensureAgentWorkspace", () => {
150
151
  expect(reseeded).toContain("name: botcord");
151
152
  });
152
153
 
154
+ it("seeds bundled skills into an attached Hermes profile without creating private home state", () => {
155
+ const profileHome = path.join(tmpHome, ".hermes", "profiles", "coder");
156
+ mkdirSync(profileHome, { recursive: true });
157
+
158
+ const { hermesHome, hermesWorkspace } = ensureAgentHermesWorkspace("ag_hermes_attach", {
159
+ attached: true,
160
+ });
161
+ ensureAttachedHermesProfileSkills(profileHome);
162
+
163
+ expect(existsSync(path.join(profileHome, "skills", "botcord", "SKILL.md"))).toBe(true);
164
+ expect(existsSync(path.join(profileHome, "skills", "botcord-user-guide", "SKILL.md"))).toBe(
165
+ true,
166
+ );
167
+ expect(existsSync(hermesWorkspace)).toBe(true);
168
+ expect(existsSync(hermesHome)).toBe(false);
169
+ });
170
+
153
171
  it("does not overwrite a user-modified memory.md on a second call", () => {
154
172
  ensureAgentWorkspace("ag_keep", {});
155
173
  const memoryPath = path.join(agentWorkspaceDir("ag_keep"), "memory.md");