@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
|
@@ -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":
|
|
248
|
-
env:
|
|
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
|
}
|
package/dist/provision.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
@@ -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");
|