lazyclaw 6.3.0 → 6.4.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 (165) 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 +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +95 -37
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +210 -95
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
package/commands/chat.mjs CHANGED
@@ -6,8 +6,10 @@
6
6
  import path from 'node:path';
7
7
  import {
8
8
  configPath, readConfig, writeConfig,
9
+ persistActiveModel, persistActiveProvider,
9
10
  _resolveAuthKey, _resolveBaseUrl, readVersionFromRepo,
10
11
  } from '../lib/config.mjs';
12
+ import { renderRecord } from '../lib/render.mjs';
11
13
  import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
12
14
  import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
13
15
  import {
@@ -16,10 +18,20 @@ import {
16
18
  _quickPrompt, _renderBanner, _renderV5Banner,
17
19
  } from '../tui/pickers.mjs';
18
20
  import { firstRunMode as _firstRunMode, hasConfiguredProvider } from '../first_run.mjs';
19
- import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
21
+ import { applyChatWindow as _applyChatWindow, estimateMessagesTokens, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
20
22
  import { makeRunTurn as _chatRunTurnFactory } from '../tui/run_turn.mjs';
21
- import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } from '../tui/slash_dispatcher.mjs';
23
+ import { hudStatus as _hudStatus } from '../tui/hud.mjs';
24
+ import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine, _makeInkApprove } from '../tui/slash_dispatcher.mjs';
22
25
  import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
26
+ import { wrapInteractiveProv, makeLegacyApprove } from './chat_hardening.mjs';
27
+
28
+ // /new, /reset and /clear must signal the Ink REPL to wipe the screen +
29
+ // scrollback via the 'NEW' sentinel (handled in tui/repl.mjs). _newReset itself
30
+ // returns a human string for the string-rendering consumers, so the Ink handler
31
+ // translates these commands here. Exported so the contract is unit-testable.
32
+ export function _isInkResetCmd(cmd) {
33
+ return /^\/(new|reset|clear)$/i.test(String(cmd || ''));
34
+ }
23
35
 
24
36
  // Legacy (non-Ink) slash routing for dispatcher-style, ctx-only commands.
25
37
  // The Ink REPL routes every slash through _dispatchSlash/SLASH_HANDLERS, but
@@ -89,9 +101,20 @@ export async function cmdChat(flags = {}) {
89
101
  }
90
102
  activeProvName = 'claude-cli';
91
103
  }
92
- let prov = lookupProv(activeProvName);
104
+ let prov = wrapInteractiveProv(lookupProv(activeProvName)); // transient-retry the chat hot path
93
105
  if (!prov) { console.error(`unknown provider: ${activeProvName}`); process.exit(2); }
94
106
 
107
+ // First-turn key preflight: warn up front when the active provider needs an
108
+ // API key but none resolves, instead of letting the first turn fail opaquely.
109
+ // Cheap (no network); TTY-only so pipelines aren't spammed.
110
+ if (process.stdout.isTTY) {
111
+ const _meta = (getRegistry().PROVIDER_INFO || {})[activeProvName] || {};
112
+ if (_meta.requiresApiKey && !_resolveAuthKey(cfg, activeProvName)) {
113
+ process.stdout.write(` ⚠ no API key found for ${activeProvName} — the first message will fail until you set one.\n`);
114
+ process.stdout.write(` fix: /provider (pick + paste a key) · or lazyclaw auth add ${activeProvName}\n`);
115
+ }
116
+ }
117
+
95
118
  // Top-of-session banner so the user can see at a glance what they're
96
119
  // talking to. Cheap (no provider call) and TTY-only.
97
120
  // v5 ink splash + REPL when stdin is a real TTY and the user has not
@@ -107,8 +130,7 @@ export async function cmdChat(flags = {}) {
107
130
  // narrow-terminal fallback: <60 cols falls back to v4
108
131
  if ((process.stdout.columns || 80) < 60) throw new Error('narrow-terminal');
109
132
 
110
- // Tool + skill groups for the splash panel — shared with the setup
111
- // wizard via tui/splash_props.mjs so both surfaces render the same panel.
133
+ // Tool + skill groups for the splash panel — shared with the setup wizard.
112
134
  const { gatherToolAndSkillGroups } = await import('../tui/splash_props.mjs');
113
135
  const { tools: toolGroups, skills: skillGroups } =
114
136
  await gatherToolAndSkillGroups(path.dirname(configPath()));
@@ -123,13 +145,9 @@ export async function cmdChat(flags = {}) {
123
145
  };
124
146
  void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
125
147
 
126
- // C7 — minimal chat-session state for the ink path so runTurn can
127
- // talk to the provider (the legacy readline path below sets up the
128
- // same shape — kept duplicated here intentionally so the ink branch
129
- // remains self-contained and the legacy path stays byte-identical).
130
- // Slash commands aren't wired into the ink REPL yet (v5.1 follow-up);
131
- // until then, system-prompt composition / --session resume happen
132
- // identically to the legacy path.
148
+ // C7 — minimal chat-session state for the ink path so runTurn can talk to
149
+ // the provider. The legacy readline path below sets up the same shape
150
+ // (kept duplicated so each branch stays self-contained).
133
151
  let _inkSandboxSpec = null;
134
152
  if (flags.sandbox) {
135
153
  const sb = await import('../sandbox.mjs');
@@ -203,8 +221,7 @@ export async function cmdChat(flags = {}) {
203
221
  }).catch(() => {});
204
222
  } catch { /* swallow */ }
205
223
  };
206
- // v5.4: chars-sent counter for the Ink chat path. Mirrors the legacy
207
- // path's `charsSent` so /usage in Ink reports the same number.
224
+ // v5.4: chars-sent counter for the Ink path (mirrors legacy `charsSent`).
208
225
  let _inkCharsSent = 0;
209
226
  const _inkCtx = {
210
227
  cfg,
@@ -219,11 +236,15 @@ export async function cmdChat(flags = {}) {
219
236
  getMessages: () => _inkMessages,
220
237
  setMessages: (next) => { _inkMessages = Array.isArray(next) ? next : []; },
221
238
  getProv: () => prov,
222
- setProv: (next) => { prov = next; },
239
+ setProv: (next) => { prov = wrapInteractiveProv(next); },
223
240
  getActiveProvName: () => activeProvName,
224
- setActiveProvName: (name) => { activeProvName = name; },
241
+ // Persist provider/model picks so they survive a restart (was
242
+ // in-memory only — a model chosen via /model reverted to cfg.model on
243
+ // the next launch). persistActiveProvider leaves orchestrator routing
244
+ // to /orchestrator on|off.
245
+ setActiveProvName: (name) => { activeProvName = name; persistActiveProvider(cfg, name); },
225
246
  getActiveModel: () => activeModel,
226
- setActiveModel: (name) => { activeModel = name; },
247
+ setActiveModel: (name) => { activeModel = name; persistActiveModel(cfg, name); },
227
248
  getSessionId: () => _inkSessionId,
228
249
  setSessionId: (id) => { _inkSessionId = id; },
229
250
  getCharsSent: () => _inkCharsSent,
@@ -235,8 +256,7 @@ export async function cmdChat(flags = {}) {
235
256
  resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
236
257
  resolveBaseUrl: (providerName) => _resolveBaseUrl(providerName),
237
258
  onCharsSent: (n) => { _inkCharsSent += Number(n) || 0; },
238
- // P2 — let /provider add register a custom OpenAI-compatible endpoint
239
- // by read-merge-writing config.json from inside the Ink session.
259
+ // P2 — /provider add read-merge-writes config.json from the Ink session.
240
260
  readConfig: () => readConfig(),
241
261
  writeConfig: (next) => writeConfig(next),
242
262
  };
@@ -253,6 +273,7 @@ export async function cmdChat(flags = {}) {
253
273
  ? api.openPicker(opts)
254
274
  : Promise.resolve(null);
255
275
  };
276
+ _inkCtx.approve = _makeInkApprove(_inkCtx); // agentic sensitive tools → Ink approval modal
256
277
  // Route streamed chunks through ReplApp's injected writeFn: chunks
257
278
  // land in React state (liveAssistant) → live region → committed to
258
279
  // the <Static/> scrollback on turn-complete, so Ink owns all output.
@@ -271,9 +292,38 @@ export async function cmdChat(flags = {}) {
271
292
  const { cmd, args } = _parseSlashLine(line);
272
293
  // Thread the REPL's abort signal so Esc/Ctrl-C can stop a /loop.
273
294
  _inkCtx.loopSignal = signal || null;
274
- return _dispatchSlash(cmd, args, _inkCtx, (chunk) => {
295
+ const result = await _dispatchSlash(cmd, args, _inkCtx, (chunk) => {
275
296
  try { process.stdout.write(chunk); } catch { /* swallow */ }
276
297
  });
298
+ // _newReset (/new, /reset, /clear) returns a human string, but repl.mjs
299
+ // only wipes the screen + scrollback on the 'NEW' sentinel. Translate
300
+ // here (mirrors the 'EXIT' sentinel) so the real /new actually clears.
301
+ if (_isInkResetCmd(cmd)) return 'NEW';
302
+ return result;
303
+ };
304
+ // v6.x slash-argument completion (two surfaces, see tui/slash_args.mjs):
305
+ // onArgList → inline candidates rendered in the popup (login, hud,
306
+ // memory, config, channels, subcommands, names, …).
307
+ // onArgComplete → Tab opens the drill-in modal for 2-step provider→model
308
+ // specs (/model, /trainer set, /orchestrator planner).
309
+ // Both resolve through _inkCtx (its openPicker IS ReplApp's modal; its
310
+ // cfgDir/registry feed the inline lists).
311
+ const { argSpecFor: _argSpecFor, runArgCompleter: _runArgCompleter, listArgCandidates: _listArgCandidates } = await import('../tui/slash_args.mjs');
312
+ const { SLASH_COMMANDS: _ARG_CATALOG } = await import('../tui/slash_commands.mjs');
313
+ const _argRegistry = await import('../providers/registry.mjs');
314
+ const _inkArgComplete = async (buffer) => {
315
+ try {
316
+ const spec = _argSpecFor(buffer, _ARG_CATALOG);
317
+ if (!spec || spec.kind !== 'modal') return null;
318
+ return await _runArgCompleter(spec, _inkCtx, _argRegistry);
319
+ } catch { return null; }
320
+ };
321
+ const _inkArgList = (buffer) => {
322
+ try {
323
+ const spec = _argSpecFor(buffer, _ARG_CATALOG);
324
+ if (!spec || spec.kind !== 'inline') return [];
325
+ return _listArgCandidates(spec, _inkCtx, _argRegistry);
326
+ } catch { return []; }
277
327
  };
278
328
  // v5.4.1: splash renders INSIDE the alt-buffer (not pre-printed to
279
329
  // primary). The v5.4.0 pre-print made the screen go blank during
@@ -282,19 +332,21 @@ export async function cmdChat(flags = {}) {
282
332
  const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
283
333
  splashProps,
284
334
  statusInfo: { provider: activeProvName, model: activeModel },
285
- // P3 — live status: read the current provider/model + token gauge so
286
- // the StatusBar refreshes after a /provider or /model switch and each
287
- // turn, instead of showing the values captured at mount.
335
+ // P3 — live status: provider/model + history-based ctx gauge (not the
336
+ // provider's self-reported per-call usage) + the HUD field bundle.
288
337
  getStatus: () => ({
289
338
  provider: activeProvName,
290
339
  model: activeModel,
291
- ctxUsed: _inkRunningUsage ? _inkRunningUsage.totalTokens : undefined,
340
+ ctxUsed: estimateMessagesTokens(_inkMessages),
292
341
  ctxTotal: Number((cfg.chat || {}).windowTokens) || CHAT_WINDOW_TOKEN_BUDGET,
342
+ hud: _hudStatus(cfg, _inkRunningUsage),
293
343
  }),
294
344
  runTurnFactory: _inkRunTurnFactory,
295
345
  onSlashCommand: _inkSlashHandler,
346
+ onArgComplete: _inkArgComplete,
347
+ onArgList: _inkArgList,
296
348
  pickerRef: _inkPickerRef,
297
- }), { exitOnCtrlC: true, patchConsole: true });
349
+ }), { exitOnCtrlC: false, patchConsole: true }); // false → editor 2-stage Ctrl+C
298
350
  await ink.waitUntilExit();
299
351
  // /setup → full wizard (then shell). /config single step → run JUST
300
352
  // that step now that Ink released stdin, then re-enter chat.
@@ -302,16 +354,21 @@ export async function cmdChat(flags = {}) {
302
354
  else if (_inkCtx.requestConfigStep) {
303
355
  await (await import('./config_step.mjs')).runConfigStep(_inkCtx.requestConfigStep);
304
356
  return cmdChat(flags);
357
+ } else if (_inkCtx.requestLogin) {
358
+ // Connect a keyless CLI provider in the foreground (Ink freed stdin), re-enter.
359
+ await (await import('../providers/cli_login.mjs')).runCliLoginInteractive(_inkCtx.requestLogin);
360
+ return cmdChat(flags);
305
361
  }
306
362
  return;
307
363
  } catch (e) {
308
- // Fall through to legacy path on any ink failure (missing import,
309
- // narrow terminal, sandboxed stdout).
310
- if (process.env.LAZYCLAW_DEBUG) console.error('[ink] fallback:', e.message);
364
+ // Fall through to the legacy readline path on any ink failure. ALWAYS
365
+ // say why, in one dim line — the silent downgrade made real-terminal
366
+ // failures (node incompat, <60-col windows) undiagnosable from reports.
367
+ process.stderr.write(`\x1b[2m(ink UI unavailable: ${e?.message || e} — using the legacy reader)\x1b[0m\n`);
311
368
  }
312
369
  }
313
370
  // ─── legacy v4 path (unchanged) ─────────────────────────────────
314
- _printChatBanner(activeProvName, activeModel, readVersionFromRepo());
371
+ await _printChatBanner(activeProvName, activeModel, readVersionFromRepo());
315
372
 
316
373
  const readline = await import('node:readline');
317
374
  // Use terminal:true when we're attached to a TTY so the prompt shows
@@ -481,8 +538,7 @@ export async function cmdChat(flags = {}) {
481
538
  getSessionId: () => sessionId,
482
539
  persistTurn,
483
540
  accumulateUsage,
484
- resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
485
- onCharsSent: (n) => { charsSent += n; },
541
+ resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName), onCharsSent: (n) => { charsSent += n; }, approve: makeLegacyApprove(),
486
542
  };
487
543
  const runTurn = _chatRunTurnFactory({
488
544
  ctx: _legacyCtx,
@@ -528,7 +584,7 @@ export async function cmdChat(flags = {}) {
528
584
  return;
529
585
  }
530
586
  activeProvName = picked.provider;
531
- prov = next;
587
+ prov = wrapInteractiveProv(next);
532
588
  if (picked.model) activeModel = picked.model;
533
589
  process.stdout.write(`provider → ${activeProvName}${picked.model ? ` · model → ${picked.model}` : ''}\n`);
534
590
  }
@@ -541,7 +597,7 @@ export async function cmdChat(flags = {}) {
541
597
  return true;
542
598
  }
543
599
  activeProvName = arg;
544
- prov = next;
600
+ prov = wrapInteractiveProv(next);
545
601
  process.stdout.write(`provider → ${arg}\n`);
546
602
  return true;
547
603
  }
@@ -574,7 +630,7 @@ export async function cmdChat(flags = {}) {
574
630
  return true;
575
631
  }
576
632
  activeProvName = parsed.provider;
577
- prov = next;
633
+ prov = wrapInteractiveProv(next);
578
634
  }
579
635
  activeModel = parsed.model || arg;
580
636
  process.stdout.write(`model → ${activeModel}${parsed.provider ? ` (provider → ${parsed.provider})` : ''}\n`);
@@ -958,10 +1014,11 @@ export async function cmdChat(flags = {}) {
958
1014
  process.stdout.write(`• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]\n`);
959
1015
  }
960
1016
  } else if (sub === 'show') {
961
- if (!aname) { process.stdout.write('usage: /agent show <name>\n'); return true; }
1017
+ if (!aname) { process.stdout.write('usage: /agent show <name> [json]\n'); return true; }
962
1018
  const a = agentsMod.getAgent(aname, cfgDir);
963
1019
  if (!a) process.stdout.write(`no agent "${aname}"\n`);
964
- else process.stdout.write(JSON.stringify(a, null, 2) + '\n');
1020
+ else if (rest[1] === 'json') process.stdout.write(JSON.stringify(a, null, 2) + '\n');
1021
+ else process.stdout.write(renderRecord(a, { fields: ['name', 'displayName', 'provider', 'model', 'role', 'tools', 'tags', 'iconEmoji', 'memoryWrite', 'skillWrite', 'createdAt', 'updatedAt'] }) + '\n');
965
1022
  } else if (sub === 'add') {
966
1023
  if (!aname) { process.stdout.write('usage: /agent add <name> [role text…]\n'); return true; }
967
1024
  const roleText = rest.slice(1).join(' ').trim();
@@ -1209,7 +1266,8 @@ export async function cmdChat(flags = {}) {
1209
1266
  try { process.stdin.pause(); } catch (_) {}
1210
1267
  try { process.stdin.unref(); } catch (_) {}
1211
1268
  }
1212
- // /config in the legacy path: re-run the wizard after the readline loop closes.
1269
+ // /config legacy path: re-run the wizard after the readline loop closes.
1270
+ // (Inline login is Ink-only — legacy ctx has no openPicker — so no requestLogin.)
1213
1271
  if (_legacyCtx.requestSetup) await (await import('./setup.mjs')).cmdSetup(undefined, [], {});
1214
1272
  }
1215
1273
 
@@ -0,0 +1,23 @@
1
+ // commands/chat_hardening.mjs — chat-path reliability helpers, extracted from
2
+ // commands/chat.mjs to keep that file under its file-size ceiling.
3
+ //
4
+ // 1. wrapInteractiveProv: wrap the active chat provider with the same
5
+ // transient-retry the daemon composes, so a 429/529/5xx before the first
6
+ // chunk is retried instead of surfacing as a chat "error: ..." line. The
7
+ // wrapper never retries a mid-stream error (would duplicate output).
8
+ // 2. makeLegacyApprove: the readline approval hook for the legacy / non-TTY
9
+ // chat path, so agentic sensitive tools have a fail-closed human gate.
10
+
11
+ import { withRateLimitRetry } from '../providers/retry.mjs';
12
+ import { makeReadlineApprove } from '../tui/terminal_approve.mjs';
13
+
14
+ // Guard-safe: returns the falsy input unchanged so the caller's
15
+ // unknown-provider check still fires (withRateLimitRetry(undefined) would
16
+ // otherwise produce a truthy "undefined+retry" wrapper).
17
+ export function wrapInteractiveProv(prov) {
18
+ return prov ? withRateLimitRetry(prov) : prov;
19
+ }
20
+
21
+ export function makeLegacyApprove() {
22
+ return makeReadlineApprove();
23
+ }
@@ -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,