@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/dist/agent-discovery.d.ts +2 -0
- package/dist/agent-discovery.js +2 -0
- package/dist/agent-workspace.d.ts +3 -1
- package/dist/agent-workspace.js +10 -2
- package/dist/daemon-config-map.d.ts +2 -0
- package/dist/daemon-config-map.js +3 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +2 -1
- package/dist/gateway/dispatcher.js +1 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +35 -0
- package/dist/gateway/runtimes/hermes-agent.js +130 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +6 -3
- package/dist/gateway/runtimes/openclaw-acp.js +75 -9
- package/dist/gateway/types.d.ts +17 -0
- package/dist/openclaw-discovery.d.ts +3 -1
- package/dist/openclaw-discovery.js +176 -2
- package/dist/provision.d.ts +12 -8
- package/dist/provision.js +194 -3
- package/package.json +1 -1
- package/src/__tests__/openclaw-acp.test.ts +172 -0
- package/src/__tests__/openclaw-discovery.test.ts +64 -0
- package/src/__tests__/provision.test.ts +159 -0
- package/src/agent-discovery.ts +3 -0
- package/src/agent-workspace.ts +13 -2
- package/src/daemon-config-map.ts +5 -0
- package/src/daemon.ts +3 -2
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +87 -0
- package/src/gateway/dispatcher.ts +1 -0
- package/src/gateway/runtimes/hermes-agent.ts +146 -3
- package/src/gateway/runtimes/openclaw-acp.ts +82 -9
- package/src/gateway/types.ts +17 -0
- package/src/openclaw-discovery.ts +180 -3
- package/src/provision.ts +217 -6
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
|
-
|
|
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 (
|
|
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
|