@botcord/daemon 0.2.27 → 0.2.28

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