@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/dist/agent-discovery.d.ts +2 -0
- package/dist/agent-discovery.js +2 -0
- package/dist/agent-workspace.d.ts +11 -1
- package/dist/agent-workspace.js +20 -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 +135 -4
- 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 +198 -4
- package/package.json +1 -1
- package/src/__tests__/agent-workspace.test.ts +18 -0
- package/src/__tests__/openclaw-acp.test.ts +172 -0
- package/src/__tests__/openclaw-discovery.test.ts +64 -0
- package/src/__tests__/provision.test.ts +164 -0
- package/src/agent-discovery.ts +3 -0
- package/src/agent-workspace.ts +24 -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 +151 -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 +221 -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,
|
|
@@ -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
|
-
|
|
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 (
|
|
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
|