@botcord/daemon 0.2.6 → 0.2.9

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/config.d.ts CHANGED
@@ -74,6 +74,16 @@ export interface AgentDiscoveryConfig {
74
74
  enabled?: boolean;
75
75
  credentialsDir?: string;
76
76
  }
77
+ export interface OpenclawDiscoveryConfig {
78
+ /** Defaults to true. */
79
+ enabled?: boolean;
80
+ /** Overrides the local config-file search roots. */
81
+ searchPaths?: string[];
82
+ /** Overrides the local loopback ports to probe. */
83
+ defaultPorts?: number[];
84
+ /** Defaults to true. When false, discovery only persists gateways. */
85
+ autoProvision?: boolean;
86
+ }
77
87
  export interface DaemonConfig {
78
88
  /**
79
89
  * @deprecated Kept for backward compatibility with pre-multi-agent configs.
@@ -113,6 +123,11 @@ export interface DaemonConfig {
113
123
  * so the dispatcher never re-queries this list.
114
124
  */
115
125
  openclawGateways?: OpenclawGatewayProfile[];
126
+ /**
127
+ * Daemon-side local OpenClaw discovery. Omitted means enabled with default
128
+ * search paths/ports and automatic adoption of discovered agents.
129
+ */
130
+ openclawDiscovery?: OpenclawDiscoveryConfig;
116
131
  }
117
132
  /**
118
133
  * Persistent transcript settings (design §6). Default-off — `botcord-daemon
package/dist/config.js CHANGED
@@ -186,6 +186,22 @@ export function loadConfig() {
186
186
  }
187
187
  out.agentDiscovery = copy;
188
188
  }
189
+ const openclawDiscovery = parsed.openclawDiscovery;
190
+ if (openclawDiscovery && typeof openclawDiscovery === "object") {
191
+ const copy = {};
192
+ if (typeof openclawDiscovery.enabled === "boolean")
193
+ copy.enabled = openclawDiscovery.enabled;
194
+ if (Array.isArray(openclawDiscovery.searchPaths)) {
195
+ copy.searchPaths = openclawDiscovery.searchPaths.filter((p) => typeof p === "string" && p.length > 0);
196
+ }
197
+ if (Array.isArray(openclawDiscovery.defaultPorts)) {
198
+ copy.defaultPorts = openclawDiscovery.defaultPorts.filter((p) => Number.isInteger(p) && p > 0 && p < 65536);
199
+ }
200
+ if (typeof openclawDiscovery.autoProvision === "boolean") {
201
+ copy.autoProvision = openclawDiscovery.autoProvision;
202
+ }
203
+ out.openclawDiscovery = copy;
204
+ }
189
205
  return out;
190
206
  }
191
207
  function validateAdapter(id, field) {
package/dist/daemon.js CHANGED
@@ -7,7 +7,8 @@ import { ensureAgentWorkspace } from "./agent-workspace.js";
7
7
  import { ControlChannel } from "./control-channel.js";
8
8
  import { toGatewayConfig } from "./daemon-config-map.js";
9
9
  import { log as daemonLog } from "./log.js";
10
- import { collectRuntimeSnapshot, createProvisioner } from "./provision.js";
10
+ import { adoptDiscoveredOpenclawAgents, collectRuntimeSnapshot, createProvisioner, } from "./provision.js";
11
+ import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
11
12
  import { SnapshotWriter } from "./snapshot-writer.js";
12
13
  import { createDaemonSystemContextBuilder } from "./system-context.js";
13
14
  import { createRoomStaticContextBuilder } from "./room-context.js";
@@ -289,6 +290,28 @@ export async function startDaemon(opts) {
289
290
  }
290
291
  await gateway.start();
291
292
  logger.info("daemon started", { agents: agentIds });
293
+ if (openclawAutoProvisionEnabled(opts.config)) {
294
+ try {
295
+ const adopted = await adoptDiscoveredOpenclawAgents({
296
+ gateway,
297
+ cfg: opts.config,
298
+ });
299
+ if (adopted.adopted.length > 0 ||
300
+ adopted.failed.length > 0 ||
301
+ adopted.skipped.length > 0) {
302
+ logger.info("openclaw auto-provision completed", {
303
+ adopted: adopted.adopted,
304
+ skipped: adopted.skipped,
305
+ failed: adopted.failed,
306
+ });
307
+ }
308
+ }
309
+ catch (err) {
310
+ logger.warn("openclaw auto-provision failed; continuing", {
311
+ error: err instanceof Error ? err.message : String(err),
312
+ });
313
+ }
314
+ }
292
315
  // Control channel is optional — daemon still runs (data-plane only)
293
316
  // when user-auth hasn't been set up yet. Operators can `login` later
294
317
  // without restarting, but for P0 we require a restart to pick it up.
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { renderStatus } from "./status-render.js";
15
15
  import { appendNextParam } from "./url-utils.js";
16
16
  import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
17
17
  import { clearWorkingMemory, readWorkingMemory, resolveMemoryDir, updateWorkingMemory, DEFAULT_SECTION, } from "./working-memory.js";
18
+ import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
18
19
  const ADAPTER_LIST = listAdapterIds().join("|");
19
20
  const DEFAULT_HUB = "https://api.botcord.chat";
20
21
  /**
@@ -320,7 +321,29 @@ async function ensureUserAuthForStart(args) {
320
321
  return runDeviceCodeFlow({ hubUrl, label });
321
322
  }
322
323
  async function cmdStart(args) {
323
- const cfg = loadOrInitConfig(args);
324
+ let cfg = loadOrInitConfig(args);
325
+ if (openclawDiscoveryConfigEnabled(cfg)) {
326
+ try {
327
+ const found = await discoverLocalOpenclawGateways({
328
+ searchPaths: cfg.openclawDiscovery?.searchPaths,
329
+ defaultPorts: cfg.openclawDiscovery?.defaultPorts,
330
+ timeoutMs: 500,
331
+ });
332
+ const merged = mergeOpenclawGateways(cfg, found);
333
+ if (merged.changed) {
334
+ cfg = merged.cfg;
335
+ saveConfig(cfg);
336
+ log.info("openclaw discovery: gateways merged", {
337
+ added: merged.added.map((g) => ({ name: g.name, url: g.url })),
338
+ });
339
+ }
340
+ }
341
+ catch (err) {
342
+ log.warn("openclaw discovery failed; continuing", {
343
+ error: err instanceof Error ? err.message : String(err),
344
+ });
345
+ }
346
+ }
324
347
  // Foreground is now the default. --background (alias -d) detaches.
325
348
  // --foreground is still accepted (no-op) for backwards compatibility and
326
349
  // is also what the detached child re-execs itself with.
@@ -0,0 +1,28 @@
1
+ import type { DaemonConfig, OpenclawGatewayProfile } from "./config.js";
2
+ import { type WsEndpointProbeFn } from "./provision.js";
3
+ export type DiscoveredOpenclawGatewaySource = "config-file" | "env" | "default-port";
4
+ export interface DiscoveredOpenclawGateway {
5
+ name: string;
6
+ url: string;
7
+ token?: string;
8
+ tokenFile?: string;
9
+ source: DiscoveredOpenclawGatewaySource;
10
+ }
11
+ export interface OpenclawGatewayDiscoveryOptions {
12
+ searchPaths?: string[];
13
+ defaultPorts?: number[];
14
+ probe?: WsEndpointProbeFn;
15
+ timeoutMs?: number;
16
+ env?: NodeJS.ProcessEnv;
17
+ }
18
+ export interface MergeOpenclawGatewayResult {
19
+ cfg: DaemonConfig;
20
+ changed: boolean;
21
+ added: OpenclawGatewayProfile[];
22
+ }
23
+ export declare function discoverLocalOpenclawGateways(opts?: OpenclawGatewayDiscoveryOptions): Promise<DiscoveredOpenclawGateway[]>;
24
+ export declare function mergeOpenclawGateways(cfg: DaemonConfig, found: DiscoveredOpenclawGateway[]): MergeOpenclawGatewayResult;
25
+ export declare function defaultOpenclawDiscoverySearchPaths(): string[];
26
+ export declare function defaultOpenclawDiscoveryPorts(): number[];
27
+ export declare function openclawDiscoveryConfigEnabled(cfg: DaemonConfig): boolean;
28
+ export declare function openclawAutoProvisionEnabled(cfg: DaemonConfig): boolean;
@@ -0,0 +1,272 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { log as daemonLog } from "./log.js";
5
+ import { probeOpenclawAgents } from "./provision.js";
6
+ const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
7
+ const DEFAULT_PORTS = [18789];
8
+ export async function discoverLocalOpenclawGateways(opts = {}) {
9
+ const found = [];
10
+ for (const root of opts.searchPaths ?? DEFAULT_SEARCH_PATHS) {
11
+ found.push(...discoverFromConfigDir(root));
12
+ }
13
+ const env = opts.env ?? process.env;
14
+ const envUrl = env.OPENCLAW_ACP_URL;
15
+ if (envUrl) {
16
+ const item = {
17
+ name: nameFromUrl(envUrl),
18
+ url: envUrl,
19
+ source: "env",
20
+ };
21
+ if (env.OPENCLAW_ACP_TOKEN)
22
+ item.token = env.OPENCLAW_ACP_TOKEN;
23
+ else if (env.OPENCLAW_ACP_TOKEN_FILE)
24
+ item.tokenFile = env.OPENCLAW_ACP_TOKEN_FILE;
25
+ found.push(item);
26
+ }
27
+ const ports = opts.defaultPorts ?? DEFAULT_PORTS;
28
+ if (ports.length > 0) {
29
+ await Promise.all(ports.map(async (port) => {
30
+ const url = `ws://127.0.0.1:${port}`;
31
+ try {
32
+ const res = await probeOpenclawAgents({ url }, { probe: opts.probe, timeoutMs: opts.timeoutMs });
33
+ if (res.ok) {
34
+ found.push({ name: nameFromUrl(url), url, source: "default-port" });
35
+ }
36
+ }
37
+ catch (err) {
38
+ daemonLog.debug("openclaw discovery default-port probe failed", {
39
+ url,
40
+ error: err instanceof Error ? err.message : String(err),
41
+ });
42
+ }
43
+ }));
44
+ }
45
+ return dedupeDiscovered(found);
46
+ }
47
+ export function mergeOpenclawGateways(cfg, found) {
48
+ const existing = cfg.openclawGateways ?? [];
49
+ const byUrl = new Map();
50
+ existing.forEach((g, i) => byUrl.set(normalizeUrlKey(g.url), i));
51
+ const existingNames = new Set(existing.map((g) => g.name));
52
+ const merged = existing.map((g) => ({ ...g }));
53
+ const added = [];
54
+ let mutated = false;
55
+ for (const item of found) {
56
+ const key = normalizeUrlKey(item.url);
57
+ const idx = byUrl.get(key);
58
+ if (idx !== undefined) {
59
+ // Same URL already configured — only fill in auth that the user is
60
+ // missing, never overwrite an existing token / tokenFile.
61
+ const cur = merged[idx];
62
+ if (!cur.token && !cur.tokenFile) {
63
+ if (item.token) {
64
+ cur.token = item.token;
65
+ mutated = true;
66
+ }
67
+ else if (item.tokenFile) {
68
+ cur.tokenFile = item.tokenFile;
69
+ mutated = true;
70
+ }
71
+ }
72
+ continue;
73
+ }
74
+ const profile = {
75
+ name: uniqueName(item.name, existingNames),
76
+ url: item.url,
77
+ };
78
+ if (item.token)
79
+ profile.token = item.token;
80
+ else if (item.tokenFile)
81
+ profile.tokenFile = item.tokenFile;
82
+ byUrl.set(key, merged.length);
83
+ existingNames.add(profile.name);
84
+ merged.push(profile);
85
+ added.push(profile);
86
+ }
87
+ if (added.length === 0 && !mutated)
88
+ return { cfg, changed: false, added };
89
+ return {
90
+ cfg: { ...cfg, openclawGateways: merged },
91
+ changed: true,
92
+ added,
93
+ };
94
+ }
95
+ function discoverFromConfigDir(root) {
96
+ const dir = expandHome(root);
97
+ let names;
98
+ try {
99
+ names = readdirSync(dir);
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ const out = [];
105
+ for (const name of names.sort()) {
106
+ if (!name.endsWith(".json") && !name.endsWith(".toml"))
107
+ continue;
108
+ const file = path.join(dir, name);
109
+ try {
110
+ const st = statSync(file);
111
+ if (!st.isFile())
112
+ continue;
113
+ const raw = readFileSync(file, "utf8");
114
+ const parsed = name.endsWith(".json") ? parseJsonConfig(raw) : parseTomlConfig(raw);
115
+ if (!parsed?.url)
116
+ continue;
117
+ const item = {
118
+ name: nameFromUrl(parsed.url),
119
+ url: parsed.url,
120
+ source: "config-file",
121
+ };
122
+ if (parsed.token)
123
+ item.token = parsed.token;
124
+ else if (parsed.tokenFile)
125
+ item.tokenFile = parsed.tokenFile;
126
+ out.push(item);
127
+ }
128
+ catch (err) {
129
+ daemonLog.debug("openclaw discovery config skipped", {
130
+ file,
131
+ error: err instanceof Error ? err.message : String(err),
132
+ });
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+ function parseJsonConfig(raw) {
138
+ const obj = JSON.parse(raw);
139
+ // Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
140
+ // The legacy `acp.url` shape is also supported for explicit user-authored configs.
141
+ const native = pickOpenclawGatewayValues(obj?.gateway);
142
+ if (native)
143
+ return native;
144
+ const acp = obj?.acp ?? obj?.gateway?.acp ?? obj?.gateway ?? obj;
145
+ return pickConfigValues(acp);
146
+ }
147
+ function pickOpenclawGatewayValues(gw) {
148
+ if (!gw || typeof gw !== "object")
149
+ return null;
150
+ const port = typeof gw.port === "number" ? gw.port : undefined;
151
+ if (!port)
152
+ return null;
153
+ // Local discovery always targets the loopback interface, regardless of how
154
+ // the gateway is bound — the daemon is on the same machine.
155
+ const url = `ws://127.0.0.1:${port}`;
156
+ const auth = gw.auth;
157
+ const out = { url };
158
+ if (auth && typeof auth === "object" && auth.mode === "token") {
159
+ if (typeof auth.token === "string" && auth.token.trim())
160
+ out.token = auth.token.trim();
161
+ else if (typeof auth.tokenFile === "string" && auth.tokenFile.trim()) {
162
+ out.tokenFile = auth.tokenFile.trim();
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+ function parseTomlConfig(raw) {
168
+ let inAcp = false;
169
+ const values = {};
170
+ for (const line of raw.split(/\r?\n/)) {
171
+ const trimmed = line.replace(/#.*/, "").trim();
172
+ if (!trimmed)
173
+ continue;
174
+ const section = trimmed.match(/^\[([^\]]+)\]$/);
175
+ if (section) {
176
+ inAcp = section[1] === "acp" || section[1].endsWith(".acp");
177
+ continue;
178
+ }
179
+ if (!inAcp)
180
+ continue;
181
+ const m = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*"(.*)"\s*$/);
182
+ if (m)
183
+ values[m[1]] = m[2];
184
+ }
185
+ return pickConfigValues(values);
186
+ }
187
+ function pickConfigValues(obj) {
188
+ if (!obj || typeof obj !== "object")
189
+ return null;
190
+ const url = pickString(obj, ["url", "wsUrl", "ws_url", "endpoint"]);
191
+ if (!url)
192
+ return null;
193
+ const token = pickString(obj, ["token", "bearerToken", "bearer_token"]);
194
+ const tokenFile = pickString(obj, ["tokenFile", "token_file"]);
195
+ return { url, token, tokenFile };
196
+ }
197
+ function pickString(obj, keys) {
198
+ for (const key of keys) {
199
+ const value = obj[key];
200
+ if (typeof value === "string" && value.trim())
201
+ return value.trim();
202
+ }
203
+ return undefined;
204
+ }
205
+ function dedupeDiscovered(items) {
206
+ const priority = {
207
+ "config-file": 3,
208
+ env: 2,
209
+ "default-port": 1,
210
+ };
211
+ const byUrl = new Map();
212
+ for (const item of items) {
213
+ const key = normalizeUrlKey(item.url);
214
+ const prev = byUrl.get(key);
215
+ if (!prev || priority[item.source] > priority[prev.source] || hasMoreAuth(item, prev)) {
216
+ byUrl.set(key, item);
217
+ }
218
+ }
219
+ return [...byUrl.values()];
220
+ }
221
+ function hasMoreAuth(a, b) {
222
+ const score = (x) => (x.token ? 2 : x.tokenFile ? 1 : 0);
223
+ return score(a) > score(b);
224
+ }
225
+ function nameFromUrl(raw) {
226
+ try {
227
+ const u = new URL(raw);
228
+ const base = `${u.hostname}-${u.port || (u.protocol === "wss:" ? "443" : "80")}`;
229
+ return `openclaw-${base.replace(/[^A-Za-z0-9_-]+/g, "-")}`;
230
+ }
231
+ catch {
232
+ return "openclaw-local";
233
+ }
234
+ }
235
+ function uniqueName(base, existing) {
236
+ let candidate = base;
237
+ let i = 2;
238
+ while (existing.has(candidate)) {
239
+ candidate = `${base}-${i}`;
240
+ i += 1;
241
+ }
242
+ return candidate;
243
+ }
244
+ function normalizeUrlKey(raw) {
245
+ try {
246
+ const u = new URL(raw);
247
+ u.hash = "";
248
+ return u.toString();
249
+ }
250
+ catch {
251
+ return raw.trim();
252
+ }
253
+ }
254
+ function expandHome(p) {
255
+ if (p === "~")
256
+ return homedir();
257
+ if (p.startsWith("~/"))
258
+ return path.join(homedir(), p.slice(2));
259
+ return p;
260
+ }
261
+ export function defaultOpenclawDiscoverySearchPaths() {
262
+ return DEFAULT_SEARCH_PATHS.slice();
263
+ }
264
+ export function defaultOpenclawDiscoveryPorts() {
265
+ return DEFAULT_PORTS.slice();
266
+ }
267
+ export function openclawDiscoveryConfigEnabled(cfg) {
268
+ return cfg.openclawDiscovery?.enabled !== false;
269
+ }
270
+ export function openclawAutoProvisionEnabled(cfg) {
271
+ return cfg.openclawDiscovery?.autoProvision !== false;
272
+ }
@@ -28,6 +28,26 @@ type AckBody = Omit<ControlAck, "id">;
28
28
  * `ControlChannelOptions.handle`.
29
29
  */
30
30
  export declare function createProvisioner(opts: ProvisionerOptions): (frame: ControlFrame) => Promise<AckBody>;
31
+ export interface AdoptDiscoveredOpenclawAgentsResult {
32
+ adopted: string[];
33
+ skipped: Array<{
34
+ gateway: string;
35
+ openclawAgent?: string;
36
+ reason: string;
37
+ }>;
38
+ failed: Array<{
39
+ gateway: string;
40
+ openclawAgent?: string;
41
+ error: string;
42
+ }>;
43
+ }
44
+ export declare function adoptDiscoveredOpenclawAgents(ctx: {
45
+ gateway: Gateway;
46
+ register?: typeof BotCordClient.register;
47
+ cfg?: DaemonConfig;
48
+ timeoutMs?: number;
49
+ probe?: WsEndpointProbeFn;
50
+ }): Promise<AdoptDiscoveredOpenclawAgentsResult>;
31
51
  /**
32
52
  * Append `agentId` to the daemon config if not already present. Returns a
33
53
  * new config object or `null` if nothing changed (so callers can skip the
@@ -70,6 +90,27 @@ export type WsEndpointProbeFn = (args: {
70
90
  }>;
71
91
  error?: string;
72
92
  }>;
93
+ export declare function probeOpenclawAgents(profile: {
94
+ url: string;
95
+ token?: string;
96
+ tokenFile?: string;
97
+ }, opts?: {
98
+ timeoutMs?: number;
99
+ probe?: WsEndpointProbeFn;
100
+ }): Promise<{
101
+ ok: boolean;
102
+ version?: string;
103
+ agents?: Array<{
104
+ id: string;
105
+ name?: string;
106
+ workspace?: string;
107
+ model?: {
108
+ name?: string;
109
+ provider?: string;
110
+ };
111
+ }>;
112
+ error?: string;
113
+ }>;
73
114
  /**
74
115
  * Async variant that includes L2 (gateway reachability) and L3 (agent listing)
75
116
  * probes for runtimes that talk to external services. Used by the production