@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.
package/src/provision.ts CHANGED
@@ -4,7 +4,7 @@
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 {
@@ -22,6 +22,7 @@ import {
22
22
  type ProvisionAgentParams,
23
23
  type RevokeAgentParams,
24
24
  type RevokeAgentResult,
25
+ type HermesProfileProbe,
25
26
  type RuntimeProbeResult,
26
27
  type StoredBotCordCredentials,
27
28
  type UpdateAgentParams,
@@ -51,9 +52,15 @@ import {
51
52
  agentStateDir,
52
53
  agentWorkspaceDir,
53
54
  applyAgentIdentity,
55
+ ensureAttachedHermesProfileSkills,
54
56
  ensureAgentWorkspace,
55
57
  } from "./agent-workspace.js";
56
58
  import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
59
+ import {
60
+ hermesProfileHomeDir,
61
+ isValidHermesProfileName,
62
+ listHermesProfiles,
63
+ } from "./gateway/runtimes/hermes-agent.js";
57
64
  import { log as daemonLog } from "./log.js";
58
65
  import { discoverAgentCredentials } from "./agent-discovery.js";
59
66
 
@@ -171,7 +178,32 @@ export function createProvisioner(opts: ProvisionerOptions): (
171
178
  runtime: pickRuntime(params) ?? null,
172
179
  name: params.name ?? null,
173
180
  });
174
- const agent = await provisionAgent(params, { gateway, register, onAgentInstalled });
181
+ let agent: ProvisionedAgent;
182
+ try {
183
+ agent = await provisionAgent(params, {
184
+ gateway,
185
+ register,
186
+ onAgentInstalled,
187
+ });
188
+ } catch (err) {
189
+ if (err instanceof HermesProfileError) {
190
+ daemonLog.warn("provision_agent: hermes profile rejected", {
191
+ code: err.code,
192
+ profile: err.profile,
193
+ occupiedBy: err.occupiedBy ?? null,
194
+ });
195
+ return {
196
+ ok: false,
197
+ error: {
198
+ code: err.code,
199
+ message: err.message,
200
+ ...(err.occupiedBy ? { occupiedBy: err.occupiedBy } : {}),
201
+ profile: err.profile,
202
+ },
203
+ };
204
+ }
205
+ throw err;
206
+ }
175
207
  // Seed the policy resolver from the optional `defaultAttention` /
176
208
  // `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
177
209
  // don't yet emit these stay backwards-compatible — the resolver just
@@ -341,6 +373,24 @@ async function provisionAgent(
341
373
  });
342
374
  }
343
375
 
376
+ const hermesSel = pickHermesSelection(resolvedParams);
377
+ if (hermesSel) {
378
+ return withHermesProvisionLock(hermesSel, async () => {
379
+ // Race-safe re-check inside the per-profile lock so two concurrent
380
+ // provisions for the same profile (e.g. two dashboard tabs) cannot
381
+ // both succeed.
382
+ validateHermesProfileForProvision(hermesSel, params.credentials?.agentId);
383
+ const cfg = loadConfig();
384
+ const credentials = await materializeCredentials(resolvedParams, cfg, ctx, explicitCwd);
385
+ return installLocalAgent(credentials, {
386
+ ...ctx,
387
+ cfg,
388
+ bio: params.bio,
389
+ source: params.credentials ? "hub-supplied" : "registered",
390
+ });
391
+ });
392
+ }
393
+
344
394
  const credentials = await materializeCredentials(resolvedParams, initialCfg, ctx, explicitCwd);
345
395
  return installLocalAgent(credentials, {
346
396
  ...ctx,
@@ -382,6 +432,9 @@ async function installLocalAgent(
382
432
  keyId: credentials.keyId,
383
433
  savedAt: credentials.savedAt,
384
434
  });
435
+ if (credentials.runtime === "hermes-agent" && credentials.hermesProfile) {
436
+ ensureAttachedHermesProfileSkills(hermesProfileHomeDir(credentials.hermesProfile));
437
+ }
385
438
  } catch (err) {
386
439
  try {
387
440
  unlinkSync(credentialsFile);
@@ -530,6 +583,9 @@ function upsertManagedRouteForCredentials(
530
583
  };
531
584
  }
532
585
  }
586
+ if (synthRoute.runtime === "hermes-agent" && credentials.hermesProfile) {
587
+ synthRoute.hermesProfile = credentials.hermesProfile;
588
+ }
533
589
  gateway.upsertManagedRoute(credentials.agentId, synthRoute);
534
590
  }
535
591
 
@@ -625,6 +681,8 @@ async function materializeCredentials(
625
681
  const openclawSel = pickOpenclawSelection(params);
626
682
  if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
627
683
  if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
684
+ const hermesSel = pickHermesSelection(params);
685
+ if (hermesSel) record.hermesProfile = hermesSel;
628
686
  return record;
629
687
  }
630
688
 
@@ -657,6 +715,8 @@ async function materializeCredentials(
657
715
  const openclawSel = pickOpenclawSelection(params);
658
716
  if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
659
717
  if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
718
+ const hermesSel = pickHermesSelection(params);
719
+ if (hermesSel) record.hermesProfile = hermesSel;
660
720
  return record;
661
721
  }
662
722
 
@@ -750,6 +810,94 @@ function withResolvedOpenclawSelection(
750
810
  };
751
811
  }
752
812
 
813
+ /**
814
+ * Resolve hermes profile selection from a `provision_agent` frame. Top-level
815
+ * `params.hermes.profile` (nested) wins over the flat
816
+ * `credentials.hermesProfile` mirror. Returning `undefined` is fine — the
817
+ * adapter falls back to the BotCord-isolated HERMES_HOME under
818
+ * `~/.botcord/agents/<id>/` when the agent is not attached to a profile.
819
+ */
820
+ function pickHermesSelection(params: ProvisionAgentParams): string | undefined {
821
+ const top = params.hermes;
822
+ if (top && typeof top.profile === "string" && top.profile.length > 0) {
823
+ return top.profile;
824
+ }
825
+ const flat = params.credentials;
826
+ if (flat && typeof flat.hermesProfile === "string" && flat.hermesProfile.length > 0) {
827
+ return flat.hermesProfile;
828
+ }
829
+ return undefined;
830
+ }
831
+
832
+ const hermesProvisionLocks = new Map<string, Promise<unknown>>();
833
+
834
+ async function withHermesProvisionLock<T>(
835
+ profile: string,
836
+ fn: () => Promise<T>,
837
+ ): Promise<T> {
838
+ const prev = hermesProvisionLocks.get(profile) ?? Promise.resolve();
839
+ const next = prev.then(fn, fn);
840
+ hermesProvisionLocks.set(profile, next);
841
+ try {
842
+ return (await next) as T;
843
+ } finally {
844
+ if (hermesProvisionLocks.get(profile) === next) {
845
+ hermesProvisionLocks.delete(profile);
846
+ }
847
+ }
848
+ }
849
+
850
+ class HermesProfileError extends Error {
851
+ constructor(
852
+ public readonly code:
853
+ | "hermes_profile_invalid"
854
+ | "hermes_profile_not_found"
855
+ | "hermes_profile_occupied",
856
+ message: string,
857
+ public readonly profile: string,
858
+ public readonly occupiedBy?: string,
859
+ ) {
860
+ super(message);
861
+ this.name = "HermesProfileError";
862
+ }
863
+ }
864
+
865
+ /**
866
+ * Validate that `profile` exists on disk and is not already bound to another
867
+ * BotCord agent. Throws {@link HermesProfileError} on failure so the caller
868
+ * can surface the structured error code via the control-frame ack.
869
+ */
870
+ function validateHermesProfileForProvision(
871
+ profile: string,
872
+ selfAgentId: string | undefined,
873
+ ): void {
874
+ if (!isValidHermesProfileName(profile)) {
875
+ throw new HermesProfileError(
876
+ "hermes_profile_invalid",
877
+ `Hermes profile "${profile}" is not a valid profile name.`,
878
+ profile,
879
+ );
880
+ }
881
+ const home = hermesProfileHomeDir(profile);
882
+ if (!existsSync(home)) {
883
+ throw new HermesProfileError(
884
+ "hermes_profile_not_found",
885
+ `Hermes profile "${profile}" does not exist at ${home}. Create it via "hermes profile create ${profile}" and retry.`,
886
+ profile,
887
+ );
888
+ }
889
+ const occupancy = profileOccupancyMap();
890
+ const owner = occupancy.get(profile);
891
+ if (owner && owner.agentId !== selfAgentId) {
892
+ throw new HermesProfileError(
893
+ "hermes_profile_occupied",
894
+ `Hermes profile "${profile}" is already bound to BotCord agent ${owner.agentId}.`,
895
+ profile,
896
+ owner.agentId,
897
+ );
898
+ }
899
+ }
900
+
753
901
  async function withOpenclawProvisionLock<T>(
754
902
  gateway: string,
755
903
  agent: string,
@@ -1100,6 +1248,30 @@ export function clearRuntimeProbeCache(): void {
1100
1248
  * Cached for {@link RUNTIME_PROBE_CACHE_TTL_MS}; pass `{ force: true }` to
1101
1249
  * bypass the cache.
1102
1250
  */
1251
+ /**
1252
+ * Build the wire-shape `HermesProfileProbe[]` for the runtime snapshot,
1253
+ * joining the on-disk profile listing with the BotCord-side occupancy map
1254
+ * so dashboards can render disabled rows without a second round trip.
1255
+ */
1256
+ function listHermesProfilesForSnapshot(
1257
+ occupancy: Map<string, { agentId: string; agentName?: string }>,
1258
+ ): HermesProfileProbe[] {
1259
+ return listHermesProfiles().map((p) => {
1260
+ const out: HermesProfileProbe = { name: p.name, home: p.home };
1261
+ if (p.isDefault) out.isDefault = true;
1262
+ if (p.isActive) out.isActive = true;
1263
+ if (p.modelName) out.modelName = p.modelName;
1264
+ if (typeof p.sessionsCount === "number") out.sessionsCount = p.sessionsCount;
1265
+ if (p.hasSoul) out.hasSoul = true;
1266
+ const owner = occupancy.get(p.name);
1267
+ if (owner) {
1268
+ out.occupiedBy = owner.agentId;
1269
+ if (owner.agentName) out.occupiedByName = owner.agentName;
1270
+ }
1271
+ return out;
1272
+ });
1273
+ }
1274
+
1103
1275
  export function collectRuntimeSnapshot(opts: { force?: boolean } = {}): ListRuntimesResult {
1104
1276
  if (
1105
1277
  !opts.force &&
@@ -1109,6 +1281,7 @@ export function collectRuntimeSnapshot(opts: { force?: boolean } = {}): ListRunt
1109
1281
  return _runtimeProbeCache.value;
1110
1282
  }
1111
1283
  const entries = detectRuntimes();
1284
+ const occupancy = profileOccupancyMap();
1112
1285
  const runtimes: RuntimeProbeResult[] = entries.map((entry) => {
1113
1286
  const record: RuntimeProbeResult = {
1114
1287
  id: entry.id,
@@ -1123,6 +1296,9 @@ export function collectRuntimeSnapshot(opts: { force?: boolean } = {}): ListRunt
1123
1296
  // already swallows throws into `{available: false}`. We leave the wire
1124
1297
  // field blank in that case and let callers treat `!available` as reason
1125
1298
  // enough; filling a synthetic message would be misleading.
1299
+ if (entry.id === "hermes-agent" && entry.result.available) {
1300
+ record.profiles = listHermesProfilesForSnapshot(occupancy);
1301
+ }
1126
1302
  return record;
1127
1303
  });
1128
1304
  const value: ListRuntimesResult = { runtimes, probedAt: Date.now() };
@@ -1695,19 +1871,20 @@ export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadRes
1695
1871
  */
1696
1872
  function readAgentRuntimesFromCredentials(
1697
1873
  agentIds: string[],
1698
- ): Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> {
1699
- const out: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> = {};
1874
+ ): Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> {
1875
+ const out: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> = {};
1700
1876
  for (const id of agentIds) {
1701
1877
  const file = defaultCredentialsFile(id);
1702
1878
  try {
1703
1879
  if (!existsSync(file)) continue;
1704
1880
  const creds = loadStoredCredentials(file);
1705
- const entry: { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string } = {};
1881
+ const entry: { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string } = {};
1706
1882
  if (creds.runtime) entry.runtime = creds.runtime;
1707
1883
  if (creds.cwd) entry.cwd = creds.cwd;
1708
1884
  if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
1709
1885
  if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
1710
- if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent) out[id] = entry;
1886
+ if (creds.hermesProfile) entry.hermesProfile = creds.hermesProfile;
1887
+ if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent || entry.hermesProfile) out[id] = entry;
1711
1888
  } catch {
1712
1889
  // best-effort — skip agents with unreadable credentials
1713
1890
  }
@@ -1715,6 +1892,44 @@ function readAgentRuntimesFromCredentials(
1715
1892
  return out;
1716
1893
  }
1717
1894
 
1895
+ /**
1896
+ * Scan `~/.botcord/credentials/*.json` and return a mapping from hermes
1897
+ * profile name to the BotCord agent currently bound to it. Used by the
1898
+ * runtime snapshot path to mark profiles as occupied in the picker, and by
1899
+ * the provision path to enforce the 1 BotCord agent : 1 hermes profile
1900
+ * invariant. The scan is cheap and authoritative — running daemon state may
1901
+ * lag (e.g. between provision and reconcile), so we always read disk.
1902
+ */
1903
+ export function profileOccupancyMap(): Map<string, { agentId: string; agentName?: string }> {
1904
+ const out = new Map<string, { agentId: string; agentName?: string }>();
1905
+ const dir = path.join(homedir(), ".botcord", "credentials");
1906
+ let names: string[] = [];
1907
+ try {
1908
+ names = readdirSync(dir);
1909
+ } catch {
1910
+ return out;
1911
+ }
1912
+ for (const file of names) {
1913
+ if (!file.endsWith(".json")) continue;
1914
+ try {
1915
+ const creds = loadStoredCredentials(path.join(dir, file));
1916
+ if (creds.runtime !== "hermes-agent") continue;
1917
+ if (!creds.hermesProfile) continue;
1918
+ // First write wins — ties shouldn't happen, but if they do, the
1919
+ // provision path's race check will surface it.
1920
+ if (!out.has(creds.hermesProfile)) {
1921
+ out.set(creds.hermesProfile, {
1922
+ agentId: creds.agentId,
1923
+ agentName: creds.displayName,
1924
+ });
1925
+ }
1926
+ } catch {
1927
+ // skip unreadable
1928
+ }
1929
+ }
1930
+ return out;
1931
+ }
1932
+
1718
1933
  /**
1719
1934
  * Per-agent entry returned by `list_agents`. Wire shape: `{id, name, online}`.
1720
1935
  * `status` and `lastMessageAt` are extra daemon-only fields the dashboard