@alfe.ai/gateway 0.9.4 → 0.9.5

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/health.js CHANGED
@@ -2,20 +2,20 @@ import { n as logger$1 } from "./logger.js";
2
2
  import { a as captureFatal, c as captureRuntimeCrash, d as initAgentSentry, f as setAgentContext, l as captureRuntimeErrorOutput, o as captureIntegrationFailure, s as captureMcpFailure, u as flushSentry } from "./sentry.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
5
- import { execFile, execSync, spawn } from "node:child_process";
6
- import { promisify } from "node:util";
7
5
  import { dirname, join } from "node:path";
8
6
  import { homedir } from "node:os";
9
7
  import pino from "pino";
10
8
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
9
  import { getEndpointFromToken, readConfig } from "@alfe.ai/config";
12
10
  import crypto from "crypto";
11
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
13
12
  import { parse } from "smol-toml";
14
13
  import WebSocket from "ws";
15
- import { createConnection, createServer } from "node:net";
14
+ import { execFile, execSync, spawn } from "node:child_process";
16
15
  import { ClaudeCodeApplier, ClaudeCodeMcpSync, HermesApplier, HermesMcpSync, IntegrationManager, IntegrationManagerAdapter, McpApplier, NoopOpenClawCliLock, OpenClawApplier, SerialOpenClawCliLock } from "@alfe.ai/integrations";
17
- import { AgentApiClient } from "@alfe.ai/agent-api-client";
18
16
  import { Manager, McpBundler, defaultConnect } from "@alfe.ai/mcp-bundler";
17
+ import { promisify } from "node:util";
18
+ import { createConnection, createServer } from "node:net";
19
19
  import stream, { Readable } from "stream";
20
20
  import util, { format } from "util";
21
21
  import http from "http";
@@ -5204,63 +5204,54 @@ async function loadDaemonConfig() {
5204
5204
  * Returns null if the agent has no template assigned or the fetch fails.
5205
5205
  */
5206
5206
  async function fetchAgentConfig(apiKey, apiEndpoint) {
5207
+ const client = new AgentApiClient({
5208
+ apiKey,
5209
+ apiUrl: apiEndpoint
5210
+ });
5211
+ logger$1.debug({ apiEndpoint }, "Fetching agent workspace config...");
5212
+ let workspace;
5207
5213
  try {
5208
- logger$1.debug({ apiEndpoint }, "Fetching agent workspace config...");
5209
- const wsResponse = await fetch(`${apiEndpoint}/agents/me/workspace`, {
5210
- method: "GET",
5211
- headers: { "Authorization": `Bearer ${apiKey}` }
5212
- });
5213
- if (!wsResponse.ok) {
5214
- logger$1.debug({ status: wsResponse.status }, "Workspace config fetch failed");
5215
- return null;
5216
- }
5217
- const wsResult = await wsResponse.json();
5218
- const templateKey = wsResult.data?.templateKey;
5219
- const defaultModel = wsResult.data?.defaultModel ?? void 0;
5220
- const installedFrom = wsResult.data?.installedFrom;
5221
- if (!templateKey) {
5222
- logger$1.debug("No templateKey in workspace response");
5223
- return defaultModel ? {
5224
- defaultModel,
5225
- files: {}
5226
- } : null;
5227
- }
5228
- const pinnedVersion = installedFrom?.templateKey === templateKey ? installedFrom.version : void 0;
5229
- const filesUrl = new URL(`${apiEndpoint}/templates/${encodeURIComponent(templateKey)}/files`);
5230
- if (pinnedVersion !== void 0) filesUrl.searchParams.set("version", String(pinnedVersion));
5231
- logger$1.debug({
5232
- templateKey,
5233
- version: pinnedVersion
5234
- }, "Fetching template files...");
5235
- const filesResponse = await fetch(filesUrl.toString(), {
5236
- method: "GET",
5237
- headers: { "Authorization": `Bearer ${apiKey}` }
5238
- });
5239
- if (!filesResponse.ok) {
5240
- logger$1.debug({ status: filesResponse.status }, "Template files fetch failed");
5241
- return {
5242
- templateKey,
5243
- defaultModel,
5244
- files: {}
5245
- };
5246
- }
5247
- const files = (await filesResponse.json()).data?.files ?? {};
5248
- const fileCount = Object.keys(files).length;
5249
- logger$1.debug({
5250
- templateKey,
5251
- version: pinnedVersion,
5252
- fileCount
5253
- }, "Template files fetched");
5214
+ workspace = await client.getWorkspace();
5215
+ } catch (err) {
5216
+ logger$1.debug({ err: err instanceof Error ? err.message : String(err) }, "Workspace config fetch failed");
5217
+ return null;
5218
+ }
5219
+ const { templateKey, installedFrom } = workspace;
5220
+ const defaultModel = workspace.defaultModel ?? void 0;
5221
+ if (!templateKey) {
5222
+ logger$1.debug("No templateKey in workspace response");
5223
+ return defaultModel ? {
5224
+ defaultModel,
5225
+ files: {}
5226
+ } : null;
5227
+ }
5228
+ const pinnedVersion = installedFrom?.templateKey === templateKey ? installedFrom.version : void 0;
5229
+ logger$1.debug({
5230
+ templateKey,
5231
+ version: pinnedVersion
5232
+ }, "Fetching template files...");
5233
+ let files;
5234
+ try {
5235
+ files = (await client.getTemplateFiles(templateKey, { version: pinnedVersion })).files;
5236
+ } catch (err) {
5237
+ logger$1.debug({ err: err instanceof Error ? err.message : String(err) }, "Template files fetch failed");
5254
5238
  return {
5255
5239
  templateKey,
5256
5240
  defaultModel,
5257
- files,
5258
- personaFiles: Object.keys(files)
5241
+ files: {}
5259
5242
  };
5260
- } catch (err) {
5261
- logger$1.debug({ err: err instanceof Error ? err.message : String(err) }, "fetchAgentConfig failed");
5262
- return null;
5263
5243
  }
5244
+ logger$1.debug({
5245
+ templateKey,
5246
+ version: pinnedVersion,
5247
+ fileCount: Object.keys(files).length
5248
+ }, "Template files fetched");
5249
+ return {
5250
+ templateKey,
5251
+ defaultModel,
5252
+ files,
5253
+ personaFiles: Object.keys(files)
5254
+ };
5264
5255
  }
5265
5256
  //#endregion
5266
5257
  //#region src/protocol.ts
@@ -6360,190 +6351,759 @@ var ConfigReconciler = class {
6360
6351
  }
6361
6352
  };
6362
6353
  //#endregion
6363
- //#region src/ipc-server.ts
6364
- /**
6365
- * IPC Server Unix socket server for local plugin connections.
6366
- *
6367
- * Plugins connect to ~/.alfe/gateway.sock and speak the IPC protocol:
6368
- * Request: { type: 'req', id, method, params }
6369
- * Response: { id, ok, payload?, error? }
6370
- * Event: { type: 'event', event, payload }
6371
- *
6372
- * Each connected plugin registers with its name/version and receives
6373
- * commands from the daemon (forwarded from cloud).
6374
- */
6375
- var IPCServer = class {
6376
- server = null;
6377
- connections = /* @__PURE__ */ new Map();
6378
- socketPath;
6379
- requestHandler = null;
6380
- constructor(socketPath) {
6381
- this.socketPath = socketPath;
6382
- }
6383
- /**
6384
- * Set the handler for incoming IPC requests from plugins.
6385
- */
6386
- setRequestHandler(handler) {
6387
- this.requestHandler = handler;
6354
+ //#region src/command-queue.ts
6355
+ const DEFAULT_TTL_MS$1 = 300 * 1e3;
6356
+ var CommandQueue = class {
6357
+ queues = /* @__PURE__ */ new Map();
6358
+ ttlMs;
6359
+ gcTimer = null;
6360
+ constructor(ttlMs = DEFAULT_TTL_MS$1) {
6361
+ this.ttlMs = ttlMs;
6388
6362
  }
6389
6363
  /**
6390
- * Start listening on the Unix socket.
6364
+ * Start periodic garbage collection of expired commands.
6391
6365
  */
6392
- async start() {
6393
- try {
6394
- unlinkSync(this.socketPath);
6395
- } catch {}
6396
- return new Promise((resolve, reject) => {
6397
- this.server = createServer((socket) => {
6398
- this.handleConnection(socket);
6399
- });
6400
- this.server.on("error", (err) => {
6401
- logger$1.error({ err: err.message }, "IPC server error");
6402
- reject(err);
6403
- });
6404
- this.server.listen(this.socketPath, () => {
6405
- try {
6406
- chmodSync(this.socketPath, 384);
6407
- } catch (err) {
6408
- logger$1.warn({ err }, "Failed to chmod socket");
6409
- }
6410
- logger$1.info({ path: this.socketPath }, "IPC server listening");
6411
- resolve();
6412
- });
6413
- });
6366
+ startGC(intervalMs = 3e4) {
6367
+ this.stopGC();
6368
+ this.gcTimer = setInterval(() => this.purgeExpired(), intervalMs);
6414
6369
  }
6415
6370
  /**
6416
- * Stop the IPC server and disconnect all plugins.
6371
+ * Stop periodic garbage collection.
6417
6372
  */
6418
- async stop() {
6419
- for (const conn of this.connections.values()) try {
6420
- this.sendEvent(conn, "daemon.shutdown", { reason: "daemon stopping" });
6421
- conn.socket.end();
6422
- } catch {}
6423
- return new Promise((resolve) => {
6424
- if (!this.server) {
6425
- resolve();
6426
- return;
6427
- }
6428
- this.server.close(() => {
6429
- try {
6430
- unlinkSync(this.socketPath);
6431
- } catch {}
6432
- logger$1.info("IPC server stopped");
6433
- resolve();
6434
- });
6435
- for (const conn of this.connections.values()) conn.socket.destroy();
6436
- this.connections.clear();
6437
- });
6373
+ stopGC() {
6374
+ if (this.gcTimer) {
6375
+ clearInterval(this.gcTimer);
6376
+ this.gcTimer = null;
6377
+ }
6438
6378
  }
6439
6379
  /**
6440
- * Send an IPC request to a specific plugin and wait for response.
6380
+ * Enqueue a command for a specific service.
6441
6381
  */
6442
- async sendRequest(pluginId, method, params, timeoutMs = 3e4) {
6443
- const conn = this.connections.get(pluginId);
6444
- if (!conn?.info) return {
6445
- id: "",
6446
- ok: false,
6447
- error: {
6448
- code: "PLUGIN_NOT_CONNECTED",
6449
- message: `Plugin ${pluginId} not connected`
6450
- }
6451
- };
6452
- const id = pluginConnectionId();
6453
- const request = {
6454
- type: "req",
6455
- id,
6456
- method,
6457
- params
6458
- };
6459
- return new Promise((resolve, reject) => {
6460
- const timer = setTimeout(() => {
6461
- conn.pending.delete(id);
6462
- resolve({
6463
- id,
6464
- ok: false,
6465
- error: {
6466
- code: "TIMEOUT",
6467
- message: `Request ${method} timed out after ${String(timeoutMs)}ms`
6468
- }
6469
- });
6470
- }, timeoutMs);
6471
- conn.pending.set(id, {
6472
- resolve,
6473
- reject,
6474
- timer
6475
- });
6476
- try {
6477
- conn.socket.write(JSON.stringify(request) + "\n");
6478
- } catch (err) {
6479
- clearTimeout(timer);
6480
- conn.pending.delete(id);
6481
- resolve({
6482
- id,
6483
- ok: false,
6484
- error: {
6485
- code: "SEND_FAILED",
6486
- message: `Failed to send to plugin: ${err instanceof Error ? err.message : String(err)}`
6487
- }
6488
- });
6489
- }
6382
+ enqueue(serviceId, request, commandId) {
6383
+ let queue = this.queues.get(serviceId);
6384
+ if (!queue) {
6385
+ queue = [];
6386
+ this.queues.set(serviceId, queue);
6387
+ }
6388
+ queue.push({
6389
+ request,
6390
+ queuedAt: Date.now(),
6391
+ commandId
6490
6392
  });
6491
6393
  }
6492
6394
  /**
6493
- * Send an IPC request to ALL registered plugins.
6494
- * Returns responses keyed by plugin ID.
6395
+ * Drain all pending (non-expired) commands for a service.
6396
+ * Returns them in order and removes them from the queue.
6495
6397
  */
6496
- async broadcastRequest(method, params, timeoutMs = 3e4) {
6497
- const results = /* @__PURE__ */ new Map();
6498
- const registered = this.getRegisteredPlugins();
6499
- await Promise.all(registered.map(async ([pluginId]) => {
6500
- const response = await this.sendRequest(pluginId, method, params, timeoutMs);
6501
- results.set(pluginId, response);
6502
- }));
6503
- return results;
6398
+ drain(serviceId) {
6399
+ const queue = this.queues.get(serviceId);
6400
+ if (!queue || queue.length === 0) return [];
6401
+ const now = Date.now();
6402
+ const valid = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
6403
+ this.queues.delete(serviceId);
6404
+ return valid;
6504
6405
  }
6505
6406
  /**
6506
- * Send an event to a specific plugin (fire-and-forget).
6407
+ * Get the number of pending commands for a service.
6507
6408
  */
6508
- sendEventToPlugin(pluginId, event, payload) {
6509
- const conn = this.connections.get(pluginId);
6510
- if (!conn) return false;
6511
- return this.sendEvent(conn, event, payload);
6409
+ pendingCount(serviceId) {
6410
+ const queue = this.queues.get(serviceId);
6411
+ if (!queue) return 0;
6412
+ const now = Date.now();
6413
+ return queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs).length;
6512
6414
  }
6513
6415
  /**
6514
- * Broadcast an event to all connected plugins.
6416
+ * Get total pending commands across all services.
6515
6417
  */
6516
- broadcastEvent(event, payload) {
6517
- for (const conn of this.connections.values()) if (conn.info) this.sendEvent(conn, event, payload);
6418
+ totalPending() {
6419
+ let total = 0;
6420
+ for (const [serviceId] of this.queues) total += this.pendingCount(serviceId);
6421
+ return total;
6518
6422
  }
6519
6423
  /**
6520
- * Get all registered plugins and their info.
6424
+ * Remove expired commands from all queues.
6521
6425
  */
6522
- getRegisteredPlugins() {
6523
- const plugins = [];
6524
- for (const [id, conn] of this.connections) if (conn.info) plugins.push([id, conn.info]);
6525
- return plugins;
6426
+ purgeExpired() {
6427
+ const now = Date.now();
6428
+ let purged = 0;
6429
+ for (const [serviceId, queue] of this.queues) {
6430
+ const before = queue.length;
6431
+ const remaining = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
6432
+ if (remaining.length === 0) this.queues.delete(serviceId);
6433
+ else this.queues.set(serviceId, remaining);
6434
+ purged += before - remaining.length;
6435
+ }
6436
+ return purged;
6526
6437
  }
6527
6438
  /**
6528
- * Get number of connected plugins (including unregistered).
6439
+ * Clear all queues.
6529
6440
  */
6530
- get connectionCount() {
6531
- return this.connections.size;
6441
+ clear() {
6442
+ this.queues.clear();
6532
6443
  }
6533
6444
  /**
6534
- * Get number of registered plugins.
6445
+ * Get all service IDs that have pending commands.
6535
6446
  */
6536
- get registeredCount() {
6537
- let count = 0;
6538
- for (const conn of this.connections.values()) if (conn.info) count++;
6539
- return count;
6447
+ serviceIds() {
6448
+ return Array.from(this.queues.keys());
6540
6449
  }
6541
- handleConnection(socket) {
6542
- const connId = pluginConnectionId();
6543
- const conn = {
6544
- id: connId,
6545
- socket,
6546
- info: null,
6450
+ };
6451
+ //#endregion
6452
+ //#region src/process-manager.ts
6453
+ /**
6454
+ * Process management — launchd/systemd service installation.
6455
+ *
6456
+ * Generates and installs user-space service units for auto-start on boot.
6457
+ * No root required — uses user agents (Mac) or user units (Linux).
6458
+ */
6459
+ const LAUNCHD_LABEL = "ai.alfe.gateway";
6460
+ const SYSTEMD_SERVICE = "alfe-gateway";
6461
+ /**
6462
+ * On-disk path for the boot-time self-heal guard script (Linux only).
6463
+ * Written next to the systemd unit at setup time and invoked via
6464
+ * `ExecStartPre=` — see `writeGuardScript` and `generateSystemdUnit`.
6465
+ */
6466
+ function getGuardScriptPath() {
6467
+ return isRootUser() ? "/usr/local/lib/alfe/alfe-cli-guard.sh" : join(homedir(), ".alfe", "alfe-cli-guard.sh");
6468
+ }
6469
+ function isRootUser() {
6470
+ return process.getuid?.() === 0;
6471
+ }
6472
+ function getSystemdSystemServicePath() {
6473
+ return `/etc/systemd/system/${SYSTEMD_SERVICE}.service`;
6474
+ }
6475
+ function getLaunchdPlistPath() {
6476
+ return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
6477
+ }
6478
+ function getSystemdServicePath() {
6479
+ return join(homedir(), ".config", "systemd", "user", `${SYSTEMD_SERVICE}.service`);
6480
+ }
6481
+ /**
6482
+ * Resolve the alfe CLI path. Globally installed via npm, always in PATH.
6483
+ */
6484
+ function getAlfeBinPath() {
6485
+ try {
6486
+ return execSync("which alfe", { encoding: "utf-8" }).trim();
6487
+ } catch {
6488
+ return "alfe";
6489
+ }
6490
+ }
6491
+ /**
6492
+ * Build the PATH for the launchd plist, ensuring the directory containing the
6493
+ * resolved alfe binary is included (e.g. /opt/homebrew/bin on Apple Silicon).
6494
+ */
6495
+ function buildLaunchdPath(alfeBin) {
6496
+ const basePaths = [
6497
+ "/usr/local/bin",
6498
+ "/usr/bin",
6499
+ "/bin",
6500
+ join(homedir(), ".local", "bin")
6501
+ ];
6502
+ const binDir = dirname(alfeBin);
6503
+ if (binDir && !basePaths.includes(binDir)) basePaths.unshift(binDir);
6504
+ return basePaths.join(":");
6505
+ }
6506
+ /**
6507
+ * Generate a launchd plist for macOS.
6508
+ */
6509
+ function generateLaunchdPlist() {
6510
+ const alfeBin = getAlfeBinPath();
6511
+ return `<?xml version="1.0" encoding="UTF-8"?>
6512
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
6513
+ <plist version="1.0">
6514
+ <dict>
6515
+ <key>Label</key>
6516
+ <string>${LAUNCHD_LABEL}</string>
6517
+ <key>ProgramArguments</key>
6518
+ <array>
6519
+ <string>${alfeBin}</string>
6520
+ <string>gateway</string>
6521
+ <string>daemon</string>
6522
+ </array>
6523
+ <key>RunAtLoad</key>
6524
+ <true/>
6525
+ <key>KeepAlive</key>
6526
+ <true/>
6527
+ <key>ThrottleInterval</key>
6528
+ <integer>10</integer>
6529
+ <key>StandardOutPath</key>
6530
+ <string>${join(homedir(), ".alfe", "logs", "gateway.log")}</string>
6531
+ <key>StandardErrorPath</key>
6532
+ <string>${join(homedir(), ".alfe", "logs", "gateway.err.log")}</string>
6533
+ <key>EnvironmentVariables</key>
6534
+ <dict>
6535
+ <key>NODE_ENV</key>
6536
+ <string>production</string>
6537
+ <key>PATH</key>
6538
+ <string>${buildLaunchdPath(alfeBin)}</string>
6539
+ </dict>
6540
+ </dict>
6541
+ </plist>`;
6542
+ }
6543
+ /**
6544
+ * Boot-time self-heal guard (Linux only).
6545
+ *
6546
+ * Runs as an `ExecStartPre=` BEFORE the daemon launches, OUTSIDE the
6547
+ * (possibly-broken) `alfe` binary. It exists for the case defense #1
6548
+ * (verify-before-exit in `upgrade.ts`) structurally cannot cover: the daemon
6549
+ * is KILLED during `npm install -g @alfe.ai/cli` (reboot / OOM), leaving the
6550
+ * package with deps but no `dist/` and a dangling `/usr/bin/alfe` symlink.
6551
+ * systemd then execs a dangling binary forever → 203/EXEC crash-loop, a
6552
+ * permanent brick with no automatic recovery (real prod incident,
6553
+ * ~13,570 restarts over 2 days).
6554
+ *
6555
+ * The guard:
6556
+ * - checks the CLI is intact: the symlink target (`dist/index.js`) exists AND
6557
+ * `alfe --version` exits 0;
6558
+ * - if broken, reinstalls `@alfe.ai/cli@$ALFE_CLI_VERSION` (the exact version
6559
+ * baked into the unit's `Environment=`; falls back to `@latest` when
6560
+ * unset), so the daemon then execs a working binary;
6561
+ * - is idempotent + fast (no-op when healthy) and NEVER wedges boot: any of
6562
+ * its own failures log + `exit 0` so a human can still SSH in.
6563
+ *
6564
+ * POSIX sh (dash-safe): the box may not have bash. Keep the two intactness
6565
+ * checks in lockstep with `verifyCliInstall` in `verify-cli-install.ts`.
6566
+ */
6567
+ /** @internal exported for unit tests; not re-exported from the barrel. */
6568
+ function generateGuardScript() {
6569
+ const version = process.env.ALFE_CLI_VERSION;
6570
+ return `#!/bin/sh
6571
+ # Alfe CLI boot-time self-heal guard. Auto-generated by 'alfe setup' — do not edit.
6572
+ # Repairs an interrupted 'npm install -g @alfe.ai/cli' before the daemon starts,
6573
+ # so a mid-install reboot can't brick the agent with a 203/EXEC crash-loop.
6574
+ # Must never wedge boot: every failure path logs and exits 0.
6575
+ set -u
6576
+
6577
+ TARGET='${version && version.length > 0 ? `@alfe.ai/cli@${version}` : "@alfe.ai/cli@latest"}'
6578
+ log() { echo "[alfe-cli-guard] $*" >&2; }
6579
+
6580
+ # Resolve the alfe bin and its real target (dist/index.js). readlink -f follows
6581
+ # the whole symlink chain; fall back to the bin path itself if unavailable.
6582
+ BIN="$(command -v alfe 2>/dev/null || true)"
6583
+ if [ -n "$BIN" ]; then
6584
+ ENTRY="$(readlink -f "$BIN" 2>/dev/null || echo "$BIN")"
6585
+ else
6586
+ ENTRY=""
6587
+ fi
6588
+
6589
+ healthy=1
6590
+ if [ -z "$ENTRY" ] || [ ! -f "$ENTRY" ]; then
6591
+ healthy=0
6592
+ log "CLI entry missing (bin=\${BIN:-<none>} entry=\${ENTRY:-<none>})"
6593
+ elif ! alfe --version >/dev/null 2>&1; then
6594
+ healthy=0
6595
+ log "alfe --version failed"
6596
+ fi
6597
+
6598
+ if [ "$healthy" -eq 1 ]; then
6599
+ exit 0
6600
+ fi
6601
+
6602
+ log "CLI install looks broken — reinstalling $TARGET"
6603
+ # Blow away a partial tree first so npm re-materialises dist/ cleanly. Best-effort.
6604
+ GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"
6605
+ if [ -n "$GLOBAL_ROOT" ] && [ -d "$GLOBAL_ROOT/@alfe.ai/cli" ]; then
6606
+ rm -rf "$GLOBAL_ROOT/@alfe.ai/cli" 2>/dev/null || true
6607
+ fi
6608
+
6609
+ # Bound the reinstall so a slow/unreachable registry at boot can't stall the
6610
+ # ExecStartPre up to systemd's DefaultTimeoutStartSec (~90s). Two layers:
6611
+ # - npm --fetch-timeout/--fetch-retries cap per-request/hung-socket time;
6612
+ # - a wall-clock 'timeout' wrapper caps the whole install when present
6613
+ # ('timeout' isn't guaranteed, so fall back to a bare install).
6614
+ # stdout->stderr ('>&2') so npm's chatter lands in the journal, not on fd1.
6615
+ if command -v timeout >/dev/null 2>&1; then
6616
+ timeout 120 npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
6617
+ rc=$?
6618
+ else
6619
+ npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
6620
+ rc=$?
6621
+ fi
6622
+ if [ "$rc" -eq 0 ]; then
6623
+ log "reinstall succeeded"
6624
+ else
6625
+ log "reinstall FAILED (rc=$rc) — daemon start may still fail; a human can SSH in to repair"
6626
+ fi
6627
+
6628
+ # Always exit 0: even a failed repair must not block the unit from attempting
6629
+ # ExecStart (which then fails visibly) rather than wedging silently in pre-start.
6630
+ exit 0
6631
+ `;
6632
+ }
6633
+ /**
6634
+ * Generate a systemd unit for Linux.
6635
+ * Root users get a system-level unit; non-root get a user-level unit.
6636
+ */
6637
+ /** @internal exported for unit tests; not re-exported from the barrel. */
6638
+ function generateSystemdUnit() {
6639
+ const alfeBin = getAlfeBinPath();
6640
+ const root = isRootUser();
6641
+ const envLines = [
6642
+ "ALFE_MANAGED",
6643
+ "ALFE_API_KEY",
6644
+ "LOG_LEVEL",
6645
+ "ALFE_CLI_VERSION"
6646
+ ].filter((key) => process.env[key]).map((key) => `Environment=${key}=${process.env[key] ?? ""}`).join("\n");
6647
+ return `[Unit]
6648
+ Description=Alfe Gateway Daemon
6649
+ After=network-online.target
6650
+ Wants=network-online.target
6651
+
6652
+ [Service]
6653
+ Type=simple
6654
+ ExecStartPre=-/bin/sh ${getGuardScriptPath()}
6655
+ ExecStart=${alfeBin} gateway daemon
6656
+ Restart=always
6657
+ RestartSec=10
6658
+ # SIGTERM only the daemon on stop/restart; it closes its MCP/runtime children
6659
+ # itself. The default (control-group) SIGTERMs the children simultaneously, so
6660
+ # they die before the daemon's orderly dispose reaches them and every planned
6661
+ # restart reports MCP "server-crash" noise to Sentry. Stragglers still get
6662
+ # SIGKILL when the stop timeout expires.
6663
+ KillMode=mixed
6664
+ Environment=NODE_ENV=production${root ? "\nEnvironment=HOME=/root\nWorkingDirectory=/root" : ""}
6665
+ ${envLines}
6666
+
6667
+ [Install]
6668
+ WantedBy=${root ? "multi-user.target" : "default.target"}`;
6669
+ }
6670
+ /**
6671
+ * Write the boot-time self-heal guard script to disk (Linux only) and make it
6672
+ * executable. Called from `installSystemd` before the unit is written.
6673
+ */
6674
+ async function writeGuardScript() {
6675
+ const guardPath = getGuardScriptPath();
6676
+ await mkdir(dirname(guardPath), { recursive: true });
6677
+ await writeFile(guardPath, generateGuardScript(), {
6678
+ encoding: "utf-8",
6679
+ mode: 493
6680
+ });
6681
+ logger$1.info({ path: guardPath }, "Wrote CLI self-heal guard script");
6682
+ }
6683
+ /**
6684
+ * Install the service unit for the current platform.
6685
+ */
6686
+ async function installService() {
6687
+ const platform = process.platform;
6688
+ if (platform === "darwin") return installLaunchd();
6689
+ if (platform === "linux") return installSystemd();
6690
+ throw new Error(`Unsupported platform: ${platform}. Only macOS and Linux are supported.`);
6691
+ }
6692
+ /**
6693
+ * Uninstall the service unit for the current platform.
6694
+ */
6695
+ async function uninstallService() {
6696
+ const platform = process.platform;
6697
+ if (platform === "darwin") return uninstallLaunchd();
6698
+ if (platform === "linux") return uninstallSystemd();
6699
+ throw new Error(`Unsupported platform: ${platform}`);
6700
+ }
6701
+ function getLaunchdUid() {
6702
+ return execSync("id -u", { encoding: "utf-8" }).trim();
6703
+ }
6704
+ async function installLaunchd() {
6705
+ const plistPath = getLaunchdPlistPath();
6706
+ const dir = join(homedir(), "Library", "LaunchAgents");
6707
+ const logsDir = join(homedir(), ".alfe", "logs");
6708
+ await mkdir(dir, { recursive: true });
6709
+ await mkdir(logsDir, { recursive: true });
6710
+ await writeFile(plistPath, generateLaunchdPlist(), "utf-8");
6711
+ logger$1.info({ path: plistPath }, "Wrote launchd plist");
6712
+ const uid = getLaunchdUid();
6713
+ try {
6714
+ execSync(`launchctl bootout gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
6715
+ } catch {}
6716
+ try {
6717
+ execSync(`launchctl bootstrap gui/${uid} ${plistPath}`, { stdio: "pipe" });
6718
+ } catch (err) {
6719
+ const msg = err instanceof Error ? err.message : String(err);
6720
+ logger$1.error({
6721
+ err: msg,
6722
+ plistPath
6723
+ }, "launchctl bootstrap failed");
6724
+ return `Installed plist at ${plistPath}, but failed to bootstrap service: ${msg}`;
6725
+ }
6726
+ return `Installed: ${plistPath}\nService will start on boot and restart on crash.`;
6727
+ }
6728
+ async function uninstallLaunchd() {
6729
+ const plistPath = getLaunchdPlistPath();
6730
+ const uid = getLaunchdUid();
6731
+ try {
6732
+ execSync(`launchctl bootout gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
6733
+ } catch {}
6734
+ try {
6735
+ await unlink(plistPath);
6736
+ } catch {}
6737
+ return `Uninstalled: ${plistPath}`;
6738
+ }
6739
+ async function installSystemd() {
6740
+ const root = isRootUser();
6741
+ const unitPath = root ? getSystemdSystemServicePath() : getSystemdServicePath();
6742
+ const dir = root ? "/etc/systemd/system" : join(homedir(), ".config", "systemd", "user");
6743
+ const ctl = root ? "systemctl" : "systemctl --user";
6744
+ if (!root) await mkdir(dir, { recursive: true });
6745
+ await writeGuardScript();
6746
+ await writeFile(unitPath, generateSystemdUnit(), "utf-8");
6747
+ logger$1.info({
6748
+ path: unitPath,
6749
+ root
6750
+ }, "Wrote systemd unit");
6751
+ try {
6752
+ execSync(`${ctl} daemon-reload`, { stdio: "pipe" });
6753
+ execSync(`${ctl} enable ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
6754
+ } catch {}
6755
+ return `Installed: ${unitPath}\nService enabled${root ? " (system-level)" : " for user session"}.`;
6756
+ }
6757
+ async function uninstallSystemd() {
6758
+ const root = isRootUser();
6759
+ const unitPath = root ? getSystemdSystemServicePath() : getSystemdServicePath();
6760
+ const ctl = root ? "systemctl" : "systemctl --user";
6761
+ try {
6762
+ execSync(`${ctl} disable ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
6763
+ execSync(`${ctl} stop ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
6764
+ } catch {}
6765
+ try {
6766
+ await unlink(unitPath);
6767
+ } catch {}
6768
+ try {
6769
+ await unlink(getGuardScriptPath());
6770
+ } catch {}
6771
+ try {
6772
+ execSync(`${ctl} daemon-reload`, { stdio: "pipe" });
6773
+ } catch {}
6774
+ return `Uninstalled: ${unitPath}`;
6775
+ }
6776
+ /**
6777
+ * Bootstrap (load) the launchd service from its plist if it isn't already
6778
+ * loaded. Already-loaded is an error we ignore. Needed so `start`/`restart`
6779
+ * work after `stopService()` boots the service OUT.
6780
+ */
6781
+ function ensureLaunchdLoaded(uid) {
6782
+ try {
6783
+ execSync(`launchctl bootstrap gui/${uid} ${getLaunchdPlistPath()}`, { stdio: "pipe" });
6784
+ } catch {}
6785
+ }
6786
+ /**
6787
+ * Start the installed service via systemctl/launchctl. Bootstraps the launchd
6788
+ * service first so this also works after `stopService()` booted it out.
6789
+ */
6790
+ function startService() {
6791
+ const platform = process.platform;
6792
+ if (platform === "darwin") {
6793
+ const uid = getLaunchdUid();
6794
+ ensureLaunchdLoaded(uid);
6795
+ execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
6796
+ logger$1.info("Started launchd service");
6797
+ return;
6798
+ }
6799
+ if (platform === "linux") {
6800
+ execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} start ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
6801
+ logger$1.info("Started systemd service");
6802
+ return;
6803
+ }
6804
+ throw new Error(`Unsupported platform: ${platform}`);
6805
+ }
6806
+ /**
6807
+ * Restart the installed service via systemctl/launchctl. This is the
6808
+ * service-manager-native restart (`kickstart -k` / `systemctl restart`) — the
6809
+ * CLI uses it instead of killing the process inline, which would fight launchd
6810
+ * `KeepAlive` / systemd `Restart=always`.
6811
+ */
6812
+ function restartService() {
6813
+ const platform = process.platform;
6814
+ if (platform === "darwin") {
6815
+ const uid = getLaunchdUid();
6816
+ ensureLaunchdLoaded(uid);
6817
+ execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
6818
+ logger$1.info("Restarted launchd service");
6819
+ return;
6820
+ }
6821
+ if (platform === "linux") {
6822
+ execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} restart ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
6823
+ logger$1.info("Restarted systemd service");
6824
+ return;
6825
+ }
6826
+ throw new Error(`Unsupported platform: ${platform}`);
6827
+ }
6828
+ /**
6829
+ * Stop the installed service via systemctl/launchctl. On macOS this boots the
6830
+ * service OUT (unloads it) so launchd's `KeepAlive` does NOT respawn it;
6831
+ * `startService()` bootstraps it again. On Linux the unit stays enabled (starts
6832
+ * on next boot); `systemctl stop` just halts the current run.
6833
+ */
6834
+ function stopService() {
6835
+ const platform = process.platform;
6836
+ if (platform === "darwin") {
6837
+ execSync(`launchctl bootout gui/${getLaunchdUid()}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
6838
+ logger$1.info("Stopped launchd service");
6839
+ return;
6840
+ }
6841
+ if (platform === "linux") {
6842
+ execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} stop ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
6843
+ logger$1.info("Stopped systemd service");
6844
+ return;
6845
+ }
6846
+ throw new Error(`Unsupported platform: ${platform}`);
6847
+ }
6848
+ /**
6849
+ * True when the gateway is installed as a system service (launchd plist /
6850
+ * systemd unit present). Lets the CLI restart/stop/start THROUGH the service
6851
+ * manager instead of driving the daemon inline via the PID file.
6852
+ */
6853
+ function isServiceInstalled() {
6854
+ const platform = process.platform;
6855
+ if (platform === "darwin") return existsSync(getLaunchdPlistPath());
6856
+ if (platform === "linux") return existsSync(isRootUser() ? getSystemdSystemServicePath() : getSystemdServicePath());
6857
+ return false;
6858
+ }
6859
+ /**
6860
+ * Write the current process PID to the PID file.
6861
+ */
6862
+ async function writePidFile() {
6863
+ await writeFile(PID_PATH, String(process.pid), "utf-8");
6864
+ }
6865
+ /**
6866
+ * Remove the PID file.
6867
+ */
6868
+ async function removePidFile() {
6869
+ try {
6870
+ await unlink(PID_PATH);
6871
+ } catch {}
6872
+ }
6873
+ /**
6874
+ * Check if a daemon is already running.
6875
+ * Returns the PID if alive, null if not running.
6876
+ */
6877
+ async function checkExistingDaemon() {
6878
+ if (!existsSync(PID_PATH)) return null;
6879
+ try {
6880
+ const pidStr = await readFile(PID_PATH, "utf-8");
6881
+ const pid = parseInt(pidStr.trim(), 10);
6882
+ if (isNaN(pid)) {
6883
+ await removePidFile();
6884
+ return null;
6885
+ }
6886
+ try {
6887
+ process.kill(pid, 0);
6888
+ return pid;
6889
+ } catch {
6890
+ await removePidFile();
6891
+ return null;
6892
+ }
6893
+ } catch {
6894
+ return null;
6895
+ }
6896
+ }
6897
+ /**
6898
+ * Send SIGTERM to an existing daemon process.
6899
+ */
6900
+ async function stopExistingDaemon() {
6901
+ const pid = await checkExistingDaemon();
6902
+ if (!pid) return false;
6903
+ try {
6904
+ process.kill(pid, "SIGTERM");
6905
+ for (let i = 0; i < 50; i++) {
6906
+ await new Promise((r) => setTimeout(r, 100));
6907
+ try {
6908
+ process.kill(pid, 0);
6909
+ } catch {
6910
+ await removePidFile();
6911
+ return true;
6912
+ }
6913
+ }
6914
+ process.kill(pid, "SIGKILL");
6915
+ await removePidFile();
6916
+ return true;
6917
+ } catch {
6918
+ await removePidFile();
6919
+ return false;
6920
+ }
6921
+ }
6922
+ //#endregion
6923
+ //#region src/ipc-server.ts
6924
+ /**
6925
+ * IPC Server — Unix socket server for local plugin connections.
6926
+ *
6927
+ * Plugins connect to ~/.alfe/gateway.sock and speak the IPC protocol:
6928
+ * Request: { type: 'req', id, method, params }
6929
+ * Response: { id, ok, payload?, error? }
6930
+ * Event: { type: 'event', event, payload }
6931
+ *
6932
+ * Each connected plugin registers with its name/version and receives
6933
+ * commands from the daemon (forwarded from cloud).
6934
+ */
6935
+ var IPCServer = class {
6936
+ server = null;
6937
+ connections = /* @__PURE__ */ new Map();
6938
+ socketPath;
6939
+ requestHandler = null;
6940
+ constructor(socketPath) {
6941
+ this.socketPath = socketPath;
6942
+ }
6943
+ /**
6944
+ * Set the handler for incoming IPC requests from plugins.
6945
+ */
6946
+ setRequestHandler(handler) {
6947
+ this.requestHandler = handler;
6948
+ }
6949
+ /**
6950
+ * Start listening on the Unix socket.
6951
+ */
6952
+ async start() {
6953
+ try {
6954
+ unlinkSync(this.socketPath);
6955
+ } catch {}
6956
+ return new Promise((resolve, reject) => {
6957
+ this.server = createServer((socket) => {
6958
+ this.handleConnection(socket);
6959
+ });
6960
+ this.server.on("error", (err) => {
6961
+ logger$1.error({ err: err.message }, "IPC server error");
6962
+ reject(err);
6963
+ });
6964
+ this.server.listen(this.socketPath, () => {
6965
+ try {
6966
+ chmodSync(this.socketPath, 384);
6967
+ } catch (err) {
6968
+ logger$1.warn({ err }, "Failed to chmod socket");
6969
+ }
6970
+ logger$1.info({ path: this.socketPath }, "IPC server listening");
6971
+ resolve();
6972
+ });
6973
+ });
6974
+ }
6975
+ /**
6976
+ * Stop the IPC server and disconnect all plugins.
6977
+ */
6978
+ async stop() {
6979
+ for (const conn of this.connections.values()) try {
6980
+ this.sendEvent(conn, "daemon.shutdown", { reason: "daemon stopping" });
6981
+ conn.socket.end();
6982
+ } catch {}
6983
+ return new Promise((resolve) => {
6984
+ if (!this.server) {
6985
+ resolve();
6986
+ return;
6987
+ }
6988
+ this.server.close(() => {
6989
+ try {
6990
+ unlinkSync(this.socketPath);
6991
+ } catch {}
6992
+ logger$1.info("IPC server stopped");
6993
+ resolve();
6994
+ });
6995
+ for (const conn of this.connections.values()) conn.socket.destroy();
6996
+ this.connections.clear();
6997
+ });
6998
+ }
6999
+ /**
7000
+ * Send an IPC request to a specific plugin and wait for response.
7001
+ */
7002
+ async sendRequest(pluginId, method, params, timeoutMs = 3e4) {
7003
+ const conn = this.connections.get(pluginId);
7004
+ if (!conn?.info) return {
7005
+ id: "",
7006
+ ok: false,
7007
+ error: {
7008
+ code: "PLUGIN_NOT_CONNECTED",
7009
+ message: `Plugin ${pluginId} not connected`
7010
+ }
7011
+ };
7012
+ const id = pluginConnectionId();
7013
+ const request = {
7014
+ type: "req",
7015
+ id,
7016
+ method,
7017
+ params
7018
+ };
7019
+ return new Promise((resolve, reject) => {
7020
+ const timer = setTimeout(() => {
7021
+ conn.pending.delete(id);
7022
+ resolve({
7023
+ id,
7024
+ ok: false,
7025
+ error: {
7026
+ code: "TIMEOUT",
7027
+ message: `Request ${method} timed out after ${String(timeoutMs)}ms`
7028
+ }
7029
+ });
7030
+ }, timeoutMs);
7031
+ conn.pending.set(id, {
7032
+ resolve,
7033
+ reject,
7034
+ timer
7035
+ });
7036
+ try {
7037
+ conn.socket.write(JSON.stringify(request) + "\n");
7038
+ } catch (err) {
7039
+ clearTimeout(timer);
7040
+ conn.pending.delete(id);
7041
+ resolve({
7042
+ id,
7043
+ ok: false,
7044
+ error: {
7045
+ code: "SEND_FAILED",
7046
+ message: `Failed to send to plugin: ${err instanceof Error ? err.message : String(err)}`
7047
+ }
7048
+ });
7049
+ }
7050
+ });
7051
+ }
7052
+ /**
7053
+ * Send an IPC request to ALL registered plugins.
7054
+ * Returns responses keyed by plugin ID.
7055
+ */
7056
+ async broadcastRequest(method, params, timeoutMs = 3e4) {
7057
+ const results = /* @__PURE__ */ new Map();
7058
+ const registered = this.getRegisteredPlugins();
7059
+ await Promise.all(registered.map(async ([pluginId]) => {
7060
+ const response = await this.sendRequest(pluginId, method, params, timeoutMs);
7061
+ results.set(pluginId, response);
7062
+ }));
7063
+ return results;
7064
+ }
7065
+ /**
7066
+ * Send an event to a specific plugin (fire-and-forget).
7067
+ */
7068
+ sendEventToPlugin(pluginId, event, payload) {
7069
+ const conn = this.connections.get(pluginId);
7070
+ if (!conn) return false;
7071
+ return this.sendEvent(conn, event, payload);
7072
+ }
7073
+ /**
7074
+ * Broadcast an event to all connected plugins.
7075
+ */
7076
+ broadcastEvent(event, payload) {
7077
+ for (const conn of this.connections.values()) if (conn.info) this.sendEvent(conn, event, payload);
7078
+ }
7079
+ /**
7080
+ * Get all registered plugins and their info.
7081
+ */
7082
+ getRegisteredPlugins() {
7083
+ const plugins = [];
7084
+ for (const [id, conn] of this.connections) if (conn.info) plugins.push([id, conn.info]);
7085
+ return plugins;
7086
+ }
7087
+ /**
7088
+ * Get number of connected plugins (including unregistered).
7089
+ */
7090
+ get connectionCount() {
7091
+ return this.connections.size;
7092
+ }
7093
+ /**
7094
+ * Get number of registered plugins.
7095
+ */
7096
+ get registeredCount() {
7097
+ let count = 0;
7098
+ for (const conn of this.connections.values()) if (conn.info) count++;
7099
+ return count;
7100
+ }
7101
+ handleConnection(socket) {
7102
+ const connId = pluginConnectionId();
7103
+ const conn = {
7104
+ id: connId,
7105
+ socket,
7106
+ info: null,
6547
7107
  buffer: "",
6548
7108
  pending: /* @__PURE__ */ new Map()
6549
7109
  };
@@ -6688,590 +7248,576 @@ var IPCServer = class {
6688
7248
  sendResponse(conn, response) {
6689
7249
  try {
6690
7250
  conn.socket.write(JSON.stringify(response) + "\n");
6691
- } catch (err) {
6692
- logger$1.error({
6693
- connId: conn.id,
6694
- err
6695
- }, "IPC: failed to send response");
6696
- }
6697
- }
6698
- sendEvent(conn, event, payload) {
6699
- try {
6700
- const msg = createIPCEvent(event, payload);
6701
- conn.socket.write(JSON.stringify(msg) + "\n");
6702
- return true;
6703
- } catch {
6704
- return false;
6705
- }
6706
- }
6707
- };
6708
- //#endregion
6709
- //#region src/command-queue.ts
6710
- const DEFAULT_TTL_MS$1 = 300 * 1e3;
6711
- var CommandQueue = class {
6712
- queues = /* @__PURE__ */ new Map();
6713
- ttlMs;
6714
- gcTimer = null;
6715
- constructor(ttlMs = DEFAULT_TTL_MS$1) {
6716
- this.ttlMs = ttlMs;
6717
- }
6718
- /**
6719
- * Start periodic garbage collection of expired commands.
6720
- */
6721
- startGC(intervalMs = 3e4) {
6722
- this.stopGC();
6723
- this.gcTimer = setInterval(() => this.purgeExpired(), intervalMs);
6724
- }
6725
- /**
6726
- * Stop periodic garbage collection.
6727
- */
6728
- stopGC() {
6729
- if (this.gcTimer) {
6730
- clearInterval(this.gcTimer);
6731
- this.gcTimer = null;
6732
- }
6733
- }
6734
- /**
6735
- * Enqueue a command for a specific service.
6736
- */
6737
- enqueue(serviceId, request, commandId) {
6738
- let queue = this.queues.get(serviceId);
6739
- if (!queue) {
6740
- queue = [];
6741
- this.queues.set(serviceId, queue);
6742
- }
6743
- queue.push({
6744
- request,
6745
- queuedAt: Date.now(),
6746
- commandId
6747
- });
6748
- }
6749
- /**
6750
- * Drain all pending (non-expired) commands for a service.
6751
- * Returns them in order and removes them from the queue.
6752
- */
6753
- drain(serviceId) {
6754
- const queue = this.queues.get(serviceId);
6755
- if (!queue || queue.length === 0) return [];
6756
- const now = Date.now();
6757
- const valid = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
6758
- this.queues.delete(serviceId);
6759
- return valid;
6760
- }
6761
- /**
6762
- * Get the number of pending commands for a service.
6763
- */
6764
- pendingCount(serviceId) {
6765
- const queue = this.queues.get(serviceId);
6766
- if (!queue) return 0;
6767
- const now = Date.now();
6768
- return queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs).length;
6769
- }
6770
- /**
6771
- * Get total pending commands across all services.
6772
- */
6773
- totalPending() {
6774
- let total = 0;
6775
- for (const [serviceId] of this.queues) total += this.pendingCount(serviceId);
6776
- return total;
6777
- }
6778
- /**
6779
- * Remove expired commands from all queues.
6780
- */
6781
- purgeExpired() {
6782
- const now = Date.now();
6783
- let purged = 0;
6784
- for (const [serviceId, queue] of this.queues) {
6785
- const before = queue.length;
6786
- const remaining = queue.filter((cmd) => now - cmd.queuedAt < this.ttlMs);
6787
- if (remaining.length === 0) this.queues.delete(serviceId);
6788
- else this.queues.set(serviceId, remaining);
6789
- purged += before - remaining.length;
6790
- }
6791
- return purged;
6792
- }
6793
- /**
6794
- * Clear all queues.
6795
- */
6796
- clear() {
6797
- this.queues.clear();
7251
+ } catch (err) {
7252
+ logger$1.error({
7253
+ connId: conn.id,
7254
+ err
7255
+ }, "IPC: failed to send response");
7256
+ }
6798
7257
  }
6799
- /**
6800
- * Get all service IDs that have pending commands.
6801
- */
6802
- serviceIds() {
6803
- return Array.from(this.queues.keys());
7258
+ sendEvent(conn, event, payload) {
7259
+ try {
7260
+ const msg = createIPCEvent(event, payload);
7261
+ conn.socket.write(JSON.stringify(msg) + "\n");
7262
+ return true;
7263
+ } catch {
7264
+ return false;
7265
+ }
6804
7266
  }
6805
7267
  };
6806
7268
  //#endregion
6807
- //#region src/process-manager.ts
7269
+ //#region src/daemon-bootstrap.ts
6808
7270
  /**
6809
- * Process managementlaunchd/systemd service installation.
7271
+ * Daemon bootstrap helpers startup ordering, config/version resolution,
7272
+ * AI-proxy + IPC server wiring.
6810
7273
  *
6811
- * Generates and installs user-space service units for auto-start on boot.
6812
- * No root required — uses user agents (Mac) or user units (Linux).
6813
- */
6814
- const LAUNCHD_LABEL = "ai.alfe.gateway";
6815
- const SYSTEMD_SERVICE = "alfe-gateway";
6816
- /**
6817
- * On-disk path for the boot-time self-heal guard script (Linux only).
6818
- * Written next to the systemd unit at setup time and invoked via
6819
- * `ExecStartPre=` — see `writeGuardScript` and `generateSystemdUnit`.
6820
- */
6821
- function getGuardScriptPath() {
6822
- return isRootUser() ? "/usr/local/lib/alfe/alfe-cli-guard.sh" : join(homedir(), ".alfe", "alfe-cli-guard.sh");
6823
- }
6824
- function isRootUser() {
6825
- return process.getuid?.() === 0;
6826
- }
6827
- function getSystemdSystemServicePath() {
6828
- return `/etc/systemd/system/${SYSTEMD_SERVICE}.service`;
6829
- }
6830
- function getLaunchdPlistPath() {
6831
- return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
6832
- }
6833
- function getSystemdServicePath() {
6834
- return join(homedir(), ".config", "systemd", "user", `${SYSTEMD_SERVICE}.service`);
6835
- }
6836
- /**
6837
- * Resolve the alfe CLI path. Globally installed via npm, always in PATH.
6838
- */
6839
- function getAlfeBinPath() {
6840
- try {
6841
- return execSync("which alfe", { encoding: "utf-8" }).trim();
6842
- } catch {
6843
- return "alfe";
6844
- }
6845
- }
6846
- /**
6847
- * Build the PATH for the launchd plist, ensuring the directory containing the
6848
- * resolved alfe binary is included (e.g. /opt/homebrew/bin on Apple Silicon).
6849
- */
6850
- function buildLaunchdPath(alfeBin) {
6851
- const basePaths = [
6852
- "/usr/local/bin",
6853
- "/usr/bin",
6854
- "/bin",
6855
- join(homedir(), ".local", "bin")
6856
- ];
6857
- const binDir = dirname(alfeBin);
6858
- if (binDir && !basePaths.includes(binDir)) basePaths.unshift(binDir);
6859
- return basePaths.join(":");
6860
- }
6861
- /**
6862
- * Generate a launchd plist for macOS.
7274
+ * Extracted from daemon.ts (which remains the orchestrator). These are the
7275
+ * standalone pieces of `startDaemon()`: version detection, the one-shot
7276
+ * persona-file migration, the AI proxy listener, and the IPC server start.
6863
7277
  */
6864
- function generateLaunchdPlist() {
6865
- const alfeBin = getAlfeBinPath();
6866
- return `<?xml version="1.0" encoding="UTF-8"?>
6867
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
6868
- <plist version="1.0">
6869
- <dict>
6870
- <key>Label</key>
6871
- <string>${LAUNCHD_LABEL}</string>
6872
- <key>ProgramArguments</key>
6873
- <array>
6874
- <string>${alfeBin}</string>
6875
- <string>gateway</string>
6876
- <string>daemon</string>
6877
- </array>
6878
- <key>RunAtLoad</key>
6879
- <true/>
6880
- <key>KeepAlive</key>
6881
- <true/>
6882
- <key>ThrottleInterval</key>
6883
- <integer>10</integer>
6884
- <key>StandardOutPath</key>
6885
- <string>${join(homedir(), ".alfe", "logs", "gateway.log")}</string>
6886
- <key>StandardErrorPath</key>
6887
- <string>${join(homedir(), ".alfe", "logs", "gateway.err.log")}</string>
6888
- <key>EnvironmentVariables</key>
6889
- <dict>
6890
- <key>NODE_ENV</key>
6891
- <string>production</string>
6892
- <key>PATH</key>
6893
- <string>${buildLaunchdPath(alfeBin)}</string>
6894
- </dict>
6895
- </dict>
6896
- </plist>`;
6897
- }
7278
+ const execFileAsync$2 = promisify(execFile);
6898
7279
  /**
6899
- * Boot-time self-heal guard (Linux only).
6900
- *
6901
- * Runs as an `ExecStartPre=` BEFORE the daemon launches, OUTSIDE the
6902
- * (possibly-broken) `alfe` binary. It exists for the case defense #1
6903
- * (verify-before-exit in `upgrade.ts`) structurally cannot cover: the daemon
6904
- * is KILLED during `npm install -g @alfe.ai/cli` (reboot / OOM), leaving the
6905
- * package with deps but no `dist/` and a dangling `/usr/bin/alfe` symlink.
6906
- * systemd then execs a dangling binary forever → 203/EXEC crash-loop, a
6907
- * permanent brick with no automatic recovery (real prod incident,
6908
- * ~13,570 restarts over 2 days).
6909
- *
6910
- * The guard:
6911
- * - checks the CLI is intact: the symlink target (`dist/index.js`) exists AND
6912
- * `alfe --version` exits 0;
6913
- * - if broken, reinstalls `@alfe.ai/cli@$ALFE_CLI_VERSION` (the exact version
6914
- * baked into the unit's `Environment=`; falls back to `@latest` when
6915
- * unset), so the daemon then execs a working binary;
6916
- * - is idempotent + fast (no-op when healthy) and NEVER wedges boot: any of
6917
- * its own failures log + `exit 0` so a human can still SSH in.
7280
+ * Resolve the installed @alfe.ai/cli version.
6918
7281
  *
6919
- * POSIX sh (dash-safe): the box may not have bash. Keep the two intactness
6920
- * checks in lockstep with `verifyCliInstall` in `verify-cli-install.ts`.
6921
- */
6922
- /** @internal exported for unit tests; not re-exported from the barrel. */
6923
- function generateGuardScript() {
6924
- const version = process.env.ALFE_CLI_VERSION;
6925
- return `#!/bin/sh
6926
- # Alfe CLI boot-time self-heal guard. Auto-generated by 'alfe setup' — do not edit.
6927
- # Repairs an interrupted 'npm install -g @alfe.ai/cli' before the daemon starts,
6928
- # so a mid-install reboot can't brick the agent with a 203/EXEC crash-loop.
6929
- # Must never wedge boot: every failure path logs and exits 0.
6930
- set -u
6931
-
6932
- TARGET='${version && version.length > 0 ? `@alfe.ai/cli@${version}` : "@alfe.ai/cli@latest"}'
6933
- log() { echo "[alfe-cli-guard] $*" >&2; }
6934
-
6935
- # Resolve the alfe bin and its real target (dist/index.js). readlink -f follows
6936
- # the whole symlink chain; fall back to the bin path itself if unavailable.
6937
- BIN="$(command -v alfe 2>/dev/null || true)"
6938
- if [ -n "$BIN" ]; then
6939
- ENTRY="$(readlink -f "$BIN" 2>/dev/null || echo "$BIN")"
6940
- else
6941
- ENTRY=""
6942
- fi
6943
-
6944
- healthy=1
6945
- if [ -z "$ENTRY" ] || [ ! -f "$ENTRY" ]; then
6946
- healthy=0
6947
- log "CLI entry missing (bin=\${BIN:-<none>} entry=\${ENTRY:-<none>})"
6948
- elif ! alfe --version >/dev/null 2>&1; then
6949
- healthy=0
6950
- log "alfe --version failed"
6951
- fi
6952
-
6953
- if [ "$healthy" -eq 1 ]; then
6954
- exit 0
6955
- fi
6956
-
6957
- log "CLI install looks broken — reinstalling $TARGET"
6958
- # Blow away a partial tree first so npm re-materialises dist/ cleanly. Best-effort.
6959
- GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"
6960
- if [ -n "$GLOBAL_ROOT" ] && [ -d "$GLOBAL_ROOT/@alfe.ai/cli" ]; then
6961
- rm -rf "$GLOBAL_ROOT/@alfe.ai/cli" 2>/dev/null || true
6962
- fi
6963
-
6964
- # Bound the reinstall so a slow/unreachable registry at boot can't stall the
6965
- # ExecStartPre up to systemd's DefaultTimeoutStartSec (~90s). Two layers:
6966
- # - npm --fetch-timeout/--fetch-retries cap per-request/hung-socket time;
6967
- # - a wall-clock 'timeout' wrapper caps the whole install when present
6968
- # ('timeout' isn't guaranteed, so fall back to a bare install).
6969
- # stdout->stderr ('>&2') so npm's chatter lands in the journal, not on fd1.
6970
- if command -v timeout >/dev/null 2>&1; then
6971
- timeout 120 npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
6972
- rc=$?
6973
- else
6974
- npm install -g "$TARGET" --fetch-timeout=60000 --fetch-retries=2 >&2
6975
- rc=$?
6976
- fi
6977
- if [ "$rc" -eq 0 ]; then
6978
- log "reinstall succeeded"
6979
- else
6980
- log "reinstall FAILED (rc=$rc) — daemon start may still fail; a human can SSH in to repair"
6981
- fi
6982
-
6983
- # Always exit 0: even a failed repair must not block the unit from attempting
6984
- # ExecStart (which then fails visibly) rather than wedging silently in pre-start.
6985
- exit 0
6986
- `;
6987
- }
6988
- /**
6989
- * Generate a systemd unit for Linux.
6990
- * Root users get a system-level unit; non-root get a user-level unit.
7282
+ * Strategy (in order):
7283
+ * 1. ALFE_CLI_VERSION env var (set by CLI entry point when run directly)
7284
+ * 2. Walk up from this file to find @alfe.ai/cli/package.json
7285
+ * (works when systemd runs the gateway binary directly, since
7286
+ * the path is .../cli/node_modules/@alfe.ai/gateway/dist/...)
6991
7287
  */
6992
- /** @internal exported for unit tests; not re-exported from the barrel. */
6993
- function generateSystemdUnit() {
6994
- const alfeBin = getAlfeBinPath();
6995
- const root = isRootUser();
6996
- const envLines = [
6997
- "ALFE_MANAGED",
6998
- "ALFE_API_KEY",
6999
- "LOG_LEVEL",
7000
- "ALFE_CLI_VERSION"
7001
- ].filter((key) => process.env[key]).map((key) => `Environment=${key}=${process.env[key] ?? ""}`).join("\n");
7002
- return `[Unit]
7003
- Description=Alfe Gateway Daemon
7004
- After=network-online.target
7005
- Wants=network-online.target
7006
-
7007
- [Service]
7008
- Type=simple
7009
- ExecStartPre=-/bin/sh ${getGuardScriptPath()}
7010
- ExecStart=${alfeBin} gateway daemon
7011
- Restart=always
7012
- RestartSec=10
7013
- # SIGTERM only the daemon on stop/restart; it closes its MCP/runtime children
7014
- # itself. The default (control-group) SIGTERMs the children simultaneously, so
7015
- # they die before the daemon's orderly dispose reaches them and every planned
7016
- # restart reports MCP "server-crash" noise to Sentry. Stragglers still get
7017
- # SIGKILL when the stop timeout expires.
7018
- KillMode=mixed
7019
- Environment=NODE_ENV=production${root ? "\nEnvironment=HOME=/root\nWorkingDirectory=/root" : ""}
7020
- ${envLines}
7021
-
7022
- [Install]
7023
- WantedBy=${root ? "multi-user.target" : "default.target"}`;
7288
+ async function getCliVersion() {
7289
+ if (process.env.ALFE_CLI_VERSION) return process.env.ALFE_CLI_VERSION;
7290
+ try {
7291
+ const { fileURLToPath } = await import("node:url");
7292
+ const { dirname } = await import("node:path");
7293
+ let dir = dirname(fileURLToPath(import.meta.url));
7294
+ for (let i = 0; i < 10; i++) {
7295
+ const candidate = join(dir, "package.json");
7296
+ try {
7297
+ const raw = await readFile(candidate, "utf-8");
7298
+ const pkg = JSON.parse(raw);
7299
+ if (pkg.name === "@alfe.ai/cli") return pkg.version;
7300
+ } catch {}
7301
+ const parent = dirname(dir);
7302
+ if (parent === dir) break;
7303
+ dir = parent;
7304
+ }
7305
+ } catch {}
7306
+ logger$1.debug("Could not resolve @alfe.ai/cli version");
7024
7307
  }
7025
7308
  /**
7026
- * Write the boot-time self-heal guard script to disk (Linux only) and make it
7027
- * executable. Called from `installSystemd` before the unit is written.
7309
+ * Per-runtime "print installed version" commands. Local to the gateway we do
7310
+ * NOT import the CLI's `detectRuntime` (`@alfe.ai/gateway` must not depend on
7311
+ * `@alfe.ai/cli`). Unknown runtimes resolve to `undefined` (never throw) so the
7312
+ * connection-status report degrades gracefully instead of crashing.
7028
7313
  */
7029
- async function writeGuardScript() {
7030
- const guardPath = getGuardScriptPath();
7031
- await mkdir(dirname(guardPath), { recursive: true });
7032
- await writeFile(guardPath, generateGuardScript(), {
7033
- encoding: "utf-8",
7034
- mode: 493
7035
- });
7036
- logger$1.info({ path: guardPath }, "Wrote CLI self-heal guard script");
7314
+ const RUNTIME_VERSION_COMMANDS = {
7315
+ openclaw: {
7316
+ command: "openclaw",
7317
+ args: ["--version"]
7318
+ },
7319
+ hermes: {
7320
+ command: "hermes",
7321
+ args: ["version"]
7322
+ },
7323
+ "claude-code": {
7324
+ command: "alfe-claude-host",
7325
+ args: ["--version"]
7326
+ }
7327
+ };
7328
+ /**
7329
+ * Pure resolver (exported for tests) — the per-runtime version command, or
7330
+ * `undefined` for an unknown runtime.
7331
+ */
7332
+ function resolveRuntimeVersionCommand(runtime) {
7333
+ return RUNTIME_VERSION_COMMANDS[runtime];
7037
7334
  }
7038
7335
  /**
7039
- * Install the service unit for the current platform.
7336
+ * Resolve the installed runtime version by running the per-runtime version
7337
+ * command and returning the trimmed stdout. An unknown runtime (no command in
7338
+ * the map) returns `undefined` without spawning — it must never throw.
7040
7339
  */
7041
- async function installService() {
7042
- const platform = process.platform;
7043
- if (platform === "darwin") return installLaunchd();
7044
- if (platform === "linux") return installSystemd();
7045
- throw new Error(`Unsupported platform: ${platform}. Only macOS and Linux are supported.`);
7340
+ async function getRuntimeVersion(runtime) {
7341
+ const cmd = resolveRuntimeVersionCommand(runtime);
7342
+ if (!cmd) {
7343
+ logger$1.debug({ runtime }, "No version command for runtime — skipping version detection");
7344
+ return;
7345
+ }
7346
+ try {
7347
+ const { stdout } = await execFileAsync$2(cmd.command, cmd.args);
7348
+ return stdout.trim() || void 0;
7349
+ } catch {
7350
+ logger$1.debug({ runtime }, "Could not resolve runtime version");
7351
+ return;
7352
+ }
7046
7353
  }
7047
7354
  /**
7048
- * Uninstall the service unit for the current platform.
7355
+ * Flush pino's async transport and exit.
7356
+ * process.exit() can drop buffered log lines — this ensures they're written first.
7049
7357
  */
7050
- async function uninstallService() {
7051
- const platform = process.platform;
7052
- if (platform === "darwin") return uninstallLaunchd();
7053
- if (platform === "linux") return uninstallSystemd();
7054
- throw new Error(`Unsupported platform: ${platform}`);
7358
+ async function flushAndExit(code) {
7359
+ await flushSentry();
7360
+ await new Promise((resolve) => {
7361
+ logger$1.flush();
7362
+ setTimeout(resolve, 500);
7363
+ });
7364
+ process.exit(code);
7055
7365
  }
7056
- function getLaunchdUid() {
7057
- return execSync("id -u", { encoding: "utf-8" }).trim();
7366
+ /**
7367
+ * One-shot migration for VMs provisioned before the persona-files-go-in-agent-workspace
7368
+ * fix landed. Old behavior wrote SOUL.md/IDENTITY.md/BOOTSTRAP.md/AGENTS.md at the
7369
+ * OpenClaw home (e.g. `~/.openclaw/`); the agent reads from the agent workspace
7370
+ * (e.g. `~/.openclaw/workspace/`). Move stale copies into place; skip if the
7371
+ * workspace already has the file. Idempotent and safe to run on every daemon start.
7372
+ */
7373
+ const PERSONA_FILES = [
7374
+ "SOUL.md",
7375
+ "IDENTITY.md",
7376
+ "BOOTSTRAP.md",
7377
+ "AGENTS.md"
7378
+ ];
7379
+ async function migrateLegacyPersonaFiles(home, agentWorkspace) {
7380
+ if (home === agentWorkspace) return;
7381
+ let moved = 0;
7382
+ let skipped = 0;
7383
+ for (const filename of PERSONA_FILES) {
7384
+ const src = join(home, filename);
7385
+ const dst = join(agentWorkspace, filename);
7386
+ try {
7387
+ await stat(src);
7388
+ } catch {
7389
+ continue;
7390
+ }
7391
+ try {
7392
+ await stat(dst);
7393
+ skipped++;
7394
+ continue;
7395
+ } catch {}
7396
+ try {
7397
+ await mkdir(agentWorkspace, { recursive: true });
7398
+ await rename(src, dst);
7399
+ moved++;
7400
+ } catch (err) {
7401
+ logger$1.warn({
7402
+ src,
7403
+ dst,
7404
+ err: err instanceof Error ? err.message : String(err)
7405
+ }, "Persona file migration: rename failed");
7406
+ }
7407
+ }
7408
+ if (moved > 0 || skipped > 0) logger$1.info({
7409
+ home,
7410
+ agentWorkspace,
7411
+ moved,
7412
+ skipped
7413
+ }, "Persona file backfill migration complete");
7058
7414
  }
7059
- async function installLaunchd() {
7060
- const plistPath = getLaunchdPlistPath();
7061
- const dir = join(homedir(), "Library", "LaunchAgents");
7062
- const logsDir = join(homedir(), ".alfe", "logs");
7063
- await mkdir(dir, { recursive: true });
7064
- await mkdir(logsDir, { recursive: true });
7065
- await writeFile(plistPath, generateLaunchdPlist(), "utf-8");
7066
- logger$1.info({ path: plistPath }, "Wrote launchd plist");
7067
- const uid = getLaunchdUid();
7068
- try {
7069
- execSync(`launchctl bootout gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
7070
- } catch {}
7415
+ /**
7416
+ * Start the local AI proxy (before the runtime so LLM requests work
7417
+ * immediately). Failure is non-fatal — the daemon starts anyway and LLM
7418
+ * requests fail until the proxy is fixed.
7419
+ */
7420
+ async function startAiProxy(apiKey) {
7421
+ logger$1.debug("Starting AI proxy...");
7422
+ const handle = {
7423
+ server: null,
7424
+ url: null,
7425
+ running: false
7426
+ };
7071
7427
  try {
7072
- execSync(`launchctl bootstrap gui/${uid} ${plistPath}`, { stdio: "pipe" });
7428
+ const { createProxyServer, DEFAULT_AI_PROXY_PORT } = await import("@alfe.ai/ai-proxy-local");
7429
+ const { getAiServiceUrlFromToken } = await import("@alfe.ai/config");
7430
+ const proxyUrl = getAiServiceUrlFromToken(apiKey);
7431
+ const port = DEFAULT_AI_PROXY_PORT ?? 18193;
7432
+ handle.server = createProxyServer({
7433
+ port,
7434
+ apiKey,
7435
+ proxyUrl
7436
+ });
7437
+ const server = handle.server;
7438
+ await new Promise((resolve, reject) => {
7439
+ server.listen(port, "127.0.0.1", () => {
7440
+ handle.running = true;
7441
+ handle.url = `http://127.0.0.1:${String(port)}`;
7442
+ logger$1.info({
7443
+ port,
7444
+ upstream: proxyUrl
7445
+ }, "AI proxy started");
7446
+ resolve();
7447
+ });
7448
+ server.on("error", reject);
7449
+ });
7073
7450
  } catch (err) {
7074
- const msg = err instanceof Error ? err.message : String(err);
7451
+ const message = err instanceof Error ? err.message : String(err);
7452
+ const stack = err instanceof Error ? err.stack : void 0;
7075
7453
  logger$1.error({
7076
- err: msg,
7077
- plistPath
7078
- }, "launchctl bootstrap failed");
7079
- return `Installed plist at ${plistPath}, but failed to bootstrap service: ${msg}`;
7454
+ err: message,
7455
+ stack
7456
+ }, "Failed to start AI proxy — LLM requests will fail");
7080
7457
  }
7081
- return `Installed: ${plistPath}\nService will start on boot and restart on crash.`;
7458
+ return handle;
7082
7459
  }
7083
- async function uninstallLaunchd() {
7084
- const plistPath = getLaunchdPlistPath();
7085
- const uid = getLaunchdUid();
7086
- try {
7087
- execSync(`launchctl bootout gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
7088
- } catch {}
7460
+ /**
7461
+ * Start the IPC server (runtime plugins connect via this socket). A failure
7462
+ * here is fatal — plugins have no other path to the daemon — so this flushes
7463
+ * logs and exits instead of returning.
7464
+ */
7465
+ async function startIpcServer(socketPath, requestHandler) {
7466
+ logger$1.debug({ socketPath }, "Starting IPC server...");
7467
+ const ipcServer = new IPCServer(socketPath);
7468
+ ipcServer.setRequestHandler(requestHandler);
7089
7469
  try {
7090
- await unlink(plistPath);
7091
- } catch {}
7092
- return `Uninstalled: ${plistPath}`;
7470
+ await ipcServer.start();
7471
+ logger$1.debug("IPC server started");
7472
+ } catch (err) {
7473
+ const message = err instanceof Error ? err.message : String(err);
7474
+ const stack = err instanceof Error ? err.stack : void 0;
7475
+ logger$1.error({
7476
+ err: message,
7477
+ stack
7478
+ }, "Failed to start IPC server");
7479
+ await flushAndExit(1);
7480
+ }
7481
+ return ipcServer;
7093
7482
  }
7094
- async function installSystemd() {
7095
- const root = isRootUser();
7096
- const unitPath = root ? getSystemdSystemServicePath() : getSystemdServicePath();
7097
- const dir = root ? "/etc/systemd/system" : join(homedir(), ".config", "systemd", "user");
7098
- const ctl = root ? "systemctl" : "systemctl --user";
7099
- if (!root) await mkdir(dir, { recursive: true });
7100
- await writeGuardScript();
7101
- await writeFile(unitPath, generateSystemdUnit(), "utf-8");
7102
- logger$1.info({
7103
- path: unitPath,
7104
- root
7105
- }, "Wrote systemd unit");
7106
- try {
7107
- execSync(`${ctl} daemon-reload`, { stdio: "pipe" });
7108
- execSync(`${ctl} enable ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
7109
- } catch {}
7110
- return `Installed: ${unitPath}\nService enabled${root ? " (system-level)" : " for user session"}.`;
7483
+ //#endregion
7484
+ //#region src/mcp-handlers.ts
7485
+ /**
7486
+ * Return the daemon-hosted MCP bundler's current namespaced tool catalog.
7487
+ * Called by the openclaw-mcp-bundler plugin on every tool-factory invocation;
7488
+ * cheap (in-memory snapshot, no I/O).
7489
+ */
7490
+ function handleMcpListTools(bundler) {
7491
+ if (!bundler) return {
7492
+ ok: false,
7493
+ error: {
7494
+ code: "MCP_BUNDLER_UNAVAILABLE",
7495
+ message: "MCP bundler not initialized"
7496
+ }
7497
+ };
7498
+ return {
7499
+ ok: true,
7500
+ payload: { tools: bundler.listTools() }
7501
+ };
7111
7502
  }
7112
- async function uninstallSystemd() {
7113
- const root = isRootUser();
7114
- const unitPath = root ? getSystemdSystemServicePath() : getSystemdServicePath();
7115
- const ctl = root ? "systemctl" : "systemctl --user";
7116
- try {
7117
- execSync(`${ctl} disable ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
7118
- execSync(`${ctl} stop ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
7119
- } catch {}
7120
- try {
7121
- await unlink(unitPath);
7122
- } catch {}
7123
- try {
7124
- await unlink(getGuardScriptPath());
7125
- } catch {}
7126
- try {
7127
- execSync(`${ctl} daemon-reload`, { stdio: "pipe" });
7128
- } catch {}
7129
- return `Uninstalled: ${unitPath}`;
7503
+ /**
7504
+ * Observability backstop for the INTEGRATION warm path. `applyForIntegration`
7505
+ * registers MCP servers into the store and the daemon warms them silently via
7506
+ * the store-change `onChange` hook so a server that fails its connect (e.g.
7507
+ * an MCP server whose backing account/credential wasn't resolvable at warm
7508
+ * time, like ctrader-mcp `exit(1)`-ing on empty accounts) leaves NO trace,
7509
+ * making the black hole undiagnosable. After a warm, read `bundler.statuses()`
7510
+ * and warn once per still-failed server so the failure is visible. These
7511
+ * self-heal on the bundler's background retry sweep once the dependency
7512
+ * appears. Returns the servers it warned about (for tests / callers).
7513
+ */
7514
+ function warnFailedMcpServers(bundler, log, reason) {
7515
+ if (!bundler) return [];
7516
+ const failed = bundler.statuses().filter((s) => !s.connected && s.lastError !== void 0);
7517
+ for (const status of failed) log.warn({
7518
+ server: status.name,
7519
+ reason,
7520
+ consecutiveFailures: status.consecutiveFailures,
7521
+ lastError: status.lastError
7522
+ }, `MCP server "${status.name}" failed to connect: ${status.lastError ?? ""}`);
7523
+ return failed;
7130
7524
  }
7131
7525
  /**
7132
- * Bootstrap (load) the launchd service from its plist if it isn't already
7133
- * loaded. Already-loaded is an error we ignore. Needed so `start`/`restart`
7134
- * work after `stopService()` boots the service OUT.
7526
+ * Route a tool call to the appropriate MCP child via the daemon-hosted
7527
+ * bundler. `name` is the prefixed (`mcp__<server>__<tool>`) name; args is
7528
+ * the raw JSON object the LLM produced.
7135
7529
  */
7136
- function ensureLaunchdLoaded(uid) {
7137
- try {
7138
- execSync(`launchctl bootstrap gui/${uid} ${getLaunchdPlistPath()}`, { stdio: "pipe" });
7139
- } catch {}
7140
- }
7141
7530
  /**
7142
- * Start the installed service via systemctl/launchctl. Bootstraps the launchd
7143
- * service first so this also works after `stopService()` booted it out.
7531
+ * List every server entry in the alfe bundler store. Lets agents inspect
7532
+ * what they've already registered before adding a new one.
7144
7533
  */
7145
- function startService() {
7146
- const platform = process.platform;
7147
- if (platform === "darwin") {
7148
- const uid = getLaunchdUid();
7149
- ensureLaunchdLoaded(uid);
7150
- execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
7151
- logger$1.info("Started launchd service");
7152
- return;
7153
- }
7154
- if (platform === "linux") {
7155
- execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} start ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
7156
- logger$1.info("Started systemd service");
7157
- return;
7158
- }
7159
- throw new Error(`Unsupported platform: ${platform}`);
7534
+ function handleMcpListServers(manager) {
7535
+ if (!manager) return {
7536
+ ok: false,
7537
+ error: {
7538
+ code: "MCP_MANAGER_UNAVAILABLE",
7539
+ message: "MCP manager not initialized"
7540
+ }
7541
+ };
7542
+ const statuses = manager.serverStatuses ? manager.serverStatuses() : [];
7543
+ const byName = new Map(statuses.map((s) => [s.name, s]));
7544
+ return {
7545
+ ok: true,
7546
+ payload: { servers: manager.listServers().map(({ id, entry }) => ({
7547
+ id,
7548
+ entry,
7549
+ status: byName.get(id) ?? {
7550
+ name: id,
7551
+ connected: false,
7552
+ toolCount: 0,
7553
+ consecutiveFailures: 0
7554
+ }
7555
+ })) }
7556
+ };
7160
7557
  }
7161
7558
  /**
7162
- * Restart the installed service via systemctl/launchctl. This is the
7163
- * service-manager-native restart (`kickstart -k` / `systemctl restart`) the
7164
- * CLI uses it instead of killing the process inline, which would fight launchd
7165
- * `KeepAlive` / systemd `Restart=always`.
7559
+ * How long the add-confirm probe waits for the freshly-added server to
7560
+ * connect before replying. This is effectively the whole budget for
7561
+ * `bundler.warmServer`: `Manager.addServer` now reconciles the bundler
7562
+ * BEFORE resolving, so the Connection object already exists and
7563
+ * `warmServer`'s pre-reconcile is a no-diff cheap pass. Kept well under the
7564
+ * plugin caller's 30s IPC timeout. A slower server still connects in the
7565
+ * background and surfaces on the next `alfe_mcp_list_tools` — the probe just
7566
+ * reports what it saw within the window; a missed connect is re-attempted by
7567
+ * the bundler's `retryNeverConnected` sweep.
7166
7568
  */
7167
- function restartService() {
7168
- const platform = process.platform;
7169
- if (platform === "darwin") {
7170
- const uid = getLaunchdUid();
7171
- ensureLaunchdLoaded(uid);
7172
- execSync(`launchctl kickstart -k gui/${uid}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
7173
- logger$1.info("Restarted launchd service");
7174
- return;
7175
- }
7176
- if (platform === "linux") {
7177
- execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} restart ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
7178
- logger$1.info("Restarted systemd service");
7179
- return;
7180
- }
7181
- throw new Error(`Unsupported platform: ${platform}`);
7182
- }
7569
+ const MCP_ADD_WARM_TIMEOUT_MS = 12e3;
7183
7570
  /**
7184
- * Stop the installed service via systemctl/launchctl. On macOS this boots the
7185
- * service OUT (unloads it) so launchd's `KeepAlive` does NOT respawn it;
7186
- * `startService()` bootstraps it again. On Linux the unit stays enabled (starts
7187
- * on next boot); `systemctl stop` just halts the current run.
7571
+ * Register a new MCP server in the alfe bundler store on behalf of the agent,
7572
+ * then CONFIRM the connect before replying so the agent learns whether the
7573
+ * server actually works. Owned as `'manual'` so the agent can later remove it
7574
+ * without an expectedOwner conflict — matches what `alfe mcp add` does from the
7575
+ * CLI. Registration is durable regardless of the probe outcome: a probe
7576
+ * failure (or a runtime with no daemon bundler, e.g. hermes) still returns
7577
+ * `ok` with `connected: false`; the store watcher / runtime picks the entry up.
7188
7578
  */
7189
- function stopService() {
7190
- const platform = process.platform;
7191
- if (platform === "darwin") {
7192
- execSync(`launchctl bootout gui/${getLaunchdUid()}/${LAUNCHD_LABEL}`, { stdio: "pipe" });
7193
- logger$1.info("Stopped launchd service");
7194
- return;
7579
+ async function handleMcpAddServer(params, manager) {
7580
+ if (!manager) return {
7581
+ ok: false,
7582
+ error: {
7583
+ code: "MCP_MANAGER_UNAVAILABLE",
7584
+ message: "MCP manager not initialized"
7585
+ }
7586
+ };
7587
+ const p = params;
7588
+ if (typeof p.id !== "string" || p.id.length === 0) return {
7589
+ ok: false,
7590
+ error: {
7591
+ code: "INVALID_PARAMS",
7592
+ message: "id is required (string)"
7593
+ }
7594
+ };
7595
+ const config = buildServerConfig(p);
7596
+ if (!config) return {
7597
+ ok: false,
7598
+ error: {
7599
+ code: "INVALID_PARAMS",
7600
+ message: "expected either { command, args?, env?, cwd? } for stdio or { url, transport, headers? } for remote"
7601
+ }
7602
+ };
7603
+ try {
7604
+ await manager.addServer(config, {
7605
+ id: p.id,
7606
+ owner: "manual"
7607
+ });
7608
+ } catch (err) {
7609
+ const message = err instanceof Error ? err.message : String(err);
7610
+ logger$1.warn({
7611
+ id: p.id,
7612
+ err: message
7613
+ }, "mcp.add_server failed");
7614
+ return {
7615
+ ok: false,
7616
+ error: {
7617
+ code: "MCP_ADD_FAILED",
7618
+ message
7619
+ }
7620
+ };
7195
7621
  }
7196
- if (platform === "linux") {
7197
- execSync(`${isRootUser() ? "systemctl" : "systemctl --user"} stop ${SYSTEMD_SERVICE}`, { stdio: "pipe" });
7198
- logger$1.info("Stopped systemd service");
7199
- return;
7622
+ let status = null;
7623
+ if (manager.warmServer) try {
7624
+ status = await manager.warmServer(p.id, MCP_ADD_WARM_TIMEOUT_MS);
7625
+ } catch (err) {
7626
+ logger$1.warn({
7627
+ id: p.id,
7628
+ err: err instanceof Error ? err.message : String(err)
7629
+ }, "mcp.add_server warm probe threw");
7200
7630
  }
7201
- throw new Error(`Unsupported platform: ${platform}`);
7631
+ return {
7632
+ ok: true,
7633
+ payload: {
7634
+ id: p.id,
7635
+ connected: status?.connected ?? false,
7636
+ toolCount: status?.toolCount ?? 0,
7637
+ ...status?.lastError !== void 0 ? { error: status.lastError } : {}
7638
+ }
7639
+ };
7202
7640
  }
7203
- /**
7204
- * True when the gateway is installed as a system service (launchd plist /
7205
- * systemd unit present). Lets the CLI restart/stop/start THROUGH the service
7206
- * manager instead of driving the daemon inline via the PID file.
7207
- */
7208
- function isServiceInstalled() {
7209
- const platform = process.platform;
7210
- if (platform === "darwin") return existsSync(getLaunchdPlistPath());
7211
- if (platform === "linux") return existsSync(isRootUser() ? getSystemdSystemServicePath() : getSystemdServicePath());
7212
- return false;
7641
+ function buildServerConfig(p) {
7642
+ if (typeof p.command === "string" && p.command.length > 0) {
7643
+ const cfg = { command: p.command };
7644
+ if (Array.isArray(p.args) && p.args.every((a) => typeof a === "string")) cfg.args = p.args;
7645
+ if (p.env && typeof p.env === "object" && !Array.isArray(p.env)) {
7646
+ const env = {};
7647
+ for (const [k, v] of Object.entries(p.env)) if (typeof v === "string") env[k] = v;
7648
+ cfg.env = env;
7649
+ }
7650
+ if (typeof p.cwd === "string") cfg.cwd = p.cwd;
7651
+ return cfg;
7652
+ }
7653
+ if (typeof p.url === "string" && p.url.length > 0) {
7654
+ const transport = p.transport === "streamable-http" ? "streamable-http" : "sse";
7655
+ const cfg = {
7656
+ url: p.url,
7657
+ transport
7658
+ };
7659
+ if (p.headers && typeof p.headers === "object" && !Array.isArray(p.headers)) {
7660
+ const headers = {};
7661
+ for (const [k, v] of Object.entries(p.headers)) if (typeof v === "string") headers[k] = v;
7662
+ cfg.headers = headers;
7663
+ }
7664
+ return cfg;
7665
+ }
7666
+ return null;
7213
7667
  }
7214
7668
  /**
7215
- * Write the current process PID to the PID file.
7669
+ * Drop a server entry the agent previously registered. Restricted to
7670
+ * `manual`-owned entries so the agent can't accidentally clobber
7671
+ * integration-installed or CLI-installed servers (the daemon owns
7672
+ * those; the agent can ask the user to uninstall an integration via
7673
+ * the dashboard).
7216
7674
  */
7217
- async function writePidFile() {
7218
- await writeFile(PID_PATH, String(process.pid), "utf-8");
7675
+ async function handleMcpRemoveServer(params, manager) {
7676
+ if (!manager) return {
7677
+ ok: false,
7678
+ error: {
7679
+ code: "MCP_MANAGER_UNAVAILABLE",
7680
+ message: "MCP manager not initialized"
7681
+ }
7682
+ };
7683
+ const { id } = params;
7684
+ if (typeof id !== "string" || id.length === 0) return {
7685
+ ok: false,
7686
+ error: {
7687
+ code: "INVALID_PARAMS",
7688
+ message: "id is required (string)"
7689
+ }
7690
+ };
7691
+ try {
7692
+ return {
7693
+ ok: true,
7694
+ payload: { removed: await manager.removeServer(id, { expectedOwner: "manual" }) }
7695
+ };
7696
+ } catch (err) {
7697
+ const message = err instanceof Error ? err.message : String(err);
7698
+ if (message.includes("owned by")) return {
7699
+ ok: false,
7700
+ error: {
7701
+ code: "MCP_OWNER_MISMATCH",
7702
+ message
7703
+ }
7704
+ };
7705
+ logger$1.warn({
7706
+ id,
7707
+ err: message
7708
+ }, "mcp.remove_server failed");
7709
+ return {
7710
+ ok: false,
7711
+ error: {
7712
+ code: "MCP_REMOVE_FAILED",
7713
+ message
7714
+ }
7715
+ };
7716
+ }
7219
7717
  }
7220
- /**
7221
- * Remove the PID file.
7222
- */
7223
- async function removePidFile() {
7718
+ async function handleMcpCallTool(bundler, params) {
7719
+ if (!bundler) return {
7720
+ ok: false,
7721
+ error: {
7722
+ code: "MCP_BUNDLER_UNAVAILABLE",
7723
+ message: "MCP bundler not initialized"
7724
+ }
7725
+ };
7726
+ const { name, args } = params;
7727
+ if (typeof name !== "string" || name.length === 0) return {
7728
+ ok: false,
7729
+ error: {
7730
+ code: "INVALID_PARAMS",
7731
+ message: "name is required (string)"
7732
+ }
7733
+ };
7224
7734
  try {
7225
- await unlink(PID_PATH);
7226
- } catch {}
7735
+ return {
7736
+ ok: true,
7737
+ payload: await bundler.callTool(name, args)
7738
+ };
7739
+ } catch (err) {
7740
+ const message = err instanceof Error ? err.message : String(err);
7741
+ logger$1.warn({
7742
+ tool: name,
7743
+ err: message
7744
+ }, "mcp.call_tool failed");
7745
+ return {
7746
+ ok: false,
7747
+ error: {
7748
+ code: "MCP_CALL_FAILED",
7749
+ message
7750
+ }
7751
+ };
7752
+ }
7227
7753
  }
7754
+ //#endregion
7755
+ //#region src/integration-bootstrap.ts
7228
7756
  /**
7229
- * Check if a daemon is already running.
7230
- * Returns the PID if alive, null if not running.
7757
+ * Adapter from `AgentApiClient`'s per-provider methods to the
7758
+ * `CredentialsResolver` shape the MCP applier expects. Returns
7759
+ * `undefined` for unknown providers and on 404/network error so the
7760
+ * applier can skip registration silently (its documented contract).
7231
7761
  */
7232
- async function checkExistingDaemon() {
7233
- if (!existsSync(PID_PATH)) return null;
7234
- try {
7235
- const pidStr = await readFile(PID_PATH, "utf-8");
7236
- const pid = parseInt(pidStr.trim(), 10);
7237
- if (isNaN(pid)) {
7238
- await removePidFile();
7239
- return null;
7762
+ async function fetchProviderCredentials(agentApi, provider, connectionId) {
7763
+ if (connectionId) try {
7764
+ const raw = await agentApi.getConnectionCredentials(connectionId);
7765
+ const fields = { ...raw.providerMetadata ?? {} };
7766
+ if (typeof raw.accessToken === "string" && raw.accessToken.length > 0) try {
7767
+ const bundle = JSON.parse(raw.accessToken);
7768
+ if (bundle && typeof bundle === "object" && !Array.isArray(bundle)) Object.assign(fields, bundle);
7769
+ } catch (err) {
7770
+ logger$1.warn({
7771
+ connectionId,
7772
+ err: err instanceof Error ? err.message : String(err)
7773
+ }, "Custom connection accessToken is not a JSON bundle — exposing as accessToken field");
7774
+ fields.accessToken = raw.accessToken;
7240
7775
  }
7241
- try {
7242
- process.kill(pid, 0);
7243
- return pid;
7244
- } catch {
7245
- await removePidFile();
7246
- return null;
7776
+ return fields;
7777
+ } catch (err) {
7778
+ logger$1.warn({
7779
+ connectionId,
7780
+ err: err instanceof Error ? err.message : String(err)
7781
+ }, "Failed to resolve connection-scoped credentials — MCP server will be skipped");
7782
+ return;
7783
+ }
7784
+ const key = provider.toLowerCase();
7785
+ try {
7786
+ switch (key) {
7787
+ case "atlassian": return await agentApi.getAtlassianCredentials();
7788
+ case "github": return await agentApi.getGithubCredentials();
7789
+ case "xero": return await agentApi.getXeroCredentials();
7790
+ case "notion": return await agentApi.getNotionCredentials();
7791
+ case "myob": return await agentApi.getMYOBCredentials();
7792
+ case "google": return await agentApi.getGoogleCredentials();
7793
+ default:
7794
+ logger$1.warn({ provider }, "Unknown OAuth provider for requires_credentials — MCP server will be skipped");
7795
+ return;
7247
7796
  }
7248
7797
  } catch {
7249
- return null;
7798
+ return;
7250
7799
  }
7251
7800
  }
7252
7801
  /**
7253
- * Send SIGTERM to an existing daemon process.
7802
+ * Post-reconcile trigger: rebuild the command registry from the manager's
7803
+ * active integrations and report the available commands to the cloud
7804
+ * gateway. Wired by daemon.ts as the cloud client's
7805
+ * `onReconciliationComplete` callback.
7254
7806
  */
7255
- async function stopExistingDaemon() {
7256
- const pid = await checkExistingDaemon();
7257
- if (!pid) return false;
7807
+ function reportAvailableCommands(deps) {
7808
+ const { integrationManager, commandRegistry, cloudClient } = deps;
7258
7809
  try {
7259
- process.kill(pid, "SIGTERM");
7260
- for (let i = 0; i < 50; i++) {
7261
- await new Promise((r) => setTimeout(r, 100));
7262
- try {
7263
- process.kill(pid, 0);
7264
- } catch {
7265
- await removePidFile();
7266
- return true;
7267
- }
7268
- }
7269
- process.kill(pid, "SIGKILL");
7270
- await removePidFile();
7271
- return true;
7272
- } catch {
7273
- await removePidFile();
7274
- return false;
7810
+ const activeCommands = integrationManager.getActiveCommands();
7811
+ commandRegistry.clear();
7812
+ for (const { integrationId, commands } of activeCommands) for (const cmd of commands) commandRegistry.register(integrationId, cmd.name, cmd.resolvedPath, cmd.method, cmd.timeoutMs);
7813
+ const commandsMsg = {
7814
+ type: "COMMANDS_AVAILABLE",
7815
+ commands: commandRegistry.listCommands()
7816
+ };
7817
+ cloudClient.sendMessage(commandsMsg);
7818
+ logger$1.info({ commandCount: commandsMsg.commands.length }, "Reported available commands to cloud");
7819
+ } catch (err) {
7820
+ logger$1.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to rebuild command registry");
7275
7821
  }
7276
7822
  }
7277
7823
  //#endregion
@@ -23151,7 +23697,7 @@ var CommandDedupe = class {
23151
23697
  * the gateway when needed and uses `callerScopes: ["operator.admin"]`.
23152
23698
  * 3. Skip iteration if `pending.json` mtime hasn't changed (cheap fast path)
23153
23699
  */
23154
- const execFileAsync$2 = promisify(execFile);
23700
+ const execFileAsync$1 = promisify(execFile);
23155
23701
  const APPROVE_TIMEOUT_MS = 1e4;
23156
23702
  function resolveStateDir(override) {
23157
23703
  if (override) return override;
@@ -23250,7 +23796,7 @@ function startPairingApprovalPoller(opts) {
23250
23796
  const intervalMs = opts.intervalMs ?? 3e4;
23251
23797
  const cliLock = opts.cliLock ?? new NoopOpenClawCliLock();
23252
23798
  const rawExec = opts.exec ?? (async (file, args, { timeout }) => {
23253
- const { stdout, stderr } = await execFileAsync$2(file, args, { timeout });
23799
+ const { stdout, stderr } = await execFileAsync$1(file, args, { timeout });
23254
23800
  return {
23255
23801
  stdout,
23256
23802
  stderr
@@ -23324,12 +23870,12 @@ function startPairingApprovalPoller(opts) {
23324
23870
  * Sentinel-gated at `~/.alfe/.openclaw-mirror-migrated` — runs exactly
23325
23871
  * once per agent.
23326
23872
  */
23327
- const execFileAsync$1 = promisify(execFile);
23873
+ const execFileAsync = promisify(execFile);
23328
23874
  const DEFAULT_SENTINEL_PATH = join(homedir(), ".alfe", ".openclaw-mirror-migrated");
23329
23875
  const defaultOpenclaw = {
23330
23876
  async listServers() {
23331
23877
  try {
23332
- const { stdout } = await execFileAsync$1("openclaw", [
23878
+ const { stdout } = await execFileAsync("openclaw", [
23333
23879
  "config",
23334
23880
  "get",
23335
23881
  "mcp.servers"
@@ -23346,7 +23892,7 @@ const defaultOpenclaw = {
23346
23892
  }
23347
23893
  },
23348
23894
  async unsetServer(key) {
23349
- await execFileAsync$1("openclaw", [
23895
+ await execFileAsync("openclaw", [
23350
23896
  "config",
23351
23897
  "unset",
23352
23898
  `mcp.servers.${key}`
@@ -23528,8 +24074,12 @@ function createMcpErrorHooks() {
23528
24074
  * 7. Start agent runtime (managed mode only — e.g. OpenClaw)
23529
24075
  * 8. Write PID file (non-managed only)
23530
24076
  * 9. Handle graceful shutdown
24077
+ *
24078
+ * Split along seams (this file stays the orchestrator):
24079
+ * - daemon-bootstrap.ts — version resolution, persona migration, AI-proxy + IPC start
24080
+ * - mcp-handlers.ts — `mcp.*` IPC request handlers
24081
+ * - integration-bootstrap.ts — provider-credential fetch + post-reconcile command reporting
23531
24082
  */
23532
- const execFileAsync = promisify(execFile);
23533
24083
  let config;
23534
24084
  let cloudClient;
23535
24085
  let ipcServer = null;
@@ -23561,202 +24111,21 @@ let cloudConnected = false;
23561
24111
  * Dedupe for at-least-once durable command delivery (Phase B). The cloud may
23562
24112
  * push a command over the live WS AND re-drain the same commandId on the next
23563
24113
  * SERVICE_REGISTER; both hit handleCloudCommand. This remembers recent outcomes
23564
- * so a duplicate is ACKed with the prior result without re-executing.
23565
- */
23566
- const commandDedupe = new CommandDedupe();
23567
- let shuttingDown = false;
23568
- let commandRegistry;
23569
- let resolvedCliVersion;
23570
- let resolvedRuntimeVersion;
23571
- let upgradingRuntime = false;
23572
- let stopPairingApprovalPoller = null;
23573
- /**
23574
- * Module-level handle on the runtime appliers built during start(), so the
23575
- * module-scoped command handler can route `alfe.config_set` to the active
23576
- * runtime's applier (mirrors `mcpManagerRef`). Null until start() builds it.
23577
- */
23578
- let runtimeAppliersRef = null;
23579
- /**
23580
- * Resolve the installed @alfe.ai/cli version.
23581
- *
23582
- * Strategy (in order):
23583
- * 1. ALFE_CLI_VERSION env var (set by CLI entry point when run directly)
23584
- * 2. Walk up from this file to find @alfe.ai/cli/package.json
23585
- * (works when systemd runs the gateway binary directly, since
23586
- * the path is .../cli/node_modules/@alfe.ai/gateway/dist/...)
23587
- */
23588
- /**
23589
- * Adapter from `AgentApiClient`'s per-provider methods to the
23590
- * `CredentialsResolver` shape the MCP applier expects. Returns
23591
- * `undefined` for unknown providers and on 404/network error so the
23592
- * applier can skip registration silently (its documented contract).
23593
- */
23594
- async function fetchProviderCredentials(agentApi, provider, connectionId) {
23595
- if (connectionId) try {
23596
- const raw = await agentApi.getConnectionCredentials(connectionId);
23597
- const fields = { ...raw.providerMetadata ?? {} };
23598
- if (typeof raw.accessToken === "string" && raw.accessToken.length > 0) try {
23599
- const bundle = JSON.parse(raw.accessToken);
23600
- if (bundle && typeof bundle === "object" && !Array.isArray(bundle)) Object.assign(fields, bundle);
23601
- } catch (err) {
23602
- logger$1.warn({
23603
- connectionId,
23604
- err: err instanceof Error ? err.message : String(err)
23605
- }, "Custom connection accessToken is not a JSON bundle — exposing as accessToken field");
23606
- fields.accessToken = raw.accessToken;
23607
- }
23608
- return fields;
23609
- } catch (err) {
23610
- logger$1.warn({
23611
- connectionId,
23612
- err: err instanceof Error ? err.message : String(err)
23613
- }, "Failed to resolve connection-scoped credentials — MCP server will be skipped");
23614
- return;
23615
- }
23616
- const key = provider.toLowerCase();
23617
- try {
23618
- switch (key) {
23619
- case "atlassian": return await agentApi.getAtlassianCredentials();
23620
- case "github": return await agentApi.getGithubCredentials();
23621
- case "xero": return await agentApi.getXeroCredentials();
23622
- case "notion": return await agentApi.getNotionCredentials();
23623
- case "myob": return await agentApi.getMYOBCredentials();
23624
- case "google": return await agentApi.getGoogleCredentials();
23625
- default:
23626
- logger$1.warn({ provider }, "Unknown OAuth provider for requires_credentials — MCP server will be skipped");
23627
- return;
23628
- }
23629
- } catch {
23630
- return;
23631
- }
23632
- }
23633
- async function getCliVersion() {
23634
- if (process.env.ALFE_CLI_VERSION) return process.env.ALFE_CLI_VERSION;
23635
- try {
23636
- const { fileURLToPath } = await import("node:url");
23637
- const { dirname } = await import("node:path");
23638
- let dir = dirname(fileURLToPath(import.meta.url));
23639
- for (let i = 0; i < 10; i++) {
23640
- const candidate = join(dir, "package.json");
23641
- try {
23642
- const raw = await readFile(candidate, "utf-8");
23643
- const pkg = JSON.parse(raw);
23644
- if (pkg.name === "@alfe.ai/cli") return pkg.version;
23645
- } catch {}
23646
- const parent = dirname(dir);
23647
- if (parent === dir) break;
23648
- dir = parent;
23649
- }
23650
- } catch {}
23651
- logger$1.debug("Could not resolve @alfe.ai/cli version");
23652
- }
23653
- /**
23654
- * Per-runtime "print installed version" commands. Local to the gateway — we do
23655
- * NOT import the CLI's `detectRuntime` (`@alfe.ai/gateway` must not depend on
23656
- * `@alfe.ai/cli`). Unknown runtimes resolve to `undefined` (never throw) so the
23657
- * connection-status report degrades gracefully instead of crashing.
23658
- */
23659
- const RUNTIME_VERSION_COMMANDS = {
23660
- openclaw: {
23661
- command: "openclaw",
23662
- args: ["--version"]
23663
- },
23664
- hermes: {
23665
- command: "hermes",
23666
- args: ["version"]
23667
- },
23668
- "claude-code": {
23669
- command: "alfe-claude-host",
23670
- args: ["--version"]
23671
- }
23672
- };
23673
- /**
23674
- * Pure resolver (exported for tests) — the per-runtime version command, or
23675
- * `undefined` for an unknown runtime.
23676
- */
23677
- function resolveRuntimeVersionCommand(runtime) {
23678
- return RUNTIME_VERSION_COMMANDS[runtime];
23679
- }
23680
- /**
23681
- * Resolve the installed runtime version by running the per-runtime version
23682
- * command and returning the trimmed stdout. An unknown runtime (no command in
23683
- * the map) returns `undefined` without spawning — it must never throw.
23684
- */
23685
- async function getRuntimeVersion(runtime) {
23686
- const cmd = resolveRuntimeVersionCommand(runtime);
23687
- if (!cmd) {
23688
- logger$1.debug({ runtime }, "No version command for runtime — skipping version detection");
23689
- return;
23690
- }
23691
- try {
23692
- const { stdout } = await execFileAsync(cmd.command, cmd.args);
23693
- return stdout.trim() || void 0;
23694
- } catch {
23695
- logger$1.debug({ runtime }, "Could not resolve runtime version");
23696
- return;
23697
- }
23698
- }
23699
- /**
23700
- * Flush pino's async transport and exit.
23701
- * process.exit() can drop buffered log lines — this ensures they're written first.
24114
+ * so a duplicate is ACKed with the prior result without re-executing.
23702
24115
  */
23703
- async function flushAndExit(code) {
23704
- await flushSentry();
23705
- await new Promise((resolve) => {
23706
- logger$1.flush();
23707
- setTimeout(resolve, 500);
23708
- });
23709
- process.exit(code);
23710
- }
24116
+ const commandDedupe = new CommandDedupe();
24117
+ let shuttingDown = false;
24118
+ let commandRegistry;
24119
+ let resolvedCliVersion;
24120
+ let resolvedRuntimeVersion;
24121
+ let upgradingRuntime = false;
24122
+ let stopPairingApprovalPoller = null;
23711
24123
  /**
23712
- * One-shot migration for VMs provisioned before the persona-files-go-in-agent-workspace
23713
- * fix landed. Old behavior wrote SOUL.md/IDENTITY.md/BOOTSTRAP.md/AGENTS.md at the
23714
- * OpenClaw home (e.g. `~/.openclaw/`); the agent reads from the agent workspace
23715
- * (e.g. `~/.openclaw/workspace/`). Move stale copies into place; skip if the
23716
- * workspace already has the file. Idempotent and safe to run on every daemon start.
24124
+ * Module-level handle on the runtime appliers built during start(), so the
24125
+ * module-scoped command handler can route `alfe.config_set` to the active
24126
+ * runtime's applier (mirrors `mcpManagerRef`). Null until start() builds it.
23717
24127
  */
23718
- const PERSONA_FILES = [
23719
- "SOUL.md",
23720
- "IDENTITY.md",
23721
- "BOOTSTRAP.md",
23722
- "AGENTS.md"
23723
- ];
23724
- async function migrateLegacyPersonaFiles(home, agentWorkspace) {
23725
- if (home === agentWorkspace) return;
23726
- let moved = 0;
23727
- let skipped = 0;
23728
- for (const filename of PERSONA_FILES) {
23729
- const src = join(home, filename);
23730
- const dst = join(agentWorkspace, filename);
23731
- try {
23732
- await stat(src);
23733
- } catch {
23734
- continue;
23735
- }
23736
- try {
23737
- await stat(dst);
23738
- skipped++;
23739
- continue;
23740
- } catch {}
23741
- try {
23742
- await mkdir(agentWorkspace, { recursive: true });
23743
- await rename(src, dst);
23744
- moved++;
23745
- } catch (err) {
23746
- logger$1.warn({
23747
- src,
23748
- dst,
23749
- err: err instanceof Error ? err.message : String(err)
23750
- }, "Persona file migration: rename failed");
23751
- }
23752
- }
23753
- if (moved > 0 || skipped > 0) logger$1.info({
23754
- home,
23755
- agentWorkspace,
23756
- moved,
23757
- skipped
23758
- }, "Persona file backfill migration complete");
23759
- }
24128
+ let runtimeAppliersRef = null;
23760
24129
  async function startDaemon() {
23761
24130
  startedAt = Date.now();
23762
24131
  const managed = isManagedMode();
@@ -23816,54 +24185,12 @@ async function startDaemon() {
23816
24185
  commandQueue = new CommandQueue();
23817
24186
  commandQueue.startGC();
23818
24187
  commandRegistry = new CommandRegistry();
23819
- logger$1.debug("Starting AI proxy...");
23820
- try {
23821
- const { createProxyServer, DEFAULT_AI_PROXY_PORT } = await import("@alfe.ai/ai-proxy-local");
23822
- const { getAiServiceUrlFromToken } = await import("@alfe.ai/config");
23823
- const proxyUrl = getAiServiceUrlFromToken(config.apiKey);
23824
- const port = DEFAULT_AI_PROXY_PORT ?? 18193;
23825
- aiProxyServer = createProxyServer({
23826
- port,
23827
- apiKey: config.apiKey,
23828
- proxyUrl
23829
- });
23830
- const server = aiProxyServer;
23831
- await new Promise((resolve, reject) => {
23832
- server.listen(port, "127.0.0.1", () => {
23833
- aiProxyRunning = true;
23834
- aiProxyUrl = `http://127.0.0.1:${String(port)}`;
23835
- logger$1.info({
23836
- port,
23837
- upstream: proxyUrl
23838
- }, "AI proxy started");
23839
- resolve();
23840
- });
23841
- server.on("error", reject);
23842
- });
23843
- } catch (err) {
23844
- const message = err instanceof Error ? err.message : String(err);
23845
- const stack = err instanceof Error ? err.stack : void 0;
23846
- logger$1.error({
23847
- err: message,
23848
- stack
23849
- }, "Failed to start AI proxy — LLM requests will fail");
23850
- }
23851
- logger$1.debug({ socketPath: config.socketPath }, "Starting IPC server...");
23852
- ipcServer = new IPCServer(config.socketPath);
23853
- ipcServer.setRequestHandler(handlePluginRequest);
24188
+ const aiProxy = await startAiProxy(config.apiKey);
24189
+ aiProxyServer = aiProxy.server;
24190
+ aiProxyUrl = aiProxy.url;
24191
+ aiProxyRunning = aiProxy.running;
24192
+ ipcServer = await startIpcServer(config.socketPath, handlePluginRequest);
23854
24193
  turnActivityProbe = new IpcTurnActivityProbe(() => ipcServer);
23855
- try {
23856
- await ipcServer.start();
23857
- logger$1.debug("IPC server started");
23858
- } catch (err) {
23859
- const message = err instanceof Error ? err.message : String(err);
23860
- const stack = err instanceof Error ? err.stack : void 0;
23861
- logger$1.error({
23862
- err: message,
23863
- stack
23864
- }, "Failed to start IPC server");
23865
- await flushAndExit(1);
23866
- }
23867
24194
  resolvedCliVersion = await getCliVersion();
23868
24195
  resolvedRuntimeVersion = await getRuntimeVersion(config.runtime);
23869
24196
  logger$1.info({
@@ -24007,19 +24334,11 @@ async function startDaemon() {
24007
24334
  }
24008
24335
  }
24009
24336
  cloudClient.setOnReconciliationComplete(() => {
24010
- try {
24011
- const activeCommands = integrationManager.getActiveCommands();
24012
- commandRegistry.clear();
24013
- for (const { integrationId, commands } of activeCommands) for (const cmd of commands) commandRegistry.register(integrationId, cmd.name, cmd.resolvedPath, cmd.method, cmd.timeoutMs);
24014
- const commandsMsg = {
24015
- type: "COMMANDS_AVAILABLE",
24016
- commands: commandRegistry.listCommands()
24017
- };
24018
- cloudClient.sendMessage(commandsMsg);
24019
- logger$1.info({ commandCount: commandsMsg.commands.length }, "Reported available commands to cloud");
24020
- } catch (err) {
24021
- logger$1.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to rebuild command registry");
24022
- }
24337
+ reportAvailableCommands({
24338
+ integrationManager,
24339
+ commandRegistry,
24340
+ cloudClient
24341
+ });
24023
24342
  });
24024
24343
  cloudClient.start();
24025
24344
  logger$1.debug("Cloud client started");
@@ -24311,513 +24630,244 @@ async function executeCloudCommand(command) {
24311
24630
  }
24312
24631
  const integrations = integrationManager.list();
24313
24632
  return {
24314
- type: "COMMAND_ACK",
24315
- commandId: command.commandId,
24316
- status: "ok",
24317
- result: { integrations }
24318
- };
24319
- } catch (err) {
24320
- const message = err instanceof Error ? err.message : String(err);
24321
- logger$1.error({ err: message }, "Integration status failed");
24322
- return {
24323
- type: "COMMAND_ACK",
24324
- commandId: command.commandId,
24325
- status: "error",
24326
- result: {
24327
- code: "STATUS_FAILED",
24328
- message
24329
- }
24330
- };
24331
- }
24332
- }
24333
- if (command.command === "alfe.config_set") {
24334
- const payload = command.payload;
24335
- const key = payload?.key;
24336
- const value = payload?.value;
24337
- if (!key || value === void 0) return {
24338
- type: "COMMAND_ACK",
24339
- commandId: command.commandId,
24340
- status: "error",
24341
- result: {
24342
- code: "INVALID_PAYLOAD",
24343
- message: "alfe.config_set requires key and value"
24344
- }
24345
- };
24346
- const runtime = config.runtime;
24347
- const applier = runtimeAppliersRef?.get(runtime);
24348
- if (!applier || typeof applier.setConfigRaw !== "function") {
24349
- const message = `No runtime applier supports config_set for runtime "${runtime}"`;
24350
- logger$1.warn({
24351
- runtime,
24352
- key
24353
- }, message);
24354
- return {
24355
- type: "COMMAND_ACK",
24356
- commandId: command.commandId,
24357
- status: "error",
24358
- result: {
24359
- code: "CONFIG_SET_UNSUPPORTED",
24360
- message
24361
- }
24362
- };
24363
- }
24364
- try {
24365
- await applier.setConfigRaw(key, value);
24366
- logger$1.info({
24367
- runtime,
24368
- key
24369
- }, "Applied config via runtime applier setConfigRaw");
24370
- return {
24371
- type: "COMMAND_ACK",
24372
- commandId: command.commandId,
24373
- status: "ok",
24374
- result: {
24375
- key,
24376
- value
24377
- }
24378
- };
24379
- } catch (err) {
24380
- const message = err instanceof Error ? err.message : String(err);
24381
- logger$1.error({
24382
- err: message,
24383
- runtime,
24384
- key
24385
- }, "Failed to apply config via runtime applier");
24386
- return {
24387
- type: "COMMAND_ACK",
24388
- commandId: command.commandId,
24389
- status: "error",
24390
- result: {
24391
- code: "CONFIG_SET_FAILED",
24392
- message
24393
- }
24394
- };
24395
- }
24396
- }
24397
- if (commandRegistry.has(command.command)) try {
24398
- const ctx = buildCommandContext();
24399
- const result = await commandRegistry.execute(command.command, typeof command.payload === "object" && command.payload !== null ? command.payload : {}, ctx);
24400
- return {
24401
- type: "COMMAND_ACK",
24402
- commandId: command.commandId,
24403
- status: result.status,
24404
- result: result.result
24405
- };
24406
- } catch (err) {
24407
- const message = err instanceof Error ? err.message : String(err);
24408
- logger$1.error({
24409
- err: message,
24410
- command: command.command
24411
- }, "Command registry execution failed");
24412
- return {
24413
- type: "COMMAND_ACK",
24414
- commandId: command.commandId,
24415
- status: "error",
24416
- result: {
24417
- code: "REGISTRY_ERROR",
24418
- message
24419
- }
24420
- };
24421
- }
24422
- const ipcRequest = cloudCommandToIPCRequest(command);
24423
- if (!ipcRequest) {
24424
- logger$1.warn({ command: command.command }, "Unrecognized cloud command");
24425
- return {
24426
- type: "COMMAND_ACK",
24427
- commandId: command.commandId,
24428
- status: "error",
24429
- result: {
24430
- code: "UNKNOWN_COMMAND",
24431
- message: `Unrecognized command: ${command.command}`
24432
- }
24433
- };
24434
- }
24435
- const plugins = ipcServer?.getRegisteredPlugins() ?? [];
24436
- if (plugins.length === 0) {
24437
- logger$1.info({
24438
- commandId: command.commandId,
24439
- command: command.command
24440
- }, "No plugins connected — queuing command");
24441
- commandQueue.enqueue("_default", ipcRequest, command.commandId);
24442
- return {
24443
- type: "COMMAND_ACK",
24444
- commandId: command.commandId,
24445
- status: "ok",
24446
- result: {
24447
- queued: true,
24448
- message: "Command queued — no plugins connected"
24449
- }
24450
- };
24451
- }
24452
- const [pluginId] = plugins[0];
24453
- if (!ipcServer) return {
24454
- type: "COMMAND_ACK",
24455
- commandId: command.commandId,
24456
- status: "error",
24457
- result: {
24458
- code: "NO_IPC",
24459
- message: "IPC server not available"
24633
+ type: "COMMAND_ACK",
24634
+ commandId: command.commandId,
24635
+ status: "ok",
24636
+ result: { integrations }
24637
+ };
24638
+ } catch (err) {
24639
+ const message = err instanceof Error ? err.message : String(err);
24640
+ logger$1.error({ err: message }, "Integration status failed");
24641
+ return {
24642
+ type: "COMMAND_ACK",
24643
+ commandId: command.commandId,
24644
+ status: "error",
24645
+ result: {
24646
+ code: "STATUS_FAILED",
24647
+ message
24648
+ }
24649
+ };
24460
24650
  }
24461
- };
24462
- try {
24463
- const response = await ipcServer.sendRequest(pluginId, ipcRequest.method, ipcRequest.params, 3e4);
24464
- return ipcResponseToCloudAck(command.commandId, response);
24465
- } catch (err) {
24466
- const message = err instanceof Error ? err.message : String(err);
24467
- return {
24651
+ }
24652
+ if (command.command === "alfe.config_set") {
24653
+ const payload = command.payload;
24654
+ const key = payload?.key;
24655
+ const value = payload?.value;
24656
+ if (!key || value === void 0) return {
24468
24657
  type: "COMMAND_ACK",
24469
24658
  commandId: command.commandId,
24470
24659
  status: "error",
24471
24660
  result: {
24472
- code: "PLUGIN_ERROR",
24473
- message
24661
+ code: "INVALID_PAYLOAD",
24662
+ message: "alfe.config_set requires key and value"
24474
24663
  }
24475
24664
  };
24476
- }
24477
- }
24478
- function buildCommandContext() {
24479
- const workspacePath = Object.values(config.runtimes)[0]?.workspace ?? "~/.openclaw";
24480
- return {
24481
- workspacePath,
24482
- aiProxyUrl: aiProxyUrl ?? void 0,
24483
- aiProxyRunning,
24484
- apiKey: config.apiKey,
24485
- async exec(cmd, opts) {
24486
- const { exec: execCb } = await import("child_process");
24487
- const { promisify } = await import("util");
24488
- const { stdout, stderr } = await promisify(execCb)(cmd, {
24489
- cwd: workspacePath,
24490
- timeout: opts?.timeoutMs ?? 25e3,
24491
- maxBuffer: opts?.maxBuffer ?? 512 * 1024
24492
- });
24665
+ const runtime = config.runtime;
24666
+ const applier = runtimeAppliersRef?.get(runtime);
24667
+ if (!applier || typeof applier.setConfigRaw !== "function") {
24668
+ const message = `No runtime applier supports config_set for runtime "${runtime}"`;
24669
+ logger$1.warn({
24670
+ runtime,
24671
+ key
24672
+ }, message);
24493
24673
  return {
24494
- stdout: stdout.trim(),
24495
- stderr: stderr.trim()
24674
+ type: "COMMAND_ACK",
24675
+ commandId: command.commandId,
24676
+ status: "error",
24677
+ result: {
24678
+ code: "CONFIG_SET_UNSUPPORTED",
24679
+ message
24680
+ }
24496
24681
  };
24497
24682
  }
24498
- };
24499
- }
24500
- function handlePluginRequest(method, params, pluginId) {
24501
- switch (method) {
24502
- case "status": return Promise.resolve(handleStatus());
24503
- case "integration.list": return Promise.resolve(handleIntegrationList());
24504
- case "integration.report": return Promise.resolve(handleIntegrationReport(params, pluginId));
24505
- case "mcp.list_tools": return Promise.resolve(handleMcpListTools(mcpBundler));
24506
- case "mcp.call_tool": return handleMcpCallTool(mcpBundler, params);
24507
- case "mcp.list_servers": return Promise.resolve(handleMcpListServers());
24508
- case "mcp.add_server": return handleMcpAddServer(params);
24509
- case "mcp.remove_server": return handleMcpRemoveServer(params);
24510
- default: return Promise.resolve({
24511
- ok: false,
24512
- error: {
24513
- code: "UNKNOWN_METHOD",
24514
- message: `Unknown method: ${method}`
24515
- }
24516
- });
24517
- }
24518
- }
24519
- function handleStatus() {
24520
- return {
24521
- ok: true,
24522
- payload: {
24523
- daemon: {
24524
- status: "running",
24525
- pid: process.pid,
24526
- uptime: (Date.now() - startedAt) / 1e3,
24527
- version: "0.1.0",
24528
- runtimeVersion: resolvedRuntimeVersion
24529
- },
24530
- cloud: {
24531
- status: cloudConnected ? "connected" : "disconnected",
24532
- latencyMs: cloudClient.getLatencyMs()
24533
- },
24534
- aiProxy: { status: aiProxyRunning ? "running" : "stopped" },
24535
- plugins: (ipcServer?.getRegisteredPlugins() ?? []).map(([, info]) => ({
24536
- name: info.name,
24537
- version: info.version,
24538
- capabilities: info.capabilities,
24539
- connectedAt: info.connectedAt,
24540
- lastSeen: info.lastSeen
24541
- })),
24542
- commandQueue: { totalPending: commandQueue.totalPending() }
24543
- }
24544
- };
24545
- }
24546
- function handleIntegrationList() {
24547
- return {
24548
- ok: true,
24549
- payload: { integrations: integrationManager.list() }
24550
- };
24551
- }
24552
- /**
24553
- * Return the daemon-hosted MCP bundler's current namespaced tool catalog.
24554
- * Called by the openclaw-mcp-bundler plugin on every tool-factory invocation;
24555
- * cheap (in-memory snapshot, no I/O).
24556
- */
24557
- function handleMcpListTools(bundler) {
24558
- if (!bundler) return {
24559
- ok: false,
24560
- error: {
24561
- code: "MCP_BUNDLER_UNAVAILABLE",
24562
- message: "MCP bundler not initialized"
24563
- }
24564
- };
24565
- return {
24566
- ok: true,
24567
- payload: { tools: bundler.listTools() }
24568
- };
24569
- }
24570
- /**
24571
- * Observability backstop for the INTEGRATION warm path. `applyForIntegration`
24572
- * registers MCP servers into the store and the daemon warms them silently via
24573
- * the store-change `onChange` hook — so a server that fails its connect (e.g.
24574
- * an MCP server whose backing account/credential wasn't resolvable at warm
24575
- * time, like ctrader-mcp `exit(1)`-ing on empty accounts) leaves NO trace,
24576
- * making the black hole undiagnosable. After a warm, read `bundler.statuses()`
24577
- * and warn once per still-failed server so the failure is visible. These
24578
- * self-heal on the bundler's background retry sweep once the dependency
24579
- * appears. Returns the servers it warned about (for tests / callers).
24580
- */
24581
- function warnFailedMcpServers(bundler, log, reason) {
24582
- if (!bundler) return [];
24583
- const failed = bundler.statuses().filter((s) => !s.connected && s.lastError !== void 0);
24584
- for (const status of failed) log.warn({
24585
- server: status.name,
24586
- reason,
24587
- consecutiveFailures: status.consecutiveFailures,
24588
- lastError: status.lastError
24589
- }, `MCP server "${status.name}" failed to connect: ${status.lastError ?? ""}`);
24590
- return failed;
24591
- }
24592
- /**
24593
- * Route a tool call to the appropriate MCP child via the daemon-hosted
24594
- * bundler. `name` is the prefixed (`mcp__<server>__<tool>`) name; args is
24595
- * the raw JSON object the LLM produced.
24596
- */
24597
- /**
24598
- * List every server entry in the alfe bundler store. Lets agents inspect
24599
- * what they've already registered before adding a new one.
24600
- */
24601
- function handleMcpListServers(manager = mcpManagerRef) {
24602
- if (!manager) return {
24603
- ok: false,
24604
- error: {
24605
- code: "MCP_MANAGER_UNAVAILABLE",
24606
- message: "MCP manager not initialized"
24607
- }
24608
- };
24609
- const statuses = manager.serverStatuses ? manager.serverStatuses() : [];
24610
- const byName = new Map(statuses.map((s) => [s.name, s]));
24611
- return {
24612
- ok: true,
24613
- payload: { servers: manager.listServers().map(({ id, entry }) => ({
24614
- id,
24615
- entry,
24616
- status: byName.get(id) ?? {
24617
- name: id,
24618
- connected: false,
24619
- toolCount: 0,
24620
- consecutiveFailures: 0
24621
- }
24622
- })) }
24623
- };
24624
- }
24625
- /**
24626
- * How long the add-confirm probe waits for the freshly-added server to
24627
- * connect before replying. This is effectively the whole budget for
24628
- * `bundler.warmServer`: `Manager.addServer` now reconciles the bundler
24629
- * BEFORE resolving, so the Connection object already exists and
24630
- * `warmServer`'s pre-reconcile is a no-diff cheap pass. Kept well under the
24631
- * plugin caller's 30s IPC timeout. A slower server still connects in the
24632
- * background and surfaces on the next `alfe_mcp_list_tools` — the probe just
24633
- * reports what it saw within the window; a missed connect is re-attempted by
24634
- * the bundler's `retryNeverConnected` sweep.
24635
- */
24636
- const MCP_ADD_WARM_TIMEOUT_MS = 12e3;
24637
- /**
24638
- * Register a new MCP server in the alfe bundler store on behalf of the agent,
24639
- * then CONFIRM the connect before replying so the agent learns whether the
24640
- * server actually works. Owned as `'manual'` so the agent can later remove it
24641
- * without an expectedOwner conflict — matches what `alfe mcp add` does from the
24642
- * CLI. Registration is durable regardless of the probe outcome: a probe
24643
- * failure (or a runtime with no daemon bundler, e.g. hermes) still returns
24644
- * `ok` with `connected: false`; the store watcher / runtime picks the entry up.
24645
- */
24646
- async function handleMcpAddServer(params, manager = mcpManagerRef) {
24647
- if (!manager) return {
24648
- ok: false,
24649
- error: {
24650
- code: "MCP_MANAGER_UNAVAILABLE",
24651
- message: "MCP manager not initialized"
24652
- }
24653
- };
24654
- const p = params;
24655
- if (typeof p.id !== "string" || p.id.length === 0) return {
24656
- ok: false,
24657
- error: {
24658
- code: "INVALID_PARAMS",
24659
- message: "id is required (string)"
24660
- }
24661
- };
24662
- const config = buildServerConfig(p);
24663
- if (!config) return {
24664
- ok: false,
24665
- error: {
24666
- code: "INVALID_PARAMS",
24667
- message: "expected either { command, args?, env?, cwd? } for stdio or { url, transport, headers? } for remote"
24683
+ try {
24684
+ await applier.setConfigRaw(key, value);
24685
+ logger$1.info({
24686
+ runtime,
24687
+ key
24688
+ }, "Applied config via runtime applier setConfigRaw");
24689
+ return {
24690
+ type: "COMMAND_ACK",
24691
+ commandId: command.commandId,
24692
+ status: "ok",
24693
+ result: {
24694
+ key,
24695
+ value
24696
+ }
24697
+ };
24698
+ } catch (err) {
24699
+ const message = err instanceof Error ? err.message : String(err);
24700
+ logger$1.error({
24701
+ err: message,
24702
+ runtime,
24703
+ key
24704
+ }, "Failed to apply config via runtime applier");
24705
+ return {
24706
+ type: "COMMAND_ACK",
24707
+ commandId: command.commandId,
24708
+ status: "error",
24709
+ result: {
24710
+ code: "CONFIG_SET_FAILED",
24711
+ message
24712
+ }
24713
+ };
24668
24714
  }
24669
- };
24670
- try {
24671
- await manager.addServer(config, {
24672
- id: p.id,
24673
- owner: "manual"
24674
- });
24715
+ }
24716
+ if (commandRegistry.has(command.command)) try {
24717
+ const ctx = buildCommandContext();
24718
+ const result = await commandRegistry.execute(command.command, typeof command.payload === "object" && command.payload !== null ? command.payload : {}, ctx);
24719
+ return {
24720
+ type: "COMMAND_ACK",
24721
+ commandId: command.commandId,
24722
+ status: result.status,
24723
+ result: result.result
24724
+ };
24675
24725
  } catch (err) {
24676
24726
  const message = err instanceof Error ? err.message : String(err);
24677
- logger$1.warn({
24678
- id: p.id,
24679
- err: message
24680
- }, "mcp.add_server failed");
24727
+ logger$1.error({
24728
+ err: message,
24729
+ command: command.command
24730
+ }, "Command registry execution failed");
24681
24731
  return {
24682
- ok: false,
24683
- error: {
24684
- code: "MCP_ADD_FAILED",
24732
+ type: "COMMAND_ACK",
24733
+ commandId: command.commandId,
24734
+ status: "error",
24735
+ result: {
24736
+ code: "REGISTRY_ERROR",
24685
24737
  message
24686
24738
  }
24687
24739
  };
24688
24740
  }
24689
- let status = null;
24690
- if (manager.warmServer) try {
24691
- status = await manager.warmServer(p.id, MCP_ADD_WARM_TIMEOUT_MS);
24692
- } catch (err) {
24693
- logger$1.warn({
24694
- id: p.id,
24695
- err: err instanceof Error ? err.message : String(err)
24696
- }, "mcp.add_server warm probe threw");
24697
- }
24698
- return {
24699
- ok: true,
24700
- payload: {
24701
- id: p.id,
24702
- connected: status?.connected ?? false,
24703
- toolCount: status?.toolCount ?? 0,
24704
- ...status?.lastError !== void 0 ? { error: status.lastError } : {}
24705
- }
24706
- };
24707
- }
24708
- function buildServerConfig(p) {
24709
- if (typeof p.command === "string" && p.command.length > 0) {
24710
- const cfg = { command: p.command };
24711
- if (Array.isArray(p.args) && p.args.every((a) => typeof a === "string")) cfg.args = p.args;
24712
- if (p.env && typeof p.env === "object" && !Array.isArray(p.env)) {
24713
- const env = {};
24714
- for (const [k, v] of Object.entries(p.env)) if (typeof v === "string") env[k] = v;
24715
- cfg.env = env;
24716
- }
24717
- if (typeof p.cwd === "string") cfg.cwd = p.cwd;
24718
- return cfg;
24741
+ const ipcRequest = cloudCommandToIPCRequest(command);
24742
+ if (!ipcRequest) {
24743
+ logger$1.warn({ command: command.command }, "Unrecognized cloud command");
24744
+ return {
24745
+ type: "COMMAND_ACK",
24746
+ commandId: command.commandId,
24747
+ status: "error",
24748
+ result: {
24749
+ code: "UNKNOWN_COMMAND",
24750
+ message: `Unrecognized command: ${command.command}`
24751
+ }
24752
+ };
24719
24753
  }
24720
- if (typeof p.url === "string" && p.url.length > 0) {
24721
- const transport = p.transport === "streamable-http" ? "streamable-http" : "sse";
24722
- const cfg = {
24723
- url: p.url,
24724
- transport
24754
+ const plugins = ipcServer?.getRegisteredPlugins() ?? [];
24755
+ if (plugins.length === 0) {
24756
+ logger$1.info({
24757
+ commandId: command.commandId,
24758
+ command: command.command
24759
+ }, "No plugins connected — queuing command");
24760
+ commandQueue.enqueue("_default", ipcRequest, command.commandId);
24761
+ return {
24762
+ type: "COMMAND_ACK",
24763
+ commandId: command.commandId,
24764
+ status: "ok",
24765
+ result: {
24766
+ queued: true,
24767
+ message: "Command queued — no plugins connected"
24768
+ }
24725
24769
  };
24726
- if (p.headers && typeof p.headers === "object" && !Array.isArray(p.headers)) {
24727
- const headers = {};
24728
- for (const [k, v] of Object.entries(p.headers)) if (typeof v === "string") headers[k] = v;
24729
- cfg.headers = headers;
24730
- }
24731
- return cfg;
24732
24770
  }
24733
- return null;
24734
- }
24735
- /**
24736
- * Drop a server entry the agent previously registered. Restricted to
24737
- * `manual`-owned entries so the agent can't accidentally clobber
24738
- * integration-installed or CLI-installed servers (the daemon owns
24739
- * those; the agent can ask the user to uninstall an integration via
24740
- * the dashboard).
24741
- */
24742
- async function handleMcpRemoveServer(params, manager = mcpManagerRef) {
24743
- if (!manager) return {
24744
- ok: false,
24745
- error: {
24746
- code: "MCP_MANAGER_UNAVAILABLE",
24747
- message: "MCP manager not initialized"
24748
- }
24749
- };
24750
- const { id } = params;
24751
- if (typeof id !== "string" || id.length === 0) return {
24752
- ok: false,
24753
- error: {
24754
- code: "INVALID_PARAMS",
24755
- message: "id is required (string)"
24771
+ const [pluginId] = plugins[0];
24772
+ if (!ipcServer) return {
24773
+ type: "COMMAND_ACK",
24774
+ commandId: command.commandId,
24775
+ status: "error",
24776
+ result: {
24777
+ code: "NO_IPC",
24778
+ message: "IPC server not available"
24756
24779
  }
24757
24780
  };
24758
24781
  try {
24759
- return {
24760
- ok: true,
24761
- payload: { removed: await manager.removeServer(id, { expectedOwner: "manual" }) }
24762
- };
24782
+ const response = await ipcServer.sendRequest(pluginId, ipcRequest.method, ipcRequest.params, 3e4);
24783
+ return ipcResponseToCloudAck(command.commandId, response);
24763
24784
  } catch (err) {
24764
24785
  const message = err instanceof Error ? err.message : String(err);
24765
- if (message.includes("owned by")) return {
24766
- ok: false,
24767
- error: {
24768
- code: "MCP_OWNER_MISMATCH",
24769
- message
24770
- }
24771
- };
24772
- logger$1.warn({
24773
- id,
24774
- err: message
24775
- }, "mcp.remove_server failed");
24776
24786
  return {
24777
- ok: false,
24778
- error: {
24779
- code: "MCP_REMOVE_FAILED",
24787
+ type: "COMMAND_ACK",
24788
+ commandId: command.commandId,
24789
+ status: "error",
24790
+ result: {
24791
+ code: "PLUGIN_ERROR",
24780
24792
  message
24781
24793
  }
24782
24794
  };
24783
24795
  }
24784
24796
  }
24785
- async function handleMcpCallTool(bundler, params) {
24786
- if (!bundler) return {
24787
- ok: false,
24788
- error: {
24789
- code: "MCP_BUNDLER_UNAVAILABLE",
24790
- message: "MCP bundler not initialized"
24791
- }
24792
- };
24793
- const { name, args } = params;
24794
- if (typeof name !== "string" || name.length === 0) return {
24795
- ok: false,
24796
- error: {
24797
- code: "INVALID_PARAMS",
24798
- message: "name is required (string)"
24797
+ function buildCommandContext() {
24798
+ const workspacePath = Object.values(config.runtimes)[0]?.workspace ?? "~/.openclaw";
24799
+ return {
24800
+ workspacePath,
24801
+ aiProxyUrl: aiProxyUrl ?? void 0,
24802
+ aiProxyRunning,
24803
+ apiKey: config.apiKey,
24804
+ async exec(cmd, opts) {
24805
+ const { exec: execCb } = await import("child_process");
24806
+ const { promisify } = await import("util");
24807
+ const { stdout, stderr } = await promisify(execCb)(cmd, {
24808
+ cwd: workspacePath,
24809
+ timeout: opts?.timeoutMs ?? 25e3,
24810
+ maxBuffer: opts?.maxBuffer ?? 512 * 1024
24811
+ });
24812
+ return {
24813
+ stdout: stdout.trim(),
24814
+ stderr: stderr.trim()
24815
+ };
24799
24816
  }
24800
24817
  };
24801
- try {
24802
- return {
24803
- ok: true,
24804
- payload: await bundler.callTool(name, args)
24805
- };
24806
- } catch (err) {
24807
- const message = err instanceof Error ? err.message : String(err);
24808
- logger$1.warn({
24809
- tool: name,
24810
- err: message
24811
- }, "mcp.call_tool failed");
24812
- return {
24818
+ }
24819
+ function handlePluginRequest(method, params, pluginId) {
24820
+ switch (method) {
24821
+ case "status": return Promise.resolve(handleStatus());
24822
+ case "integration.list": return Promise.resolve(handleIntegrationList());
24823
+ case "integration.report": return Promise.resolve(handleIntegrationReport(params, pluginId));
24824
+ case "mcp.list_tools": return Promise.resolve(handleMcpListTools(mcpBundler));
24825
+ case "mcp.call_tool": return handleMcpCallTool(mcpBundler, params);
24826
+ case "mcp.list_servers": return Promise.resolve(handleMcpListServers(mcpManagerRef));
24827
+ case "mcp.add_server": return handleMcpAddServer(params, mcpManagerRef);
24828
+ case "mcp.remove_server": return handleMcpRemoveServer(params, mcpManagerRef);
24829
+ default: return Promise.resolve({
24813
24830
  ok: false,
24814
24831
  error: {
24815
- code: "MCP_CALL_FAILED",
24816
- message
24832
+ code: "UNKNOWN_METHOD",
24833
+ message: `Unknown method: ${method}`
24817
24834
  }
24818
- };
24835
+ });
24819
24836
  }
24820
24837
  }
24838
+ function handleStatus() {
24839
+ return {
24840
+ ok: true,
24841
+ payload: {
24842
+ daemon: {
24843
+ status: "running",
24844
+ pid: process.pid,
24845
+ uptime: (Date.now() - startedAt) / 1e3,
24846
+ version: "0.1.0",
24847
+ runtimeVersion: resolvedRuntimeVersion
24848
+ },
24849
+ cloud: {
24850
+ status: cloudConnected ? "connected" : "disconnected",
24851
+ latencyMs: cloudClient.getLatencyMs()
24852
+ },
24853
+ aiProxy: { status: aiProxyRunning ? "running" : "stopped" },
24854
+ plugins: (ipcServer?.getRegisteredPlugins() ?? []).map(([, info]) => ({
24855
+ name: info.name,
24856
+ version: info.version,
24857
+ capabilities: info.capabilities,
24858
+ connectedAt: info.connectedAt,
24859
+ lastSeen: info.lastSeen
24860
+ })),
24861
+ commandQueue: { totalPending: commandQueue.totalPending() }
24862
+ }
24863
+ };
24864
+ }
24865
+ function handleIntegrationList() {
24866
+ return {
24867
+ ok: true,
24868
+ payload: { integrations: integrationManager.list() }
24869
+ };
24870
+ }
24821
24871
  function handleIntegrationReport(params, pluginId) {
24822
24872
  const { name, status, detail } = params;
24823
24873
  if (!name || !status) return {