@botcord/daemon 0.2.51 → 0.2.52

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.
@@ -3,8 +3,8 @@ import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { log as daemonLog } from "./log.js";
5
5
  import { probeOpenclawAgents } from "./provision.js";
6
- const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
7
- const DEFAULT_PORTS = [18789, 16200];
6
+ const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "~/.qclaw/", "/etc/openclaw/"];
7
+ const DEFAULT_PORTS = [18789, 16200, 28789];
8
8
  const DEFAULT_TOKEN_FILE_PATHS = [
9
9
  "/run/openclaw/gateway-token",
10
10
  "/var/run/openclaw/gateway-token",
@@ -346,6 +346,9 @@ function discoverFromConfigDir(root) {
346
346
  }
347
347
  function parseJsonConfig(raw) {
348
348
  const obj = JSON.parse(raw);
349
+ const qclaw = pickQclawGatewayValues(obj);
350
+ if (qclaw)
351
+ return qclaw;
349
352
  // Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
350
353
  // The legacy `acp.url` shape is also supported for explicit user-authored configs.
351
354
  const native = pickOpenclawGatewayValues(obj?.gateway);
@@ -354,6 +357,34 @@ function parseJsonConfig(raw) {
354
357
  const acp = obj?.acp ?? obj?.gateway?.acp ?? obj?.gateway ?? obj;
355
358
  return pickConfigValues(acp);
356
359
  }
360
+ function pickQclawGatewayValues(obj) {
361
+ if (!obj || typeof obj !== "object")
362
+ return null;
363
+ const port = typeof obj.port === "number" ? obj.port : undefined;
364
+ const configPath = typeof obj.configPath === "string" && obj.configPath.trim()
365
+ ? obj.configPath.trim()
366
+ : undefined;
367
+ if (!port && !configPath)
368
+ return null;
369
+ const fromConfig = configPath ? readGatewayValuesFromConfigPath(configPath) : null;
370
+ if (fromConfig)
371
+ return fromConfig;
372
+ if (!port)
373
+ return null;
374
+ return { url: `ws://127.0.0.1:${port}` };
375
+ }
376
+ function readGatewayValuesFromConfigPath(configPath) {
377
+ try {
378
+ const raw = readFileSync(expandHome(configPath), "utf8");
379
+ const parsed = parseJsonConfig(raw);
380
+ if (parsed?.url)
381
+ return parsed;
382
+ }
383
+ catch {
384
+ // qclaw.json may be copied without its referenced openclaw.json.
385
+ }
386
+ return null;
387
+ }
357
388
  function pickOpenclawGatewayValues(gw) {
358
389
  if (!gw || typeof gw !== "object")
359
390
  return null;
package/dist/provision.js CHANGED
@@ -1075,10 +1075,10 @@ function localOpenclawAcpDisabled(rawUrl) {
1075
1075
  if (!isLoopbackUrl(rawUrl))
1076
1076
  return false;
1077
1077
  try {
1078
- const file = path.join(homedir(), ".openclaw", "openclaw.json");
1079
- if (!existsSync(file))
1078
+ const source = pickLocalOpenclawConfig(rawUrl);
1079
+ if (!source)
1080
1080
  return false;
1081
- const cfg = JSON.parse(readFileSync(file, "utf8"));
1081
+ const cfg = JSON.parse(readFileSync(source.file, "utf8"));
1082
1082
  return cfg?.acp?.enabled === false;
1083
1083
  }
1084
1084
  catch {
@@ -1491,12 +1491,13 @@ export async function probeOpenclawAgents(profile, opts = {}) {
1491
1491
  token: prepared.resolvedToken,
1492
1492
  timeoutMs: opts.timeoutMs ?? 3000,
1493
1493
  });
1494
- // For loopback gateways the agent roster lives in `~/.openclaw/openclaw.json`
1494
+ // For loopback gateways the agent roster lives in local OpenClaw config
1495
+ // (`~/.openclaw/openclaw.json`, or QClaw's `~/.qclaw/openclaw.json`)
1495
1496
  // and is the source of truth — listing it over the wire would require a
1496
1497
  // paired device identity (operator.read scope). When the WS probe is the
1497
1498
  // default (i.e. no test injection) we enrich the result from disk.
1498
1499
  if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
1499
- const local = readLocalOpenclawAgents();
1500
+ const local = readLocalOpenclawAgents(profile.url);
1500
1501
  if (local && local.length > 0)
1501
1502
  result.agents = local;
1502
1503
  }
@@ -1511,17 +1512,18 @@ function isLoopbackUrl(raw) {
1511
1512
  return false;
1512
1513
  }
1513
1514
  }
1514
- function readLocalOpenclawAgents() {
1515
+ function readLocalOpenclawAgents(rawUrl) {
1515
1516
  try {
1516
- const file = path.join(homedir(), ".openclaw", "openclaw.json");
1517
- if (!existsSync(file))
1518
- return readLocalOpenclawAgentDirs() ?? [{ id: "default" }];
1517
+ const source = pickLocalOpenclawConfig(rawUrl);
1518
+ if (!source)
1519
+ return readLocalOpenclawAgentDirs(path.join(homedir(), ".openclaw")) ?? [{ id: "default" }];
1520
+ const { file, stateDir } = source;
1519
1521
  const cfg = JSON.parse(readFileSync(file, "utf8"));
1520
1522
  const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
1521
1523
  const explicitDefaultId = typeof cfg?.agents?.defaults?.id === "string" && cfg.agents.defaults.id
1522
1524
  ? cfg.agents.defaults.id
1523
1525
  : null;
1524
- const dirAgents = readLocalOpenclawAgentDirs();
1526
+ const dirAgents = readLocalOpenclawAgentDirs(stateDir);
1525
1527
  const defaultId = explicitDefaultId ?? (list.length === 0 && !dirAgents ? "default" : null);
1526
1528
  const seen = new Set();
1527
1529
  const out = [];
@@ -1565,16 +1567,52 @@ function readLocalOpenclawAgents() {
1565
1567
  return null;
1566
1568
  }
1567
1569
  }
1568
- function readLocalOpenclawAgentDirs() {
1570
+ function pickLocalOpenclawConfig(rawUrl) {
1571
+ const candidates = [
1572
+ { file: path.join(homedir(), ".openclaw", "openclaw.json"), stateDir: path.join(homedir(), ".openclaw") },
1573
+ { file: path.join(homedir(), ".qclaw", "openclaw.json"), stateDir: path.join(homedir(), ".qclaw") },
1574
+ ];
1575
+ const targetPort = urlPort(rawUrl);
1576
+ let firstExisting = null;
1577
+ for (const candidate of candidates) {
1578
+ if (!existsSync(candidate.file))
1579
+ continue;
1580
+ firstExisting ??= candidate;
1581
+ if (!targetPort)
1582
+ continue;
1583
+ try {
1584
+ const cfg = JSON.parse(readFileSync(candidate.file, "utf8"));
1585
+ if (Number(cfg?.gateway?.port) === targetPort)
1586
+ return candidate;
1587
+ }
1588
+ catch {
1589
+ // Try the next local config.
1590
+ }
1591
+ }
1592
+ return firstExisting;
1593
+ }
1594
+ function urlPort(rawUrl) {
1595
+ if (!rawUrl)
1596
+ return null;
1569
1597
  try {
1570
- const dir = path.join(homedir(), ".openclaw", "agents");
1598
+ const u = new URL(rawUrl);
1599
+ const port = Number(u.port || (u.protocol === "wss:" ? 443 : 80));
1600
+ return Number.isInteger(port) && port > 0 ? port : null;
1601
+ }
1602
+ catch {
1603
+ return null;
1604
+ }
1605
+ }
1606
+ function readLocalOpenclawAgentDirs(stateDir) {
1607
+ try {
1608
+ const dir = path.join(stateDir, "agents");
1571
1609
  if (!existsSync(dir))
1572
1610
  return null;
1573
1611
  const agents = readdirSync(dir, { withFileTypes: true })
1574
1612
  .filter((entry) => entry.isDirectory() && entry.name.length > 0)
1575
1613
  .map((entry) => ({
1576
1614
  id: entry.name,
1577
- workspace: path.join(dir, entry.name),
1615
+ workspace: resolveAgentDirWorkspace(dir, entry.name),
1578
1616
  }));
1579
1617
  if (agents.length === 0)
1580
1618
  return null;
@@ -1591,6 +1629,10 @@ function readLocalOpenclawAgentDirs() {
1591
1629
  return null;
1592
1630
  }
1593
1631
  }
1632
+ function resolveAgentDirWorkspace(agentsDir, agentId) {
1633
+ const nested = path.join(agentsDir, agentId, "agent");
1634
+ return existsSync(nested) ? nested : path.join(agentsDir, agentId);
1635
+ }
1594
1636
  function resolveOpenclawIdentityName(agentId, workspace, cfg) {
1595
1637
  const root = workspace ?? resolveOpenclawWorkspace(agentId, cfg);
1596
1638
  if (!root)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.51",
3
+ "version": "0.2.52",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -128,6 +128,41 @@ describe("discoverLocalOpenclawGateways", () => {
128
128
  ]);
129
129
  });
130
130
 
131
+ it("discovers QClaw's state config and referenced OpenClaw config", async () => {
132
+ const dir = tempDir();
133
+ const openclawConfig = path.join(dir, "openclaw.json");
134
+ writeFileSync(
135
+ openclawConfig,
136
+ JSON.stringify({
137
+ gateway: {
138
+ port: 28789,
139
+ bind: "loopback",
140
+ auth: { mode: "token", token: "qclaw-token" },
141
+ },
142
+ }),
143
+ );
144
+ writeFileSync(
145
+ path.join(dir, "qclaw.json"),
146
+ JSON.stringify({
147
+ configPath: openclawConfig,
148
+ port: 28789,
149
+ }),
150
+ );
151
+
152
+ const found = await discoverLocalOpenclawGateways({
153
+ searchPaths: [dir],
154
+ defaultPorts: [],
155
+ });
156
+
157
+ expect(found).toEqual([
158
+ expect.objectContaining({
159
+ url: "ws://127.0.0.1:28789",
160
+ token: "qclaw-token",
161
+ source: "config-file",
162
+ }),
163
+ ]);
164
+ });
165
+
131
166
  it("uses OPENCLAW_ACP_URL and token env vars", async () => {
132
167
  const found = await discoverLocalOpenclawGateways({
133
168
  searchPaths: [],
@@ -269,8 +304,8 @@ describe("discoverLocalOpenclawGateways", () => {
269
304
  ]);
270
305
  });
271
306
 
272
- it("includes 16200 in default discovery ports", () => {
273
- expect(defaultOpenclawDiscoveryPorts()).toEqual(expect.arrayContaining([18789, 16200]));
307
+ it("includes OpenClaw and QClaw ports in default discovery ports", () => {
308
+ expect(defaultOpenclawDiscoveryPorts()).toEqual(expect.arrayContaining([18789, 16200, 28789]));
274
309
  });
275
310
 
276
311
  it("adds default-port candidates only when the probe succeeds", async () => {
@@ -27,6 +27,7 @@ vi.mock("../config.js", async () => {
27
27
  const {
28
28
  addAgentToConfig,
29
29
  adoptDiscoveredOpenclawAgents,
30
+ probeOpenclawAgents,
30
31
  removeAgentFromConfig,
31
32
  reloadConfig,
32
33
  setRoute,
@@ -34,6 +35,7 @@ const {
34
35
  } = await import("../provision.js");
35
36
  const { CONTROL_FRAME_TYPES } = await import("@botcord/protocol-core");
36
37
  import type { DaemonConfig } from "../config.js";
38
+ import type { WsEndpointProbeFn } from "../provision.js";
37
39
  import type {
38
40
  GatewayChannelConfig,
39
41
  GatewayRoute,
@@ -1139,7 +1141,7 @@ describe("adoptDiscoveredOpenclawAgents", () => {
1139
1141
  openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
1140
1142
  };
1141
1143
  const register = vi.fn();
1142
- const probe = vi.fn<Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["probe"]>(
1144
+ const probe = vi.fn<WsEndpointProbeFn>(
1143
1145
  async () => ({ ok: true, agents: [{ id: "main" }] }),
1144
1146
  );
1145
1147
 
@@ -1224,6 +1226,70 @@ describe("adoptDiscoveredOpenclawAgents", () => {
1224
1226
  });
1225
1227
  });
1226
1228
 
1229
+ describe("probeOpenclawAgents local profiles", () => {
1230
+ it("enriches loopback QClaw gateways from ~/.qclaw/openclaw.json", async () => {
1231
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
1232
+ const { WebSocketServer } = await import("ws");
1233
+ const qclawDir = nodePath.join(tmp, ".qclaw");
1234
+ fs.mkdirSync(qclawDir, { recursive: true });
1235
+
1236
+ const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
1237
+ await new Promise<void>((resolve) => wss.once("listening", resolve));
1238
+ const address = wss.address();
1239
+ if (typeof address === "string" || address === null) {
1240
+ throw new Error("expected tcp websocket address");
1241
+ }
1242
+
1243
+ fs.writeFileSync(
1244
+ nodePath.join(qclawDir, "openclaw.json"),
1245
+ JSON.stringify({
1246
+ agents: {
1247
+ defaults: {
1248
+ workspace: nodePath.join(qclawDir, "workspace"),
1249
+ model: { primary: "qclaw/modelroute" },
1250
+ },
1251
+ list: [{ id: "main", name: "QClaw" }],
1252
+ },
1253
+ gateway: {
1254
+ port: address.port,
1255
+ auth: { mode: "token", token: "qclaw-token" },
1256
+ },
1257
+ }),
1258
+ );
1259
+
1260
+ wss.on("connection", (ws) => {
1261
+ ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: "n" } }));
1262
+ ws.on("message", (raw) => {
1263
+ const msg = JSON.parse(raw.toString("utf8"));
1264
+ if (msg.method === "connect") {
1265
+ ws.send(
1266
+ JSON.stringify({
1267
+ type: "res",
1268
+ id: msg.id,
1269
+ ok: true,
1270
+ payload: { type: "hello-ok", server: { version: "2026.4.21" } },
1271
+ }),
1272
+ );
1273
+ }
1274
+ });
1275
+ });
1276
+
1277
+ try {
1278
+ const res = await probeOpenclawAgents({
1279
+ url: `ws://127.0.0.1:${address.port}`,
1280
+ token: "qclaw-token",
1281
+ });
1282
+
1283
+ expect(res.ok).toBe(true);
1284
+ expect(res.version).toBe("2026.4.21");
1285
+ expect(res.agents).toEqual([{ id: "main", name: "QClaw" }]);
1286
+ } finally {
1287
+ await new Promise<void>((resolve) => wss.close(() => resolve()));
1288
+ }
1289
+ });
1290
+ });
1291
+ });
1292
+
1227
1293
  // ---------------------------------------------------------------------------
1228
1294
  // revoke_agent — new flag semantics (plan §11.3)
1229
1295
  // ---------------------------------------------------------------------------
@@ -34,8 +34,8 @@ export interface MergeOpenclawGatewayResult {
34
34
  added: OpenclawGatewayProfile[];
35
35
  }
36
36
 
37
- const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
38
- const DEFAULT_PORTS = [18789, 16200];
37
+ const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "~/.qclaw/", "/etc/openclaw/"];
38
+ const DEFAULT_PORTS = [18789, 16200, 28789];
39
39
  const DEFAULT_TOKEN_FILE_PATHS = [
40
40
  "/run/openclaw/gateway-token",
41
41
  "/var/run/openclaw/gateway-token",
@@ -382,6 +382,8 @@ function discoverFromConfigDir(root: string): DiscoveredOpenclawGateway[] {
382
382
 
383
383
  function parseJsonConfig(raw: string): { url?: string; token?: string; tokenFile?: string } | null {
384
384
  const obj = JSON.parse(raw) as any;
385
+ const qclaw = pickQclawGatewayValues(obj);
386
+ if (qclaw) return qclaw;
385
387
  // Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
386
388
  // The legacy `acp.url` shape is also supported for explicit user-authored configs.
387
389
  const native = pickOpenclawGatewayValues(obj?.gateway);
@@ -390,6 +392,35 @@ function parseJsonConfig(raw: string): { url?: string; token?: string; tokenFile
390
392
  return pickConfigValues(acp);
391
393
  }
392
394
 
395
+ function pickQclawGatewayValues(
396
+ obj: any,
397
+ ): { url?: string; token?: string; tokenFile?: string } | null {
398
+ if (!obj || typeof obj !== "object") return null;
399
+ const port = typeof obj.port === "number" ? obj.port : undefined;
400
+ const configPath = typeof obj.configPath === "string" && obj.configPath.trim()
401
+ ? obj.configPath.trim()
402
+ : undefined;
403
+ if (!port && !configPath) return null;
404
+
405
+ const fromConfig = configPath ? readGatewayValuesFromConfigPath(configPath) : null;
406
+ if (fromConfig) return fromConfig;
407
+ if (!port) return null;
408
+ return { url: `ws://127.0.0.1:${port}` };
409
+ }
410
+
411
+ function readGatewayValuesFromConfigPath(
412
+ configPath: string,
413
+ ): { url?: string; token?: string; tokenFile?: string } | null {
414
+ try {
415
+ const raw = readFileSync(expandHome(configPath), "utf8");
416
+ const parsed = parseJsonConfig(raw);
417
+ if (parsed?.url) return parsed;
418
+ } catch {
419
+ // qclaw.json may be copied without its referenced openclaw.json.
420
+ }
421
+ return null;
422
+ }
423
+
393
424
  function pickOpenclawGatewayValues(
394
425
  gw: any,
395
426
  ): { url?: string; token?: string; tokenFile?: string } | null {
package/src/provision.ts CHANGED
@@ -1362,9 +1362,9 @@ export async function adoptDiscoveredOpenclawAgents(ctx: {
1362
1362
  function localOpenclawAcpDisabled(rawUrl: string): boolean {
1363
1363
  if (!isLoopbackUrl(rawUrl)) return false;
1364
1364
  try {
1365
- const file = path.join(homedir(), ".openclaw", "openclaw.json");
1366
- if (!existsSync(file)) return false;
1367
- const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
1365
+ const source = pickLocalOpenclawConfig(rawUrl);
1366
+ if (!source) return false;
1367
+ const cfg = JSON.parse(readFileSync(source.file, "utf8")) as any;
1368
1368
  return cfg?.acp?.enabled === false;
1369
1369
  } catch {
1370
1370
  return false;
@@ -1838,12 +1838,13 @@ export async function probeOpenclawAgents(
1838
1838
  token: prepared.resolvedToken,
1839
1839
  timeoutMs: opts.timeoutMs ?? 3000,
1840
1840
  });
1841
- // For loopback gateways the agent roster lives in `~/.openclaw/openclaw.json`
1841
+ // For loopback gateways the agent roster lives in local OpenClaw config
1842
+ // (`~/.openclaw/openclaw.json`, or QClaw's `~/.qclaw/openclaw.json`)
1842
1843
  // and is the source of truth — listing it over the wire would require a
1843
1844
  // paired device identity (operator.read scope). When the WS probe is the
1844
1845
  // default (i.e. no test injection) we enrich the result from disk.
1845
1846
  if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
1846
- const local = readLocalOpenclawAgents();
1847
+ const local = readLocalOpenclawAgents(profile.url);
1847
1848
  if (local && local.length > 0) result.agents = local;
1848
1849
  }
1849
1850
  return result;
@@ -1858,22 +1859,23 @@ function isLoopbackUrl(raw: string): boolean {
1858
1859
  }
1859
1860
  }
1860
1861
 
1861
- function readLocalOpenclawAgents(): Array<{
1862
+ function readLocalOpenclawAgents(rawUrl?: string): Array<{
1862
1863
  id: string;
1863
1864
  name?: string;
1864
1865
  workspace?: string;
1865
1866
  model?: { name?: string; provider?: string };
1866
1867
  }> | null {
1867
1868
  try {
1868
- const file = path.join(homedir(), ".openclaw", "openclaw.json");
1869
- if (!existsSync(file)) return readLocalOpenclawAgentDirs() ?? [{ id: "default" }];
1869
+ const source = pickLocalOpenclawConfig(rawUrl);
1870
+ if (!source) return readLocalOpenclawAgentDirs(path.join(homedir(), ".openclaw")) ?? [{ id: "default" }];
1871
+ const { file, stateDir } = source;
1870
1872
  const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
1871
1873
  const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
1872
1874
  const explicitDefaultId =
1873
1875
  typeof cfg?.agents?.defaults?.id === "string" && cfg.agents.defaults.id
1874
1876
  ? cfg.agents.defaults.id
1875
1877
  : null;
1876
- const dirAgents = readLocalOpenclawAgentDirs();
1878
+ const dirAgents = readLocalOpenclawAgentDirs(stateDir);
1877
1879
  const defaultId = explicitDefaultId ?? (list.length === 0 && !dirAgents ? "default" : null);
1878
1880
  const seen = new Set<string>();
1879
1881
  const out: Array<{ id: string; name?: string; workspace?: string; model?: { name?: string; provider?: string } }> = [];
@@ -1906,18 +1908,50 @@ function readLocalOpenclawAgents(): Array<{
1906
1908
  }
1907
1909
  }
1908
1910
 
1909
- function readLocalOpenclawAgentDirs(): Array<{
1911
+ function pickLocalOpenclawConfig(rawUrl?: string): { file: string; stateDir: string } | null {
1912
+ const candidates = [
1913
+ { file: path.join(homedir(), ".openclaw", "openclaw.json"), stateDir: path.join(homedir(), ".openclaw") },
1914
+ { file: path.join(homedir(), ".qclaw", "openclaw.json"), stateDir: path.join(homedir(), ".qclaw") },
1915
+ ];
1916
+ const targetPort = urlPort(rawUrl);
1917
+ let firstExisting: { file: string; stateDir: string } | null = null;
1918
+ for (const candidate of candidates) {
1919
+ if (!existsSync(candidate.file)) continue;
1920
+ firstExisting ??= candidate;
1921
+ if (!targetPort) continue;
1922
+ try {
1923
+ const cfg = JSON.parse(readFileSync(candidate.file, "utf8")) as any;
1924
+ if (Number(cfg?.gateway?.port) === targetPort) return candidate;
1925
+ } catch {
1926
+ // Try the next local config.
1927
+ }
1928
+ }
1929
+ return firstExisting;
1930
+ }
1931
+
1932
+ function urlPort(rawUrl?: string): number | null {
1933
+ if (!rawUrl) return null;
1934
+ try {
1935
+ const u = new URL(rawUrl);
1936
+ const port = Number(u.port || (u.protocol === "wss:" ? 443 : 80));
1937
+ return Number.isInteger(port) && port > 0 ? port : null;
1938
+ } catch {
1939
+ return null;
1940
+ }
1941
+ }
1942
+
1943
+ function readLocalOpenclawAgentDirs(stateDir: string): Array<{
1910
1944
  id: string;
1911
1945
  workspace?: string;
1912
1946
  }> | null {
1913
1947
  try {
1914
- const dir = path.join(homedir(), ".openclaw", "agents");
1948
+ const dir = path.join(stateDir, "agents");
1915
1949
  if (!existsSync(dir)) return null;
1916
1950
  const agents = readdirSync(dir, { withFileTypes: true })
1917
1951
  .filter((entry) => entry.isDirectory() && entry.name.length > 0)
1918
1952
  .map((entry) => ({
1919
1953
  id: entry.name,
1920
- workspace: path.join(dir, entry.name),
1954
+ workspace: resolveAgentDirWorkspace(dir, entry.name),
1921
1955
  }));
1922
1956
  if (agents.length === 0) return null;
1923
1957
  agents.sort((a, b) => {
@@ -1931,6 +1965,11 @@ function readLocalOpenclawAgentDirs(): Array<{
1931
1965
  }
1932
1966
  }
1933
1967
 
1968
+ function resolveAgentDirWorkspace(agentsDir: string, agentId: string): string {
1969
+ const nested = path.join(agentsDir, agentId, "agent");
1970
+ return existsSync(nested) ? nested : path.join(agentsDir, agentId);
1971
+ }
1972
+
1934
1973
  function resolveOpenclawIdentityName(
1935
1974
  agentId: string,
1936
1975
  workspace?: string,