@botcord/daemon 0.2.26 → 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.
@@ -1,4 +1,5 @@
1
- import { mkdirSync, renameSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import path from "node:path";
3
4
  import {
4
5
  agentHermesHomeDir,
@@ -65,6 +66,139 @@ export function probeHermesAgent(deps: ProbeDeps = {}): RuntimeProbeResult {
65
66
  };
66
67
  }
67
68
 
69
+ /**
70
+ * Discovered hermes profile entry (daemon-side shape; wire shape lives in
71
+ * protocol-core's `HermesProfileProbe`). Occupancy is filled in later by
72
+ * `provision.ts` from local credentials, not here.
73
+ */
74
+ export interface HermesProfileInfo {
75
+ name: string;
76
+ home: string;
77
+ isDefault?: boolean;
78
+ isActive?: boolean;
79
+ modelName?: string;
80
+ sessionsCount?: number;
81
+ hasSoul?: boolean;
82
+ }
83
+
84
+ /**
85
+ * Resolve the hermes root (`~/.hermes`) — this is the location of the
86
+ * synthetic `default` profile per upstream's "default profile = HERMES_HOME
87
+ * itself" convention (`hermes_cli/profiles.py:8`).
88
+ */
89
+ export function hermesRootDir(): string {
90
+ return path.join(homedir(), ".hermes");
91
+ }
92
+
93
+ /** Profile-name shape mirrors `hermes_cli/profiles.py:_PROFILE_ID_RE`. */
94
+ const HERMES_PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
95
+
96
+ export function isValidHermesProfileName(name: string): boolean {
97
+ return name === "default" || HERMES_PROFILE_NAME_RE.test(name);
98
+ }
99
+
100
+ /**
101
+ * Resolve a hermes profile's HERMES_HOME directory. `default` maps to
102
+ * `~/.hermes`; all other names map to `~/.hermes/profiles/<name>`. Mirrors
103
+ * `hermes_cli/profiles.py:get_profile_dir`.
104
+ */
105
+ export function hermesProfileHomeDir(name: string): string {
106
+ if (!isValidHermesProfileName(name)) {
107
+ throw new Error(`Invalid hermes profile name: ${name}`);
108
+ }
109
+ if (name === "default") return hermesRootDir();
110
+ return path.join(hermesRootDir(), "profiles", name);
111
+ }
112
+
113
+ function readActiveProfileName(): string {
114
+ try {
115
+ const raw = readFileSync(path.join(hermesRootDir(), "active_profile"), "utf8").trim();
116
+ return raw || "default";
117
+ } catch {
118
+ return "default";
119
+ }
120
+ }
121
+
122
+ function readProfileModelName(profileHome: string): string | undefined {
123
+ try {
124
+ const raw = readFileSync(path.join(profileHome, "config.yaml"), "utf8");
125
+ // Cheap surface-level YAML peek — config.yaml's first block is
126
+ // `model:\n default: <name>`. Avoid pulling in a YAML dependency for
127
+ // a single optional field.
128
+ const match = raw.match(/^model:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+default:\s*([^\n#]+)/m);
129
+ if (!match) return undefined;
130
+ return match[1].trim().replace(/^['"]|['"]$/g, "") || undefined;
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ }
135
+
136
+ function countSessions(profileHome: string): number | undefined {
137
+ try {
138
+ const dir = path.join(profileHome, "sessions");
139
+ if (!existsSync(dir)) return 0;
140
+ return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).length;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ function hasSoul(profileHome: string): boolean {
147
+ return existsSync(path.join(profileHome, "SOUL.md"));
148
+ }
149
+
150
+ /**
151
+ * Enumerate available hermes profiles on this device. Pure local filesystem
152
+ * scan — does not invoke any hermes binary. Returns the synthetic `default`
153
+ * entry first when `~/.hermes` exists (which it should, given that the probe
154
+ * already located `hermes-acp`); each `~/.hermes/profiles/<name>/` directory
155
+ * follows.
156
+ */
157
+ export function listHermesProfiles(): HermesProfileInfo[] {
158
+ const out: HermesProfileInfo[] = [];
159
+ const root = hermesRootDir();
160
+ const active = readActiveProfileName();
161
+
162
+ if (existsSync(root)) {
163
+ out.push({
164
+ name: "default",
165
+ home: root,
166
+ isDefault: true,
167
+ isActive: active === "default",
168
+ modelName: readProfileModelName(root),
169
+ sessionsCount: countSessions(root),
170
+ hasSoul: hasSoul(root),
171
+ });
172
+ }
173
+
174
+ const profilesDir = path.join(root, "profiles");
175
+ let entries: string[] = [];
176
+ try {
177
+ entries = readdirSync(profilesDir);
178
+ } catch {
179
+ return out;
180
+ }
181
+ for (const name of entries) {
182
+ if (!HERMES_PROFILE_NAME_RE.test(name)) continue;
183
+ const home = path.join(profilesDir, name);
184
+ try {
185
+ if (!statSync(home).isDirectory()) continue;
186
+ } catch {
187
+ continue;
188
+ }
189
+ out.push({
190
+ name,
191
+ home,
192
+ isActive: active === name,
193
+ modelName: readProfileModelName(home),
194
+ sessionsCount: countSessions(home),
195
+ hasSoul: hasSoul(home),
196
+ });
197
+ }
198
+
199
+ return out;
200
+ }
201
+
68
202
  /**
69
203
  * Hermes Agent adapter. Drives `hermes-acp` (the ACP stdio adapter shipped
70
204
  * with `pip install "hermes-agent[acp]"`).
@@ -141,7 +275,14 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
141
275
  // Route dangerous tool calls through ACP request_permission.
142
276
  HERMES_INTERACTIVE: "1",
143
277
  };
144
- if (opts.accountId) {
278
+ // Attach mode: BotCord agent shares a hermes profile (state.db /
279
+ // sessions / skills / .env) with the user's command-line `hermes`. In
280
+ // this mode we DO NOT seed a private home — the profile is wholly owned
281
+ // by the user, and AGENTS.md is written under the per-agent
282
+ // hermes-workspace cwd (NOT into the profile root) by `prepareTurn`.
283
+ if (opts.hermesProfile) {
284
+ env.HERMES_HOME = hermesProfileHomeDir(opts.hermesProfile);
285
+ } else if (opts.accountId) {
145
286
  env.HERMES_HOME = agentHermesHomeDir(opts.accountId);
146
287
  }
147
288
  return env;
@@ -160,7 +301,9 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
160
301
  */
161
302
  protected prepareTurn(opts: RuntimeRunOptions): void {
162
303
  if (!opts.accountId) return;
163
- const { hermesWorkspace } = ensureAgentHermesWorkspace(opts.accountId);
304
+ const { hermesWorkspace } = ensureAgentHermesWorkspace(opts.accountId, {
305
+ attached: !!opts.hermesProfile,
306
+ });
164
307
  const target = path.join(hermesWorkspace, "AGENTS.md");
165
308
  const tmp = path.join(hermesWorkspace, `.AGENTS.md.${process.pid}.tmp`);
166
309
  mkdirSync(hermesWorkspace, { recursive: true, mode: 0o700 });
@@ -140,9 +140,11 @@ interface SpawnDeps {
140
140
  *
141
141
  * Spawns `openclaw acp --url <gateway> [--token <token>]` per
142
142
  * `(accountId, gatewayName)` pair and reuses the process across turns. The
143
- * child speaks JSON-RPC over stdio; we send `initialize` once, then
144
- * `newSession` (with `_meta.sessionKey`) when the daemon has no persisted
145
- * runtime session id, and `prompt` for each turn. Streaming `session/update`
143
+ * child speaks JSON-RPC over stdio; we send `initialize` once, then derive a
144
+ * stable OpenClaw `sessionKey` for the BotCord conversation. The persisted
145
+ * `runtimeSessionId` is only an ACP transport handle cached from a previous
146
+ * turn, so every resume first goes through `session/load` with
147
+ * `_meta.sessionKey` before `prompt`. Streaming `session/update`
146
148
  * notifications are relayed to `onBlock`.
147
149
  *
148
150
  * Process-pool lifetime + abort/cancel semantics live at module scope; see
@@ -190,6 +192,8 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
190
192
  handle.inFlight += 1;
191
193
  if (handle.idleTimer) clearTimeout(handle.idleTimer);
192
194
 
195
+ // ACP session ids are process-local transport handles. They are useful as
196
+ // a cache, but the stable conversation identity is `sessionKey`.
193
197
  let acpSessionId = opts.sessionId ?? "";
194
198
  let seq = 0;
195
199
  let assistantText = "";
@@ -230,8 +234,27 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
230
234
 
231
235
  let abortListener: (() => void) | undefined;
232
236
  try {
233
- // Ensure we have an ACP session id. When the dispatcher doesn't carry
234
- // one, ask the child to create or rebind one for our sessionKey.
237
+ // Ensure we have a live ACP transport session. If the dispatcher passes a
238
+ // cached session id, ask OpenClaw to load/rebind it with the stable
239
+ // sessionKey. If that handle is gone, discard it and create a fresh one.
240
+ if (acpSessionId) {
241
+ try {
242
+ acpSessionId = await this.loadSession(handle, {
243
+ sessionId: acpSessionId,
244
+ cwd: opts.cwd,
245
+ sessionKey,
246
+ });
247
+ } catch (err) {
248
+ if (!isSessionNotFoundError(err)) throw err;
249
+ log.warn("openclaw-acp.session-load-not-found", {
250
+ accountId: opts.accountId,
251
+ oldSessionId: acpSessionId,
252
+ sessionKey,
253
+ });
254
+ acpSessionId = "";
255
+ }
256
+ }
257
+
235
258
  if (!acpSessionId) {
236
259
  try {
237
260
  acpSessionId = await this.newSession(handle, {
@@ -261,11 +284,16 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
261
284
  text: opts.text,
262
285
  });
263
286
  } catch (err) {
264
- const msg = (err as Error).message ?? "prompt failed";
265
287
  // If the child says the session is gone (process restart, GC),
266
288
  // recreate it so the next turn doesn't hard-fail.
267
- if (/session not found|unknown session/i.test(msg)) {
289
+ if (isSessionNotFoundError(err)) {
268
290
  try {
291
+ const oldSessionId = acpSessionId;
292
+ log.warn("openclaw-acp.prompt-session-not-found-retry", {
293
+ accountId: opts.accountId,
294
+ oldSessionId,
295
+ sessionKey,
296
+ });
269
297
  const fresh = await this.newSession(handle, {
270
298
  cwd: opts.cwd,
271
299
  sessionKey,
@@ -273,6 +301,12 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
273
301
  handle.subscribers.delete(acpSessionId);
274
302
  acpSessionId = fresh;
275
303
  handle.subscribers.set(acpSessionId, onNotification);
304
+ log.info("openclaw-acp.session-recreated", {
305
+ accountId: opts.accountId,
306
+ oldSessionId,
307
+ newSessionId: acpSessionId,
308
+ sessionKey,
309
+ });
276
310
  promptResult = await this.prompt(handle, {
277
311
  sessionId: acpSessionId,
278
312
  text: opts.text,
@@ -299,7 +333,11 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
299
333
  newSessionId: acpSessionId,
300
334
  };
301
335
  } catch (err) {
302
- return failResult(acpSessionId, `openclaw-acp: ${(err as Error).message}`);
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ return failResult(
338
+ isSessionNotFoundError(err) ? "" : acpSessionId,
339
+ `openclaw-acp: ${message}`,
340
+ );
303
341
  } finally {
304
342
  if (abortListener && opts.signal) {
305
343
  try {
@@ -426,6 +464,22 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
426
464
  return result.sessionId;
427
465
  }
428
466
 
467
+ private async loadSession(
468
+ handle: AcpProcessHandle,
469
+ args: { sessionId: string; cwd: string; sessionKey: string },
470
+ ): Promise<string> {
471
+ const result = (await sendRequest(handle, "session/load", {
472
+ sessionId: args.sessionId,
473
+ cwd: args.cwd,
474
+ mcpServers: [],
475
+ _meta: { sessionKey: args.sessionKey },
476
+ })) as { sessionId?: string } | null;
477
+ if (result?.sessionId && typeof result.sessionId === "string") {
478
+ return result.sessionId;
479
+ }
480
+ return args.sessionId;
481
+ }
482
+
429
483
  private async prompt(
430
484
  handle: AcpProcessHandle,
431
485
  args: { sessionId: string; text: string },
@@ -469,7 +523,7 @@ function routeMessage(handle: AcpProcessHandle, msg: any): void {
469
523
  if (!pending) return;
470
524
  handle.pending.delete(id);
471
525
  if (msg.error) {
472
- const message = typeof msg.error?.message === "string" ? msg.error.message : "rpc error";
526
+ const message = formatRpcError(msg.error);
473
527
  pending.reject(new Error(message));
474
528
  } else {
475
529
  pending.resolve(msg.result);
@@ -539,6 +593,25 @@ function failResult(sessionId: string, error: string): RuntimeRunResult {
539
593
  };
540
594
  }
541
595
 
596
+ function formatRpcError(error: unknown): string {
597
+ if (!error || typeof error !== "object") return "rpc error";
598
+ const e = error as Record<string, unknown>;
599
+ const message = typeof e.message === "string" ? e.message : "rpc error";
600
+ const data = e.data;
601
+ if (data && typeof data === "object") {
602
+ const details = (data as Record<string, unknown>).details;
603
+ if (typeof details === "string" && details.length > 0) {
604
+ return `${message}: ${details}`;
605
+ }
606
+ }
607
+ return message;
608
+ }
609
+
610
+ function isSessionNotFoundError(err: unknown): boolean {
611
+ const msg = err instanceof Error ? err.message : String(err);
612
+ return /session(?:\s+[\w-]+)?\s+not\s+found|unknown\s+session/i.test(msg);
613
+ }
614
+
542
615
  function classifyAcpUpdate(note: AcpNotification): StreamBlock["kind"] {
543
616
  const update = note.params?.update;
544
617
  const kind: string | undefined = update?.sessionUpdate;
@@ -45,6 +45,14 @@ export interface GatewayRoute {
45
45
  trustLevel?: TrustLevel;
46
46
  /** Required when `runtime === "openclaw-acp"`. Resolved at config-load time. */
47
47
  gateway?: ResolvedOpenclawGateway;
48
+ /**
49
+ * Hermes profile name to attach to. Set when `runtime === "hermes-agent"`
50
+ * and the agent is bound to a specific `~/.hermes/profiles/<name>/`. The
51
+ * dispatcher forwards this to the adapter as
52
+ * {@link RuntimeRunOptions.hermesProfile}, which is what the adapter uses
53
+ * to switch `HERMES_HOME` at spawn time.
54
+ */
55
+ hermesProfile?: string;
48
56
  }
49
57
 
50
58
  // ---------------------------------------------------------------------------
@@ -362,6 +370,15 @@ export interface RuntimeRunOptions {
362
370
  * lifting service URLs out of `extraArgs` into typed first-class fields.
363
371
  */
364
372
  gateway?: ResolvedOpenclawGateway;
373
+ /**
374
+ * Hermes profile to attach to. Only meaningful when `runtime ===
375
+ * "hermes-agent"`. When set, the adapter switches
376
+ * `HERMES_HOME=~/.hermes/profiles/<name>/` (or `~/.hermes` for `default`)
377
+ * so the BotCord agent shares state.db / sessions / skills with the
378
+ * user's command-line `hermes`. Mirrors how `gateway` is lifted out of
379
+ * `extraArgs` for the openclaw-acp runtime.
380
+ */
381
+ hermesProfile?: string;
365
382
  }
366
383
 
367
384
  /** Result returned by a runtime adapter after a turn completes. */
@@ -5,7 +5,11 @@ import type { DaemonConfig, OpenclawGatewayProfile } from "./config.js";
5
5
  import { log as daemonLog } from "./log.js";
6
6
  import { probeOpenclawAgents, type WsEndpointProbeFn } from "./provision.js";
7
7
 
8
- export type DiscoveredOpenclawGatewaySource = "config-file" | "env" | "default-port";
8
+ export type DiscoveredOpenclawGatewaySource =
9
+ | "config-file"
10
+ | "env"
11
+ | "systemd-unit"
12
+ | "default-port";
9
13
 
10
14
  export interface DiscoveredOpenclawGateway {
11
15
  name: string;
@@ -21,6 +25,7 @@ export interface OpenclawGatewayDiscoveryOptions {
21
25
  probe?: WsEndpointProbeFn;
22
26
  timeoutMs?: number;
23
27
  env?: NodeJS.ProcessEnv;
28
+ systemdUnitPaths?: string[];
24
29
  }
25
30
 
26
31
  export interface MergeOpenclawGatewayResult {
@@ -36,6 +41,14 @@ const DEFAULT_TOKEN_FILE_PATHS = [
36
41
  "/var/run/openclaw/gateway-token",
37
42
  "~/.openclaw/gateway-token",
38
43
  ];
44
+ const DEFAULT_SYSTEMD_UNIT_PATHS = [
45
+ "/etc/systemd/system/openclaw.service",
46
+ "/etc/systemd/system/openclaw-gateway.service",
47
+ "/lib/systemd/system/openclaw.service",
48
+ "/lib/systemd/system/openclaw-gateway.service",
49
+ "/usr/lib/systemd/system/openclaw.service",
50
+ "/usr/lib/systemd/system/openclaw-gateway.service",
51
+ ];
39
52
 
40
53
  export async function discoverLocalOpenclawGateways(
41
54
  opts: OpenclawGatewayDiscoveryOptions = {},
@@ -47,6 +60,7 @@ export async function discoverLocalOpenclawGateways(
47
60
 
48
61
  const env = opts.env ?? process.env;
49
62
  found.push(...discoverFromEnv(env));
63
+ found.push(...discoverFromSystemdUnits(opts.systemdUnitPaths ?? DEFAULT_SYSTEMD_UNIT_PATHS));
50
64
  const envAuth = pickOpenclawEnvAuth(env) ?? pickDefaultTokenFile();
51
65
 
52
66
  const ports = opts.defaultPorts ?? DEFAULT_PORTS;
@@ -75,6 +89,164 @@ export async function discoverLocalOpenclawGateways(
75
89
  return dedupeDiscovered(found);
76
90
  }
77
91
 
92
+ function discoverFromSystemdUnits(paths: string[]): DiscoveredOpenclawGateway[] {
93
+ const out: DiscoveredOpenclawGateway[] = [];
94
+ for (const unitPath of paths) {
95
+ try {
96
+ if (!existsSync(unitPath)) continue;
97
+ const parsed = parseSystemdUnit(readFileSync(unitPath, "utf8"), path.dirname(unitPath));
98
+ const url = parsed.url ?? urlFromGatewayPort(parsed.env);
99
+ if (!url) continue;
100
+ const auth = pickOpenclawEnvAuth(parsed.env);
101
+ out.push({
102
+ name: nameFromUrl(url),
103
+ url,
104
+ source: "systemd-unit",
105
+ ...auth,
106
+ });
107
+ } catch (err) {
108
+ daemonLog.debug("openclaw discovery systemd unit skipped", {
109
+ file: unitPath,
110
+ error: err instanceof Error ? err.message : String(err),
111
+ });
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function parseSystemdUnit(
118
+ raw: string,
119
+ unitDir: string,
120
+ ): { env: NodeJS.ProcessEnv; url?: string } {
121
+ const env: NodeJS.ProcessEnv = {};
122
+ let url: string | undefined;
123
+ for (const line of joinedSystemdLines(raw)) {
124
+ const trimmed = line.trim();
125
+ if (!trimmed || trimmed.startsWith("#")) continue;
126
+ const eq = trimmed.indexOf("=");
127
+ if (eq <= 0) continue;
128
+ const key = trimmed.slice(0, eq);
129
+ const value = trimmed.slice(eq + 1).trim();
130
+ if (key === "Environment") {
131
+ Object.assign(env, parseSystemdEnvironment(value));
132
+ } else if (key === "EnvironmentFile") {
133
+ for (const file of splitSystemdWords(value)) {
134
+ const optional = file.startsWith("-");
135
+ const resolved = path.resolve(unitDir, expandHome(optional ? file.slice(1) : file));
136
+ try {
137
+ Object.assign(env, parseEnvFile(readFileSync(resolved, "utf8")));
138
+ } catch (err) {
139
+ if (!optional) {
140
+ daemonLog.debug("openclaw discovery environment file skipped", {
141
+ file: resolved,
142
+ error: err instanceof Error ? err.message : String(err),
143
+ });
144
+ }
145
+ }
146
+ }
147
+ } else if (key === "ExecStart") {
148
+ url = urlFromExecStart(value) ?? url;
149
+ }
150
+ }
151
+ return { env, url };
152
+ }
153
+
154
+ function joinedSystemdLines(raw: string): string[] {
155
+ const out: string[] = [];
156
+ let cur = "";
157
+ for (const line of raw.split(/\r?\n/)) {
158
+ const trimmedEnd = line.replace(/\s+$/, "");
159
+ if (trimmedEnd.endsWith("\\")) {
160
+ cur += trimmedEnd.slice(0, -1) + " ";
161
+ continue;
162
+ }
163
+ out.push(cur + line);
164
+ cur = "";
165
+ }
166
+ if (cur) out.push(cur);
167
+ return out;
168
+ }
169
+
170
+ function parseSystemdEnvironment(raw: string): NodeJS.ProcessEnv {
171
+ const env: NodeJS.ProcessEnv = {};
172
+ for (const word of splitSystemdWords(raw)) {
173
+ const eq = word.indexOf("=");
174
+ if (eq <= 0) continue;
175
+ env[word.slice(0, eq)] = word.slice(eq + 1);
176
+ }
177
+ return env;
178
+ }
179
+
180
+ function parseEnvFile(raw: string): NodeJS.ProcessEnv {
181
+ const env: NodeJS.ProcessEnv = {};
182
+ for (const line of raw.split(/\r?\n/)) {
183
+ const trimmed = line.trim();
184
+ if (!trimmed || trimmed.startsWith("#")) continue;
185
+ const eq = trimmed.indexOf("=");
186
+ if (eq <= 0) continue;
187
+ env[trimmed.slice(0, eq)] = unquote(trimmed.slice(eq + 1).trim());
188
+ }
189
+ return env;
190
+ }
191
+
192
+ function urlFromExecStart(raw: string): string | undefined {
193
+ const words = splitSystemdWords(raw);
194
+ const portIdx = words.indexOf("--port");
195
+ const rawPort =
196
+ portIdx >= 0 ? words[portIdx + 1] : words.find((w) => w.startsWith("--port="))?.slice(7);
197
+ if (!rawPort) return undefined;
198
+ const port = Number(rawPort);
199
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return undefined;
200
+ return `ws://127.0.0.1:${port}`;
201
+ }
202
+
203
+ function splitSystemdWords(raw: string): string[] {
204
+ const words: string[] = [];
205
+ let cur = "";
206
+ let quote: '"' | "'" | null = null;
207
+ let escaped = false;
208
+ for (const ch of raw) {
209
+ if (escaped) {
210
+ cur += ch;
211
+ escaped = false;
212
+ continue;
213
+ }
214
+ if (ch === "\\") {
215
+ escaped = true;
216
+ continue;
217
+ }
218
+ if (quote) {
219
+ if (ch === quote) quote = null;
220
+ else cur += ch;
221
+ continue;
222
+ }
223
+ if (ch === '"' || ch === "'") {
224
+ quote = ch;
225
+ continue;
226
+ }
227
+ if (/\s/.test(ch)) {
228
+ if (cur) {
229
+ words.push(cur);
230
+ cur = "";
231
+ }
232
+ continue;
233
+ }
234
+ cur += ch;
235
+ }
236
+ if (cur) words.push(cur);
237
+ return words.map(unquote);
238
+ }
239
+
240
+ function unquote(raw: string): string {
241
+ if (
242
+ (raw.startsWith('"') && raw.endsWith('"')) ||
243
+ (raw.startsWith("'") && raw.endsWith("'"))
244
+ ) {
245
+ return raw.slice(1, -1);
246
+ }
247
+ return raw;
248
+ }
249
+
78
250
  function discoverFromEnv(env: NodeJS.ProcessEnv): DiscoveredOpenclawGateway[] {
79
251
  const url =
80
252
  pickEnv(env, "OPENCLAW_ACP_URL") ??
@@ -275,8 +447,9 @@ function pickString(obj: Record<string, unknown>, keys: string[]): string | unde
275
447
 
276
448
  function dedupeDiscovered(items: DiscoveredOpenclawGateway[]): DiscoveredOpenclawGateway[] {
277
449
  const priority: Record<DiscoveredOpenclawGatewaySource, number> = {
278
- "config-file": 3,
279
- env: 2,
450
+ "config-file": 4,
451
+ env: 3,
452
+ "systemd-unit": 2,
280
453
  "default-port": 1,
281
454
  };
282
455
  const byUrl = new Map<string, DiscoveredOpenclawGateway>();
@@ -343,6 +516,10 @@ export function defaultOpenclawDiscoveryTokenFilePaths(): string[] {
343
516
  return DEFAULT_TOKEN_FILE_PATHS.slice();
344
517
  }
345
518
 
519
+ export function defaultOpenclawDiscoverySystemdUnitPaths(): string[] {
520
+ return DEFAULT_SYSTEMD_UNIT_PATHS.slice();
521
+ }
522
+
346
523
  export function openclawDiscoveryConfigEnabled(cfg: DaemonConfig): boolean {
347
524
  return cfg.openclawDiscovery?.enabled !== false;
348
525
  }