@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.
@@ -224,6 +224,8 @@ describe("OpenclawAcpAdapter.run", () => {
224
224
  const frame = JSON.parse(line);
225
225
  if (frame.method === "initialize") {
226
226
  child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
227
+ } else if (frame.method === "session/load") {
228
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: frame.params.sessionId } }) + "\n");
227
229
  } else if (frame.method === "session/new") {
228
230
  child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "s1" } }) + "\n");
229
231
  } else if (frame.method === "session/prompt") {
@@ -245,4 +247,174 @@ describe("OpenclawAcpAdapter.run", () => {
245
247
  await adapter.run({ ...opts, sessionId: "s1" });
246
248
  expect(spawnFn).toHaveBeenCalledTimes(1);
247
249
  });
250
+
251
+ it("loads a cached ACP session id with the stable sessionKey before prompting", async () => {
252
+ const child = new FakeChild();
253
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
254
+ const gateway: ResolvedOpenclawGateway = {
255
+ name: "local",
256
+ url: "ws://127.0.0.1:1",
257
+ openclawAgent: "swe",
258
+ };
259
+ const seen: any[] = [];
260
+
261
+ child.stdin.on("data", (chunk: Buffer) => {
262
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
263
+ const frame = JSON.parse(line);
264
+ seen.push(frame);
265
+ if (frame.method === "initialize") {
266
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
267
+ } else if (frame.method === "session/load") {
268
+ expect(frame.params.sessionId).toBe("cached-id");
269
+ expect(frame.params._meta.sessionKey).toBe(
270
+ "agent:swe:ag_337518f31844:direct:rm_oc_owner",
271
+ );
272
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "cached-id" } }) + "\n");
273
+ } else if (frame.method === "session/prompt") {
274
+ expect(frame.params.sessionId).toBe("cached-id");
275
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "loaded ok" } }) + "\n");
276
+ }
277
+ }
278
+ });
279
+
280
+ const res = await adapter.run({
281
+ text: "hi",
282
+ sessionId: "cached-id",
283
+ cwd: "/tmp",
284
+ accountId: "ag_337518f31844",
285
+ signal: new AbortController().signal,
286
+ trustLevel: "owner",
287
+ gateway,
288
+ context: { conversationKey: "direct:rm_oc_owner" },
289
+ });
290
+
291
+ expect(res.error).toBeUndefined();
292
+ expect(res.text).toBe("loaded ok");
293
+ expect(res.newSessionId).toBe("cached-id");
294
+ expect(seen.map((f) => f.method)).toEqual(["initialize", "session/load", "session/prompt"]);
295
+ });
296
+
297
+ it("discards a cached ACP session id when session/load reports not found", async () => {
298
+ const child = new FakeChild();
299
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
300
+ const gateway: ResolvedOpenclawGateway = {
301
+ name: "local",
302
+ url: "ws://127.0.0.1:1",
303
+ openclawAgent: "swe",
304
+ };
305
+ const seen: any[] = [];
306
+
307
+ child.stdin.on("data", (chunk: Buffer) => {
308
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
309
+ const frame = JSON.parse(line);
310
+ seen.push(frame);
311
+ if (frame.method === "initialize") {
312
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
313
+ } else if (frame.method === "session/load") {
314
+ child.stdout.write(
315
+ JSON.stringify({
316
+ jsonrpc: "2.0",
317
+ id: frame.id,
318
+ error: {
319
+ code: -32603,
320
+ message: "Internal error",
321
+ data: { details: "Session cached-id not found" },
322
+ },
323
+ }) + "\n",
324
+ );
325
+ } else if (frame.method === "session/new") {
326
+ expect(frame.params._meta.sessionKey).toBe(
327
+ "agent:swe:ag_337518f31844:direct:rm_oc_owner",
328
+ );
329
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "fresh-id" } }) + "\n");
330
+ } else if (frame.method === "session/prompt") {
331
+ expect(frame.params.sessionId).toBe("fresh-id");
332
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "fresh ok" } }) + "\n");
333
+ }
334
+ }
335
+ });
336
+
337
+ const res = await adapter.run({
338
+ text: "hi",
339
+ sessionId: "cached-id",
340
+ cwd: "/tmp",
341
+ accountId: "ag_337518f31844",
342
+ signal: new AbortController().signal,
343
+ trustLevel: "owner",
344
+ gateway,
345
+ context: { conversationKey: "direct:rm_oc_owner" },
346
+ });
347
+
348
+ expect(res.error).toBeUndefined();
349
+ expect(res.text).toBe("fresh ok");
350
+ expect(res.newSessionId).toBe("fresh-id");
351
+ expect(seen.map((f) => f.method)).toEqual([
352
+ "initialize",
353
+ "session/load",
354
+ "session/new",
355
+ "session/prompt",
356
+ ]);
357
+ });
358
+
359
+ it("recreates the ACP session and retries once when prompt reports not found", async () => {
360
+ const child = new FakeChild();
361
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
362
+ const gateway: ResolvedOpenclawGateway = {
363
+ name: "local",
364
+ url: "ws://127.0.0.1:1",
365
+ openclawAgent: "swe",
366
+ };
367
+ const seen: any[] = [];
368
+
369
+ child.stdin.on("data", (chunk: Buffer) => {
370
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
371
+ const frame = JSON.parse(line);
372
+ seen.push(frame);
373
+ if (frame.method === "initialize") {
374
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
375
+ } else if (frame.method === "session/load") {
376
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "cached-id" } }) + "\n");
377
+ } else if (frame.method === "session/new") {
378
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "fresh-id" } }) + "\n");
379
+ } else if (frame.method === "session/prompt" && frame.params.sessionId === "cached-id") {
380
+ child.stdout.write(
381
+ JSON.stringify({
382
+ jsonrpc: "2.0",
383
+ id: frame.id,
384
+ error: {
385
+ code: -32603,
386
+ message: "Internal error",
387
+ data: { details: "Session cached-id not found" },
388
+ },
389
+ }) + "\n",
390
+ );
391
+ } else if (frame.method === "session/prompt") {
392
+ expect(frame.params.sessionId).toBe("fresh-id");
393
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "retry ok" } }) + "\n");
394
+ }
395
+ }
396
+ });
397
+
398
+ const res = await adapter.run({
399
+ text: "hi",
400
+ sessionId: "cached-id",
401
+ cwd: "/tmp",
402
+ accountId: "ag_337518f31844",
403
+ signal: new AbortController().signal,
404
+ trustLevel: "owner",
405
+ gateway,
406
+ context: { conversationKey: "direct:rm_oc_owner" },
407
+ });
408
+
409
+ expect(res.error).toBeUndefined();
410
+ expect(res.text).toBe("retry ok");
411
+ expect(res.newSessionId).toBe("fresh-id");
412
+ expect(seen.map((f) => f.method)).toEqual([
413
+ "initialize",
414
+ "session/load",
415
+ "session/prompt",
416
+ "session/new",
417
+ "session/prompt",
418
+ ]);
419
+ });
248
420
  });
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import {
6
6
  defaultOpenclawDiscoveryPorts,
7
+ defaultOpenclawDiscoverySystemdUnitPaths,
7
8
  defaultOpenclawDiscoveryTokenFilePaths,
8
9
  discoverLocalOpenclawGateways,
9
10
  mergeOpenclawGateways,
@@ -148,6 +149,69 @@ describe("discoverLocalOpenclawGateways", () => {
148
149
  ]);
149
150
  });
150
151
 
152
+ it("discovers gateway port and token from a systemd OpenClaw unit", async () => {
153
+ const dir = tempDir();
154
+ const unit = path.join(dir, "openclaw.service");
155
+ writeFileSync(
156
+ unit,
157
+ [
158
+ "[Service]",
159
+ "User=openclaw",
160
+ 'Environment="OPENCLAW_GATEWAY_TOKEN=systemd-token"',
161
+ "ExecStart=/usr/bin/openclaw gateway --bind lan --port 16200 --allow-unconfigured",
162
+ ].join("\n"),
163
+ );
164
+
165
+ const found = await discoverLocalOpenclawGateways({
166
+ searchPaths: [],
167
+ defaultPorts: [],
168
+ systemdUnitPaths: [unit],
169
+ env: {},
170
+ });
171
+
172
+ expect(defaultOpenclawDiscoverySystemdUnitPaths()).toEqual(
173
+ expect.arrayContaining(["/etc/systemd/system/openclaw.service"]),
174
+ );
175
+ expect(found).toEqual([
176
+ expect.objectContaining({
177
+ url: "ws://127.0.0.1:16200",
178
+ token: "systemd-token",
179
+ source: "systemd-unit",
180
+ }),
181
+ ]);
182
+ });
183
+
184
+ it("discovers gateway token from a systemd EnvironmentFile", async () => {
185
+ const dir = tempDir();
186
+ const unit = path.join(dir, "openclaw.service");
187
+ const envFile = path.join(dir, "openclaw.env");
188
+ writeFileSync(envFile, 'OPENCLAW_GATEWAY_TOKEN="file-token"\n');
189
+ writeFileSync(
190
+ unit,
191
+ [
192
+ "[Service]",
193
+ `EnvironmentFile=${envFile}`,
194
+ "Environment=OPENCLAW_GATEWAY_PORT=16200",
195
+ "ExecStart=/usr/bin/openclaw gateway --bind lan --allow-unconfigured",
196
+ ].join("\n"),
197
+ );
198
+
199
+ const found = await discoverLocalOpenclawGateways({
200
+ searchPaths: [],
201
+ defaultPorts: [],
202
+ systemdUnitPaths: [unit],
203
+ env: {},
204
+ });
205
+
206
+ expect(found).toEqual([
207
+ expect.objectContaining({
208
+ url: "ws://127.0.0.1:16200",
209
+ token: "file-token",
210
+ source: "systemd-unit",
211
+ }),
212
+ ]);
213
+ });
214
+
151
215
  it("prefers OPENCLAW_ACP env vars over OPENCLAW_GATEWAY env vars", async () => {
152
216
  const found = await discoverLocalOpenclawGateways({
153
217
  searchPaths: [],
@@ -1421,3 +1421,167 @@ describe("update_agent handler", () => {
1421
1421
  expect(ack.error?.code).toBe("bad_params");
1422
1422
  });
1423
1423
  });
1424
+
1425
+ describe("provision_agent hermes profile attach", () => {
1426
+ it("rejects invalid hermes profile names before resolving HERMES_HOME", async () => {
1427
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
1428
+ fs.mkdirSync(nodePath.join(tmp, ".hermes", "profiles"), {
1429
+ recursive: true,
1430
+ });
1431
+ fs.mkdirSync(nodePath.join(tmp, "outside"), { recursive: true });
1432
+ const gw = makeFakeGateway();
1433
+ const provisioner = createProvisioner({
1434
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1435
+ });
1436
+ const privateKey = Buffer.alloc(32, 23).toString("base64");
1437
+ const ack = await provisioner({
1438
+ id: "req_hp_invalid",
1439
+ type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
1440
+ params: {
1441
+ runtime: "hermes-agent",
1442
+ hermes: { profile: "../../outside" },
1443
+ credentials: {
1444
+ agentId: "ag_hp_invalid",
1445
+ keyId: "k_hp_invalid",
1446
+ privateKey,
1447
+ hubUrl: "https://hub.example",
1448
+ runtime: "hermes-agent",
1449
+ },
1450
+ },
1451
+ });
1452
+ expect(ack.ok).toBe(false);
1453
+ expect(ack.error?.code).toBe("hermes_profile_invalid");
1454
+ expect(gw.addChannel).not.toHaveBeenCalled();
1455
+ });
1456
+ });
1457
+
1458
+ it("rejects with hermes_profile_not_found when the profile does not exist", async () => {
1459
+ await withSandboxHome(async ({ tmp: _tmp }) => {
1460
+ const gw = makeFakeGateway();
1461
+ const provisioner = createProvisioner({
1462
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1463
+ });
1464
+ const privateKey = Buffer.alloc(32, 11).toString("base64");
1465
+ const ack = await provisioner({
1466
+ id: "req_hp_missing",
1467
+ type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
1468
+ params: {
1469
+ runtime: "hermes-agent",
1470
+ hermes: { profile: "ghost" },
1471
+ credentials: {
1472
+ agentId: "ag_hp_missing",
1473
+ keyId: "k_hp",
1474
+ privateKey,
1475
+ hubUrl: "https://hub.example",
1476
+ runtime: "hermes-agent",
1477
+ },
1478
+ },
1479
+ });
1480
+ expect(ack.ok).toBe(false);
1481
+ expect(ack.error?.code).toBe("hermes_profile_not_found");
1482
+ expect(ack.error?.profile).toBe("ghost");
1483
+ expect(gw.addChannel).not.toHaveBeenCalled();
1484
+ });
1485
+ });
1486
+
1487
+ it("persists hermesProfile to credentials when the profile exists and is free", async () => {
1488
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
1489
+ // Lay down ~/.hermes/profiles/coder so validateHermesProfileForProvision
1490
+ // sees it.
1491
+ fs.mkdirSync(nodePath.join(tmp, ".hermes", "profiles", "coder"), {
1492
+ recursive: true,
1493
+ });
1494
+ const gw = makeFakeGateway();
1495
+ const provisioner = createProvisioner({
1496
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1497
+ });
1498
+ const privateKey = Buffer.alloc(32, 13).toString("base64");
1499
+ const ack = await provisioner({
1500
+ id: "req_hp_ok",
1501
+ type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
1502
+ params: {
1503
+ runtime: "hermes-agent",
1504
+ hermes: { profile: "coder" },
1505
+ credentials: {
1506
+ agentId: "ag_hp_ok",
1507
+ keyId: "k_hp",
1508
+ privateKey,
1509
+ hubUrl: "https://hub.example",
1510
+ displayName: "coder agent",
1511
+ runtime: "hermes-agent",
1512
+ },
1513
+ },
1514
+ });
1515
+ expect(ack.ok).toBe(true);
1516
+ expect(gw.addChannel).toHaveBeenCalledOnce();
1517
+
1518
+ const credFile = nodePath.join(
1519
+ tmp,
1520
+ ".botcord",
1521
+ "credentials",
1522
+ "ag_hp_ok.json",
1523
+ );
1524
+ const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<
1525
+ string,
1526
+ unknown
1527
+ >;
1528
+ expect(saved.hermesProfile).toBe("coder");
1529
+ expect(saved.runtime).toBe("hermes-agent");
1530
+ expect(
1531
+ fs.existsSync(
1532
+ nodePath.join(tmp, ".hermes", "profiles", "coder", "skills", "botcord", "SKILL.md"),
1533
+ ),
1534
+ ).toBe(true);
1535
+ });
1536
+ });
1537
+
1538
+ it("rejects with hermes_profile_occupied when another agent already binds the profile", async () => {
1539
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
1540
+ fs.mkdirSync(nodePath.join(tmp, ".hermes", "profiles", "coder"), {
1541
+ recursive: true,
1542
+ });
1543
+ const gw = makeFakeGateway();
1544
+ const provisioner = createProvisioner({
1545
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1546
+ });
1547
+
1548
+ const privateKey = Buffer.alloc(32, 17).toString("base64");
1549
+ const okAck = await provisioner({
1550
+ id: "req_first",
1551
+ type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
1552
+ params: {
1553
+ runtime: "hermes-agent",
1554
+ hermes: { profile: "coder" },
1555
+ credentials: {
1556
+ agentId: "ag_first",
1557
+ keyId: "k_first",
1558
+ privateKey,
1559
+ hubUrl: "https://hub.example",
1560
+ runtime: "hermes-agent",
1561
+ },
1562
+ },
1563
+ });
1564
+ expect(okAck.ok).toBe(true);
1565
+
1566
+ const privateKey2 = Buffer.alloc(32, 19).toString("base64");
1567
+ const ack = await provisioner({
1568
+ id: "req_second",
1569
+ type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
1570
+ params: {
1571
+ runtime: "hermes-agent",
1572
+ hermes: { profile: "coder" },
1573
+ credentials: {
1574
+ agentId: "ag_second",
1575
+ keyId: "k_second",
1576
+ privateKey: privateKey2,
1577
+ hubUrl: "https://hub.example",
1578
+ runtime: "hermes-agent",
1579
+ },
1580
+ },
1581
+ });
1582
+ expect(ack.ok).toBe(false);
1583
+ expect(ack.error?.code).toBe("hermes_profile_occupied");
1584
+ expect(ack.error?.occupiedBy).toBe("ag_first");
1585
+ });
1586
+ });
1587
+ });
@@ -42,6 +42,8 @@ export interface DiscoveredAgentCredential {
42
42
  openclawGateway?: string;
43
43
  /** OpenClaw agent profile override from credentials. */
44
44
  openclawAgent?: string;
45
+ /** Hermes profile name from credentials (only meaningful for hermes-agent). */
46
+ hermesProfile?: string;
45
47
  /** Key id from the credentials file — surfaced so boot-time workspace
46
48
  * seeding (see daemon-agent-workspace-plan.md §9) can render identity.md
47
49
  * without re-reading the file. */
@@ -182,6 +184,7 @@ export function discoverAgentCredentials(
182
184
  if (creds.cwd) entry.cwd = creds.cwd;
183
185
  if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
184
186
  if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
187
+ if (creds.hermesProfile) entry.hermesProfile = creds.hermesProfile;
185
188
  if (creds.keyId) entry.keyId = creds.keyId;
186
189
  if (creds.savedAt) entry.savedAt = creds.savedAt;
187
190
  agents.push(entry);
@@ -357,14 +357,25 @@ export function ensureAgentCodexHome(agentId: string): string {
357
357
  * loader (which only sees this isolated HERMES_HOME, not `~/.hermes`)
358
358
  * can discover them.
359
359
  */
360
- export function ensureAgentHermesWorkspace(agentId: string): {
360
+ export function ensureAgentHermesWorkspace(
361
+ agentId: string,
362
+ opts: { attached?: boolean } = {},
363
+ ): {
361
364
  hermesHome: string;
362
365
  hermesWorkspace: string;
363
366
  } {
364
367
  const hermesHome = agentHermesHomeDir(agentId);
365
368
  const hermesWorkspace = agentHermesWorkspaceDir(agentId);
366
- mkdirTolerant(hermesHome);
367
369
  mkdirTolerant(hermesWorkspace);
370
+ // Attach mode: HERMES_HOME points at the user's `~/.hermes/profiles/<n>/`
371
+ // so we MUST NOT touch the per-agent isolated home. The cwd
372
+ // (`hermesWorkspace`) is still ours and `prepareTurn` writes AGENTS.md
373
+ // there. Profile-owned skill seeding is handled separately by
374
+ // `ensureAttachedHermesProfileSkills`.
375
+ if (opts.attached) {
376
+ return { hermesHome, hermesWorkspace };
377
+ }
378
+ mkdirTolerant(hermesHome);
368
379
  writeIfMissing(
369
380
  path.join(hermesHome, ".env"),
370
381
  "# hermes-agent environment overrides for this BotCord agent.\n" +
@@ -451,6 +462,17 @@ function seedHermesAgentSkills(hermesHome: string): void {
451
462
  copyBundledSkills(path.join(hermesHome, "skills"));
452
463
  }
453
464
 
465
+ /**
466
+ * Seed BotCord's bundled Hermes skills into a user-owned Hermes profile used
467
+ * by attach mode. Unlike `ensureAgentHermesWorkspace({ attached: true })`,
468
+ * this intentionally writes only the managed `botcord*` skill directories
469
+ * under the profile's `skills/` directory; it does not touch `.env`,
470
+ * `config.yaml`, sessions, or any user-authored skills.
471
+ */
472
+ export function ensureAttachedHermesProfileSkills(profileHome: string): void {
473
+ seedHermesAgentSkills(profileHome);
474
+ }
475
+
454
476
  /**
455
477
  * Idempotently create the agent's home / workspace / state directories and
456
478
  * seed the workspace Markdown files. Existing files are never overwritten —
@@ -27,6 +27,8 @@ export interface AgentRuntimeMeta {
27
27
  openclawGateway?: string;
28
28
  /** Optional override of the OpenClaw agent profile within the gateway. */
29
29
  openclawAgent?: string;
30
+ /** Hermes profile name to attach to (`runtime === "hermes-agent"` only). */
31
+ hermesProfile?: string;
30
32
  }
31
33
 
32
34
  /** Profile + tokenFile-resolved bearer token. Exported so other module-boundary
@@ -339,6 +341,9 @@ export function buildManagedRoutes(
339
341
  }
340
342
  route.gateway = resolved;
341
343
  }
344
+ if (runtime === "hermes-agent" && meta.hermesProfile) {
345
+ route.hermesProfile = meta.hermesProfile;
346
+ }
342
347
  out.set(agentId, route);
343
348
  }
344
349
  return out;
package/src/daemon.ts CHANGED
@@ -562,7 +562,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
562
562
  */
563
563
  export interface BootBackfillResult {
564
564
  credentialPathByAgentId: Map<string, string>;
565
- agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }>;
565
+ agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }>;
566
566
  }
567
567
 
568
568
  /**
@@ -585,12 +585,13 @@ export function backfillBootAgents(
585
585
  const failed: string[] = [];
586
586
  for (const a of agents) {
587
587
  if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
588
- if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent) {
588
+ if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent || a.hermesProfile) {
589
589
  agentRuntimes[a.agentId] = {
590
590
  ...(a.runtime ? { runtime: a.runtime } : {}),
591
591
  ...(a.cwd ? { cwd: a.cwd } : {}),
592
592
  ...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
593
593
  ...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
594
+ ...(a.hermesProfile ? { hermesProfile: a.hermesProfile } : {}),
594
595
  };
595
596
  }
596
597
  // Seed files are written only when missing (see `ensureAgentWorkspace`),
@@ -12,6 +12,8 @@ import os from "node:os";
12
12
  import path from "node:path";
13
13
  import {
14
14
  HermesAgentAdapter,
15
+ hermesProfileHomeDir,
16
+ listHermesProfiles,
15
17
  resolveHermesAcpCommand,
16
18
  } from "../runtimes/hermes-agent.js";
17
19
  import { agentHermesWorkspaceDir } from "../../agent-workspace.js";
@@ -367,3 +369,88 @@ describe("HermesAgentAdapter", () => {
367
369
  expect(status.some((s) => s.phase === "stopped")).toBe(true);
368
370
  });
369
371
  });
372
+
373
+ describe("listHermesProfiles", () => {
374
+ it("returns the synthetic default entry when only ~/.hermes exists", () => {
375
+ // beforeAll set HOME to an empty tmp dir; create just ~/.hermes.
376
+ const root = path.join(agentHomeRoot, ".hermes");
377
+ mkdirSync(root, { recursive: true });
378
+ // Drop a config.yaml to exercise the optional model snapshot.
379
+ writeFileSync(
380
+ path.join(root, "config.yaml"),
381
+ "model:\n default: anthropic/claude-opus-4.6\n provider: custom\n",
382
+ );
383
+ writeFileSync(path.join(root, "SOUL.md"), "test soul");
384
+ mkdirSync(path.join(root, "sessions"), { recursive: true });
385
+ writeFileSync(path.join(root, "sessions", "20260101_abc.jsonl"), "{}\n");
386
+
387
+ const profiles = listHermesProfiles();
388
+ const def = profiles.find((p) => p.name === "default");
389
+ expect(def).toBeDefined();
390
+ expect(def?.isDefault).toBe(true);
391
+ expect(def?.home).toBe(root);
392
+ expect(def?.modelName).toBe("anthropic/claude-opus-4.6");
393
+ expect(def?.hasSoul).toBe(true);
394
+ expect(def?.sessionsCount).toBe(1);
395
+
396
+ rmSync(root, { recursive: true, force: true });
397
+ });
398
+
399
+ it("enumerates named profiles and honours active_profile", () => {
400
+ const root = path.join(agentHomeRoot, ".hermes");
401
+ mkdirSync(path.join(root, "profiles", "coder"), { recursive: true });
402
+ mkdirSync(path.join(root, "profiles", "writer"), { recursive: true });
403
+ // Garbage / invalid profile names should be skipped.
404
+ mkdirSync(path.join(root, "profiles", "BadName"), { recursive: true });
405
+ writeFileSync(path.join(root, "active_profile"), "writer\n");
406
+
407
+ const profiles = listHermesProfiles();
408
+ const names = profiles.map((p) => p.name).sort();
409
+ expect(names).toEqual(["coder", "default", "writer"]);
410
+ const writer = profiles.find((p) => p.name === "writer");
411
+ expect(writer?.isActive).toBe(true);
412
+ expect(writer?.home).toBe(hermesProfileHomeDir("writer"));
413
+
414
+ rmSync(root, { recursive: true, force: true });
415
+ });
416
+ });
417
+
418
+ describe("HermesAgentAdapter.spawnEnv attach mode", () => {
419
+ it("points HERMES_HOME at the user profile when hermesProfile is set", () => {
420
+ const adapter = new HermesAgentAdapter({ binary: "/dev/null" });
421
+ type EnvProbe = { HERMES_HOME?: string };
422
+ const env = (
423
+ adapter as unknown as {
424
+ spawnEnv: (opts: unknown) => EnvProbe;
425
+ }
426
+ ).spawnEnv({
427
+ text: "",
428
+ sessionId: null,
429
+ cwd: tmpRoot,
430
+ accountId: "ag_attach",
431
+ signal: new AbortController().signal,
432
+ trustLevel: "owner",
433
+ hermesProfile: "coder",
434
+ });
435
+ expect(env.HERMES_HOME).toBe(hermesProfileHomeDir("coder"));
436
+ });
437
+
438
+ it("falls back to per-agent isolated home when hermesProfile is unset", () => {
439
+ const adapter = new HermesAgentAdapter({ binary: "/dev/null" });
440
+ type EnvProbe = { HERMES_HOME?: string };
441
+ const env = (
442
+ adapter as unknown as {
443
+ spawnEnv: (opts: unknown) => EnvProbe;
444
+ }
445
+ ).spawnEnv({
446
+ text: "",
447
+ sessionId: null,
448
+ cwd: tmpRoot,
449
+ accountId: "ag_isolated",
450
+ signal: new AbortController().signal,
451
+ trustLevel: "owner",
452
+ });
453
+ expect(env.HERMES_HOME).toContain(path.join(".botcord", "agents", "ag_isolated"));
454
+ expect(env.HERMES_HOME).not.toBe(hermesProfileHomeDir("default"));
455
+ });
456
+ });
@@ -1050,6 +1050,7 @@ export class Dispatcher {
1050
1050
  onBlock,
1051
1051
  onStatus,
1052
1052
  gateway: route.gateway,
1053
+ ...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
1053
1054
  });
1054
1055
  } catch (err) {
1055
1056
  threw = err;