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
@@ -26,20 +26,37 @@
26
26
  // skillsMod, // optional — falls back to dynamic import
27
27
  // }
28
28
  //
29
- // Interactive sub-menus (provider/model pickers, /personality picker) are
30
- // readline-coupled in cli.mjs. In Ink we surface a hint instead of crashing;
31
- // operators can pass an arg form (e.g. `/provider openai`) or fall back to
32
- // LAZYCLAW_NO_INK=1. Re-implementing these as Ink overlays is a v5.5 item.
29
+ // Interactive sub-menus that are readline-coupled in cli.mjs surface a hint in
30
+ // Ink instead of crashing; pass an arg form (`/provider openai`) or set
31
+ // LAZYCLAW_NO_INK=1.
33
32
 
34
33
  import { SLASH_COMMANDS } from './slash_commands.mjs';
35
- import { supportsLiveFetch, fetchModelsForProvider } from '../providers/model_catalogue.mjs';
36
- import { providerFamilies, providerTag } from './provider_families.mjs';
34
+ import { nearest } from '../lib/args.mjs';
35
+ import {
36
+ pickProviderModel,
37
+ pickProviderDrillIn as _pickProviderDrillIn,
38
+ infoFor as _infoFor,
39
+ providerLookup as _providerLookup,
40
+ } from './model_pick.mjs';
41
+ import { renderRecord } from '../lib/render.mjs';
37
42
  import { addCustomProvider } from '../providers/custom_provider.mjs';
38
43
  import { setAuthKey } from '../providers/auth_store.mjs';
44
+ import { runProviderLogin, loginSlash } from './login_flow.mjs';
45
+ import { hudSlash } from './hud.mjs';
46
+ import { agenticSlash, planSlash, CHAT_MODE_SLASH_COMMANDS } from './chat_mode_slash.mjs';
47
+ import { orchestratorSlash, pickAndSetModel } from './orchestrator_flow.mjs';
39
48
  import { attachGoalCron, detachGoalCron } from '../goals_cron.mjs';
40
49
  import { loadDotenvIfAny } from '../dotenv_min.mjs';
41
50
  import { SUBCOMMAND_GROUPS } from './subcommands.mjs';
42
51
  import { redactSecrets } from '../mas/redact.mjs';
52
+ import { splitWhitespace, _mod, _promptText, _promptConfirm } from './slash_helpers.mjs';
53
+ import { _dashboard, parseDashboardUrl } from './slash_dashboard.mjs';
54
+ import { _channels, _context } from './slash_channels.mjs';
55
+ import { _trainer } from './slash_trainer.mjs';
56
+
57
+ // Re-export so callers/tests that import parseDashboardUrl from this module
58
+ // (the dispatcher was its original home) keep resolving after the extraction.
59
+ export { parseDashboardUrl };
43
60
 
44
61
  // ─── helpers ─────────────────────────────────────────────────────────────
45
62
 
@@ -55,29 +72,6 @@ export function parseSlashLine(line) {
55
72
  return { cmd: m[1], args: (m[3] || '').trim() };
56
73
  }
57
74
 
58
- // Tiny utility — split args on whitespace, drop empties. Used by sub-command
59
- // handlers that don't need the loop-engine's full quote-aware splitter.
60
- function splitWhitespace(s) {
61
- return (s || '').split(/\s+/).filter(Boolean);
62
- }
63
-
64
- // Parse a "provider[:model]" spec, preferring the registry's parser.
65
- function _parseProvModel(registry, spec) {
66
- if (registry && typeof registry.parseProviderModel === 'function') return registry.parseProviderModel(spec);
67
- const s = String(spec || '');
68
- const i = s.indexOf(':');
69
- if (i < 0) return { provider: s || null, model: null };
70
- return { provider: s.slice(0, i) || null, model: s.slice(i + 1) || null };
71
- }
72
-
73
- // Best-effort dynamic import. Returns the resolved ctx field if the caller
74
- // pre-injected it (test hot path), else loads the real module. Throwing is
75
- // fine — handlers wrap calls in try/catch where appropriate.
76
- async function _mod(ctx, key, importer) {
77
- if (ctx && ctx[key]) return ctx[key];
78
- return importer();
79
- }
80
-
81
75
  // ─── handlers ────────────────────────────────────────────────────────────
82
76
 
83
77
  async function _help() {
@@ -156,110 +150,11 @@ async function _newReset(_args, ctx) {
156
150
  return 'cleared — new conversation';
157
151
  }
158
152
 
159
- function _providerLookup(registry, name) {
160
- if (typeof registry.lookupProv === 'function') return registry.lookupProv(name);
161
- return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
162
- }
163
-
164
- // Pick a provider via the family drill-in (API key / CLI-Local / Mock),
165
- // mirroring the legacy readline wizard. Single-option steps auto-advance.
166
- // orchestrator is never listed. Returns a provider id or null on cancel.
167
- async function _pickProviderDrillIn(ctx, registry) {
168
- const info = registry.PROVIDER_INFO || {};
169
- const families = providerFamilies(registry);
170
- const nonEmpty = Object.values(families).filter((f) => f.members.length > 0);
171
- if (!nonEmpty.length) return null;
172
-
173
- // ── Step 1 — auth family (skipped when only one is populated) ──
174
- let family = nonEmpty[0];
175
- if (nonEmpty.length > 1) {
176
- const picked = await ctx.openPicker({
177
- kind: 'provider-family',
178
- title: 'select provider — how do you want to auth?',
179
- subtitle: 'API: bring your own key · CLI/Local: use this machine · Mock: offline',
180
- items: nonEmpty.map((f) => ({
181
- id: f.id,
182
- label: f.label,
183
- desc: `${f.desc} · ${f.members.slice(0, 3).join(' / ')}${f.members.length > 3 ? ` (+${f.members.length - 3})` : ''}`,
184
- tag: f.tag,
185
- })),
186
- });
187
- if (!picked || typeof picked !== 'string') return null;
188
- family = families[picked];
189
- if (!family || !family.members.length) return null;
190
- }
191
-
192
- // ── Step 2 — provider in that family (auto-advances on a single member) ──
193
- // The API-key family also offers "+ add a custom endpoint" so NIM /
194
- // OpenRouter / vLLM / etc. can be registered without leaving chat. The
195
- // add-custom row is never auto-advanced past, so don't single-skip it in.
196
- const items = family.members.map((id) => {
197
- const meta = info[id] || {};
198
- let desc = '';
199
- if (meta.custom) desc = `custom · ${meta.baseUrl || ''}`;
200
- else if (meta.builtinOpenAICompat) desc = meta.label || meta.baseUrl || '';
201
- else if (meta.label && meta.label !== id) desc = meta.label;
202
- return { id, label: id, desc, tag: providerTag(meta) };
203
- });
204
- if (family.id === 'api') {
205
- items.push({
206
- id: '__add_custom__',
207
- label: '+ add a custom OpenAI-compatible endpoint…',
208
- desc: 'NIM · OpenRouter · Together · Groq · vLLM · LM Studio',
209
- tag: 'new',
210
- });
211
- }
212
- if (items.length === 1) return items[0].id;
213
- const picked = await ctx.openPicker({
214
- kind: 'provider',
215
- title: `select provider — ${family.label}`,
216
- subtitle: `current: ${ctx.getActiveProvName()}`,
217
- items,
218
- });
219
- return typeof picked === 'string' ? picked : null;
220
- }
221
-
222
- // Single free-text prompt reusing the modal's filter buffer (no dedicated
223
- // input widget). Returns the typed value, '' (only when allowEmpty), or null
224
- // on cancel / required-but-empty.
225
- async function _promptText(ctx, { title, subtitle, allowEmpty } = {}) {
226
- if (typeof ctx.openPicker !== 'function') return null;
227
- const picked = await ctx.openPicker({
228
- kind: 'text',
229
- title,
230
- subtitle: subtitle || 'type into the filter, then pick the row · Esc cancels',
231
- items: [{ id: '__text__', label: '✓ use what I typed above', desc: '', pinned: true, freeText: true }],
232
- });
233
- if (picked == null) return null;
234
- if (typeof picked === 'object') {
235
- const v = String(picked.query || '').trim();
236
- if (!v && !allowEmpty) return null;
237
- return v;
238
- }
239
- return null;
240
- }
241
-
242
- // Yes/no confirmation modal for sensitive-tool approval. Esc (or no modal
243
- // available) DENIES — approval is never granted by omission.
244
- async function _promptConfirm(ctx, { title, subtitle } = {}) {
245
- if (typeof ctx.openPicker !== 'function') return false;
246
- const picked = await ctx.openPicker({
247
- kind: 'menu',
248
- title: title || 'Approve sensitive tool?',
249
- subtitle: subtitle || 'Enter selects · Esc denies',
250
- items: [
251
- { id: 'approve', label: '✓ approve once', desc: 'run this tool call' },
252
- { id: 'deny', label: '✗ deny', desc: 'block this tool call' },
253
- ],
254
- });
255
- const id = picked && typeof picked === 'object' ? picked.id : picked;
256
- return id === 'approve';
257
- }
258
153
 
259
154
  // Default in-chat approval hook: prompts the operator to confirm each
260
155
  // sensitive tool call. Used to drive the fail-closed tool runner from the
261
156
  // Ink REPL, where stdin is owned by Ink so a raw readline prompt can't run.
262
- function _makeInkApprove(ctx) {
157
+ export function _makeInkApprove(ctx) {
263
158
  return async function approve({ tool, args, agent }) {
264
159
  const raw = typeof args === 'object' ? JSON.stringify(args) : String(args ?? '');
265
160
  const summary = redactSecrets(raw).slice(0, 300);
@@ -310,6 +205,7 @@ async function _maybePromptForKey(ctx, registry, provName) {
310
205
  const key = await _promptText(ctx, {
311
206
  title: `${provName} needs an api key`,
312
207
  subtitle: 'paste it now, or Esc to skip (set later via: lazyclaw auth)',
208
+ secret: true,
313
209
  });
314
210
  if (!key) return;
315
211
  const next = setAuthKey({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, provider: provName, key });
@@ -327,30 +223,30 @@ async function _addCustomFlow(ctx, registry) {
327
223
  if (!name) return 'cancelled';
328
224
  const baseUrl = await _promptText(ctx, { title: `baseUrl for ${name}`, subtitle: 'must start with http(s) and end in /v1' });
329
225
  if (!baseUrl) return 'cancelled';
330
- const apiKey = await _promptText(ctx, { title: `api-key for ${name}`, subtitle: 'leave blank for an auth-less endpoint (e.g. local vLLM)', allowEmpty: true });
226
+ const apiKey = await _promptText(ctx, { title: `api-key for ${name}`, subtitle: 'leave blank for an auth-less endpoint (e.g. local vLLM)', allowEmpty: true, secret: true });
331
227
  if (apiKey === null) return 'cancelled';
332
228
  return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
333
229
  }
334
230
 
335
231
  async function _provider(args, ctx) {
336
232
  const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
337
- // `/provider add <name> <baseUrl> [apiKey]` — register a custom OpenAI-
338
- // compatible endpoint non-interactively.
233
+ // `/provider add <name> <baseUrl> [apiKey]` — register a custom OpenAI-compat endpoint.
339
234
  const addMatch = args && args.match(/^add\s+(.+)$/i);
340
235
  if (addMatch) {
341
236
  const [name, baseUrl, apiKey] = splitWhitespace(addMatch[1]);
342
237
  if (!name || !baseUrl) return 'usage: /provider add <name> <baseUrl> [apiKey]';
343
238
  return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
344
239
  }
345
- // No arg → drill-in modal picker (family -> provider). Falls back to the
346
- // pre-v5.4.3 hint string when ctx.openPicker isn't available (e.g. non-Ink
347
- // callers or before the picker ref settles).
240
+ // No arg → drill-in modal picker (family -> provider); falls back to a hint
241
+ // string when ctx.openPicker isn't available (non-Ink / picker not settled).
242
+ let _fromPicker = false;
348
243
  if (!args) {
349
244
  if (typeof ctx.openPicker === 'function') {
350
245
  const picked = await _pickProviderDrillIn(ctx, registry);
351
246
  if (!picked) return 'cancelled';
352
247
  if (picked === '__add_custom__') return _addCustomFlow(ctx, registry);
353
248
  args = picked;
249
+ _fromPicker = true;
354
250
  // Built-in api-key provider with no key configured → offer to set one.
355
251
  await _maybePromptForKey(ctx, registry, args);
356
252
  } else {
@@ -364,190 +260,43 @@ async function _provider(args, ctx) {
364
260
  }
365
261
  if (ctx.setActiveProvName) ctx.setActiveProvName(args);
366
262
  if (ctx.setProv) ctx.setProv(next);
367
- return `provider${args}`;
368
- }
369
-
370
- function _infoFor(registry, provName) {
371
- return (registry.PROVIDER_INFO && registry.PROVIDER_INFO[provName]) || {};
372
- }
373
-
374
- // A composite provider (orchestrator) has no real model list — its only
375
- // "model" is the provider name itself, so the model picker would dead-end
376
- // on a single bogus row. Detect it so we can redirect to a provider pick.
377
- function _isCompositeProvider(info, provName) {
378
- if (info && info.composite) return true;
379
- const s = info && Array.isArray(info.suggestedModels) ? info.suggestedModels : null;
380
- return !!(s && s.length === 1 && s[0] === provName);
381
- }
382
-
383
- // Whether the active provider exposes any selectable model (a suggested
384
- // model that isn't just the provider name, or a live-fetchable catalogue).
385
- function _hasRealModels(info, provName) {
386
- if (supportsLiveFetch(info, provName)) return true;
387
- const s = info && Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
388
- return s.some((m) => m && m !== provName);
389
- }
390
-
391
- // Build the model-picker rows: suggested + any live-fetched models, deduped,
392
- // with the provider default tagged, plus pinned sentinel rows for live-fetch
393
- // (when supported) and free-text custom entry.
394
- function _buildModelItems(info, provName, dynamicModels) {
395
- const base = Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
396
- const all = Array.from(new Set([...(dynamicModels || []), ...base])).filter((m) => m && m !== provName);
397
- const items = [];
398
- if (supportsLiveFetch(info, provName)) {
399
- items.push({
400
- id: '__fetch_models__',
401
- label: '↻ fetch live model list',
402
- desc: 'pull the current catalogue from the provider',
403
- pinned: true,
404
- });
263
+ // Keyless CLI not signed in inline connect menu ('EXIT' = foreground login).
264
+ const r = await runProviderLogin(ctx, args, { promptText: _promptText });
265
+ if (r !== null) return r;
266
+ // Picked from the modal → chain straight into a model pick so provider+model
267
+ // are set together (no separate /model step). Composites have no model list.
268
+ if (_fromPicker && args !== 'orchestrator' && typeof ctx.openPicker === 'function') {
269
+ const mm = await pickAndSetModel(ctx, registry, args);
270
+ if (mm) return `provider ${args} · ${mm}`;
405
271
  }
406
- for (const m of all) {
407
- items.push({ id: m, label: m, desc: info.defaultModel === m ? '(default)' : '' });
408
- }
409
- items.push({
410
- id: '__custom_model__',
411
- label: '… type a custom model id',
412
- desc: 'type the id into the filter above, then pick this row',
413
- pinned: true,
414
- freeText: true,
415
- });
416
- // Reach another provider's models (e.g. claude-cli's opus) without leaving
417
- // /model — the active provider isn't the only place to pick a model.
418
- items.push({
419
- id: '__switch_provider__',
420
- label: '⇄ pick a different provider…',
421
- desc: 'switch provider (e.g. claude-cli for opus/sonnet), then its model',
422
- pinned: true,
423
- });
424
- return items;
425
- }
426
-
427
- // Run the model picker for `provName`, looping on the live-fetch row and
428
- // resolving the free-text row from the typed filter. Returns a concrete
429
- // model id, or null on cancel.
430
- async function _pickModelLoop(ctx, registry, provName) {
431
- const info = _infoFor(registry, provName);
432
- let dynamic = [];
433
- let note = '';
434
- for (let guard = 0; guard < 50; guard++) {
435
- const items = _buildModelItems(info, provName, dynamic);
436
- const picked = await ctx.openPicker({
437
- kind: 'model',
438
- title: `select model for ${provName}`,
439
- subtitle: note || `current: ${ctx.getActiveModel() || '(default)'}`,
440
- items,
441
- });
442
- if (picked == null) return null;
443
- if (typeof picked === 'object') {
444
- // free-text custom row → { id, query }
445
- const typed = String(picked.query || '').trim();
446
- if (!typed) { note = 'type a model id into the filter first'; continue; }
447
- return typed;
448
- }
449
- if (picked === '__fetch_models__') {
450
- try {
451
- const fetcher = typeof ctx.fetchModels === 'function'
452
- ? ctx.fetchModels
453
- : (provId) => fetchModelsForProvider({
454
- cfg: ctx.cfg,
455
- registryMod: registry,
456
- resolveAuthKey: (id) => (ctx.resolveAuthKey ? ctx.resolveAuthKey(id) : ''),
457
- providerId: provId,
458
- });
459
- const fetched = await fetcher(provName);
460
- if (Array.isArray(fetched) && fetched.length) {
461
- dynamic = fetched;
462
- note = `fetched ${fetched.length} model(s) — pick one or type a custom id`;
463
- } else {
464
- note = 'no models returned — using the suggested list';
465
- }
466
- } catch (e) {
467
- note = `fetch failed: ${e && e.message ? e.message : e}`;
468
- }
469
- continue;
470
- }
471
- // Switch to a different provider's models (e.g. claude-cli's opus).
472
- if (picked === '__switch_provider__') return '__switch_provider__';
473
- return picked;
474
- }
475
- return null;
272
+ return `provider ${args}`;
476
273
  }
477
274
 
478
- // Flat provider picker used to escape a composite/model-less active provider
479
- // or to switch providers from inside /model. Hides composites + mock,
480
- // mirroring the legacy wizard's filter (cli.mjs:1979).
481
- async function _pickProviderForModel(ctx, registry, subtitle) {
482
- const info = registry.PROVIDER_INFO || {};
483
- const known = Object.keys(registry.PROVIDERS || {})
484
- .filter((id) => id !== 'mock' && !((info[id] || {}).composite))
485
- .sort();
486
- const items = known.map((id) => ({
487
- id,
488
- label: id,
489
- desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
490
- }));
491
- const picked = await ctx.openPicker({
492
- kind: 'provider',
493
- title: 'select provider (then a model)',
494
- subtitle: subtitle || `${ctx.getActiveProvName()} has no selectable models — pick a provider`,
495
- items,
496
- });
497
- return typeof picked === 'string' ? picked : null;
498
- }
499
275
 
500
276
  async function _model(args, ctx) {
501
277
  const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
502
278
  if (!args) {
503
279
  if (typeof ctx.openPicker === 'function') {
504
- let provName = ctx.getActiveProvName();
505
- let info = _infoFor(registry, provName);
506
- let switched = false;
507
- // Composite (orchestrator) or model-less active provider pick a
508
- // provider first so the user is never dead-ended on a single row.
509
- if (_isCompositeProvider(info, provName) || !_hasRealModels(info, provName)) {
510
- const pickedProv = await _pickProviderForModel(ctx, registry);
511
- if (!pickedProv) return 'cancelled';
512
- if (pickedProv !== provName) {
513
- const next = _providerLookup(registry, pickedProv);
514
- if (!next) return `unknown provider: ${pickedProv}`;
515
- if (ctx.setActiveProvName) ctx.setActiveProvName(pickedProv);
516
- if (ctx.setProv) ctx.setProv(next);
517
- switched = true;
518
- }
519
- provName = pickedProv;
520
- info = _infoFor(registry, provName);
521
- }
522
- // Model pick loop — the "⇄ pick a different provider" row re-enters the
523
- // provider picker so models from any provider (e.g. claude-cli's opus)
524
- // are reachable without leaving /model.
525
- let model = null;
526
- for (let guard = 0; guard < 25; guard++) {
527
- model = await _pickModelLoop(ctx, registry, provName);
528
- if (model === '__switch_provider__') {
529
- const np = await _pickProviderForModel(ctx, registry, `current: ${provName} — pick a provider`);
530
- if (!np) continue; // cancelled the switch → back to the model list
531
- if (np !== provName) {
532
- const next = _providerLookup(registry, np);
533
- if (!next) { continue; }
534
- if (ctx.setActiveProvName) ctx.setActiveProvName(np);
535
- if (ctx.setProv) ctx.setProv(next);
536
- switched = true;
537
- provName = np;
538
- info = _infoFor(registry, provName);
539
- }
540
- continue;
541
- }
542
- break;
280
+ // Shared canonical picker: provider drill-in (only when the active
281
+ // provider has no models) → model loop with the "⇄ switch provider"
282
+ // and custom-id rows. Session-only — /model does not persist to disk.
283
+ const r = await pickProviderModel(ctx, registry, { includeSwitch: true });
284
+ if (!r) return 'cancelled';
285
+ const switched = r.provider !== ctx.getActiveProvName();
286
+ if (switched) {
287
+ const next = _providerLookup(registry, r.provider);
288
+ if (!next) return `unknown provider: ${r.provider}`;
289
+ if (ctx.setActiveProvName) ctx.setActiveProvName(r.provider);
290
+ if (ctx.setProv) ctx.setProv(next);
543
291
  }
544
- if (model == null || model === '__switch_provider__') {
545
- return switched ? `provider → ${provName} (model unchanged)` : 'cancelled';
292
+ // Model pick cancelled (null) keep any provider switch, leave model.
293
+ if (r.model == null) {
294
+ return switched ? `provider → ${r.provider} (model unchanged)` : 'cancelled';
546
295
  }
547
- if (ctx.setActiveModel) ctx.setActiveModel(model);
296
+ if (ctx.setActiveModel) ctx.setActiveModel(r.model);
548
297
  return switched
549
- ? `provider → ${provName} · model → ${model}`
550
- : `model → ${model}`;
298
+ ? `provider → ${r.provider} · model → ${r.model}`
299
+ : `model → ${r.model}`;
551
300
  }
552
301
  return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
553
302
  }
@@ -569,13 +318,22 @@ async function _model(args, ctx) {
569
318
  async function _skill(args, ctx) {
570
319
  // `/skill name1,name2` — replace the active system message with a
571
320
  // composition. No-arg → clear system message. Mirrors cli.mjs:3046.
572
- const names = args.split(',').map((s) => s.trim()).filter(Boolean);
321
+ // `clear`/`unset` are not skill names treat them as the explicit clear verb
322
+ // so they reach the clear branch instead of being composed as a skill.
323
+ const isClear = /^(clear|unset)$/i.test((args || '').trim());
324
+ const names = isClear ? [] : args.split(',').map((s) => s.trim()).filter(Boolean);
573
325
  const messages = ctx.getMessages().slice(); // mutable copy
574
326
  const sysIdx = messages.findIndex((m) => m.role === 'system');
575
327
  const sid = ctx.getSessionId && ctx.getSessionId();
576
328
  const sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
577
329
 
578
330
  if (names.length === 0) {
331
+ // Footgun guard: bare `/skill` used to silently wipe the active skills.
332
+ // Now no-arg opens the skill picker; clearing requires explicit /skill clear.
333
+ if (!isClear) {
334
+ if (typeof ctx.openPicker === 'function') return _skillsList('', ctx);
335
+ return 'usage: /skill <name>[,<name>] · /skill clear to unset · /skills to pick';
336
+ }
579
337
  if (sysIdx >= 0) messages.splice(sysIdx, 1);
580
338
  if (ctx.setMessages) ctx.setMessages(messages);
581
339
  if (sid) {
@@ -619,7 +377,8 @@ async function _skillsList(args, ctx) {
619
377
  if (!names.length) {
620
378
  return [
621
379
  'no skills installed.',
622
- 'install one: lazyclaw skills install <owner>/<repo>',
380
+ 'starter pack: lazyclaw skills starter',
381
+ 'install more: lazyclaw skills install <owner>/<repo>',
623
382
  'then /skills to pick, or /skill <name>[,<name>] to activate.',
624
383
  ].join('\n');
625
384
  }
@@ -677,6 +436,18 @@ async function _memory(args, ctx) {
677
436
  try { mem = await import('../memory.mjs'); }
678
437
  catch (e) { return `memory unavailable: ${e?.message || e}`; }
679
438
  const tokens = splitWhitespace(args);
439
+ if (!tokens.length && typeof ctx.openPicker === 'function') {
440
+ const picked = await ctx.openPicker({
441
+ kind: 'menu', title: 'Memory', subtitle: 'view a memory store',
442
+ items: [
443
+ { id: 'core', label: 'Core', desc: 'persistent core memory' },
444
+ { id: 'recent', label: 'Recent', desc: 'last ~20 messages' },
445
+ { id: 'episodic', label: 'Episodic', desc: 'consolidated topic files' },
446
+ ],
447
+ });
448
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
449
+ return _memory(typeof pid === 'string' && pid ? pid : 'core', ctx);
450
+ }
680
451
  const which = tokens[0] || 'core';
681
452
  if (which === 'core') {
682
453
  const body = mem.loadCore(ctx.cfgDir);
@@ -740,6 +511,20 @@ async function _agent(args, ctx) {
740
511
  const rest = tokens.slice(1);
741
512
  const aname = rest[0];
742
513
  try {
514
+ if (!sub && typeof ctx.openPicker === 'function') {
515
+ const picked = await ctx.openPicker({
516
+ kind: 'menu', title: 'Agents', subtitle: `${agentsMod.listAgents(ctx.cfgDir).length} registered`,
517
+ items: [
518
+ { id: 'list', label: 'List agents', desc: 'show all' },
519
+ { id: 'add', label: 'Add agent…', desc: '/agent add <name> [role]' },
520
+ { id: 'edit', label: 'Edit agent…', desc: 'pick provider + model' },
521
+ { id: 'show', label: 'Show agent…', desc: 'print one record' },
522
+ { id: 'remove', label: 'Remove agent…', desc: 'delete a record' },
523
+ ],
524
+ });
525
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
526
+ return _agent(typeof pid === 'string' && pid ? pid : 'list', ctx);
527
+ }
743
528
  if (!sub || sub === 'list') {
744
529
  const agents = agentsMod.listAgents(ctx.cfgDir);
745
530
  if (agents.length === 0) return 'no agents registered. /agent add <name> [...] to create.';
@@ -749,33 +534,63 @@ async function _agent(args, ctx) {
749
534
  }).join('\n');
750
535
  }
751
536
  if (sub === 'show') {
752
- if (!aname) return 'usage: /agent show <name>';
537
+ if (!aname) return 'usage: /agent show <name> [json]';
753
538
  const a = agentsMod.getAgent(aname, ctx.cfgDir);
754
539
  if (!a) return `no agent "${aname}"`;
755
- return JSON.stringify(a, null, 2);
540
+ return rest[1] === 'json'
541
+ ? JSON.stringify(a, null, 2)
542
+ : renderRecord(a, { fields: ['name', 'displayName', 'provider', 'model', 'role', 'tools', 'tags', 'iconEmoji', 'memoryWrite', 'skillWrite', 'createdAt', 'updatedAt'] });
756
543
  }
757
544
  if (sub === 'add') {
758
- if (!aname) return 'usage: /agent add <name> [role text…]';
759
- const roleText = rest.slice(1).join(' ').trim();
760
- const a = agentsMod.registerAgent({ name: aname, role: roleText }, ctx.cfgDir);
761
- return `✓ added agent ${a.name} (tools=${(a.tools || []).join(',')})`;
545
+ let name = aname;
546
+ let roleText = rest.slice(1).join(' ').trim();
547
+ // Guided fill: no name typed + a modal available → prompt for it.
548
+ if (!name && typeof ctx.openPicker === 'function') {
549
+ name = await _promptText(ctx, { title: 'New agent — name', subtitle: 'short id, e.g. scout (Esc cancels)' });
550
+ if (!name) return 'agent add: cancelled';
551
+ if (!roleText) {
552
+ const r = await _promptText(ctx, { title: `Role for ${name}`, subtitle: 'one line describing what this agent does (Esc to skip)', allowEmpty: true });
553
+ roleText = r || '';
554
+ }
555
+ }
556
+ if (!name) return 'usage: /agent add <name> [role text…]';
557
+ const a = agentsMod.registerAgent({ name, role: roleText }, ctx.cfgDir);
558
+ return `✓ added agent ${a.name} (tools=${(a.tools || []).join(',')}) — set its model with /agent edit ${a.name}`;
559
+ }
560
+ if (sub === 'edit') {
561
+ if (!aname) return 'usage: /agent edit <name>';
562
+ const existing = agentsMod.getAgent(aname, ctx.cfgDir);
563
+ if (!existing) return `no agent "${aname}"`;
564
+ if (typeof ctx.openPicker !== 'function') {
565
+ return `agent edit: picker unavailable here — use: lazyclaw agent edit ${aname} --provider <p> --model <m>`;
566
+ }
567
+ const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
568
+ const r = await pickProviderModel(ctx, registry, { pickProvider: true, includeDefault: true, includeSwitch: false });
569
+ if (!r || r.model == null) return 'agent edit: cancelled';
570
+ const patched = agentsMod.patchAgent(aname, { provider: r.provider, model: r.model || '' }, ctx.cfgDir);
571
+ return `✓ ${patched.name} → ${patched.provider}${patched.model ? '/' + patched.model : ''}`;
762
572
  }
763
573
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
764
574
  if (!aname) return 'usage: /agent remove <name>';
575
+ if (typeof ctx.openPicker === 'function') {
576
+ const ok = await _promptConfirm(ctx, { title: `Remove agent "${aname}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
577
+ if (!ok) return `agent remove: cancelled — "${aname}" not removed`;
578
+ }
765
579
  agentsMod.removeAgent(aname, ctx.cfgDir);
766
580
  return `✓ removed agent ${aname}`;
767
581
  }
768
- return `/agent: unknown sub "${sub}" — list|show|add|remove`;
582
+ return `/agent: unknown sub "${sub}" — list|show|add|edit|remove`;
769
583
  } catch (e) {
770
584
  return `/agent error: ${e?.message || e}`;
771
585
  }
772
586
  }
773
587
 
774
588
  async function _team(args, ctx) {
775
- let teamsMod, loopMod;
589
+ let teamsMod, loopMod, agentsMod;
776
590
  try {
777
591
  teamsMod = await import('../teams.mjs');
778
592
  loopMod = await import('../loop-engine.mjs');
593
+ agentsMod = await import('../agents.mjs');
779
594
  } catch (e) { return `/team unavailable: ${e?.message || e}`; }
780
595
  let tokens;
781
596
  try { tokens = loopMod.splitArgs(args); }
@@ -784,6 +599,19 @@ async function _team(args, ctx) {
784
599
  const rest = tokens.slice(1);
785
600
  const tname = rest[0];
786
601
  try {
602
+ if (!sub && typeof ctx.openPicker === 'function') {
603
+ const picked = await ctx.openPicker({
604
+ kind: 'menu', title: 'Teams', subtitle: `${teamsMod.listTeams(ctx.cfgDir).length} registered`,
605
+ items: [
606
+ { id: 'list', label: 'List teams', desc: 'show all' },
607
+ { id: 'add', label: 'Add team…', desc: '/team add <name> --agents a,b --lead a' },
608
+ { id: 'show', label: 'Show team…', desc: 'print one record' },
609
+ { id: 'remove', label: 'Remove team…', desc: 'delete a team' },
610
+ ],
611
+ });
612
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
613
+ return _team(typeof pid === 'string' && pid ? pid : 'list', ctx);
614
+ }
787
615
  if (!sub || sub === 'list') {
788
616
  const teams = teamsMod.listTeams(ctx.cfgDir);
789
617
  if (teams.length === 0) return 'no teams registered. /team add <name> --agents a,b --lead a [--channel #x]';
@@ -793,14 +621,16 @@ async function _team(args, ctx) {
793
621
  }).join('\n');
794
622
  }
795
623
  if (sub === 'show') {
796
- if (!tname) return 'usage: /team show <name>';
624
+ if (!tname) return 'usage: /team show <name> [json]';
797
625
  const t = teamsMod.getTeam(tname, ctx.cfgDir);
798
626
  if (!t) return `no team "${tname}"`;
799
- return JSON.stringify(t, null, 2);
627
+ return rest[1] === 'json'
628
+ ? JSON.stringify(t, null, 2)
629
+ : renderRecord(t, { fields: ['name', 'displayName', 'lead', 'agents', 'slackChannel', 'createdAt', 'updatedAt'] });
800
630
  }
801
631
  if (sub === 'add') {
802
- if (!tname) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
803
632
  let agentsCsv = null, lead = null, channel = '';
633
+ let teamName = tname;
804
634
  for (let i = 1; i < rest.length; i++) {
805
635
  const t = rest[i];
806
636
  if (t === '--agents') agentsCsv = rest[++i] || '';
@@ -808,18 +638,51 @@ async function _team(args, ctx) {
808
638
  else if (t === '--channel') channel = rest[++i] || '';
809
639
  else return `/team error: unknown token "${t}"`;
810
640
  }
811
- if (!agentsCsv) return '/team add: --agents is required';
812
- const agentsList = teamsMod.parseListFlag(agentsCsv);
641
+ // Guided fill: no --agents + a modal available → name prompt, then a
642
+ // multi-pick over registered agents, then a lead pick. Typed form
643
+ // (--agents …) and the no-modal path are unchanged.
644
+ let agentsList;
645
+ if (!agentsCsv && typeof ctx.openPicker === 'function') {
646
+ if (!teamName) {
647
+ teamName = await _promptText(ctx, { title: 'New team — name', subtitle: 'short id (Esc cancels)' });
648
+ if (!teamName) return 'team add: cancelled';
649
+ }
650
+ const all = agentsMod ? agentsMod.listAgents(ctx.cfgDir).map((a) => a.name) : [];
651
+ if (!all.length) return 'team add: no agents registered yet — add one with /agent add first';
652
+ const chosen = [];
653
+ for (let guard = 0; guard < 50; guard++) {
654
+ const items = all.filter((n) => !chosen.includes(n)).map((n) => ({ id: n, label: n }));
655
+ if (chosen.length) items.unshift({ id: '__done__', label: `✓ done (${chosen.length} selected)`, pinned: true });
656
+ if (!items.length) break;
657
+ const p = await ctx.openPicker({ kind: 'menu', title: `Team agents — ${chosen.length} picked`, subtitle: 'pick agents one at a time · ✓ done to finish · Esc cancels', items });
658
+ const id = p && typeof p === 'object' ? p.id : p;
659
+ if (!id) { if (chosen.length) break; return 'team add: cancelled'; }
660
+ if (id === '__done__') break;
661
+ chosen.push(id);
662
+ }
663
+ if (!chosen.length) return 'team add: cancelled (no agents picked)';
664
+ const lp = await ctx.openPicker({ kind: 'menu', title: 'Team lead', subtitle: 'who leads this team?', items: chosen.map((n) => ({ id: n, label: n })) });
665
+ lead = (lp && typeof lp === 'object' ? lp.id : lp) || chosen[0];
666
+ agentsList = chosen;
667
+ } else {
668
+ if (!teamName) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
669
+ if (!agentsCsv) return '/team add: --agents is required';
670
+ agentsList = teamsMod.parseListFlag(agentsCsv);
671
+ }
813
672
  const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
814
673
  botToken: process.env.SLACK_BOT_TOKEN || null,
815
674
  apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
816
675
  logger: () => {},
817
676
  }) : '';
818
- const team = teamsMod.registerTeam({ name: tname, agents: agentsList, lead, slackChannel: ch }, ctx.cfgDir);
677
+ const team = teamsMod.registerTeam({ name: teamName, agents: agentsList, lead, slackChannel: ch }, ctx.cfgDir);
819
678
  return `✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})`;
820
679
  }
821
680
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
822
681
  if (!tname) return 'usage: /team remove <name>';
682
+ if (typeof ctx.openPicker === 'function') {
683
+ const ok = await _promptConfirm(ctx, { title: `Remove team "${tname}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
684
+ if (!ok) return `team remove: cancelled — "${tname}" not removed`;
685
+ }
823
686
  teamsMod.removeTeam(tname, ctx.cfgDir);
824
687
  return `✓ removed team ${tname}`;
825
688
  }
@@ -945,6 +808,22 @@ async function _goal(args, ctx) {
945
808
  sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
946
809
  } catch (e) { return `/goal unavailable: ${e?.message || e}`; }
947
810
  if (!args) {
811
+ if (typeof ctx.openPicker === 'function') {
812
+ const active = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
813
+ const picked = await ctx.openPicker({
814
+ kind: 'menu', title: 'Goals', subtitle: `${active.length} active`,
815
+ items: [
816
+ { id: 'list', label: 'List goals', desc: 'show all' },
817
+ { id: 'add', label: 'Add goal…', desc: '/goal add <name> [--desc] [--cron]' },
818
+ { id: 'show', label: 'Show goal…', desc: 'print one record' },
819
+ { id: 'close', label: 'Close goal…', desc: 'mark done/abandoned' },
820
+ ...active.map((g) => ({ id: g.name, label: `↪ switch: ${g.name}`, desc: g.description || '' })),
821
+ ],
822
+ });
823
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
824
+ if (pid && typeof pid === 'string') return _goal(pid, ctx);
825
+ // cancelled → fall through to the text list below
826
+ }
948
827
  const items = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
949
828
  if (!items.length) return 'no active goals';
950
829
  return items.map((g) =>
@@ -967,6 +846,30 @@ async function _goal(args, ctx) {
967
846
  else if (!name) name = t;
968
847
  else return `goal error: unexpected arg "${t}"`;
969
848
  }
849
+ // Guided fill: no name + a modal available → prompt name + desc, and offer
850
+ // a cron preset picker instead of a hand-typed spec. Typed form unchanged.
851
+ if (!name && typeof ctx.openPicker === 'function') {
852
+ name = await _promptText(ctx, { title: 'New goal — name', subtitle: 'short id (Esc cancels)' });
853
+ if (!name) return 'goal add: cancelled';
854
+ if (!desc) {
855
+ const d = await _promptText(ctx, { title: `Goal "${name}" — description`, subtitle: 'what is this goal? (Esc to skip)', allowEmpty: true });
856
+ desc = d || '';
857
+ }
858
+ if (!cron) {
859
+ const cp = await ctx.openPicker({
860
+ kind: 'menu', title: 'Schedule (optional)', subtitle: 'run this goal on a cron?',
861
+ items: [
862
+ { id: '', label: 'none', desc: 'no schedule' },
863
+ { id: '0 9 * * *', label: 'daily 09:00', desc: '0 9 * * *' },
864
+ { id: '0 * * * *', label: 'hourly', desc: '0 * * * *' },
865
+ { id: '0 9 * * 1', label: 'weekly (Mon 09:00)', desc: '0 9 * * 1' },
866
+ { id: '__custom__', label: 'custom…', desc: 'type a cron spec', freeText: true },
867
+ ],
868
+ });
869
+ if (cp && typeof cp === 'object' && cp.id === '__custom__') cron = String(cp.query || '').trim() || null;
870
+ else { const cid = cp && typeof cp === 'object' ? cp.id : cp; cron = cid && typeof cid === 'string' ? cid : null; }
871
+ }
872
+ }
970
873
  if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
971
874
  try {
972
875
  const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
@@ -993,10 +896,12 @@ async function _goal(args, ctx) {
993
896
  if (sub === 'list') return JSON.stringify(goalsMod.listGoals(ctx.cfgDir), null, 2);
994
897
  if (sub === 'show') {
995
898
  const name = rest[0];
996
- if (!name) return 'usage: /goal show <name>';
899
+ if (!name) return 'usage: /goal show <name> [json]';
997
900
  const g = goalsMod.getGoal(name, ctx.cfgDir);
998
901
  if (!g) return `no goal "${name}"`;
999
- return JSON.stringify(g, null, 2);
902
+ return rest[1] === 'json'
903
+ ? JSON.stringify(g, null, 2)
904
+ : renderRecord(g, { fields: ['name', 'status', 'description', 'schedule', 'channels', 'createdAt', 'memoryPath'] });
1000
905
  }
1001
906
  if (sub === 'close') {
1002
907
  const name = rest[0];
@@ -1014,7 +919,7 @@ async function _goal(args, ctx) {
1014
919
  if (removed) detachNote = ' (cron detached)';
1015
920
  } catch { /* best-effort */ }
1016
921
  }
1017
- return `✓ goal ${g.name} closed (status: ${g.status})${detachNote}`;
922
+ return `✓ goal ${g.name} closed (status: ${g.status})${detachNote} — start a fresh goal with /goal add ${g.name} --desc "..."`;
1018
923
  } catch (e) { return `goal error: ${e?.message || e}`; }
1019
924
  }
1020
925
  // single-arg branch: switch
@@ -1116,17 +1021,37 @@ async function _personality(args, ctx) {
1116
1021
  return fs.readFileSync(p, 'utf8');
1117
1022
  }
1118
1023
  if (sub === 'install') {
1119
- if (!a || !b) return 'usage: /personality install <name> <file.md>';
1120
- const dst = path.join(dir, `${a}.md`);
1121
- if (fs.existsSync(dst)) return `personality already installed: ${a}`;
1122
- if (!fs.existsSync(b)) return `source file not found: ${b}`;
1123
- fs.writeFileSync(dst, fs.readFileSync(b, 'utf8'));
1124
- return `installed ${a}`;
1024
+ let nm = a, src = b;
1025
+ // Guided fill: missing name/file + a modal → prompt for them, and retry on
1026
+ // a bad path instead of erroring out (up to a few attempts).
1027
+ if ((!nm || !src) && typeof ctx.openPicker === 'function') {
1028
+ if (!nm) {
1029
+ nm = await _promptText(ctx, { title: 'Install personality — name', subtitle: 'short id to install it as (Esc cancels)' });
1030
+ if (!nm) return 'personality install: cancelled';
1031
+ }
1032
+ if (fs.existsSync(path.join(dir, `${nm}.md`))) return `personality already installed: ${nm}`;
1033
+ for (let attempt = 0; attempt < 3 && !src; attempt++) {
1034
+ const p = await _promptText(ctx, { title: `Source file for ${nm}`, subtitle: attempt ? 'not found — try again (Esc cancels)' : 'path to a .md file (Esc cancels)' });
1035
+ if (!p) return 'personality install: cancelled';
1036
+ if (fs.existsSync(p)) { src = p; break; }
1037
+ }
1038
+ if (!src) return 'personality install: source file not found after 3 tries';
1039
+ }
1040
+ if (!nm || !src) return 'usage: /personality install <name> <file.md>';
1041
+ const dst = path.join(dir, `${nm}.md`);
1042
+ if (fs.existsSync(dst)) return `personality already installed: ${nm}`;
1043
+ if (!fs.existsSync(src)) return `source file not found: ${src}`;
1044
+ fs.writeFileSync(dst, fs.readFileSync(src, 'utf8'));
1045
+ return `installed ${nm}`;
1125
1046
  }
1126
1047
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
1127
1048
  if (!a) return 'usage: /personality remove <name>';
1128
1049
  const p = path.join(dir, `${a}.md`);
1129
1050
  if (!fs.existsSync(p)) return `personality not installed: ${a}`;
1051
+ if (typeof ctx.openPicker === 'function') {
1052
+ const ok = await _promptConfirm(ctx, { title: `Remove personality "${a}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
1053
+ if (!ok) return `personality remove: cancelled — "${a}" not removed`;
1054
+ }
1130
1055
  fs.unlinkSync(p);
1131
1056
  return `removed ${a}`;
1132
1057
  }
@@ -1168,18 +1093,37 @@ async function _task(args, ctx, write) {
1168
1093
  const id = tokens[1];
1169
1094
 
1170
1095
  try {
1096
+ if (!sub && typeof ctx.openPicker === 'function') {
1097
+ const picked = await ctx.openPicker({
1098
+ kind: 'menu', title: 'Tasks', subtitle: `${tasksMod.listTasks(ctx.cfgDir).length} task(s)`,
1099
+ items: [
1100
+ { id: 'list', label: 'List tasks', desc: 'show all' },
1101
+ { id: 'start', label: 'Start task…', desc: 'open a new multi-agent task' },
1102
+ { id: 'tick', label: 'Tick task…', desc: 'one router turn' },
1103
+ { id: 'show', label: 'Show task…', desc: 'print a record' },
1104
+ { id: 'transcript', label: 'Transcript…', desc: 'dump turns' },
1105
+ { id: 'done', label: 'Mark done…', desc: 'close a task' },
1106
+ { id: 'abandon', label: 'Abandon…', desc: 'abandon a task' },
1107
+ { id: 'remove', label: 'Remove…', desc: 'delete a task' },
1108
+ ],
1109
+ });
1110
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
1111
+ return _task(typeof pid === 'string' && pid ? pid : 'list', ctx, write);
1112
+ }
1171
1113
  if (!sub || sub === 'list') {
1172
1114
  const items = tasksMod.listTasks(ctx.cfgDir);
1173
- if (!items.length) return 'no tasks. `lazyclaw task start --team ... --title ...` from the shell to create one.';
1115
+ if (!items.length) return 'no tasks yet. /task start <team> --title "..." to create one.';
1174
1116
  return items.map((t) =>
1175
1117
  `• ${t.id} [${t.status || 'unknown'}] ${t.title || '(no title)'}${t.team ? ` — team=${t.team}` : ''}${t.lead ? ` — lead=${t.lead}` : ''}`
1176
1118
  ).join('\n');
1177
1119
  }
1178
1120
  if (sub === 'show') {
1179
- if (!id) return 'usage: /task show <id>';
1121
+ if (!id) return 'usage: /task show <id> [json]';
1180
1122
  const t = tasksMod.getTask(id, ctx.cfgDir);
1181
1123
  if (!t) return `no task "${id}"`;
1182
- return JSON.stringify(t, null, 2);
1124
+ return tokens[2] === 'json'
1125
+ ? JSON.stringify(t, null, 2)
1126
+ : renderRecord(t, { fields: ['id', 'status', 'title', 'description', 'team', 'lead', 'slackChannel', 'createdAt', 'updatedAt'] });
1183
1127
  }
1184
1128
  if (sub === 'transcript') {
1185
1129
  if (!id) return 'usage: /task transcript <id> [text|md|json]';
@@ -1218,6 +1162,10 @@ async function _task(args, ctx, write) {
1218
1162
  }
1219
1163
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
1220
1164
  if (!id) return 'usage: /task remove <id>';
1165
+ if (typeof ctx.openPicker === 'function') {
1166
+ const ok = await _promptConfirm(ctx, { title: `Remove task ${id}?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
1167
+ if (!ok) return `task remove: cancelled — ${id} not removed`;
1168
+ }
1221
1169
  tasksMod.removeTask(id, ctx.cfgDir);
1222
1170
  return `✓ removed task ${id}`;
1223
1171
  }
@@ -1234,6 +1182,25 @@ async function _task(args, ctx, write) {
1234
1182
  else if (t === '--lead') lead = rest[++i] || null;
1235
1183
  else if (!teamName && !t.startsWith('--')) teamName = t;
1236
1184
  }
1185
+ // Guided fill: missing team/title + a modal available → pick the team
1186
+ // from the registry and prompt for a title. Typed form unchanged.
1187
+ if ((!teamName || !title) && typeof ctx.openPicker === 'function') {
1188
+ if (!teamName) {
1189
+ const teams = teamsMod.listTeams(ctx.cfgDir);
1190
+ if (!teams.length) return 'task start: no teams yet — create one with /team add first';
1191
+ const tp = await ctx.openPicker({ kind: 'menu', title: 'Start task — pick a team', items: teams.map((t) => ({ id: t.name, label: t.name, desc: `lead=${t.lead || '?'} · agents=${(t.agents || []).join(',')}` })) });
1192
+ teamName = tp && typeof tp === 'object' ? tp.id : tp;
1193
+ if (!teamName || typeof teamName !== 'string') return 'task start: cancelled';
1194
+ }
1195
+ if (!title) {
1196
+ title = await _promptText(ctx, { title: `Task title (team: ${teamName})`, subtitle: 'one line describing the task (Esc cancels)' });
1197
+ if (!title) return 'task start: cancelled';
1198
+ }
1199
+ if (!description) {
1200
+ const d = await _promptText(ctx, { title: 'Task description', subtitle: 'optional detail (Esc to skip)', allowEmpty: true });
1201
+ description = d || '';
1202
+ }
1203
+ }
1237
1204
  if (!teamName || !title) return 'usage: /task start <team> --title "..." [--description "..."] [--lead <name>]';
1238
1205
  const team = teamsMod.getTeam(teamName, ctx.cfgDir);
1239
1206
  if (!team) return `no team "${teamName}"`;
@@ -1292,6 +1259,9 @@ async function _task(args, ctx, write) {
1292
1259
  const userMsg = tokens.slice(2).join(' ').trim();
1293
1260
  try {
1294
1261
  if (typeof write === 'function') { try { write(' ↻ running task turn…\n'); } catch {} }
1262
+ // Default-on isolation: confine every tool the team runs. Lazy-imported
1263
+ // so the sandbox backends stay off the chat module-load path.
1264
+ const { defaultSandboxSpec } = await import('../sandbox/index.mjs');
1295
1265
  const result = await router.runTaskTurn({
1296
1266
  task, team, agentsById,
1297
1267
  userMessage: userMsg || undefined,
@@ -1300,6 +1270,7 @@ async function _task(args, ctx, write) {
1300
1270
  logger: (line) => { if (typeof write === 'function') { try { write(line); } catch {} } },
1301
1271
  approve: _makeInkApprove(ctx),
1302
1272
  security: ctx.cfg?.security,
1273
+ sandbox: defaultSandboxSpec(ctx.cfg, { cwd: process.cwd(), configDir: ctx.cfgDir }),
1303
1274
  });
1304
1275
  return `✓ task ${result.task.id} → ${result.task.status} (${result.iterations} agent turn(s)${result.stoppedBy ? `, stopped by ${result.stoppedBy}` : ''})`;
1305
1276
  } catch (e) {
@@ -1312,335 +1283,15 @@ async function _task(args, ctx, write) {
1312
1283
  }
1313
1284
  }
1314
1285
 
1315
- async function _trainer(args, ctx) {
1316
- const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
1317
- const tokens = splitWhitespace(args);
1318
- const sub = tokens[0] || 'show';
1319
-
1320
- if (sub === 'show') {
1321
- let effective = { provider: ctx.getActiveProvName ? ctx.getActiveProvName() : null,
1322
- model: ctx.getActiveModel ? ctx.getActiveModel() : null };
1323
- try {
1324
- if (typeof registry.resolveTrainer === 'function') {
1325
- effective = registry.resolveTrainer(ctx.cfg || {});
1326
- }
1327
- } catch { /* fall through */ }
1328
- const configured = (ctx.cfg && ctx.cfg.trainer) || null;
1329
- const cfgRender = configured
1330
- ? JSON.stringify(configured, null, 2)
1331
- : '(unset — trainer mirrors the chat provider/model)';
1332
- return [
1333
- 'trainer (effective):',
1334
- ` provider: ${effective.provider}`,
1335
- ` model: ${effective.model || '(default)'}`,
1336
- 'trainer (configured):',
1337
- cfgRender,
1338
- ].join('\n');
1339
- }
1340
-
1341
- if (sub === 'set') {
1342
- const spec = tokens[1];
1343
- if (!spec) return 'usage: /trainer set <provider>[:<model>] (or `auto` for orchestrator-managed)';
1344
- const parsed = typeof registry.parseProviderModel === 'function'
1345
- ? registry.parseProviderModel(spec)
1346
- : { provider: spec.split(':')[0], model: spec.split(':')[1] || null };
1347
- if (!parsed || !parsed.provider) return `/trainer set: could not parse "${spec}"`;
1348
- if (parsed.provider !== 'auto') {
1349
- const next = _providerLookup(registry, parsed.provider);
1350
- if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
1351
- }
1352
- // Optional `--fallback <provider[:model]>` — resolveTrainer routes here
1353
- // when opts.useFallback is set. Validate before persisting.
1354
- let fallbackSpec = null;
1355
- const fi = tokens.indexOf('--fallback');
1356
- if (fi >= 0) {
1357
- fallbackSpec = tokens[fi + 1];
1358
- if (!fallbackSpec) return 'usage: /trainer set <p:m> --fallback <p:m>';
1359
- const fp = _parseProvModel(registry, fallbackSpec);
1360
- if (!fp.provider) return `/trainer set: could not parse fallback "${fallbackSpec}"`;
1361
- if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
1362
- return `/trainer set: unknown provider "${fp.provider}"`;
1363
- }
1364
- }
1365
- // Read-merge-write so unrelated cfg keys survive.
1366
- const fs = await import('node:fs');
1367
- const path = await import('node:path');
1368
- const cfgPath = path.join(ctx.cfgDir, 'config.json');
1369
- let diskCfg = {};
1370
- try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
1371
- diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
1372
- if (parsed.model) diskCfg.trainer.model = parsed.model;
1373
- else delete diskCfg.trainer.model;
1374
- if (fallbackSpec) diskCfg.trainer.fallback = fallbackSpec;
1375
- try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1376
- fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1377
- if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
1378
- return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}${fallbackSpec ? ` (fallback: ${fallbackSpec})` : ''}`;
1379
- }
1380
-
1381
- if (sub === 'fallback') {
1382
- const spec = tokens[1];
1383
- if (!spec) return 'usage: /trainer fallback <provider>[:<model>] | clear';
1384
- const fs = await import('node:fs');
1385
- const path = await import('node:path');
1386
- const cfgPath = path.join(ctx.cfgDir, 'config.json');
1387
- let diskCfg = {};
1388
- try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
1389
- if (spec === 'clear' || spec === 'unset') {
1390
- if (diskCfg.trainer) delete diskCfg.trainer.fallback;
1391
- try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1392
- fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1393
- if (ctx.cfg && ctx.cfg.trainer) delete ctx.cfg.trainer.fallback;
1394
- return '✓ trainer fallback cleared';
1395
- }
1396
- const fp = _parseProvModel(registry, spec);
1397
- if (!fp.provider) return `/trainer fallback: could not parse "${spec}"`;
1398
- if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
1399
- return `/trainer fallback: unknown provider "${fp.provider}"`;
1400
- }
1401
- diskCfg.trainer = { ...(diskCfg.trainer || {}), fallback: spec };
1402
- try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1403
- fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1404
- if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
1405
- return `✓ trainer fallback → ${spec}`;
1406
- }
1407
-
1408
- if (sub === 'clear' || sub === 'unset') {
1409
- const fs = await import('node:fs');
1410
- const path = await import('node:path');
1411
- const cfgPath = path.join(ctx.cfgDir, 'config.json');
1412
- let diskCfg = {};
1413
- try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
1414
- delete diskCfg.trainer;
1415
- try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1416
- fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1417
- if (ctx.cfg) delete ctx.cfg.trainer;
1418
- return '✓ trainer cleared (will mirror chat provider/model)';
1419
- }
1420
-
1421
- return `/trainer: unknown sub "${sub}" — show|set <p:m>|clear`;
1422
- }
1423
-
1424
- // /dashboard — open the lazyclaw web UI.
1425
- //
1426
- // v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
1427
- // session spawned 20+ daemon children).
1428
- //
1429
- // Original implementation:
1430
- // probe /healthz → if !200, spawn detached `lazyclaw dashboard
1431
- // --no-open` and poll for up to 3s.
1432
- //
1433
- // Failure mode that produced the 20+ spawn pile-up:
1434
- // 1. User types /dashboard. probe fails (no daemon). Spawn child A.
1435
- // 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
1436
- // 3. User types /dashboard again BEFORE A is ready. probe still fails.
1437
- // Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
1438
- // (cli.mjs:3611) which SIGTERMs child A. B takes over.
1439
- // 4. Repeat. Each /dashboard kills the previous daemon and starts a
1440
- // new one. With autorepeat / many slash calls this stacks fast.
1441
- //
1442
- // Two-layer guard:
1443
- // - A module-level _dashboardSpawning latch refuses concurrent spawn
1444
- // attempts. While a spawn is in flight, /dashboard says so + returns
1445
- // without firing another child.
1446
- // - A _dashboardChildPid cache remembers the PID we already spawned;
1447
- // subsequent calls check kill(pid, 0) to confirm the child is alive
1448
- // and just open the browser without spawning.
1449
- //
1450
- // We probe both /healthz (HTTP) AND a raw net.connect port check so a
1451
- // slow-starting daemon (binding the listener but not yet answering HTTP)
1452
- // still counts as "running".
1453
- let _dashboardSpawning = false;
1454
- let _dashboardChildPid = null;
1455
-
1456
- function _portIsListening(port, timeoutMs = 200) {
1457
- return new Promise((resolve) => {
1458
- import('node:net').then(({ createConnection }) => {
1459
- let settled = false;
1460
- const sock = createConnection({ host: '127.0.0.1', port });
1461
- const done = (ok) => {
1462
- if (settled) return;
1463
- settled = true;
1464
- try { sock.destroy(); } catch {}
1465
- resolve(ok);
1466
- };
1467
- sock.once('connect', () => done(true));
1468
- sock.once('error', () => done(false));
1469
- setTimeout(() => done(false), timeoutMs);
1470
- }).catch(() => resolve(false));
1471
- });
1472
- }
1473
-
1474
- async function _dashboardProbe(port) {
1475
- // Fast path — port-level probe. Catches a daemon that has bound the
1476
- // socket but hasn't finished initializing its HTTP routes.
1477
- if (await _portIsListening(port, 200)) return true;
1478
- // Slow path — full /healthz fetch, for defense in depth.
1479
- if (typeof fetch !== 'function') return false;
1480
- try {
1481
- const ac = new AbortController();
1482
- const t = setTimeout(() => ac.abort(), 250);
1483
- const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
1484
- clearTimeout(t);
1485
- return !!(r && r.ok);
1486
- } catch { return false; }
1487
- }
1488
-
1489
- function _openBrowser(url) {
1490
- return import('node:child_process').then(({ spawn }) => {
1491
- let cmd, args;
1492
- if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
1493
- else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
1494
- else { cmd = 'xdg-open'; args = [url]; }
1495
- try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
1496
- });
1497
- }
1498
-
1499
- async function _dashboardStop(port) {
1500
- // Best-effort kill of every lazyclaw dashboard daemon on the box.
1501
- // Used to clean up after the v5.4.3 spawn pile-up bug.
1502
- if (process.platform === 'win32') {
1503
- return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
1504
- }
1505
- const { spawn } = await import('node:child_process');
1506
- // Step 1: lsof the port and SIGTERM each PID.
1507
- const portPids = await new Promise((resolve) => {
1508
- try {
1509
- const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
1510
- let buf = '';
1511
- lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
1512
- lsof.on('error', () => resolve([]));
1513
- lsof.on('close', () => resolve(
1514
- buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
1515
- ));
1516
- } catch { resolve([]); }
1517
- });
1518
- for (const pid of portPids) {
1519
- try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
1520
- }
1521
- // Step 2: pkill any process whose command line includes "lazyclaw dashboard"
1522
- // — catches detached children that bound a different (random) port via
1523
- // cmdDashboard's EADDRINUSE fallback.
1524
- let pkilled = 0;
1525
- try {
1526
- const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
1527
- pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
1528
- } catch { /* fine */ }
1529
- _dashboardChildPid = null;
1530
- return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
1531
- }
1532
-
1533
- // Parse the daemon's "listening at <url>" stdout line so /dashboard opens the
1534
- // actually-bound port (the child may fall back to a random port on EADDRINUSE).
1535
- export function parseDashboardUrl(text) {
1536
- const m = String(text || '').match(/listening at\s+(https?:\/\/\S+)/i);
1537
- return m ? m[1] : null;
1538
- }
1539
-
1540
- // Resolve the daemon's real URL from its stdout within `timeoutMs`, or null.
1541
- function _waitForDashboardUrl(child, timeoutMs) {
1542
- return new Promise((resolve) => {
1543
- if (!child || !child.stdout) { resolve(null); return; }
1544
- let buf = '';
1545
- let done = false;
1546
- const finish = (v) => {
1547
- if (done) return;
1548
- done = true;
1549
- try { child.stdout.off('data', onData); } catch { /* ignore */ }
1550
- resolve(v);
1551
- };
1552
- const onData = (d) => {
1553
- buf += d.toString('utf8');
1554
- const u = parseDashboardUrl(buf);
1555
- if (u) finish(u);
1556
- };
1557
- child.stdout.on('data', onData);
1558
- setTimeout(() => finish(null), timeoutMs);
1559
- });
1560
- }
1561
-
1562
- async function _dashboard(args) {
1563
- const port = 19600;
1564
- const url = `http://127.0.0.1:${port}/dashboard`;
1565
- // Under the node:test runner, never launch a real daemon or open a browser
1566
- // (it leaked a background daemon + opened a tab on every test run).
1567
- if (process.env.NODE_TEST_CONTEXT) return `dashboard: ${url} (spawn skipped under test)`;
1568
- const sub = splitWhitespace(args)[0];
1569
- if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
1570
-
1571
- // 1. Already running anywhere on the machine? → reuse.
1572
- if (await _dashboardProbe(port)) {
1573
- await _openBrowser(url);
1574
- return `✓ dashboard already running — opened ${url}`;
1575
- }
1576
-
1577
- // 2. We spawned in this chat — is that child still alive?
1578
- if (_dashboardChildPid != null) {
1579
- try {
1580
- process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
1581
- // Child alive but not answering yet. Don't re-spawn; just nudge.
1582
- await _openBrowser(url);
1583
- return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
1584
- } catch {
1585
- _dashboardChildPid = null; // child died; fall through and respawn.
1586
- }
1587
- }
1588
-
1589
- // 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
1590
- if (_dashboardSpawning) {
1591
- await _openBrowser(url);
1592
- return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
1593
- }
1594
-
1595
- // 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
1596
- // spawn flag in a finally so it always clears.
1597
- _dashboardSpawning = true;
1598
- try {
1599
- const { spawn } = await import('node:child_process');
1600
- let child;
1601
- try {
1602
- // Pass --port so the child tries 19600 first; pipe stdout so we can read
1603
- // the real bound URL (it may fall back to a random port on EADDRINUSE).
1604
- child = spawn(process.execPath, [process.argv[1], 'dashboard', '--port', String(port), '--no-open'], {
1605
- detached: true, stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd(), env: process.env,
1606
- });
1607
- _dashboardChildPid = child.pid;
1608
- } catch (e) {
1609
- return `dashboard error: failed to spawn — ${e?.message || e}`;
1610
- }
1611
- // Prefer the daemon's own "listening at <url>" line — it carries the
1612
- // actual port even after a random-port fallback.
1613
- const boundUrl = await _waitForDashboardUrl(child, 3000);
1614
- // Release the captured stdout pipe so the detached daemon doesn't keep
1615
- // OUR event loop alive (unref, not destroy — destroying would EPIPE the
1616
- // daemon on its next stdout write). Then unref the child itself.
1617
- try { if (child.stdout) { child.stdout.removeAllListeners('data'); child.stdout.unref(); } } catch { /* ignore */ }
1618
- child.unref();
1619
- if (boundUrl) {
1620
- await _openBrowser(boundUrl);
1621
- return `✓ started dashboard (pid ${child.pid}) — opened ${boundUrl}`;
1622
- }
1623
- // Fallback: the line never arrived — poll the default port best-effort.
1624
- const start = Date.now();
1625
- while (Date.now() - start < 1500) {
1626
- if (await _dashboardProbe(port)) {
1627
- await _openBrowser(url);
1628
- return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
1629
- }
1630
- await new Promise((r) => setTimeout(r, 150));
1631
- }
1632
- return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
1633
- } finally {
1634
- _dashboardSpawning = false;
1635
- }
1636
- }
1286
+ // /trainer — moved to ./slash_trainer.mjs (Group 4) so the set/fallback/show/
1287
+ // clear branches can grow off this file's size ratchet.
1637
1288
 
1638
1289
  // /menu — in-chat command palette over the full subcommand catalog. The
1639
1290
  // no-arg launcher menu used to be the home screen; defaulting to chat hid it
1640
1291
  // behind `lazyclaw menu`. This restores discoverability: browse subcommands
1641
1292
  // and get the exact command to run. (Most subcommands own stdout / spawn, so
1642
1293
  // they can't safely run inline in the Ink scrollback — we echo the command.)
1643
- async function _menu(args, ctx) {
1294
+ async function _menu(args, ctx, write) {
1644
1295
  if (typeof ctx.openPicker === 'function') {
1645
1296
  const items = [];
1646
1297
  const seen = new Set();
@@ -1648,17 +1299,23 @@ async function _menu(args, ctx) {
1648
1299
  for (const c of cmds) {
1649
1300
  if (seen.has(c)) continue;
1650
1301
  seen.add(c);
1651
- items.push({ id: c, label: c, desc: group });
1302
+ // Mark which subcommands can run in-chat (a /slash equivalent exists).
1303
+ const inChat = SLASH_HANDLERS.has(`/${c}`);
1304
+ items.push({ id: c, label: c, desc: inChat ? `${group} · runs in chat` : group });
1652
1305
  }
1653
1306
  }
1654
1307
  const picked = await ctx.openPicker({
1655
1308
  kind: 'menu',
1656
1309
  title: 'lazyclaw subcommands',
1657
- subtitle: 'Enter shows how to run it · Esc cancels',
1310
+ subtitle: 'Enter runs it in chat (or shows the shell command) · Esc cancels',
1658
1311
  items,
1659
1312
  });
1660
1313
  if (!picked) return 'cancelled';
1661
1314
  const cmd = typeof picked === 'string' ? picked : picked.id;
1315
+ // If there's an in-chat slash equivalent, dispatch it directly instead of
1316
+ // telling the user to leave chat.
1317
+ const handler = SLASH_HANDLERS.get(`/${cmd}`);
1318
+ if (handler) return handler('', ctx, write);
1662
1319
  return `run from a shell: lazyclaw ${cmd}`;
1663
1320
  }
1664
1321
  return [
@@ -1668,117 +1325,18 @@ async function _menu(args, ctx) {
1668
1325
  ].join('\n');
1669
1326
  }
1670
1327
 
1671
- // /channelsview configured channels and toggle them. `/channels` lists;
1672
- // `/channels <name> on|off` enables/disables. Reads/writes cfg via ctx when
1673
- // available, else lib/config directly, so it works on both REPL paths.
1674
- async function _channels(args, ctx = {}) {
1675
- const cf = await import('../config_features.mjs');
1676
- const cfgMod = await import('../lib/config.mjs');
1677
- const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
1678
- const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
1679
- const [name, action] = (args || '').trim().split(/\s+/).filter(Boolean);
1680
- if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
1681
- const en = /^(on|enable)$/i.test(action);
1682
- const cfg = read();
1683
- const key = name.toLowerCase();
1684
- // Reject unknown names so a typo can't silently create a bogus
1685
- // cfg.channels.<name> section (which would then leak into the list).
1686
- // Stay permissive for pre-existing custom sections.
1687
- const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
1688
- if (!cf.KNOWN_CHANNELS.includes(key) && !(key in existing)) {
1689
- return `unknown channel: ${key} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
1690
- }
1691
- cf.channelSetEnabled(cfg, key, en); write(cfg);
1692
- // Legacy fallback path: the readline ctx (_legacyCtx) has no
1693
- // readConfig/writeConfig, so we read/wrote disk above against a fresh
1694
- // cfg object. Mirror the toggle onto the in-session ctx.cfg so a
1695
- // follow-up `/channels` (list) or other in-session read stays
1696
- // consistent instead of showing the stale pre-toggle value.
1697
- if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
1698
- cf.channelSetEnabled(ctx.cfg, key, en);
1699
- }
1700
- return `channel ${key} → ${en ? 'enabled' : 'disabled'}`;
1701
- }
1702
- const rows = cf.channelStatusList(read());
1703
- if (!rows.length) return 'no channels configured. add creds with /config (re-runs setup) or `lazyclaw setup`.';
1704
- const lines = ['configured channels:'];
1705
- for (const c of rows) lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}`);
1706
- lines.push('toggle: /channels <name> on|off · add creds: /config');
1707
- return lines.join('\n');
1708
- }
1709
-
1710
- // /orchestrator — view/edit multi-agent config. `status` (default), `on`/`off`,
1711
- // `planner <spec>`, `worker add|remove <spec>`, `maxsubtasks <N>`. ctx-or-
1712
- // lib/config fallback so it works on both REPL paths.
1713
- async function _orchestrator(args, ctx = {}) {
1714
- const cf = await import('../config_features.mjs');
1715
- const cfgMod = await import('../lib/config.mjs');
1716
- const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
1717
- const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
1718
- const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
1719
- const parts = (args || '').trim().split(/\s+/).filter(Boolean);
1720
- const fmt = () => {
1721
- const s = cf.orchestratorGet(read());
1722
- return `orchestrator: ${s.active ? 'ON' : 'off'} · planner: ${s.planner || '(default)'} · workers: ${s.workers.length ? s.workers.join(', ') : '(none)'} · maxSubtasks: ${s.maxSubtasks}`;
1723
- };
1724
- // Bare `/orchestrator` → arrow-key picker (Ink). Pick ON/OFF/Status instead
1725
- // of typing the subcommand. Legacy path (no openPicker) shows status text.
1726
- if (parts.length === 0 && typeof ctx.openPicker === 'function') {
1727
- const s = cf.orchestratorGet(read());
1728
- const picked = await ctx.openPicker({
1729
- title: 'Orchestration',
1730
- subtitle: `now ${s.active ? 'ON' : 'off'} · planner ${s.planner || '(default)'} · ${s.workers.length} worker(s)`,
1731
- items: [
1732
- { id: 'on', label: 'Turn ON', desc: 'route chats through planner + workers' },
1733
- { id: 'off', label: 'Turn OFF', desc: 'back to a single provider' },
1734
- { id: 'status', label: 'Status', desc: 'show current config' },
1735
- ],
1736
- });
1737
- if (!picked || typeof picked !== 'string') return fmt();
1738
- return _orchestrator(picked, ctx);
1739
- }
1740
- const sub = (parts[0] || 'status').toLowerCase();
1741
- if (sub === 'status') return fmt();
1742
- const cfg = read();
1743
- if (sub === 'on' || sub === 'enable') {
1744
- if (!cf.orchestratorGet(cfg).planner) {
1745
- const base = cfg.provider && cfg.provider !== 'orchestrator' ? cfg.provider : 'claude-cli';
1746
- cf.orchestratorSet(cfg, { planner: base });
1747
- }
1748
- cf.orchestratorEnable(cfg, true); persist(cfg);
1749
- const after = cf.orchestratorGet(read());
1750
- return after.workers.length ? 'orchestration ON.\n' + fmt() : 'orchestration ON — but no workers yet. Add one: /orchestrator worker add <provider[:model]>';
1751
- }
1752
- if (sub === 'off' || sub === 'disable') { cf.orchestratorEnable(cfg, false); persist(cfg); return 'orchestration off. provider → ' + read().provider; }
1753
- if (sub === 'planner') { if (!parts[1]) return 'usage: /orchestrator planner <provider[:model]>'; cf.orchestratorSet(cfg, { planner: parts[1] }); persist(cfg); return 'planner → ' + parts[1]; }
1754
- if (sub === 'maxsubtasks') { const n = parseInt(parts[1], 10); if (!Number.isFinite(n)) return 'usage: /orchestrator maxsubtasks <N>'; cf.orchestratorSet(cfg, { maxSubtasks: Math.max(1, Math.min(10, n)) }); persist(cfg); return fmt(); }
1755
- if (sub === 'worker') {
1756
- const action = (parts[1] || '').toLowerCase(); const spec = parts[2];
1757
- const workers = [...cf.orchestratorGet(cfg).workers];
1758
- if (action === 'add' && spec) { if (!workers.includes(spec)) workers.push(spec); cf.orchestratorSet(cfg, { workers }); persist(cfg); return 'workers: ' + workers.join(', '); }
1759
- if ((action === 'remove' || action === 'rm') && spec) { const next = workers.filter((w) => w !== spec); cf.orchestratorSet(cfg, { workers: next }); persist(cfg); return 'workers: ' + (next.join(', ') || '(none)'); }
1760
- return 'usage: /orchestrator worker add|remove <provider[:model]>';
1761
- }
1762
- return 'usage: /orchestrator [status|on|off|planner <spec>|worker add|remove <spec>|maxsubtasks <N>]';
1763
- }
1764
-
1765
- // /context — view/set the chat history window (turns + token budget). This is
1766
- // the sliding history budget sent each turn, NOT the model's hard context
1767
- // limit. ctx-or-lib/config fallback so it works on both REPL paths.
1768
- async function _context(args, ctx = {}) {
1769
- const cf = await import('../config_features.mjs');
1770
- const cfgMod = await import('../lib/config.mjs');
1771
- const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
1772
- const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
1773
- const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
1774
- const parts = (args || '').trim().split(/\s+/).filter(Boolean);
1775
- const sub = (parts[0] || 'status').toLowerCase();
1776
- const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
1777
- if (sub === 'status') return fmt();
1778
- const n = parseInt(parts[1], 10);
1779
- if (sub === 'turns') { if (!Number.isFinite(n) || n < 1) return 'usage: /context turns <N>'; const cfg = read(); cf.chatWindowSet(cfg, { turns: n }); persist(cfg); return fmt(); }
1780
- if (sub === 'tokens') { if (!Number.isFinite(n) || n < 256) return 'usage: /context tokens <N> (min 256)'; const cfg = read(); cf.chatWindowSet(cfg, { tokens: n }); persist(cfg); return fmt(); }
1781
- return 'usage: /context [status | turns <N> | tokens <N>]';
1328
+ // /orchestratormoved to ./orchestrator_flow.mjs (orchestratorSlash) so the
1329
+ // interactive fetch+pick planner/worker editor can grow off the ratchet.
1330
+ // /channels + /context moved to ./slash_channels.mjs (Group 3) so they can
1331
+ // grow off this file's size ratchet.
1332
+
1333
+ // /agentic + /plan live in ./chat_mode_slash.mjs (Group 1) — kept out of this
1334
+ // file (at its size ratchet) so the toggles can grow there. Register their
1335
+ // catalog rows in the shared SLASH_COMMANDS so /help, the popup,
1336
+ // ghost-autocomplete, and the d6 drift-guard see them (idempotent ESM runs
1337
+ // module init once; appended per the catalog's ordering note).
1338
+ for (const entry of CHAT_MODE_SLASH_COMMANDS) {
1339
+ if (!SLASH_COMMANDS.some((c) => c.cmd === entry.cmd)) SLASH_COMMANDS.push(entry);
1782
1340
  }
1783
1341
 
1784
1342
  // ─── dispatch table ──────────────────────────────────────────────────────
@@ -1792,6 +1350,10 @@ export const SLASH_HANDLERS = new Map([
1792
1350
  ['/reset', _newReset],
1793
1351
  ['/clear', _newReset],
1794
1352
  ['/provider', _provider],
1353
+ ['/login', (a, ctx) => loginSlash(a, ctx, { promptText: _promptText })],
1354
+ ['/hud', hudSlash],
1355
+ ['/agentic', agenticSlash],
1356
+ ['/plan', planSlash],
1795
1357
  ['/model', _model],
1796
1358
  ['/skill', _skill],
1797
1359
  ['/skills', _skillsList],
@@ -1810,7 +1372,7 @@ export const SLASH_HANDLERS = new Map([
1810
1372
  ['/dashboard', _dashboard],
1811
1373
  ['/menu', _menu],
1812
1374
  ['/channels', _channels],
1813
- ['/orchestrator', _orchestrator],
1375
+ ['/orchestrator', orchestratorSlash],
1814
1376
  ['/context', _context],
1815
1377
  // /setup — full wizard (every step); /config — pick ONE setting to change
1816
1378
  // (in-chat where possible; credential steps unmount, run, re-enter chat).
@@ -1827,6 +1389,9 @@ export const SLASH_HANDLERS = new Map([
1827
1389
  */
1828
1390
  export async function dispatchSlash(cmd, args, ctx, write) {
1829
1391
  const handler = SLASH_HANDLERS.get(cmd);
1830
- if (!handler) return `unknown slash command: ${cmd} (try /help)`;
1392
+ if (!handler) {
1393
+ const hint = nearest(cmd, [...SLASH_HANDLERS.keys()]);
1394
+ return `unknown slash command: ${cmd}${hint ? ` — did you mean ${hint}?` : ''} (try /help)`;
1395
+ }
1831
1396
  return handler(args || '', ctx || {}, write);
1832
1397
  }