@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
|
@@ -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,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 { BotCordClient, CONTROL_FRAME_TYPES, defaultCredentialsFile, derivePublicKey, loadStoredCredentials, writeCredentialsFile, } from "@botcord/protocol-core";
|
|
@@ -12,6 +12,7 @@ import { loadConfig, resolveConfiguredAgentIds, saveConfig, } from "./config.js"
|
|
|
12
12
|
import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes, prepareGatewayProfile, } from "./daemon-config-map.js";
|
|
13
13
|
import { agentHomeDir, agentStateDir, agentWorkspaceDir, applyAgentIdentity, 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,
|
|
@@ -391,6 +435,9 @@ function upsertManagedRouteForCredentials(credentials, cfg, gateway) {
|
|
|
391
435
|
};
|
|
392
436
|
}
|
|
393
437
|
}
|
|
438
|
+
if (synthRoute.runtime === "hermes-agent" && credentials.hermesProfile) {
|
|
439
|
+
synthRoute.hermesProfile = credentials.hermesProfile;
|
|
440
|
+
}
|
|
394
441
|
gateway.upsertManagedRoute(credentials.agentId, synthRoute);
|
|
395
442
|
}
|
|
396
443
|
async function installExistingOpenclawBinding(agentId, ctx) {
|
|
@@ -482,6 +529,9 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
|
|
|
482
529
|
record.openclawGateway = openclawSel.gateway;
|
|
483
530
|
if (openclawSel.agent)
|
|
484
531
|
record.openclawAgent = openclawSel.agent;
|
|
532
|
+
const hermesSel = pickHermesSelection(params);
|
|
533
|
+
if (hermesSel)
|
|
534
|
+
record.hermesProfile = hermesSel;
|
|
485
535
|
return record;
|
|
486
536
|
}
|
|
487
537
|
// Slow path: daemon registers a fresh identity against Hub. We need a
|
|
@@ -514,6 +564,9 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
|
|
|
514
564
|
record.openclawGateway = openclawSel.gateway;
|
|
515
565
|
if (openclawSel.agent)
|
|
516
566
|
record.openclawAgent = openclawSel.agent;
|
|
567
|
+
const hermesSel = pickHermesSelection(params);
|
|
568
|
+
if (hermesSel)
|
|
569
|
+
record.hermesProfile = hermesSel;
|
|
517
570
|
return record;
|
|
518
571
|
}
|
|
519
572
|
/**
|
|
@@ -596,6 +649,69 @@ function withResolvedOpenclawSelection(params, selection) {
|
|
|
596
649
|
},
|
|
597
650
|
};
|
|
598
651
|
}
|
|
652
|
+
/**
|
|
653
|
+
* Resolve hermes profile selection from a `provision_agent` frame. Top-level
|
|
654
|
+
* `params.hermes.profile` (nested) wins over the flat
|
|
655
|
+
* `credentials.hermesProfile` mirror. Returning `undefined` is fine — the
|
|
656
|
+
* adapter falls back to the BotCord-isolated HERMES_HOME under
|
|
657
|
+
* `~/.botcord/agents/<id>/` when the agent is not attached to a profile.
|
|
658
|
+
*/
|
|
659
|
+
function pickHermesSelection(params) {
|
|
660
|
+
const top = params.hermes;
|
|
661
|
+
if (top && typeof top.profile === "string" && top.profile.length > 0) {
|
|
662
|
+
return top.profile;
|
|
663
|
+
}
|
|
664
|
+
const flat = params.credentials;
|
|
665
|
+
if (flat && typeof flat.hermesProfile === "string" && flat.hermesProfile.length > 0) {
|
|
666
|
+
return flat.hermesProfile;
|
|
667
|
+
}
|
|
668
|
+
return undefined;
|
|
669
|
+
}
|
|
670
|
+
const hermesProvisionLocks = new Map();
|
|
671
|
+
async function withHermesProvisionLock(profile, fn) {
|
|
672
|
+
const prev = hermesProvisionLocks.get(profile) ?? Promise.resolve();
|
|
673
|
+
const next = prev.then(fn, fn);
|
|
674
|
+
hermesProvisionLocks.set(profile, next);
|
|
675
|
+
try {
|
|
676
|
+
return (await next);
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
if (hermesProvisionLocks.get(profile) === next) {
|
|
680
|
+
hermesProvisionLocks.delete(profile);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
class HermesProfileError extends Error {
|
|
685
|
+
code;
|
|
686
|
+
profile;
|
|
687
|
+
occupiedBy;
|
|
688
|
+
constructor(code, message, profile, occupiedBy) {
|
|
689
|
+
super(message);
|
|
690
|
+
this.code = code;
|
|
691
|
+
this.profile = profile;
|
|
692
|
+
this.occupiedBy = occupiedBy;
|
|
693
|
+
this.name = "HermesProfileError";
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Validate that `profile` exists on disk and is not already bound to another
|
|
698
|
+
* BotCord agent. Throws {@link HermesProfileError} on failure so the caller
|
|
699
|
+
* can surface the structured error code via the control-frame ack.
|
|
700
|
+
*/
|
|
701
|
+
function validateHermesProfileForProvision(profile, selfAgentId) {
|
|
702
|
+
if (!isValidHermesProfileName(profile)) {
|
|
703
|
+
throw new HermesProfileError("hermes_profile_invalid", `Hermes profile "${profile}" is not a valid profile name.`, profile);
|
|
704
|
+
}
|
|
705
|
+
const home = hermesProfileHomeDir(profile);
|
|
706
|
+
if (!existsSync(home)) {
|
|
707
|
+
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);
|
|
708
|
+
}
|
|
709
|
+
const occupancy = profileOccupancyMap();
|
|
710
|
+
const owner = occupancy.get(profile);
|
|
711
|
+
if (owner && owner.agentId !== selfAgentId) {
|
|
712
|
+
throw new HermesProfileError("hermes_profile_occupied", `Hermes profile "${profile}" is already bound to BotCord agent ${owner.agentId}.`, profile, owner.agentId);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
599
715
|
async function withOpenclawProvisionLock(gateway, agent, fn) {
|
|
600
716
|
const key = `${gateway}\0${agent}`;
|
|
601
717
|
const prev = openclawProvisionLocks.get(key) ?? Promise.resolve();
|
|
@@ -922,6 +1038,33 @@ export function clearRuntimeProbeCache() {
|
|
|
922
1038
|
* Cached for {@link RUNTIME_PROBE_CACHE_TTL_MS}; pass `{ force: true }` to
|
|
923
1039
|
* bypass the cache.
|
|
924
1040
|
*/
|
|
1041
|
+
/**
|
|
1042
|
+
* Build the wire-shape `HermesProfileProbe[]` for the runtime snapshot,
|
|
1043
|
+
* joining the on-disk profile listing with the BotCord-side occupancy map
|
|
1044
|
+
* so dashboards can render disabled rows without a second round trip.
|
|
1045
|
+
*/
|
|
1046
|
+
function listHermesProfilesForSnapshot(occupancy) {
|
|
1047
|
+
return listHermesProfiles().map((p) => {
|
|
1048
|
+
const out = { name: p.name, home: p.home };
|
|
1049
|
+
if (p.isDefault)
|
|
1050
|
+
out.isDefault = true;
|
|
1051
|
+
if (p.isActive)
|
|
1052
|
+
out.isActive = true;
|
|
1053
|
+
if (p.modelName)
|
|
1054
|
+
out.modelName = p.modelName;
|
|
1055
|
+
if (typeof p.sessionsCount === "number")
|
|
1056
|
+
out.sessionsCount = p.sessionsCount;
|
|
1057
|
+
if (p.hasSoul)
|
|
1058
|
+
out.hasSoul = true;
|
|
1059
|
+
const owner = occupancy.get(p.name);
|
|
1060
|
+
if (owner) {
|
|
1061
|
+
out.occupiedBy = owner.agentId;
|
|
1062
|
+
if (owner.agentName)
|
|
1063
|
+
out.occupiedByName = owner.agentName;
|
|
1064
|
+
}
|
|
1065
|
+
return out;
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
925
1068
|
export function collectRuntimeSnapshot(opts = {}) {
|
|
926
1069
|
if (!opts.force &&
|
|
927
1070
|
_runtimeProbeCache &&
|
|
@@ -929,6 +1072,7 @@ export function collectRuntimeSnapshot(opts = {}) {
|
|
|
929
1072
|
return _runtimeProbeCache.value;
|
|
930
1073
|
}
|
|
931
1074
|
const entries = detectRuntimes();
|
|
1075
|
+
const occupancy = profileOccupancyMap();
|
|
932
1076
|
const runtimes = entries.map((entry) => {
|
|
933
1077
|
const record = {
|
|
934
1078
|
id: entry.id,
|
|
@@ -945,6 +1089,9 @@ export function collectRuntimeSnapshot(opts = {}) {
|
|
|
945
1089
|
// already swallows throws into `{available: false}`. We leave the wire
|
|
946
1090
|
// field blank in that case and let callers treat `!available` as reason
|
|
947
1091
|
// enough; filling a synthetic message would be misleading.
|
|
1092
|
+
if (entry.id === "hermes-agent" && entry.result.available) {
|
|
1093
|
+
record.profiles = listHermesProfilesForSnapshot(occupancy);
|
|
1094
|
+
}
|
|
948
1095
|
return record;
|
|
949
1096
|
});
|
|
950
1097
|
const value = { runtimes, probedAt: Date.now() };
|
|
@@ -1457,7 +1604,9 @@ function readAgentRuntimesFromCredentials(agentIds) {
|
|
|
1457
1604
|
entry.openclawGateway = creds.openclawGateway;
|
|
1458
1605
|
if (creds.openclawAgent)
|
|
1459
1606
|
entry.openclawAgent = creds.openclawAgent;
|
|
1460
|
-
if (
|
|
1607
|
+
if (creds.hermesProfile)
|
|
1608
|
+
entry.hermesProfile = creds.hermesProfile;
|
|
1609
|
+
if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent || entry.hermesProfile)
|
|
1461
1610
|
out[id] = entry;
|
|
1462
1611
|
}
|
|
1463
1612
|
catch {
|
|
@@ -1466,6 +1615,48 @@ function readAgentRuntimesFromCredentials(agentIds) {
|
|
|
1466
1615
|
}
|
|
1467
1616
|
return out;
|
|
1468
1617
|
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Scan `~/.botcord/credentials/*.json` and return a mapping from hermes
|
|
1620
|
+
* profile name to the BotCord agent currently bound to it. Used by the
|
|
1621
|
+
* runtime snapshot path to mark profiles as occupied in the picker, and by
|
|
1622
|
+
* the provision path to enforce the 1 BotCord agent : 1 hermes profile
|
|
1623
|
+
* invariant. The scan is cheap and authoritative — running daemon state may
|
|
1624
|
+
* lag (e.g. between provision and reconcile), so we always read disk.
|
|
1625
|
+
*/
|
|
1626
|
+
export function profileOccupancyMap() {
|
|
1627
|
+
const out = new Map();
|
|
1628
|
+
const dir = path.join(homedir(), ".botcord", "credentials");
|
|
1629
|
+
let names = [];
|
|
1630
|
+
try {
|
|
1631
|
+
names = readdirSync(dir);
|
|
1632
|
+
}
|
|
1633
|
+
catch {
|
|
1634
|
+
return out;
|
|
1635
|
+
}
|
|
1636
|
+
for (const file of names) {
|
|
1637
|
+
if (!file.endsWith(".json"))
|
|
1638
|
+
continue;
|
|
1639
|
+
try {
|
|
1640
|
+
const creds = loadStoredCredentials(path.join(dir, file));
|
|
1641
|
+
if (creds.runtime !== "hermes-agent")
|
|
1642
|
+
continue;
|
|
1643
|
+
if (!creds.hermesProfile)
|
|
1644
|
+
continue;
|
|
1645
|
+
// First write wins — ties shouldn't happen, but if they do, the
|
|
1646
|
+
// provision path's race check will surface it.
|
|
1647
|
+
if (!out.has(creds.hermesProfile)) {
|
|
1648
|
+
out.set(creds.hermesProfile, {
|
|
1649
|
+
agentId: creds.agentId,
|
|
1650
|
+
agentName: creds.displayName,
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
catch {
|
|
1655
|
+
// skip unreadable
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return out;
|
|
1659
|
+
}
|
|
1469
1660
|
function listAgentsFromGateway(gateway) {
|
|
1470
1661
|
const snap = gateway.snapshot();
|
|
1471
1662
|
// Include any configured agents that the gateway may not have a status for
|