lazyclaw 6.3.1 → 6.5.0

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.
Files changed (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
@@ -102,11 +102,45 @@ export async function cmdConfigEdit() {
102
102
  }
103
103
  }
104
104
 
105
+ // Coerce a CLI string value into the type it most likely represents so a
106
+ // boolean/number setting isn't stored as a string the rest of the code then
107
+ // mis-compares (`if (cfg.chat.recall)` is truthy for the string "false").
108
+ // 'true' / 'false' → boolean
109
+ // integer / float strings → number
110
+ // everything else → the original string (provider/model/api-key/…)
111
+ function coerceConfigValue(value) {
112
+ if (typeof value !== 'string') return value;
113
+ if (value === 'true') return true;
114
+ if (value === 'false') return false;
115
+ // Only treat as a number when the WHOLE string is a clean numeric literal —
116
+ // Number('') is 0 and Number('1.2.3') is NaN, both of which we reject so
117
+ // ids like "gpt-4.1" or empty values stay strings.
118
+ if (value.trim() !== '' && /^-?(?:\d+|\d*\.\d+|\d+\.\d*)$/.test(value.trim()) && Number.isFinite(Number(value))) {
119
+ return Number(value);
120
+ }
121
+ return value;
122
+ }
123
+
105
124
  export function cmdConfigSet(key, value) {
106
125
  const cfg = readConfig();
107
- cfg[key] = value;
126
+ const coerced = coerceConfigValue(value);
127
+ if (typeof key === 'string' && key.includes('.')) {
128
+ // Dotted key → nested path. Walk/create each intermediate object so
129
+ // `chat.recall` lands as cfg.chat.recall (not a flat "chat.recall" key).
130
+ // A non-object value blocking the path is replaced rather than crashing.
131
+ const segs = key.split('.');
132
+ let node = cfg;
133
+ for (let i = 0; i < segs.length - 1; i++) {
134
+ const seg = segs[i];
135
+ if (!node[seg] || typeof node[seg] !== 'object' || Array.isArray(node[seg])) node[seg] = {};
136
+ node = node[seg];
137
+ }
138
+ node[segs[segs.length - 1]] = coerced;
139
+ } else {
140
+ cfg[key] = coerced;
141
+ }
108
142
  writeConfig(cfg);
109
- console.log(JSON.stringify({ ok: true, key, value }));
143
+ console.log(JSON.stringify({ ok: true, key, value: coerced }));
110
144
  }
111
145
  export async function cmdDoctor() {
112
146
  await ensureRegistry();
@@ -1,9 +1,70 @@
1
1
  // Daemon + dashboard lifecycle commands (cmdDashboard, cmdDaemon) plus the
2
2
  // _killPortOccupant helper, extracted from cli.mjs in Phase D3.
3
3
  import path from 'node:path';
4
+ import fs from 'node:fs';
4
5
  import { ensureRegistry } from '../lib/registry_boot.mjs';
5
6
  import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
6
7
  import { assertUnattendedSafe, installCrashHandlers } from '../lib/gateway_guard.mjs';
8
+ import { isProcessAlive } from '../loops.mjs';
9
+
10
+ // The bare `lazyclaw daemon` runs in the foreground, so a started daemon
11
+ // records its pid + the actually-bound port here. status/stop read it back;
12
+ // the start path writes it after bind and removes it on graceful shutdown.
13
+ export function _daemonPidfilePath(configDir) {
14
+ return path.join(configDir, 'daemon.pid');
15
+ }
16
+
17
+ // Parse the pidfile into { pid, port } or null if missing/garbage. Never
18
+ // throws — a corrupt pidfile is treated the same as "no daemon".
19
+ export function readDaemonPidfile(pidfilePath) {
20
+ try {
21
+ const raw = fs.readFileSync(pidfilePath, 'utf8');
22
+ const obj = JSON.parse(raw);
23
+ const pid = parseInt(obj?.pid, 10);
24
+ if (!Number.isFinite(pid)) return null;
25
+ const port = Number.isFinite(parseInt(obj?.port, 10)) ? parseInt(obj.port, 10) : null;
26
+ return { pid, port };
27
+ } catch { return null; }
28
+ }
29
+
30
+ // Inspect the daemon pidfile and report liveness. A pidfile pointing at a
31
+ // dead pid is stale (the daemon crashed without removing it), so we clean it
32
+ // up here to keep `status` self-healing.
33
+ export function daemonStatus({ configDir }, deps = {}) {
34
+ const isAlive = deps.isAlive || isProcessAlive;
35
+ const pf = _daemonPidfilePath(configDir);
36
+ const rec = readDaemonPidfile(pf);
37
+ if (!rec) return { running: false, pid: null, port: null };
38
+ if (isAlive(rec.pid)) return { running: true, pid: rec.pid, port: rec.port };
39
+ // Stale pidfile — remove it so the next start/status starts clean.
40
+ try { fs.rmSync(pf); } catch { /* already gone */ }
41
+ return { running: false, pid: null, port: null };
42
+ }
43
+
44
+ // SIGTERM the recorded daemon, falling back to SIGKILL only if it ignores the
45
+ // graceful signal. Returns { running, pid, port, killed, exitCode }; a missing
46
+ // or dead pidfile is "not running" (exit 0), never an error.
47
+ export function daemonStop({ configDir }, deps = {}) {
48
+ const isAlive = deps.isAlive || isProcessAlive;
49
+ const kill = deps.kill || ((pid, sig) => process.kill(pid, sig));
50
+ const pf = _daemonPidfilePath(configDir);
51
+ const rec = readDaemonPidfile(pf);
52
+ if (!rec || !isAlive(rec.pid)) {
53
+ try { fs.rmSync(pf); } catch { /* nothing to clean */ }
54
+ return { running: false, pid: rec ? rec.pid : null, port: rec ? rec.port : null, killed: false, exitCode: 0 };
55
+ }
56
+ try { kill(rec.pid, 'SIGTERM'); } catch { /* raced with exit */ }
57
+ // Short grace window, then SIGKILL the holdout. This is best-effort and
58
+ // synchronous so the CLI exits deterministically; the daemon's own
59
+ // graceful-shutdown hook usually wins well inside the window.
60
+ if (isAlive(rec.pid)) {
61
+ const until = Date.now() + 1500;
62
+ while (isAlive(rec.pid) && Date.now() < until) { /* spin briefly */ }
63
+ if (isAlive(rec.pid)) { try { kill(rec.pid, 'SIGKILL'); } catch { /* gone */ } }
64
+ }
65
+ try { fs.rmSync(pf); } catch { /* removed by the daemon already */ }
66
+ return { running: true, pid: rec.pid, port: rec.port, killed: true, exitCode: 0 };
67
+ }
7
68
 
8
69
  // Fail closed before binding the HTTP surface: the daemon/dashboard serve
9
70
  // POST /inbound + /agent, so the global unattended-sensitive override must
@@ -134,6 +195,35 @@ export async function cmdDashboard(flags = {}) {
134
195
  }
135
196
 
136
197
  export async function cmdDaemon(flags) {
198
+ // cli.mjs dispatches `daemon` with only the parsed flags, so the lifecycle
199
+ // subcommand (status|stop|logs) is recovered from argv here. The first
200
+ // non-flag positional after `daemon` is the subcommand; absent it, we start
201
+ // the server as before.
202
+ const { parseArgs } = await import('../lib/args.mjs');
203
+ const sub = parseArgs(process.argv.slice(3)).positional[0];
204
+ const cfgDirEarly = path.dirname(configPath());
205
+ if (sub === 'status') {
206
+ const st = daemonStatus({ configDir: cfgDirEarly });
207
+ process.stdout.write(JSON.stringify(st) + '\n');
208
+ process.exit(0);
209
+ }
210
+ if (sub === 'stop') {
211
+ const r = daemonStop({ configDir: cfgDirEarly });
212
+ if (!r.running) process.stdout.write('lazyclaw daemon: not running\n');
213
+ else process.stdout.write(`lazyclaw daemon: stopped pid ${r.pid}${r.killed ? '' : ''}\n`);
214
+ process.exit(r.exitCode);
215
+ }
216
+ if (sub === 'logs') {
217
+ // The foreground daemon writes its boot line + access logs to stdout; a
218
+ // logfile only exists when it was installed as an OS service (see
219
+ // lib/service_install.mjs servicePaths). Point the user at whichever is
220
+ // applicable. No tail-follower — keep it simple.
221
+ const { servicePaths } = await import('../lib/service_install.mjs');
222
+ const logfile = servicePaths('daemon', { configDir: cfgDirEarly }).logfile;
223
+ if (fs.existsSync(logfile)) process.stdout.write(logfile + '\n');
224
+ else process.stdout.write(`No service logfile at ${logfile}.\nThe foreground daemon logs to stdout (run \`lazyclaw daemon --log info\`).\n`);
225
+ process.exit(0);
226
+ }
137
227
  await ensureRegistry();
138
228
  _bootGuard('daemon');
139
229
  const sessionsMod = await import('../sessions.mjs');
@@ -245,6 +335,13 @@ export async function cmdDaemon(flags) {
245
335
  costCap: costCapOrNull,
246
336
  }) + '\n');
247
337
  if (!once) {
338
+ // Record pid + the ACTUAL bound port (d.port, not the requested port,
339
+ // which may be 0) so `daemon status`/`daemon stop` can find us without
340
+ // an lsof on the port. Removed on graceful shutdown below.
341
+ const pidfile = _daemonPidfilePath(cfgDir);
342
+ try { fs.writeFileSync(pidfile, JSON.stringify({ pid: process.pid, port: d.port })); }
343
+ catch { /* non-fatal: the daemon still runs, just isn't stoppable by pidfile */ }
344
+ const removePidfile = () => { try { fs.rmSync(pidfile); } catch { /* already gone */ } };
248
345
  // Forward SIGINT/SIGTERM to a graceful shutdown with a hard timeout
249
346
  // (default 10 s, override with --shutdown-timeout-ms). Second signal
250
347
  // bypasses the wait and exits immediately — the orchestrator's "I
@@ -253,7 +350,7 @@ export async function cmdDaemon(flags) {
253
350
  const timeoutMs = flags['shutdown-timeout-ms'] ? parseInt(flags['shutdown-timeout-ms'], 10) : 10_000;
254
351
  // Always-on: make an unhandled crash observable + drain sockets, then
255
352
  // exit non-zero so a service manager restarts us (vs. silent death).
256
- installCrashHandlers({ label: 'daemon', logger, stop: () => gracefulShutdown(d.server, timeoutMs) });
353
+ installCrashHandlers({ label: 'daemon', logger, stop: () => { removePidfile(); return gracefulShutdown(d.server, timeoutMs); } });
257
354
  let shuttingDown = false;
258
355
  const shutdown = async () => {
259
356
  if (shuttingDown) {
@@ -263,6 +360,7 @@ export async function cmdDaemon(flags) {
263
360
  shuttingDown = true;
264
361
  if (logger) logger.info('shutdown.begin', { timeoutMs });
265
362
  const result = await gracefulShutdown(d.server, timeoutMs);
363
+ removePidfile();
266
364
  if (logger) logger.info('shutdown.end', result);
267
365
  process.exit(result.forced ? 1 : 0);
268
366
  };
@@ -14,6 +14,8 @@
14
14
  import path from 'node:path';
15
15
  import fs from 'node:fs';
16
16
  import { randomBytes } from 'node:crypto';
17
+ import { createRequire } from 'node:module';
18
+ import { pathToFileURL } from 'node:url';
17
19
  import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
18
20
  import { ensureRegistry } from '../lib/registry_boot.mjs';
19
21
  import { loadDotenvIfAny } from '../dotenv_min.mjs';
@@ -22,6 +24,13 @@ import { makeInboundHandler } from '../lib/inbound_client.mjs';
22
24
 
23
25
  export const GATEWAY_CHANNELS = ['slack', 'telegram', 'matrix'];
24
26
 
27
+ // In-tree plugin channels (channels-<name>/index.mjs). These are NOT builtins:
28
+ // each ships as a @lazyclaw/channel-* package that exports
29
+ // register({ addChannel }) and wires a factory returning a channels/base.mjs
30
+ // Channel (start/send/stop). The gateway loads the ENABLED ones at runtime so
31
+ // `channels enable discord` is actually reachable instead of a no-op.
32
+ export const PLUGIN_CHANNELS = ['discord', 'email', 'signal', 'voice', 'whatsapp'];
33
+
25
34
  // Default per-channel constructors. Each receives { handler, logger,
26
35
  // allowlist }, must return a started channel exposing send()/stop().
27
36
  // Injectable so tests can run the full gateway with stub transports.
@@ -47,15 +56,113 @@ const DEFAULT_FACTORIES = {
47
56
  },
48
57
  };
49
58
 
59
+ // Resolve an enabled plugin channel into the same gateway transport factory
60
+ // shape the builtins use: ({ handler, logger, allowlist }) -> a STARTED
61
+ // channel exposing send()/stop(). Dynamically imports channels-<name>/index.mjs
62
+ // and runs its register({ addChannel }) hook to capture the channel factory.
63
+ //
64
+ // A plugin that does not conform — import fails, no register export, never
65
+ // registers the requested name — is SKIPPED (returns null) so the gateway can
66
+ // log a warning and keep the other channels running. A factory that hands back
67
+ // the wrong shape is caught lazily, when the returned transport factory is
68
+ // invoked, so runGateway's per-channel try/catch turns it into a skip+warn.
69
+ // Resolve an in-tree adapter's runtime dependency from <cfgDir>/node_modules
70
+ // (where `lazyclaw channels install <name>` puts it), falling back to a bare
71
+ // import (dep installed alongside lazyclaw). The adapters do a bare
72
+ // `import('discord.js')` which Node resolves from the adapter's own location —
73
+ // NOT the config dir — so without this an installed dep is never found.
74
+ export function _makeDepLoader(cfgDir) {
75
+ return async (specifier) => {
76
+ if (cfgDir) {
77
+ try {
78
+ // Anchor resolution inside <cfgDir>/node_modules. The anchor file need
79
+ // not exist; createRequire only uses its directory as the base.
80
+ const req = createRequire(path.join(cfgDir, 'node_modules', '_anchor_.js'));
81
+ const resolved = req.resolve(specifier);
82
+ return await import(pathToFileURL(resolved).href);
83
+ } catch { /* fall through to a bare import */ }
84
+ }
85
+ return import(specifier);
86
+ };
87
+ }
88
+
89
+ // Map the .env-derived credentials into each in-tree adapter's constructor opts
90
+ // (the gateway loads .env into process.env at boot). discord/whatsapp also fall
91
+ // back to env internally, but email has NO env fallback and threw
92
+ // IMAP_CONFIG_MISSING when the gateway passed only { allowlist }.
93
+ export function _pluginChannelOpts(name, env = process.env, cfgDir = '') {
94
+ switch (name) {
95
+ case 'discord':
96
+ return { token: env.DISCORD_BOT_TOKEN || null };
97
+ case 'email':
98
+ return {
99
+ imap: {
100
+ host: env.EMAIL_IMAP_HOST, user: env.EMAIL_IMAP_USER, password: env.EMAIL_IMAP_PASS,
101
+ port: env.EMAIL_IMAP_PORT ? Number(env.EMAIL_IMAP_PORT) : undefined,
102
+ tls: env.EMAIL_IMAP_TLS ? env.EMAIL_IMAP_TLS !== 'false' : undefined,
103
+ },
104
+ smtp: {
105
+ host: env.EMAIL_SMTP_HOST || env.EMAIL_IMAP_HOST, port: env.EMAIL_SMTP_PORT ? Number(env.EMAIL_SMTP_PORT) : undefined,
106
+ user: env.EMAIL_SMTP_USER || env.EMAIL_IMAP_USER, pass: env.EMAIL_SMTP_PASS || env.EMAIL_IMAP_PASS,
107
+ },
108
+ };
109
+ case 'whatsapp':
110
+ return { dataPath: path.join(cfgDir || '.', 'whatsapp') };
111
+ case 'signal':
112
+ return { account: env.SIGNAL_ACCOUNT || null };
113
+ case 'voice':
114
+ return { apiKey: env.OPENAI_API_KEY || null };
115
+ default:
116
+ return {};
117
+ }
118
+ }
119
+
120
+ // `importer` is injectable for tests. cfgDir + env let the factory resolve the
121
+ // adapter's runtime dep from the config dir and thread credentials in.
122
+ export async function _loadPluginChannel(name, { importer, cfgDir = '', env = process.env } = {}) {
123
+ const load = importer || ((n) => import(`../channels-${n}/index.mjs`));
124
+ let mod;
125
+ try {
126
+ mod = await load(name);
127
+ } catch {
128
+ return null; // missing optional dep / module: skip, do not crash the gateway.
129
+ }
130
+ if (!mod || typeof mod.register !== 'function') return null;
131
+ let channelFactory = null;
132
+ try {
133
+ mod.register({ addChannel: (n, factory) => { if (n === name) channelFactory = factory; } });
134
+ } catch {
135
+ return null;
136
+ }
137
+ if (typeof channelFactory !== 'function') return null;
138
+ const loadDep = _makeDepLoader(cfgDir);
139
+ return async ({ handler, logger, allowlist }) => {
140
+ const ch = channelFactory({ allowlist, loadDep, ..._pluginChannelOpts(name, env, cfgDir) });
141
+ if (!ch || typeof ch.start !== 'function' || typeof ch.send !== 'function' || typeof ch.stop !== 'function') {
142
+ throw new Error(`plugin channel "${name}" does not conform to the channel interface (start/send/stop)`);
143
+ }
144
+ await ch.start(handler, { poll: true, logger });
145
+ return ch;
146
+ };
147
+ }
148
+
50
149
  // Which channels should the gateway run? --channels a,b wins; otherwise the
51
- // enabled cfg.channels sections that we have a built-in transport for.
150
+ // enabled cfg.channels sections we can actually run — a built-in transport OR
151
+ // an in-tree plugin channel.
52
152
  export function _selectChannels(cfg, flags = {}) {
153
+ const runnable = (n) => GATEWAY_CHANNELS.includes(n) || PLUGIN_CHANNELS.includes(n);
53
154
  if (flags.channels) {
54
155
  return String(flags.channels).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
55
- .filter((n) => GATEWAY_CHANNELS.includes(n));
156
+ .filter(runnable);
56
157
  }
57
158
  const configured = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
58
- return GATEWAY_CHANNELS.filter((n) => configured[n] && configured[n].enabled !== false);
159
+ const isEnabled = (n) => configured[n] && configured[n].enabled !== false;
160
+ // Builtins first (in their canonical order, preserving existing behavior),
161
+ // then enabled plugin channels.
162
+ return [
163
+ ...GATEWAY_CHANNELS.filter(isEnabled),
164
+ ...PLUGIN_CHANNELS.filter(isEnabled),
165
+ ];
59
166
  }
60
167
 
61
168
  // The whole gateway, with injectable deps so tests can drive it end-to-end
@@ -129,7 +236,19 @@ export async function runGateway(flags = {}, deps = {}) {
129
236
  provider: flags.provider, model: flags.model,
130
237
  });
131
238
  try {
132
- const ch = await factories[name]({
239
+ // Builtin transport, else resolve an enabled in-tree plugin channel and
240
+ // adapt it to the same factory shape. A plugin that won't load is skipped
241
+ // (factory === null) rather than crashing the gateway.
242
+ let factory = factories[name];
243
+ if (!factory) {
244
+ factory = await _loadPluginChannel(name, { importer: deps.pluginImporter, cfgDir, env: process.env });
245
+ if (!factory) {
246
+ skipped.push({ name, error: 'plugin channel did not conform (no usable register/factory)' });
247
+ log(`[gateway] ${name}: skipped (plugin channel did not load or does not conform)\n`);
248
+ continue;
249
+ }
250
+ }
251
+ const ch = await factory({
133
252
  handler,
134
253
  logger: (line) => log(line),
135
254
  allowlist: allowlistArr.length ? allowlistArr : null,
@@ -0,0 +1,78 @@
1
+ // commands/help_text.mjs — pure-data help tables for `lazyclaw help`,
2
+ // extracted verbatim from commands/setup.mjs (file-size gate, no behavior
3
+ // change). HELP_SUMMARIES (one-liners for `help`) and HELP_DETAILS (per-
4
+ // subcommand usage for `help <name>`) are read only by cmdHelp. They import
5
+ // nothing and reference nothing else — pure data, lowest-risk extraction.
6
+
7
+ // One-line summaries used by `lazyclaw help`. Format keeps it scan-friendly
8
+ // in a 80-column terminal: subcommand padded to 12 chars, then the summary.
9
+ export const HELP_SUMMARIES = {
10
+ run: 'Execute a workflow file (run <session-id> <workflow.mjs>)',
11
+ resume: 'Resume a workflow from its last persisted checkpoint',
12
+ config: 'Manage local config (get|set|list|delete <key>)',
13
+ chat: 'Interactive REPL with the configured provider',
14
+ agent: 'One-shot prompt: streams a single response, exits',
15
+ doctor: 'Print diagnostic JSON; exits non-zero on issues',
16
+ status: 'Print current provider/model/masked key as JSON',
17
+ onboard: 'Guided setup (use --non-interactive for scripts)',
18
+ sessions: 'Persistent chat sessions (list|show|clear|export)',
19
+ skills: 'Markdown skill bundles (list|show|install|remove)',
20
+ providers: 'Inspect / register providers (list|info|test|add|remove|models)',
21
+ daemon: 'Run the local HTTP gateway (--port, --auth-token, --allow-origin)',
22
+ version: 'Print VERSION + node + platform as JSON',
23
+ completion: 'Emit shell completion script (completion <bash|zsh>)',
24
+ export: 'Dump config + skills (+ optional sessions) as a JSON bundle',
25
+ import: 'Apply a JSON bundle from stdin or --from <path>',
26
+ rates: 'Manage cost rate-cards in config (rates list|set <provider/model>|delete|shape)',
27
+ auth: 'Multiple keys per provider (auth list|add|remove|use|rotate <provider>)',
28
+ pairing: 'Sender allowlist for the messaging surface (pairing list|add|remove <id>)',
29
+ nodes: 'Companion device registration (nodes list|register|remove <id>)',
30
+ message: 'Outbound webhook messaging (message list|add|remove|send <name>)',
31
+ workspace: 'AGENTS.md / SOUL.md / TOOLS.md system-prompt convention (workspace list|init|show|remove|path)',
32
+ browse: 'Fetch a URL and emit Markdown on stdout (browse <url> [--max-bytes <N>])',
33
+ cron: 'Schedule recurring agent runs via launchd / crontab (cron list|add|remove|show|sync|run)',
34
+ setup: 'Hermes-style phased first-run wizard (provider + verify chat + channel + workspace + skill + webhook)',
35
+ dashboard: 'Launch the lazyclaw-only web UI (lighter than the full lazyclaude dashboard)',
36
+ inspect: 'Print persisted workflow state without executing',
37
+ clear: 'Delete a persisted workflow state file (idempotent)',
38
+ validate: 'Static-check a workflow file: shape, deps, cycles, parallelism',
39
+ graph: 'Emit workflow DAG as Mermaid syntax (paste-ready for docs)',
40
+ orchestrator: 'Multi-agent dispatch — planner decomposes, workers run, planner synthesises',
41
+ };
42
+
43
+ // Detailed usage per subcommand for `lazyclaw help <name>`. Kept as flat
44
+ // strings so the help output is identical in every terminal.
45
+ export const HELP_DETAILS = {
46
+ run: 'Usage: lazyclaw run <session-id> <workflow.mjs> [--parallel | --parallel-persistent] [--concurrency <N>]\n Default: runPersistent — sequential, persists state, resumable via `lazyclaw resume`.\n --parallel: runParallel — topological-level DAG, in-memory only, NOT resumable.\n --parallel-persistent: runPersistentDag — DAG + checkpoint + resume.\n --concurrency <N>: cap in-flight nodes within a level (DAG modes only). 0/missing → unbounded.\n Workflow file exports `nodes`; deps: string[] declares dependencies for both DAG modes.',
47
+ resume: 'Usage: lazyclaw resume <session-id> <workflow.mjs> [--parallel-persistent] [--concurrency <N>]\n Re-enters a previously persisted run; succeeds nodes are skipped.\n Pass --parallel-persistent to resume a DAG run (must match the original run\'s mode).\n --concurrency <N>: cap in-flight nodes per level (DAG mode only).',
48
+ inspect: 'Usage: lazyclaw inspect [<session-id>] [--dir <state-dir>] [--status done|resumable|failed|running] [--summary] [--filter <substr>] [--limit <N>] [--node <node-id>] [--slowest <N>] [--critical-path <workflow.mjs>] [--aggregate]\n With no session-id: list every persisted session in the state dir, sorted by recency.\n --aggregate (list mode): per-node stats across all sessions (count, success/failed/pending/running, min/max/avg/total duration).\n --status filters the listing to a single lifecycle bucket.\n --filter / --limit refine list-mode further (case-insensitive sessionId substring + post-filter cap).\n --summary trims per-node detail in single-session mode (matches list-mode shape).\n --node <id>: print just that node\'s state. Exit 0 success/pending/running, 1 failed, 2 no such node.\n --slowest <N>: top N nodes by durationMs (descending, ties broken by id).\n --critical-path <workflow.mjs>: longest-weighted-path analysis using each node\'s recorded durationMs (bottleneck finder).\n With a session-id (no per-node flag): print full state. Exit code: 0=resumable, 1=fully done, 2=no state, 3=terminal failure.',
49
+ clear: 'Usage: lazyclaw clear <session-id> [--dir <state-dir>]\n Delete the state file for <session-id>. Idempotent — exits 0 whether the file existed or not.\n Refuses sessionIds that resolve outside <state-dir>. Mirrors DELETE /workflows/<id> on the daemon.',
50
+ validate: 'Usage: lazyclaw validate <workflow.mjs>\n Static check: load + shape + dep + cycle + parallelism estimate.\n Exit 0 valid · 1 hard failure (issues populated) · 2 file/import error.',
51
+ graph: 'Usage: lazyclaw graph <workflow.mjs> [--lr] [--state <session-id>] [--dir <state-dir>]\n Emit the workflow DAG as Mermaid syntax (graph TD by default; --lr for left-right).\n --state overlays a persisted run\'s status (success ✓ / running ⏳ / failed ✗ / pending) with classDef styling.\n Output is paste-ready for GitHub markdown / Notion / Obsidian.',
52
+ config: 'Usage: lazyclaw config <get|set|list|delete|path|edit|validate> [key] [value]\n Local key-value config at $LAZYCLAW_CONFIG_DIR/config.json (default ~/.lazyclaw).\n `path` prints the file location; `edit` opens it in $EDITOR (or $LAZYCLAW_EDITOR / $VISUAL / vi) and validates JSON on save.\n `validate` checks the structural integrity of the whole config file (typed values, known providers, rate-card shape).',
53
+ chat: 'Usage: lazyclaw chat [--session <id>] [--skill name1,name2] [--workspace <name>] [--pick] [--sandbox docker:<image>] [--sandbox-network <net>] [--sandbox-mount <m>] [--sandbox-env <e>]\n --session persists turns to <configDir>/sessions/<id>.jsonl across invocations.\n --skill composes named skills into a system message at the head of the conversation.\n --workspace stitches AGENTS.md/SOUL.md/TOOLS.md from <configDir>/workspaces/<name>/ into the system prompt.\n --pick opens an interactive provider/model picker before the prompt (also auto-fires on first run).\n --sandbox routes the underlying claude CLI through `docker run --rm -i --network <net> -v cwd:cwd ...` (default --network=none).',
54
+ agent: 'Usage: lazyclaw agent <prompt|-> [--provider X] [--model Y] [--skill list] [--workspace <name>] [--thinking N] [--show-thinking] [--usage] [--cost] [--sandbox docker:<image>]\n One-shot non-interactive call. Pass "-" as the prompt to read from stdin.\n --workspace stitches AGENTS.md/SOUL.md/TOOLS.md into the system prompt (combines with --skill).\n --usage prints normalized {inputTokens, outputTokens, ...} to stderr after the response.\n --cost adds a cost line on stderr when config.rates has a card for the active provider/model.\n --sandbox docker:<image> wraps the subprocess provider (claude-cli) in a Docker container; --sandbox-network defaults to none.',
55
+ doctor: 'Usage: lazyclaw doctor\n Validates configuration and registered providers. Exits 0 only when no issues.',
56
+ status: 'Usage: lazyclaw status\n Provider, model, and masked API key. Never prints the raw key.',
57
+ onboard: 'Usage: lazyclaw onboard [--non-interactive] [--provider X] [--model Y] [--api-key Z]\n --model accepts the unified "provider/model" string (e.g. anthropic/claude-opus-4-7).',
58
+ sessions: 'Usage: lazyclaw sessions <list [--filter <substr>] [--limit <N>]|show <id>|clear <id>|export <id> [--format md|json|text]|search <query> [--regex]>\n list — recent sessions by mtime; --filter caps to ids containing substring (case-insensitive); --limit caps result count.\n export — render in chosen format (md default for human sharing, json for tooling, text for paste).\n search — case-insensitive substring (or --regex pattern) match across all session content; returns first excerpt + match count per matching session.',
59
+ skills: 'Usage: lazyclaw skills <list [--filter <substr>] [--limit <N>]|show <name>|install <user/repo[@ref][:path]> [--prefix <p>] [--force] | install <name> [--from <path> | --from-url <https://...>]|remove <name>|search <query> [--regex]>\n list — installed skills; --filter caps to names containing substring (case-insensitive); --limit caps result count.\n install <user>/<repo>[@<ref>][:<subpath>] — fetch a GitHub tarball, install every .md under skills/ (or the explicit subpath, or repo root). Default ref is `main`.\n --prefix prepends a name prefix so a multi-skill repo doesn\'t collide with locally-managed skills. --force overwrites existing names.\n install <name> --from <path> | --from-url <https://...> — single-file install. --from-url is HTTPS-only with a 1 MiB cap.\n search — case-insensitive substring (or --regex) match across all skill markdown bodies; returns first excerpt + match count per skill.',
60
+ providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T] | add <name> --base-url <url> [--api-key <k>] [--default-model <id>] [--no-probe] | remove <name> | models <name> [--filter <substr>]>\n list — registered providers (--filter case-insensitive name substring; --limit caps post-filter count).\n info — static metadata: requiresApiKey, defaultModel, suggestedModels, endpoint.\n test — send a 1-token "ping" through the provider and report ok/error + duration.\n Useful after configuring an API key to verify it works before relying on it.\n No name OR --all: tests every registered provider in parallel; exits 0 only when ALL pass.\n add — register a custom OpenAI-compatible endpoint (NIM / OpenRouter / Together / Groq / vLLM / LM Studio / …).\n Probes /v1/models on success unless --no-probe is set; persists to cfg.customProviders[].\n remove — drop a custom provider entry from cfg.customProviders[].\n models — fetch + print the live model catalogue from <provider>/v1/models (works for openai / ollama / custom).',
61
+ daemon: 'Usage: lazyclaw daemon [--port <N>] [--once] [--auth-token <token>] [--allow-origin <origin>] [--rate-limit <N>] [--response-cache] [--log <level>] [--shutdown-timeout-ms <N>] [--cost-cap-<currency> <N> ...] [--workflow-state-dir <dir>]\n Always binds 127.0.0.1. --port 0 picks a random port and prints the URL.\n --auth-token also reads $LAZYCLAW_AUTH_TOKEN; --allow-origin also reads $LAZYCLAW_ALLOW_ORIGINS.\n --rate-limit <N> caps each remote IP at N requests / 60 s.\n --response-cache enables process-scoped memoization; per-request opt-in via body.cache.\n --log <debug|info|warn|error> emits JSON-line access logs on stderr (also reads $LAZYCLAW_LOG_LEVEL).\n --shutdown-timeout-ms <N> caps graceful drain on SIGINT/SIGTERM (default 10000). Second signal forces immediate exit.\n --cost-cap-usd 100 (or any currency code in lowercase) rejects POST /agent + /chat with 402 once cumulative cost reaches the cap.\n --workflow-state-dir <dir> backs GET /workflows + GET /workflows/<id> (default .workflow-state, also reads $LAZYCLAW_WORKFLOW_STATE_DIR).',
62
+ version: 'Usage: lazyclaw version\n Aliases: --version, -v.',
63
+ completion: 'Usage: lazyclaw completion <bash|zsh>\n bash: eval "$(lazyclaw completion bash)"\n zsh: lazyclaw completion zsh > "${fpath[1]}/_lazyclaw"',
64
+ export: 'Usage: lazyclaw export [--include-secrets] [--include-sessions] > bundle.json\n --include-secrets keeps the raw api-key in the bundle (default redacts it).\n --include-sessions adds full turn content (default keeps metadata only).',
65
+ import: 'Usage: lazyclaw import [--from <path>] [--overwrite-skills] [--no-overwrite-config] [--import-sessions]\n Reads JSON from stdin (or --from <path>). Sessions are NEVER overwritten.\n Redacted api-keys (***REDACTED***) are dropped, never written.',
66
+ rates: 'Usage: lazyclaw rates <list [--filter <substr>] [--limit <N>] | set <provider/model> --input <N> --output <N> [--cache-read <N>] [--cache-create <N>] [--currency USD] | delete <key> | shape | validate | copy <src> <dst> [--force]>\n Rates are per million tokens. costFromUsage uses cfg.rates to compute the cost block in /usage and body.cost.\n `list` accepts --filter (case-insensitive key substring) and --limit (post-filter cap), same shape sessions/skills/workflows lists use.\n `shape` prints the reference template (zero-filled) you can copy into config.\n `validate` checks the cfg.rates shape: required fields, non-negative numbers, known providers (warn-only).\n `copy` clones an existing card to a new key (use when a new model launches at the same price as an old one).',
67
+ sandbox: 'Usage: lazyclaw sandbox <list|test|add|use> [args]\n list show 6 backends (local, docker, ssh, singularity, modal, daytona)\n test <kind> run echo through the backend (or argv-shape check for remote)\n add <name> --kind <kind> [--image|--host|--user|--workspace|--app|--confiner ...]\n use <profile> set the profile as cfg.sandbox.default',
68
+ auth: 'Usage: lazyclaw auth <list <provider> | add <provider> <key> [--label <name>] | remove <provider> <label> | use <provider> <label> | rotate <provider>>\n Multiple keys per provider for rate-limit rotation. The active label is sent on every chat / agent call.\n `rotate` advances the cursor to the next label; pair with a 429 hook for auto-failover.',
69
+ pairing: 'Usage: lazyclaw pairing <list | add <id> [--label <name>] | remove <id>>\n Sender allowlist for the messaging surface. Inbound senders not on this list are rejected.\n Sender ids are opaque per-channel: Slack member id, Discord user id, phone number for SMS, etc.',
70
+ nodes: 'Usage: lazyclaw nodes <list | register <id> [--platform macos|ios|android|web|cli] [--label <name>] | remove <id>>\n Companion device registration table. CLI only — the actual mobile / menu-bar apps are out of scope here.\n Platform is free-form lower-case; future surfaces (iOS / Android nodes) authenticate against the daemon using these ids.',
71
+ message: 'Usage: lazyclaw message <list | add <name> <webhook-url> [--kind slack|discord|generic] | remove <name> | send <name> <text>>\n Outbound webhook messaging — Slack / Discord Incoming Webhooks. Auto-detects kind from the URL pattern.\n send accepts a literal string, or `-` to read the body from stdin.',
72
+ workspace: 'Usage: lazyclaw workspace <list | init <name> | show <name> [<file>] | remove <name> | path <name>>\n Workspace = a directory under <configDir>/workspaces/<name>/ containing AGENTS.md, SOUL.md, TOOLS.md.\n When `chat` or `agent` is invoked with --workspace <name>, the three files are stitched into a single system prompt at the head of the conversation. Missing files are skipped silently.\n init scaffolds the three files with short stubs you replace.\n show prints the composed prompt; show <name> AGENTS.md (etc) prints just one file.',
73
+ browse: 'Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--user-agent <ua>] [--meta]\n Fetches the URL and emits Markdown on stdout. Pipes cleanly into `agent`:\n lazyclaw browse https://example.com/docs | lazyclaw agent -\n Strips <script>/<style>/<svg>/comments, prefers <main>/<article>, falls back to <body>.\n --max-bytes caps the body read (default 2 MB) so a misconfigured upstream can\'t OOM the process.\n --meta prints { url, title, bytes, truncated } as JSON to stderr alongside the markdown on stdout.',
74
+ cron: 'Usage: lazyclaw cron <list | add <name> "<cron-spec>" -- <cmd> ... | remove <name> | show <name> | sync | run <name>>\n Schedule recurring agent runs. macOS uses launchd (~/Library/LaunchAgents/com.lazyclaw.<name>.plist); Linux / WSL uses the user crontab.\n Cron spec is the standard 5-field form (minute hour dom month dow). Supports *, range a-b, list a,b,c, step */N.\n add: pass the command after `--`. Typical use:\n lazyclaw cron add daily-summary "0 9 * * 1-5" -- lazyclaw agent "Summarise today\'s TODOs"\n list / show: read from cfg.cron[name] (config is the source of truth).\n sync: re-installs every job in cfg.cron into the system scheduler — handy after a reinstall.\n run: one-shot in-process execution of the named job; the OS scheduler does the same thing on its trigger.\n Logs: ~/.lazyclaw/logs/cron-<name>.{out,err}.log (macOS launchd path).',
75
+ setup: 'Usage: lazyclaw setup [--skip-test] [--only <steps>] [--skip <steps>]\n --only/--skip take a comma list of: provider verify channel workspace skill webhook orchestrator (e.g. --only channel re-runs just that step).\n Hermes-style phased first-run wizard — get one clean chat working first,\n then optionally add the rest. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick; ≥64k-context tip)\n 2. Verify the provider responds (1-token ping; --skip-test bypasses)\n 3. Optional channel / gateway (Slack / Telegram / Matrix / HTTP built in;\n Discord / Email / Signal / Voice / WhatsApp via plugin packages)\n 4. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 5. Optional skill bundle install from GitHub\n 6. Optional outbound webhook (Slack / Discord)\n 7. Optional multi-agent orchestration (planner + workers)\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
76
+ dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n See web/dashboard.html for the current tab set (v5: Chat / Sessions / Workflows / Skills / Providers / Rates / Metrics / Doctor / Config / Status / Agents / Teams / Tasks / Trainer / Recall / Sandbox / Channels).\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
77
+ orchestrator: 'Usage: lazyclaw orchestrator <status | set-planner <provider[:model]> | workers add <spec> | workers remove <spec> | workers set <spec,spec,...> | workers clear | set-max-subtasks <N> | clear>\n Read/write cfg.orchestrator without editing config.json by hand.\n status — print {planner, workers, maxSubtasks} as JSON; lists registered providers for reference.\n set-planner — replace the planner spec ("provider" or "provider:model"). "orchestrator" itself is rejected (self-recursion).\n workers add — append a worker (idempotent — duplicates skipped).\n workers remove — drop a worker by exact match. Idempotent.\n workers set — replace the whole list (comma-separated specs).\n workers clear — empty the workers list.\n set-max-subtasks <N> — cap subtasks per request, clamped 1..10 (default 5).\n clear — delete the cfg.orchestrator block entirely.\n Pair with: `lazyclaw config set provider orchestrator` to route chats through it.',
78
+ };
@@ -0,0 +1,150 @@
1
+ // commands/mcp.mjs — `lazyclaw mcp <list|add|remove|call>`.
2
+ //
3
+ // MCP servers are stored in cfg.mcp.servers[] (stdio transport only; an entry
4
+ // is { name, command, args[], env{}, allowGlob }). They are spawned at daemon
5
+ // boot by mcp/server_spawn.startConfigured. This command manages that list and
6
+ // lets you invoke a single tool from the CLI for a quick check.
7
+ //
8
+ // Verb dispatch + output convention mirror commands/agents.mjs cmdTeam:
9
+ // success → JSON on stdout, usage/validation errors → console.error + exit 2,
10
+ // operational failures → exit 1.
11
+
12
+ import { readConfig, writeConfig } from '../lib/config.mjs';
13
+
14
+ // The name becomes the tool namespace `mcp:<name>:<tool>`, so it must not carry
15
+ // a colon or whitespace — reuse the cron/job name grammar.
16
+ const NAME_RE = /^[A-Za-z0-9_.-]+$/;
17
+
18
+ const emitJson = (o) => process.stdout.write(JSON.stringify(o, null, 2) + '\n');
19
+
20
+ // `--args "-y pkg /tmp"` and `--env "K=V K2=V2"` arrive as one whitespace-
21
+ // joined string (parseArgs consumes a single argv token), so split on spaces.
22
+ function splitWords(raw) {
23
+ if (raw === undefined || raw === null || raw === true) return [];
24
+ return String(raw).split(/\s+/).filter(Boolean);
25
+ }
26
+ function parseEnv(raw) {
27
+ const env = {};
28
+ for (const pair of splitWords(raw)) {
29
+ const i = pair.indexOf('=');
30
+ if (i > 0) env[pair.slice(0, i)] = pair.slice(i + 1);
31
+ }
32
+ return env;
33
+ }
34
+
35
+ export async function cmdMcp(sub, positional = [], flags = {}) {
36
+ const cfg = readConfig();
37
+ const servers = (cfg.mcp && Array.isArray(cfg.mcp.servers)) ? cfg.mcp.servers : [];
38
+
39
+ switch (sub) {
40
+ case undefined:
41
+ case 'list': {
42
+ const configured = servers.map((s) => ({
43
+ name: s.name, command: s.command, args: s.args || [], allowGlob: s.allowGlob || '*',
44
+ }));
45
+ // listServers() reports servers connected in THIS process; the short-lived
46
+ // CLI never boots them, so this is normally empty — surfaced anyway so the
47
+ // same shape works when the command runs inside the daemon.
48
+ let connected = [];
49
+ try { connected = (await import('../mcp/client.mjs')).listServers(); }
50
+ catch { /* mcp client unavailable — report configured only */ }
51
+ emitJson({ ok: true, configured, connected });
52
+ return;
53
+ }
54
+
55
+ case 'add': {
56
+ const name = positional[0];
57
+ const command = flags.command;
58
+ if (!name || !command || command === true) {
59
+ console.error('Usage: lazyclaw mcp add <name> --command <cmd> [--args "<a b c>"] [--allow-glob <glob>] [--env "K=V ..."]');
60
+ process.exit(2);
61
+ }
62
+ if (!NAME_RE.test(name)) {
63
+ console.error(`mcp add: invalid name "${name}" — letters/digits/.-_ only (it becomes the tool namespace mcp:${name}:<tool>)`);
64
+ process.exit(2);
65
+ }
66
+ if (servers.find((s) => s.name === name)) {
67
+ console.error(`mcp add: server "${name}" already exists — remove it first or pick another name`);
68
+ process.exit(2);
69
+ }
70
+ const record = {
71
+ name,
72
+ command: String(command),
73
+ args: splitWords(flags.args),
74
+ allowGlob: (flags['allow-glob'] && flags['allow-glob'] !== true) ? String(flags['allow-glob']) : '*',
75
+ };
76
+ const env = parseEnv(flags.env);
77
+ if (Object.keys(env).length) record.env = env;
78
+ cfg.mcp = cfg.mcp || {};
79
+ cfg.mcp.servers = servers.concat([record]);
80
+ writeConfig(cfg);
81
+ emitJson({ ok: true, added: record });
82
+ return;
83
+ }
84
+
85
+ case 'remove':
86
+ case 'rm':
87
+ case 'delete': {
88
+ const name = positional[0];
89
+ if (!name) { console.error('Usage: lazyclaw mcp remove <name>'); process.exit(2); }
90
+ if (!servers.find((s) => s.name === name)) {
91
+ console.error(`mcp remove: no configured server "${name}"`);
92
+ process.exit(2);
93
+ }
94
+ cfg.mcp = cfg.mcp || {};
95
+ cfg.mcp.servers = servers.filter((s) => s.name !== name);
96
+ writeConfig(cfg);
97
+ emitJson({ ok: true, removed: name });
98
+ return;
99
+ }
100
+
101
+ case 'call': {
102
+ const server = positional[0];
103
+ const toolName = positional[1];
104
+ if (!server || !toolName) {
105
+ console.error('Usage: lazyclaw mcp call <server> <tool> [--args-json \'{"k":"v"}\']');
106
+ process.exit(2);
107
+ }
108
+ const sconf = servers.find((s) => s.name === server);
109
+ if (!sconf) {
110
+ console.error(`mcp call: no configured server "${server}" — add it with 'lazyclaw mcp add ${server} --command <cmd>'`);
111
+ process.exit(2);
112
+ }
113
+ let args = {};
114
+ if (flags['args-json'] !== undefined && flags['args-json'] !== true) {
115
+ try { args = JSON.parse(String(flags['args-json'])); }
116
+ catch (e) { console.error(`mcp call: --args-json is not valid JSON: ${e.message}`); process.exit(2); }
117
+ }
118
+ const mcpClient = await import('../mcp/client.mjs');
119
+ const registry = await import('../mas/tools/registry.mjs');
120
+ let started = false;
121
+ try {
122
+ // sensitive:true — the same approval-gate invariant the daemon boot
123
+ // enforces (server_spawn). A CLI call can never downgrade an MCP tool.
124
+ await mcpClient.startServer({
125
+ name: sconf.name, command: sconf.command, args: sconf.args || [],
126
+ env: sconf.env || {}, allowGlob: sconf.allowGlob || '*', sensitive: true,
127
+ });
128
+ started = true;
129
+ const full = `mcp:${server}:${toolName}`;
130
+ const tool = registry.lookup(full);
131
+ if (!tool) {
132
+ const available = registry.listNames().filter((n) => n.startsWith(`mcp:${server}:`));
133
+ console.error(`mcp call: tool "${full}" not found. Available: ${available.join(', ') || '(none)'}`);
134
+ process.exit(1);
135
+ }
136
+ emitJson(await tool.exec(args));
137
+ } catch (e) {
138
+ console.error(`mcp call: ${e?.message || e}`);
139
+ process.exit(1);
140
+ } finally {
141
+ if (started) { try { await mcpClient.stopServer(server); } catch { /* best-effort */ } }
142
+ }
143
+ return;
144
+ }
145
+
146
+ default:
147
+ console.error('Usage: lazyclaw mcp <list|add|remove|call> ...');
148
+ process.exit(2);
149
+ }
150
+ }
package/commands/misc.mjs CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import { configPath } from '../lib/config.mjs';
6
6
  import { resolveSandbox, listBackends } from '../sandbox/index.mjs';
7
+ import { pickAvailableConfiner } from '../sandbox/spawn.mjs';
7
8
 
8
9
  export async function cmdBrowse(url, flags = {}) {
9
10
  if (!url) { console.error('Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--meta]'); process.exit(2); }
@@ -33,6 +34,25 @@ export async function cmdSandbox(args, flags = {}) {
33
34
  return 0;
34
35
  }
35
36
 
37
+ if (sub === 'status') {
38
+ // Show the EFFECTIVE default-on confinement posture for this host so the
39
+ // operator can audit what sensitive tools run under.
40
+ const cfg = _sandboxLoadConfigOrEmpty();
41
+ const sb = cfg.sandbox || {};
42
+ const off = sb.confine === false || sb.default === 'off' || sb.default === 'none';
43
+ const confiner = (sb.local && sb.local.confiner && sb.local.confiner !== 'auto')
44
+ ? sb.local.confiner
45
+ : pickAvailableConfiner();
46
+ const allowNet = sb.allowNet !== false;
47
+ process.stdout.write(`confinement: ${off ? 'OFF' : 'ON'} (default-on; opt out with cfg.sandbox.confine=false)\n`);
48
+ process.stdout.write(`host confiner: ${confiner}\n`);
49
+ process.stdout.write(`policy: writes → workspace + temp only · secret dirs unreadable · network ${allowNet ? 'allowed' : 'denied'}\n`);
50
+ if (!off && confiner === 'none') {
51
+ process.stdout.write('note: no OS confiner available on this host — sensitive tools run UNCONFINED. Install bubblewrap or firejail on Linux.\n');
52
+ }
53
+ return 0;
54
+ }
55
+
36
56
  if (sub === 'test') {
37
57
  const name = args[1];
38
58
  if (!name) { process.stderr.write('usage: lazyclaw sandbox test <kind|profile>\n'); return 2; }