lazyclaw 6.3.1 → 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 +90 -33
  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 +196 -80
  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
@@ -26,16 +26,25 @@
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';
@@ -156,77 +165,18 @@ async function _newReset(_args, ctx) {
156
165
  return 'cleared — new conversation';
157
166
  }
158
167
 
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
168
 
222
169
  // Single free-text prompt reusing the modal's filter buffer (no dedicated
223
170
  // input widget). Returns the typed value, '' (only when allowEmpty), or null
224
171
  // on cancel / required-but-empty.
225
- async function _promptText(ctx, { title, subtitle, allowEmpty } = {}) {
172
+ async function _promptText(ctx, { title, subtitle, allowEmpty, secret } = {}) {
226
173
  if (typeof ctx.openPicker !== 'function') return null;
227
174
  const picked = await ctx.openPicker({
228
175
  kind: 'text',
229
176
  title,
177
+ // `secret` masks the typed query on screen (api-key / token entry) while
178
+ // the real value still reaches the caller — the modal picker honors it.
179
+ secret: !!secret,
230
180
  subtitle: subtitle || 'type into the filter, then pick the row · Esc cancels',
231
181
  items: [{ id: '__text__', label: '✓ use what I typed above', desc: '', pinned: true, freeText: true }],
232
182
  });
@@ -259,7 +209,7 @@ async function _promptConfirm(ctx, { title, subtitle } = {}) {
259
209
  // Default in-chat approval hook: prompts the operator to confirm each
260
210
  // sensitive tool call. Used to drive the fail-closed tool runner from the
261
211
  // Ink REPL, where stdin is owned by Ink so a raw readline prompt can't run.
262
- function _makeInkApprove(ctx) {
212
+ export function _makeInkApprove(ctx) {
263
213
  return async function approve({ tool, args, agent }) {
264
214
  const raw = typeof args === 'object' ? JSON.stringify(args) : String(args ?? '');
265
215
  const summary = redactSecrets(raw).slice(0, 300);
@@ -310,6 +260,7 @@ async function _maybePromptForKey(ctx, registry, provName) {
310
260
  const key = await _promptText(ctx, {
311
261
  title: `${provName} needs an api key`,
312
262
  subtitle: 'paste it now, or Esc to skip (set later via: lazyclaw auth)',
263
+ secret: true,
313
264
  });
314
265
  if (!key) return;
315
266
  const next = setAuthKey({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, provider: provName, key });
@@ -327,30 +278,30 @@ async function _addCustomFlow(ctx, registry) {
327
278
  if (!name) return 'cancelled';
328
279
  const baseUrl = await _promptText(ctx, { title: `baseUrl for ${name}`, subtitle: 'must start with http(s) and end in /v1' });
329
280
  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 });
281
+ 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
282
  if (apiKey === null) return 'cancelled';
332
283
  return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
333
284
  }
334
285
 
335
286
  async function _provider(args, ctx) {
336
287
  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.
288
+ // `/provider add <name> <baseUrl> [apiKey]` — register a custom OpenAI-compat endpoint.
339
289
  const addMatch = args && args.match(/^add\s+(.+)$/i);
340
290
  if (addMatch) {
341
291
  const [name, baseUrl, apiKey] = splitWhitespace(addMatch[1]);
342
292
  if (!name || !baseUrl) return 'usage: /provider add <name> <baseUrl> [apiKey]';
343
293
  return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
344
294
  }
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).
295
+ // No arg → drill-in modal picker (family -> provider); falls back to a hint
296
+ // string when ctx.openPicker isn't available (non-Ink / picker not settled).
297
+ let _fromPicker = false;
348
298
  if (!args) {
349
299
  if (typeof ctx.openPicker === 'function') {
350
300
  const picked = await _pickProviderDrillIn(ctx, registry);
351
301
  if (!picked) return 'cancelled';
352
302
  if (picked === '__add_custom__') return _addCustomFlow(ctx, registry);
353
303
  args = picked;
304
+ _fromPicker = true;
354
305
  // Built-in api-key provider with no key configured → offer to set one.
355
306
  await _maybePromptForKey(ctx, registry, args);
356
307
  } else {
@@ -364,190 +315,43 @@ async function _provider(args, ctx) {
364
315
  }
365
316
  if (ctx.setActiveProvName) ctx.setActiveProvName(args);
366
317
  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
- });
405
- }
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;
318
+ // Keyless CLI not signed in inline connect menu ('EXIT' = foreground login).
319
+ const r = await runProviderLogin(ctx, args, { promptText: _promptText });
320
+ if (r !== null) return r;
321
+ // Picked from the modal → chain straight into a model pick so provider+model
322
+ // are set together (no separate /model step). Composites have no model list.
323
+ if (_fromPicker && args !== 'orchestrator' && typeof ctx.openPicker === 'function') {
324
+ const mm = await pickAndSetModel(ctx, registry, args);
325
+ if (mm) return `provider ${args} · ${mm}`;
474
326
  }
475
- return null;
327
+ return `provider → ${args}`;
476
328
  }
477
329
 
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
330
 
500
331
  async function _model(args, ctx) {
501
332
  const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
502
333
  if (!args) {
503
334
  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;
335
+ // Shared canonical picker: provider drill-in (only when the active
336
+ // provider has no models) → model loop with the "⇄ switch provider"
337
+ // and custom-id rows. Session-only — /model does not persist to disk.
338
+ const r = await pickProviderModel(ctx, registry, { includeSwitch: true });
339
+ if (!r) return 'cancelled';
340
+ const switched = r.provider !== ctx.getActiveProvName();
341
+ if (switched) {
342
+ const next = _providerLookup(registry, r.provider);
343
+ if (!next) return `unknown provider: ${r.provider}`;
344
+ if (ctx.setActiveProvName) ctx.setActiveProvName(r.provider);
345
+ if (ctx.setProv) ctx.setProv(next);
543
346
  }
544
- if (model == null || model === '__switch_provider__') {
545
- return switched ? `provider → ${provName} (model unchanged)` : 'cancelled';
347
+ // Model pick cancelled (null) keep any provider switch, leave model.
348
+ if (r.model == null) {
349
+ return switched ? `provider → ${r.provider} (model unchanged)` : 'cancelled';
546
350
  }
547
- if (ctx.setActiveModel) ctx.setActiveModel(model);
351
+ if (ctx.setActiveModel) ctx.setActiveModel(r.model);
548
352
  return switched
549
- ? `provider → ${provName} · model → ${model}`
550
- : `model → ${model}`;
353
+ ? `provider → ${r.provider} · model → ${r.model}`
354
+ : `model → ${r.model}`;
551
355
  }
552
356
  return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
553
357
  }
@@ -569,13 +373,22 @@ async function _model(args, ctx) {
569
373
  async function _skill(args, ctx) {
570
374
  // `/skill name1,name2` — replace the active system message with a
571
375
  // composition. No-arg → clear system message. Mirrors cli.mjs:3046.
572
- const names = args.split(',').map((s) => s.trim()).filter(Boolean);
376
+ // `clear`/`unset` are not skill names treat them as the explicit clear verb
377
+ // so they reach the clear branch instead of being composed as a skill.
378
+ const isClear = /^(clear|unset)$/i.test((args || '').trim());
379
+ const names = isClear ? [] : args.split(',').map((s) => s.trim()).filter(Boolean);
573
380
  const messages = ctx.getMessages().slice(); // mutable copy
574
381
  const sysIdx = messages.findIndex((m) => m.role === 'system');
575
382
  const sid = ctx.getSessionId && ctx.getSessionId();
576
383
  const sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
577
384
 
578
385
  if (names.length === 0) {
386
+ // Footgun guard: bare `/skill` used to silently wipe the active skills.
387
+ // Now no-arg opens the skill picker; clearing requires explicit /skill clear.
388
+ if (!isClear) {
389
+ if (typeof ctx.openPicker === 'function') return _skillsList('', ctx);
390
+ return 'usage: /skill <name>[,<name>] · /skill clear to unset · /skills to pick';
391
+ }
579
392
  if (sysIdx >= 0) messages.splice(sysIdx, 1);
580
393
  if (ctx.setMessages) ctx.setMessages(messages);
581
394
  if (sid) {
@@ -619,7 +432,8 @@ async function _skillsList(args, ctx) {
619
432
  if (!names.length) {
620
433
  return [
621
434
  'no skills installed.',
622
- 'install one: lazyclaw skills install <owner>/<repo>',
435
+ 'starter pack: lazyclaw skills starter',
436
+ 'install more: lazyclaw skills install <owner>/<repo>',
623
437
  'then /skills to pick, or /skill <name>[,<name>] to activate.',
624
438
  ].join('\n');
625
439
  }
@@ -677,6 +491,18 @@ async function _memory(args, ctx) {
677
491
  try { mem = await import('../memory.mjs'); }
678
492
  catch (e) { return `memory unavailable: ${e?.message || e}`; }
679
493
  const tokens = splitWhitespace(args);
494
+ if (!tokens.length && typeof ctx.openPicker === 'function') {
495
+ const picked = await ctx.openPicker({
496
+ kind: 'menu', title: 'Memory', subtitle: 'view a memory store',
497
+ items: [
498
+ { id: 'core', label: 'Core', desc: 'persistent core memory' },
499
+ { id: 'recent', label: 'Recent', desc: 'last ~20 messages' },
500
+ { id: 'episodic', label: 'Episodic', desc: 'consolidated topic files' },
501
+ ],
502
+ });
503
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
504
+ return _memory(typeof pid === 'string' && pid ? pid : 'core', ctx);
505
+ }
680
506
  const which = tokens[0] || 'core';
681
507
  if (which === 'core') {
682
508
  const body = mem.loadCore(ctx.cfgDir);
@@ -740,6 +566,20 @@ async function _agent(args, ctx) {
740
566
  const rest = tokens.slice(1);
741
567
  const aname = rest[0];
742
568
  try {
569
+ if (!sub && typeof ctx.openPicker === 'function') {
570
+ const picked = await ctx.openPicker({
571
+ kind: 'menu', title: 'Agents', subtitle: `${agentsMod.listAgents(ctx.cfgDir).length} registered`,
572
+ items: [
573
+ { id: 'list', label: 'List agents', desc: 'show all' },
574
+ { id: 'add', label: 'Add agent…', desc: '/agent add <name> [role]' },
575
+ { id: 'edit', label: 'Edit agent…', desc: 'pick provider + model' },
576
+ { id: 'show', label: 'Show agent…', desc: 'print one record' },
577
+ { id: 'remove', label: 'Remove agent…', desc: 'delete a record' },
578
+ ],
579
+ });
580
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
581
+ return _agent(typeof pid === 'string' && pid ? pid : 'list', ctx);
582
+ }
743
583
  if (!sub || sub === 'list') {
744
584
  const agents = agentsMod.listAgents(ctx.cfgDir);
745
585
  if (agents.length === 0) return 'no agents registered. /agent add <name> [...] to create.';
@@ -749,33 +589,63 @@ async function _agent(args, ctx) {
749
589
  }).join('\n');
750
590
  }
751
591
  if (sub === 'show') {
752
- if (!aname) return 'usage: /agent show <name>';
592
+ if (!aname) return 'usage: /agent show <name> [json]';
753
593
  const a = agentsMod.getAgent(aname, ctx.cfgDir);
754
594
  if (!a) return `no agent "${aname}"`;
755
- return JSON.stringify(a, null, 2);
595
+ return rest[1] === 'json'
596
+ ? JSON.stringify(a, null, 2)
597
+ : renderRecord(a, { fields: ['name', 'displayName', 'provider', 'model', 'role', 'tools', 'tags', 'iconEmoji', 'memoryWrite', 'skillWrite', 'createdAt', 'updatedAt'] });
756
598
  }
757
599
  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(',')})`;
600
+ let name = aname;
601
+ let roleText = rest.slice(1).join(' ').trim();
602
+ // Guided fill: no name typed + a modal available → prompt for it.
603
+ if (!name && typeof ctx.openPicker === 'function') {
604
+ name = await _promptText(ctx, { title: 'New agent — name', subtitle: 'short id, e.g. scout (Esc cancels)' });
605
+ if (!name) return 'agent add: cancelled';
606
+ if (!roleText) {
607
+ const r = await _promptText(ctx, { title: `Role for ${name}`, subtitle: 'one line describing what this agent does (Esc to skip)', allowEmpty: true });
608
+ roleText = r || '';
609
+ }
610
+ }
611
+ if (!name) return 'usage: /agent add <name> [role text…]';
612
+ const a = agentsMod.registerAgent({ name, role: roleText }, ctx.cfgDir);
613
+ return `✓ added agent ${a.name} (tools=${(a.tools || []).join(',')}) — set its model with /agent edit ${a.name}`;
614
+ }
615
+ if (sub === 'edit') {
616
+ if (!aname) return 'usage: /agent edit <name>';
617
+ const existing = agentsMod.getAgent(aname, ctx.cfgDir);
618
+ if (!existing) return `no agent "${aname}"`;
619
+ if (typeof ctx.openPicker !== 'function') {
620
+ return `agent edit: picker unavailable here — use: lazyclaw agent edit ${aname} --provider <p> --model <m>`;
621
+ }
622
+ const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
623
+ const r = await pickProviderModel(ctx, registry, { pickProvider: true, includeDefault: true, includeSwitch: false });
624
+ if (!r || r.model == null) return 'agent edit: cancelled';
625
+ const patched = agentsMod.patchAgent(aname, { provider: r.provider, model: r.model || '' }, ctx.cfgDir);
626
+ return `✓ ${patched.name} → ${patched.provider}${patched.model ? '/' + patched.model : ''}`;
762
627
  }
763
628
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
764
629
  if (!aname) return 'usage: /agent remove <name>';
630
+ if (typeof ctx.openPicker === 'function') {
631
+ const ok = await _promptConfirm(ctx, { title: `Remove agent "${aname}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
632
+ if (!ok) return `agent remove: cancelled — "${aname}" not removed`;
633
+ }
765
634
  agentsMod.removeAgent(aname, ctx.cfgDir);
766
635
  return `✓ removed agent ${aname}`;
767
636
  }
768
- return `/agent: unknown sub "${sub}" — list|show|add|remove`;
637
+ return `/agent: unknown sub "${sub}" — list|show|add|edit|remove`;
769
638
  } catch (e) {
770
639
  return `/agent error: ${e?.message || e}`;
771
640
  }
772
641
  }
773
642
 
774
643
  async function _team(args, ctx) {
775
- let teamsMod, loopMod;
644
+ let teamsMod, loopMod, agentsMod;
776
645
  try {
777
646
  teamsMod = await import('../teams.mjs');
778
647
  loopMod = await import('../loop-engine.mjs');
648
+ agentsMod = await import('../agents.mjs');
779
649
  } catch (e) { return `/team unavailable: ${e?.message || e}`; }
780
650
  let tokens;
781
651
  try { tokens = loopMod.splitArgs(args); }
@@ -784,6 +654,19 @@ async function _team(args, ctx) {
784
654
  const rest = tokens.slice(1);
785
655
  const tname = rest[0];
786
656
  try {
657
+ if (!sub && typeof ctx.openPicker === 'function') {
658
+ const picked = await ctx.openPicker({
659
+ kind: 'menu', title: 'Teams', subtitle: `${teamsMod.listTeams(ctx.cfgDir).length} registered`,
660
+ items: [
661
+ { id: 'list', label: 'List teams', desc: 'show all' },
662
+ { id: 'add', label: 'Add team…', desc: '/team add <name> --agents a,b --lead a' },
663
+ { id: 'show', label: 'Show team…', desc: 'print one record' },
664
+ { id: 'remove', label: 'Remove team…', desc: 'delete a team' },
665
+ ],
666
+ });
667
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
668
+ return _team(typeof pid === 'string' && pid ? pid : 'list', ctx);
669
+ }
787
670
  if (!sub || sub === 'list') {
788
671
  const teams = teamsMod.listTeams(ctx.cfgDir);
789
672
  if (teams.length === 0) return 'no teams registered. /team add <name> --agents a,b --lead a [--channel #x]';
@@ -793,14 +676,16 @@ async function _team(args, ctx) {
793
676
  }).join('\n');
794
677
  }
795
678
  if (sub === 'show') {
796
- if (!tname) return 'usage: /team show <name>';
679
+ if (!tname) return 'usage: /team show <name> [json]';
797
680
  const t = teamsMod.getTeam(tname, ctx.cfgDir);
798
681
  if (!t) return `no team "${tname}"`;
799
- return JSON.stringify(t, null, 2);
682
+ return rest[1] === 'json'
683
+ ? JSON.stringify(t, null, 2)
684
+ : renderRecord(t, { fields: ['name', 'displayName', 'lead', 'agents', 'slackChannel', 'createdAt', 'updatedAt'] });
800
685
  }
801
686
  if (sub === 'add') {
802
- if (!tname) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
803
687
  let agentsCsv = null, lead = null, channel = '';
688
+ let teamName = tname;
804
689
  for (let i = 1; i < rest.length; i++) {
805
690
  const t = rest[i];
806
691
  if (t === '--agents') agentsCsv = rest[++i] || '';
@@ -808,18 +693,51 @@ async function _team(args, ctx) {
808
693
  else if (t === '--channel') channel = rest[++i] || '';
809
694
  else return `/team error: unknown token "${t}"`;
810
695
  }
811
- if (!agentsCsv) return '/team add: --agents is required';
812
- const agentsList = teamsMod.parseListFlag(agentsCsv);
696
+ // Guided fill: no --agents + a modal available → name prompt, then a
697
+ // multi-pick over registered agents, then a lead pick. Typed form
698
+ // (--agents …) and the no-modal path are unchanged.
699
+ let agentsList;
700
+ if (!agentsCsv && typeof ctx.openPicker === 'function') {
701
+ if (!teamName) {
702
+ teamName = await _promptText(ctx, { title: 'New team — name', subtitle: 'short id (Esc cancels)' });
703
+ if (!teamName) return 'team add: cancelled';
704
+ }
705
+ const all = agentsMod ? agentsMod.listAgents(ctx.cfgDir).map((a) => a.name) : [];
706
+ if (!all.length) return 'team add: no agents registered yet — add one with /agent add first';
707
+ const chosen = [];
708
+ for (let guard = 0; guard < 50; guard++) {
709
+ const items = all.filter((n) => !chosen.includes(n)).map((n) => ({ id: n, label: n }));
710
+ if (chosen.length) items.unshift({ id: '__done__', label: `✓ done (${chosen.length} selected)`, pinned: true });
711
+ if (!items.length) break;
712
+ 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 });
713
+ const id = p && typeof p === 'object' ? p.id : p;
714
+ if (!id) { if (chosen.length) break; return 'team add: cancelled'; }
715
+ if (id === '__done__') break;
716
+ chosen.push(id);
717
+ }
718
+ if (!chosen.length) return 'team add: cancelled (no agents picked)';
719
+ const lp = await ctx.openPicker({ kind: 'menu', title: 'Team lead', subtitle: 'who leads this team?', items: chosen.map((n) => ({ id: n, label: n })) });
720
+ lead = (lp && typeof lp === 'object' ? lp.id : lp) || chosen[0];
721
+ agentsList = chosen;
722
+ } else {
723
+ if (!teamName) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
724
+ if (!agentsCsv) return '/team add: --agents is required';
725
+ agentsList = teamsMod.parseListFlag(agentsCsv);
726
+ }
813
727
  const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
814
728
  botToken: process.env.SLACK_BOT_TOKEN || null,
815
729
  apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
816
730
  logger: () => {},
817
731
  }) : '';
818
- const team = teamsMod.registerTeam({ name: tname, agents: agentsList, lead, slackChannel: ch }, ctx.cfgDir);
732
+ const team = teamsMod.registerTeam({ name: teamName, agents: agentsList, lead, slackChannel: ch }, ctx.cfgDir);
819
733
  return `✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})`;
820
734
  }
821
735
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
822
736
  if (!tname) return 'usage: /team remove <name>';
737
+ if (typeof ctx.openPicker === 'function') {
738
+ const ok = await _promptConfirm(ctx, { title: `Remove team "${tname}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
739
+ if (!ok) return `team remove: cancelled — "${tname}" not removed`;
740
+ }
823
741
  teamsMod.removeTeam(tname, ctx.cfgDir);
824
742
  return `✓ removed team ${tname}`;
825
743
  }
@@ -945,6 +863,22 @@ async function _goal(args, ctx) {
945
863
  sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
946
864
  } catch (e) { return `/goal unavailable: ${e?.message || e}`; }
947
865
  if (!args) {
866
+ if (typeof ctx.openPicker === 'function') {
867
+ const active = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
868
+ const picked = await ctx.openPicker({
869
+ kind: 'menu', title: 'Goals', subtitle: `${active.length} active`,
870
+ items: [
871
+ { id: 'list', label: 'List goals', desc: 'show all' },
872
+ { id: 'add', label: 'Add goal…', desc: '/goal add <name> [--desc] [--cron]' },
873
+ { id: 'show', label: 'Show goal…', desc: 'print one record' },
874
+ { id: 'close', label: 'Close goal…', desc: 'mark done/abandoned' },
875
+ ...active.map((g) => ({ id: g.name, label: `↪ switch: ${g.name}`, desc: g.description || '' })),
876
+ ],
877
+ });
878
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
879
+ if (pid && typeof pid === 'string') return _goal(pid, ctx);
880
+ // cancelled → fall through to the text list below
881
+ }
948
882
  const items = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
949
883
  if (!items.length) return 'no active goals';
950
884
  return items.map((g) =>
@@ -967,6 +901,30 @@ async function _goal(args, ctx) {
967
901
  else if (!name) name = t;
968
902
  else return `goal error: unexpected arg "${t}"`;
969
903
  }
904
+ // Guided fill: no name + a modal available → prompt name + desc, and offer
905
+ // a cron preset picker instead of a hand-typed spec. Typed form unchanged.
906
+ if (!name && typeof ctx.openPicker === 'function') {
907
+ name = await _promptText(ctx, { title: 'New goal — name', subtitle: 'short id (Esc cancels)' });
908
+ if (!name) return 'goal add: cancelled';
909
+ if (!desc) {
910
+ const d = await _promptText(ctx, { title: `Goal "${name}" — description`, subtitle: 'what is this goal? (Esc to skip)', allowEmpty: true });
911
+ desc = d || '';
912
+ }
913
+ if (!cron) {
914
+ const cp = await ctx.openPicker({
915
+ kind: 'menu', title: 'Schedule (optional)', subtitle: 'run this goal on a cron?',
916
+ items: [
917
+ { id: '', label: 'none', desc: 'no schedule' },
918
+ { id: '0 9 * * *', label: 'daily 09:00', desc: '0 9 * * *' },
919
+ { id: '0 * * * *', label: 'hourly', desc: '0 * * * *' },
920
+ { id: '0 9 * * 1', label: 'weekly (Mon 09:00)', desc: '0 9 * * 1' },
921
+ { id: '__custom__', label: 'custom…', desc: 'type a cron spec', freeText: true },
922
+ ],
923
+ });
924
+ if (cp && typeof cp === 'object' && cp.id === '__custom__') cron = String(cp.query || '').trim() || null;
925
+ else { const cid = cp && typeof cp === 'object' ? cp.id : cp; cron = cid && typeof cid === 'string' ? cid : null; }
926
+ }
927
+ }
970
928
  if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
971
929
  try {
972
930
  const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
@@ -993,10 +951,12 @@ async function _goal(args, ctx) {
993
951
  if (sub === 'list') return JSON.stringify(goalsMod.listGoals(ctx.cfgDir), null, 2);
994
952
  if (sub === 'show') {
995
953
  const name = rest[0];
996
- if (!name) return 'usage: /goal show <name>';
954
+ if (!name) return 'usage: /goal show <name> [json]';
997
955
  const g = goalsMod.getGoal(name, ctx.cfgDir);
998
956
  if (!g) return `no goal "${name}"`;
999
- return JSON.stringify(g, null, 2);
957
+ return rest[1] === 'json'
958
+ ? JSON.stringify(g, null, 2)
959
+ : renderRecord(g, { fields: ['name', 'status', 'description', 'schedule', 'channels', 'createdAt', 'memoryPath'] });
1000
960
  }
1001
961
  if (sub === 'close') {
1002
962
  const name = rest[0];
@@ -1014,7 +974,7 @@ async function _goal(args, ctx) {
1014
974
  if (removed) detachNote = ' (cron detached)';
1015
975
  } catch { /* best-effort */ }
1016
976
  }
1017
- return `✓ goal ${g.name} closed (status: ${g.status})${detachNote}`;
977
+ return `✓ goal ${g.name} closed (status: ${g.status})${detachNote} — start a fresh goal with /goal add ${g.name} --desc "..."`;
1018
978
  } catch (e) { return `goal error: ${e?.message || e}`; }
1019
979
  }
1020
980
  // single-arg branch: switch
@@ -1116,17 +1076,37 @@ async function _personality(args, ctx) {
1116
1076
  return fs.readFileSync(p, 'utf8');
1117
1077
  }
1118
1078
  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}`;
1079
+ let nm = a, src = b;
1080
+ // Guided fill: missing name/file + a modal → prompt for them, and retry on
1081
+ // a bad path instead of erroring out (up to a few attempts).
1082
+ if ((!nm || !src) && typeof ctx.openPicker === 'function') {
1083
+ if (!nm) {
1084
+ nm = await _promptText(ctx, { title: 'Install personality — name', subtitle: 'short id to install it as (Esc cancels)' });
1085
+ if (!nm) return 'personality install: cancelled';
1086
+ }
1087
+ if (fs.existsSync(path.join(dir, `${nm}.md`))) return `personality already installed: ${nm}`;
1088
+ for (let attempt = 0; attempt < 3 && !src; attempt++) {
1089
+ 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)' });
1090
+ if (!p) return 'personality install: cancelled';
1091
+ if (fs.existsSync(p)) { src = p; break; }
1092
+ }
1093
+ if (!src) return 'personality install: source file not found after 3 tries';
1094
+ }
1095
+ if (!nm || !src) return 'usage: /personality install <name> <file.md>';
1096
+ const dst = path.join(dir, `${nm}.md`);
1097
+ if (fs.existsSync(dst)) return `personality already installed: ${nm}`;
1098
+ if (!fs.existsSync(src)) return `source file not found: ${src}`;
1099
+ fs.writeFileSync(dst, fs.readFileSync(src, 'utf8'));
1100
+ return `installed ${nm}`;
1125
1101
  }
1126
1102
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
1127
1103
  if (!a) return 'usage: /personality remove <name>';
1128
1104
  const p = path.join(dir, `${a}.md`);
1129
1105
  if (!fs.existsSync(p)) return `personality not installed: ${a}`;
1106
+ if (typeof ctx.openPicker === 'function') {
1107
+ const ok = await _promptConfirm(ctx, { title: `Remove personality "${a}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
1108
+ if (!ok) return `personality remove: cancelled — "${a}" not removed`;
1109
+ }
1130
1110
  fs.unlinkSync(p);
1131
1111
  return `removed ${a}`;
1132
1112
  }
@@ -1168,18 +1148,37 @@ async function _task(args, ctx, write) {
1168
1148
  const id = tokens[1];
1169
1149
 
1170
1150
  try {
1151
+ if (!sub && typeof ctx.openPicker === 'function') {
1152
+ const picked = await ctx.openPicker({
1153
+ kind: 'menu', title: 'Tasks', subtitle: `${tasksMod.listTasks(ctx.cfgDir).length} task(s)`,
1154
+ items: [
1155
+ { id: 'list', label: 'List tasks', desc: 'show all' },
1156
+ { id: 'start', label: 'Start task…', desc: 'open a new multi-agent task' },
1157
+ { id: 'tick', label: 'Tick task…', desc: 'one router turn' },
1158
+ { id: 'show', label: 'Show task…', desc: 'print a record' },
1159
+ { id: 'transcript', label: 'Transcript…', desc: 'dump turns' },
1160
+ { id: 'done', label: 'Mark done…', desc: 'close a task' },
1161
+ { id: 'abandon', label: 'Abandon…', desc: 'abandon a task' },
1162
+ { id: 'remove', label: 'Remove…', desc: 'delete a task' },
1163
+ ],
1164
+ });
1165
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
1166
+ return _task(typeof pid === 'string' && pid ? pid : 'list', ctx, write);
1167
+ }
1171
1168
  if (!sub || sub === 'list') {
1172
1169
  const items = tasksMod.listTasks(ctx.cfgDir);
1173
- if (!items.length) return 'no tasks. `lazyclaw task start --team ... --title ...` from the shell to create one.';
1170
+ if (!items.length) return 'no tasks yet. /task start <team> --title "..." to create one.';
1174
1171
  return items.map((t) =>
1175
1172
  `• ${t.id} [${t.status || 'unknown'}] ${t.title || '(no title)'}${t.team ? ` — team=${t.team}` : ''}${t.lead ? ` — lead=${t.lead}` : ''}`
1176
1173
  ).join('\n');
1177
1174
  }
1178
1175
  if (sub === 'show') {
1179
- if (!id) return 'usage: /task show <id>';
1176
+ if (!id) return 'usage: /task show <id> [json]';
1180
1177
  const t = tasksMod.getTask(id, ctx.cfgDir);
1181
1178
  if (!t) return `no task "${id}"`;
1182
- return JSON.stringify(t, null, 2);
1179
+ return tokens[2] === 'json'
1180
+ ? JSON.stringify(t, null, 2)
1181
+ : renderRecord(t, { fields: ['id', 'status', 'title', 'description', 'team', 'lead', 'slackChannel', 'createdAt', 'updatedAt'] });
1183
1182
  }
1184
1183
  if (sub === 'transcript') {
1185
1184
  if (!id) return 'usage: /task transcript <id> [text|md|json]';
@@ -1218,6 +1217,10 @@ async function _task(args, ctx, write) {
1218
1217
  }
1219
1218
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
1220
1219
  if (!id) return 'usage: /task remove <id>';
1220
+ if (typeof ctx.openPicker === 'function') {
1221
+ const ok = await _promptConfirm(ctx, { title: `Remove task ${id}?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
1222
+ if (!ok) return `task remove: cancelled — ${id} not removed`;
1223
+ }
1221
1224
  tasksMod.removeTask(id, ctx.cfgDir);
1222
1225
  return `✓ removed task ${id}`;
1223
1226
  }
@@ -1234,6 +1237,25 @@ async function _task(args, ctx, write) {
1234
1237
  else if (t === '--lead') lead = rest[++i] || null;
1235
1238
  else if (!teamName && !t.startsWith('--')) teamName = t;
1236
1239
  }
1240
+ // Guided fill: missing team/title + a modal available → pick the team
1241
+ // from the registry and prompt for a title. Typed form unchanged.
1242
+ if ((!teamName || !title) && typeof ctx.openPicker === 'function') {
1243
+ if (!teamName) {
1244
+ const teams = teamsMod.listTeams(ctx.cfgDir);
1245
+ if (!teams.length) return 'task start: no teams yet — create one with /team add first';
1246
+ 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(',')}` })) });
1247
+ teamName = tp && typeof tp === 'object' ? tp.id : tp;
1248
+ if (!teamName || typeof teamName !== 'string') return 'task start: cancelled';
1249
+ }
1250
+ if (!title) {
1251
+ title = await _promptText(ctx, { title: `Task title (team: ${teamName})`, subtitle: 'one line describing the task (Esc cancels)' });
1252
+ if (!title) return 'task start: cancelled';
1253
+ }
1254
+ if (!description) {
1255
+ const d = await _promptText(ctx, { title: 'Task description', subtitle: 'optional detail (Esc to skip)', allowEmpty: true });
1256
+ description = d || '';
1257
+ }
1258
+ }
1237
1259
  if (!teamName || !title) return 'usage: /task start <team> --title "..." [--description "..."] [--lead <name>]';
1238
1260
  const team = teamsMod.getTeam(teamName, ctx.cfgDir);
1239
1261
  if (!team) return `no team "${teamName}"`;
@@ -1292,6 +1314,9 @@ async function _task(args, ctx, write) {
1292
1314
  const userMsg = tokens.slice(2).join(' ').trim();
1293
1315
  try {
1294
1316
  if (typeof write === 'function') { try { write(' ↻ running task turn…\n'); } catch {} }
1317
+ // Default-on isolation: confine every tool the team runs. Lazy-imported
1318
+ // so the sandbox backends stay off the chat module-load path.
1319
+ const { defaultSandboxSpec } = await import('../sandbox/index.mjs');
1295
1320
  const result = await router.runTaskTurn({
1296
1321
  task, team, agentsById,
1297
1322
  userMessage: userMsg || undefined,
@@ -1300,6 +1325,7 @@ async function _task(args, ctx, write) {
1300
1325
  logger: (line) => { if (typeof write === 'function') { try { write(line); } catch {} } },
1301
1326
  approve: _makeInkApprove(ctx),
1302
1327
  security: ctx.cfg?.security,
1328
+ sandbox: defaultSandboxSpec(ctx.cfg, { cwd: process.cwd(), configDir: ctx.cfgDir }),
1303
1329
  });
1304
1330
  return `✓ task ${result.task.id} → ${result.task.status} (${result.iterations} agent turn(s)${result.stoppedBy ? `, stopped by ${result.stoppedBy}` : ''})`;
1305
1331
  } catch (e) {
@@ -1315,6 +1341,35 @@ async function _task(args, ctx, write) {
1315
1341
  async function _trainer(args, ctx) {
1316
1342
  const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
1317
1343
  const tokens = splitWhitespace(args);
1344
+
1345
+ // Bare /trainer with a modal available → an action menu (mirrors the bare
1346
+ // /orchestrator menu). "Set"/"Fallback" re-enter and drill the shared
1347
+ // provider→model picker; "Clear"/"Show" run their subcommands. Typed forms
1348
+ // (/trainer set <p:m>, etc.) still work and skip the menu.
1349
+ if (tokens.length === 0 && typeof ctx.openPicker === 'function') {
1350
+ let cur = '';
1351
+ try {
1352
+ if (typeof registry.resolveTrainer === 'function') {
1353
+ const e = registry.resolveTrainer(ctx.cfg || {});
1354
+ cur = `now: ${e.provider}${e.model ? ':' + e.model : ':(default)'}`;
1355
+ }
1356
+ } catch { /* show menu without the current hint */ }
1357
+ const picked = await ctx.openPicker({
1358
+ kind: 'menu',
1359
+ title: 'Trainer — synthesis / learning model',
1360
+ subtitle: cur || 'pick provider + model for trainer turns',
1361
+ items: [
1362
+ { id: 'set', label: 'Set trainer…', desc: 'pick provider + model (or auto / provider default)' },
1363
+ { id: 'fallback', label: 'Set fallback…', desc: 'pick a fallback provider + model' },
1364
+ { id: 'clear', label: 'Clear', desc: 'unset — mirror the chat provider/model' },
1365
+ { id: 'show', label: 'Show', desc: 'print the effective + configured trainer' },
1366
+ ],
1367
+ });
1368
+ const id = picked && typeof picked === 'object' ? picked.id : picked;
1369
+ if (!id || typeof id !== 'string') return _trainer('show', ctx); // cancelled → show status
1370
+ return _trainer(id, ctx); // re-enter; set/fallback open the picker (no spec)
1371
+ }
1372
+
1318
1373
  const sub = tokens[0] || 'show';
1319
1374
 
1320
1375
  if (sub === 'show') {
@@ -1327,7 +1382,7 @@ async function _trainer(args, ctx) {
1327
1382
  } catch { /* fall through */ }
1328
1383
  const configured = (ctx.cfg && ctx.cfg.trainer) || null;
1329
1384
  const cfgRender = configured
1330
- ? JSON.stringify(configured, null, 2)
1385
+ ? renderRecord(configured, { fields: ['provider', 'model', 'fallback'] }).split('\n').map((l) => ' ' + l).join('\n')
1331
1386
  : '(unset — trainer mirrors the chat provider/model)';
1332
1387
  return [
1333
1388
  'trainer (effective):',
@@ -1339,7 +1394,16 @@ async function _trainer(args, ctx) {
1339
1394
  }
1340
1395
 
1341
1396
  if (sub === 'set') {
1342
- const spec = tokens[1];
1397
+ let spec = tokens[1];
1398
+ let _setFromPicker = false;
1399
+ // No spec + a modal available → drill the shared picker (with an "auto"
1400
+ // row and a "provider default" row) instead of requiring a typed spec.
1401
+ if (!spec && typeof ctx.openPicker === 'function') {
1402
+ const r = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
1403
+ if (!r || r.model == null) return 'trainer set: cancelled';
1404
+ spec = r.provider === 'auto' ? 'auto' : (r.model ? `${r.provider}:${r.model}` : r.provider);
1405
+ _setFromPicker = true;
1406
+ }
1343
1407
  if (!spec) return 'usage: /trainer set <provider>[:<model>] (or `auto` for orchestrator-managed)';
1344
1408
  const parsed = typeof registry.parseProviderModel === 'function'
1345
1409
  ? registry.parseProviderModel(spec)
@@ -1362,6 +1426,17 @@ async function _trainer(args, ctx) {
1362
1426
  return `/trainer set: unknown provider "${fp.provider}"`;
1363
1427
  }
1364
1428
  }
1429
+ // Picker-driven set with no --fallback flag → offer an optional fallback
1430
+ // pick (the flag form stays the escape hatch for typed callers).
1431
+ if (_setFromPicker && !fallbackSpec && typeof ctx.openPicker === 'function') {
1432
+ const addFb = await _promptConfirm(ctx, { title: 'Add a fallback trainer?', subtitle: 'used when the primary is unavailable · Esc / deny to skip' });
1433
+ if (addFb) {
1434
+ const fr = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
1435
+ if (fr && fr.model != null) {
1436
+ fallbackSpec = fr.provider === 'auto' ? 'auto' : (fr.model ? `${fr.provider}:${fr.model}` : fr.provider);
1437
+ }
1438
+ }
1439
+ }
1365
1440
  // Read-merge-write so unrelated cfg keys survive.
1366
1441
  const fs = await import('node:fs');
1367
1442
  const path = await import('node:path');
@@ -1379,7 +1454,12 @@ async function _trainer(args, ctx) {
1379
1454
  }
1380
1455
 
1381
1456
  if (sub === 'fallback') {
1382
- const spec = tokens[1];
1457
+ let spec = tokens[1];
1458
+ if (!spec && typeof ctx.openPicker === 'function') {
1459
+ const r = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
1460
+ if (!r || r.model == null) return 'trainer fallback: cancelled';
1461
+ spec = r.provider === 'auto' ? 'auto' : (r.model ? `${r.provider}:${r.model}` : r.provider);
1462
+ }
1383
1463
  if (!spec) return 'usage: /trainer fallback <provider>[:<model>] | clear';
1384
1464
  const fs = await import('node:fs');
1385
1465
  const path = await import('node:path');
@@ -1640,7 +1720,7 @@ async function _dashboard(args) {
1640
1720
  // behind `lazyclaw menu`. This restores discoverability: browse subcommands
1641
1721
  // and get the exact command to run. (Most subcommands own stdout / spawn, so
1642
1722
  // they can't safely run inline in the Ink scrollback — we echo the command.)
1643
- async function _menu(args, ctx) {
1723
+ async function _menu(args, ctx, write) {
1644
1724
  if (typeof ctx.openPicker === 'function') {
1645
1725
  const items = [];
1646
1726
  const seen = new Set();
@@ -1648,17 +1728,23 @@ async function _menu(args, ctx) {
1648
1728
  for (const c of cmds) {
1649
1729
  if (seen.has(c)) continue;
1650
1730
  seen.add(c);
1651
- items.push({ id: c, label: c, desc: group });
1731
+ // Mark which subcommands can run in-chat (a /slash equivalent exists).
1732
+ const inChat = SLASH_HANDLERS.has(`/${c}`);
1733
+ items.push({ id: c, label: c, desc: inChat ? `${group} · runs in chat` : group });
1652
1734
  }
1653
1735
  }
1654
1736
  const picked = await ctx.openPicker({
1655
1737
  kind: 'menu',
1656
1738
  title: 'lazyclaw subcommands',
1657
- subtitle: 'Enter shows how to run it · Esc cancels',
1739
+ subtitle: 'Enter runs it in chat (or shows the shell command) · Esc cancels',
1658
1740
  items,
1659
1741
  });
1660
1742
  if (!picked) return 'cancelled';
1661
1743
  const cmd = typeof picked === 'string' ? picked : picked.id;
1744
+ // If there's an in-chat slash equivalent, dispatch it directly instead of
1745
+ // telling the user to leave chat.
1746
+ const handler = SLASH_HANDLERS.get(`/${cmd}`);
1747
+ if (handler) return handler('', ctx, write);
1662
1748
  return `run from a shell: lazyclaw ${cmd}`;
1663
1749
  }
1664
1750
  return [
@@ -1676,7 +1762,86 @@ async function _channels(args, ctx = {}) {
1676
1762
  const cfgMod = await import('../lib/config.mjs');
1677
1763
  const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
1678
1764
  const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
1679
- const [name, action] = (args || '').trim().split(/\s+/).filter(Boolean);
1765
+ const toks = (args || '').trim().split(/\s+/).filter(Boolean);
1766
+ const [name, action] = toks;
1767
+
1768
+ // `/channels [<name>] setup` — set the channel's credentials (bot token,
1769
+ // homeserver, …) from chat instead of redirecting to /config. Reuses the
1770
+ // masked modal prompt (_promptText) so secrets are never echoed. No modal →
1771
+ // fall back to the readline channel step (same as /config's channel item).
1772
+ const wantSetup = toks.some((t) => /^setup$/i.test(t));
1773
+ if (wantSetup) {
1774
+ const channelMod = await import('../commands/setup_channels.mjs');
1775
+ const picked = toks.find((t) => !/^setup$/i.test(t));
1776
+ if (typeof ctx.openPicker !== 'function') {
1777
+ // Readline path: hand off to the existing channel wizard step.
1778
+ ctx.requestConfigStep = 'channel';
1779
+ return 'EXIT';
1780
+ }
1781
+ let chName = picked && picked.toLowerCase();
1782
+ if (!chName) {
1783
+ const sel = await ctx.openPicker({
1784
+ kind: 'menu',
1785
+ title: 'channel — set credentials',
1786
+ subtitle: 'pick a channel to configure',
1787
+ items: channelMod.CHANNEL_CATALOG.map((c) => ({
1788
+ id: c.name,
1789
+ label: c.label,
1790
+ desc: c.builtin ? '' : `needs: ${(c.deps && c.deps.length) ? c.deps.join(', ') : (c.binary || 'creds only')}`,
1791
+ })),
1792
+ });
1793
+ chName = sel && typeof sel === 'object' ? sel.id : sel;
1794
+ if (!chName || typeof chName !== 'string') return 'channel setup: cancelled';
1795
+ }
1796
+ const spec = channelMod.channelByName(chName);
1797
+ if (!spec) return `unknown channel: ${chName} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
1798
+ if (!spec.fields.length) {
1799
+ // No creds (e.g. http / whatsapp) — just enable it.
1800
+ const cfgDirX = ctx.cfgDir || (await import('node:path')).dirname(cfgMod.configPath());
1801
+ channelMod.persistChannel(cfgDirX, chName, {});
1802
+ if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: true }; }
1803
+ return `channel ${chName} → enabled (no credentials needed)`;
1804
+ }
1805
+ const answers = {};
1806
+ for (const f of spec.fields) {
1807
+ const v = await _promptText(ctx, {
1808
+ title: `${spec.label} — ${f.prompt}`,
1809
+ subtitle: f.optional ? 'optional · Esc to skip' : 'Esc cancels',
1810
+ secret: !!f.secret,
1811
+ allowEmpty: !!f.optional,
1812
+ });
1813
+ if (v === null) {
1814
+ if (f.optional) continue; // Esc on an optional field → skip it
1815
+ return 'channel setup: cancelled';
1816
+ }
1817
+ if (v) answers[f.key] = v;
1818
+ }
1819
+ const path = await import('node:path');
1820
+ const cfgDirX = ctx.cfgDir || path.dirname(cfgMod.configPath());
1821
+ const entry = channelMod.persistChannel(cfgDirX, chName, answers);
1822
+ // Mirror the PERSISTED enabled state onto the in-session cfg so a follow-up
1823
+ // list is fresh — an in-tree channel whose runtime dep is missing stays
1824
+ // disabled (persistChannel gates it) rather than being force-enabled.
1825
+ if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: !!entry.ready }; }
1826
+ const setKeys = Object.keys(answers);
1827
+ let note = '';
1828
+ if (!entry.ready) {
1829
+ if (entry.missingDeps && entry.missingDeps.length) note += `\n(needs ${entry.missingDeps.join(', ')} — run: lazyclaw channels install ${chName})`;
1830
+ if (entry.missingBinary) note += `\n(needs the ${entry.missingBinary} binary on your PATH)`;
1831
+ }
1832
+ return `✓ ${spec.label} credentials saved (${setKeys.join(', ') || 'none'}) → ${entry.ready ? 'channel enabled' : 'saved (enable once the requirement is installed)'}${note}`;
1833
+ }
1834
+
1835
+ // `/channels <name> test` — verify the stored credentials with a live call.
1836
+ if (name && /^test$/i.test(action || '')) {
1837
+ const channelMod = await import('../commands/setup_channels.mjs');
1838
+ try { loadDotenvIfAny(ctx.cfgDir); } catch { /* best-effort */ }
1839
+ const r = await channelMod.verifyChannel(name.toLowerCase());
1840
+ if (r.ok === true) return `✓ ${name} verified — ${r.detail}`;
1841
+ if (r.ok === null) return `· ${name}: ${r.detail}`;
1842
+ return `✗ ${name}: ${r.detail}${r.hint ? `\n fix: ${r.hint}` : ''}`;
1843
+ }
1844
+
1680
1845
  if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
1681
1846
  const en = /^(on|enable)$/i.test(action);
1682
1847
  const cfg = read();
@@ -1697,70 +1862,56 @@ async function _channels(args, ctx = {}) {
1697
1862
  if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
1698
1863
  cf.channelSetEnabled(ctx.cfg, key, en);
1699
1864
  }
1700
- return `channel ${key} → ${en ? 'enabled' : 'disabled'}`;
1865
+ return en
1866
+ ? `channel ${key} → enabled`
1867
+ : `channel ${key} → disabled (re-enable with /channels ${key} on)`;
1868
+ }
1869
+ // No-arg + modal → an action menu: each row toggles in place, plus a
1870
+ // "set credentials" row. Falls through to the text list when no modal.
1871
+ if (!toks.length && typeof ctx.openPicker === 'function') {
1872
+ const statusRows = cf.channelStatusList(read());
1873
+ const items = statusRows.map((c) => ({
1874
+ id: `toggle:${c.name}`,
1875
+ label: `${c.name} — ${c.enabled ? 'enabled' : 'disabled'}`,
1876
+ desc: c.enabled ? 'Enter to disable' : 'Enter to enable',
1877
+ }));
1878
+ items.push({ id: 'setup', label: '+ Set credentials…', desc: 'pick a channel and enter bot token / homeserver / …' });
1879
+ const picked = await ctx.openPicker({ kind: 'menu', title: 'Channels', subtitle: `${statusRows.length} configured`, items });
1880
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
1881
+ if (!pid || typeof pid !== 'string') return 'cancelled';
1882
+ if (pid === 'setup') return _channels('setup', ctx);
1883
+ if (pid.startsWith('toggle:')) {
1884
+ const nm = pid.slice(7);
1885
+ const cur = statusRows.find((r) => r.name === nm);
1886
+ return _channels(`${nm} ${cur && cur.enabled ? 'off' : 'on'}`, ctx);
1887
+ }
1701
1888
  }
1702
1889
  const rows = cf.channelStatusList(read());
1703
- if (!rows.length) return 'no channels configured. add creds with /config (re-runs setup) or `lazyclaw setup`.';
1890
+ if (!rows.length) return 'no channels configured. set credentials with /channels setup (or `lazyclaw setup` for the full wizard).';
1891
+ // Cross-reference each channel's required env creds against the loaded env so
1892
+ // the list flags a channel that's "enabled" but missing its token.
1893
+ let channelMod = null;
1894
+ try {
1895
+ loadDotenvIfAny(ctx.cfgDir);
1896
+ channelMod = await import('../commands/setup_channels.mjs');
1897
+ } catch { /* hint is best-effort */ }
1898
+ const missingFor = (name) => {
1899
+ const spec = channelMod && channelMod.channelByName(name);
1900
+ if (!spec) return [];
1901
+ return spec.fields.filter((f) => !f.optional && !process.env[f.env]).map((f) => f.env);
1902
+ };
1704
1903
  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');
1904
+ for (const c of rows) {
1905
+ const miss = missingFor(c.name);
1906
+ const credNote = miss.length ? ` · creds: missing ${miss.join(', ')}` : '';
1907
+ lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}${credNote}`);
1908
+ }
1909
+ lines.push('toggle: /channels <name> on|off · set creds: /channels <name> setup');
1707
1910
  return lines.join('\n');
1708
1911
  }
1709
1912
 
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
- }
1913
+ // /orchestrator — moved to ./orchestrator_flow.mjs (orchestratorSlash) so the
1914
+ // interactive fetch+pick planner/worker editor can grow off the ratchet.
1764
1915
 
1765
1916
  // /context — view/set the chat history window (turns + token budget). This is
1766
1917
  // the sliding history budget sent each turn, NOT the model's hard context
@@ -1774,6 +1925,32 @@ async function _context(args, ctx = {}) {
1774
1925
  const parts = (args || '').trim().split(/\s+/).filter(Boolean);
1775
1926
  const sub = (parts[0] || 'status').toLowerCase();
1776
1927
  const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
1928
+ // No-arg + modal → action menu → numeric picker (mirrors orchestrator maxsubtasks).
1929
+ if (!parts.length && typeof ctx.openPicker === 'function') {
1930
+ const w = cf.chatWindowGet(read());
1931
+ const action = await ctx.openPicker({
1932
+ kind: 'menu', title: 'Context window (history budget)', subtitle: `now ${w.turns} turns · ${w.tokens} tokens`,
1933
+ items: [
1934
+ { id: 'turns', label: 'Set turns…', desc: 'past turns to send' },
1935
+ { id: 'tokens', label: 'Set tokens…', desc: 'token budget (min 256)' },
1936
+ { id: 'status', label: 'Status', desc: 'show current' },
1937
+ ],
1938
+ });
1939
+ const aid = action && typeof action === 'object' ? action.id : action;
1940
+ if (!aid || typeof aid !== 'string' || aid === 'status') return fmt();
1941
+ if (aid === 'turns') {
1942
+ const np = await ctx.openPicker({ kind: 'menu', title: 'Turns to keep', subtitle: `currently ${w.turns}`, items: [5, 10, 15, 20, 30, 40, 50].map((x) => ({ id: String(x), label: String(x) })) });
1943
+ const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
1944
+ if (!Number.isFinite(v)) return 'context turns: cancelled';
1945
+ const cfg = read(); cf.chatWindowSet(cfg, { turns: v }); persist(cfg); return fmt();
1946
+ }
1947
+ if (aid === 'tokens') {
1948
+ const np = await ctx.openPicker({ kind: 'menu', title: 'Token budget', subtitle: `currently ${w.tokens}`, items: [2000, 4000, 8000, 12000, 16000, 32000].map((x) => ({ id: String(x), label: String(x) })) });
1949
+ const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
1950
+ if (!Number.isFinite(v) || v < 256) return 'context tokens: cancelled';
1951
+ const cfg = read(); cf.chatWindowSet(cfg, { tokens: v }); persist(cfg); return fmt();
1952
+ }
1953
+ }
1777
1954
  if (sub === 'status') return fmt();
1778
1955
  const n = parseInt(parts[1], 10);
1779
1956
  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(); }
@@ -1781,6 +1958,15 @@ async function _context(args, ctx = {}) {
1781
1958
  return 'usage: /context [status | turns <N> | tokens <N>]';
1782
1959
  }
1783
1960
 
1961
+ // /agentic + /plan live in ./chat_mode_slash.mjs (Group 1) — kept out of this
1962
+ // file (at its size ratchet) so the toggles can grow there. Register their
1963
+ // catalog rows in the shared SLASH_COMMANDS so /help, the popup,
1964
+ // ghost-autocomplete, and the d6 drift-guard see them (idempotent — ESM runs
1965
+ // module init once; appended per the catalog's ordering note).
1966
+ for (const entry of CHAT_MODE_SLASH_COMMANDS) {
1967
+ if (!SLASH_COMMANDS.some((c) => c.cmd === entry.cmd)) SLASH_COMMANDS.push(entry);
1968
+ }
1969
+
1784
1970
  // ─── dispatch table ──────────────────────────────────────────────────────
1785
1971
 
1786
1972
  export const SLASH_HANDLERS = new Map([
@@ -1792,6 +1978,10 @@ export const SLASH_HANDLERS = new Map([
1792
1978
  ['/reset', _newReset],
1793
1979
  ['/clear', _newReset],
1794
1980
  ['/provider', _provider],
1981
+ ['/login', (a, ctx) => loginSlash(a, ctx, { promptText: _promptText })],
1982
+ ['/hud', hudSlash],
1983
+ ['/agentic', agenticSlash],
1984
+ ['/plan', planSlash],
1795
1985
  ['/model', _model],
1796
1986
  ['/skill', _skill],
1797
1987
  ['/skills', _skillsList],
@@ -1810,7 +2000,7 @@ export const SLASH_HANDLERS = new Map([
1810
2000
  ['/dashboard', _dashboard],
1811
2001
  ['/menu', _menu],
1812
2002
  ['/channels', _channels],
1813
- ['/orchestrator', _orchestrator],
2003
+ ['/orchestrator', orchestratorSlash],
1814
2004
  ['/context', _context],
1815
2005
  // /setup — full wizard (every step); /config — pick ONE setting to change
1816
2006
  // (in-chat where possible; credential steps unmount, run, re-enter chat).
@@ -1827,6 +2017,9 @@ export const SLASH_HANDLERS = new Map([
1827
2017
  */
1828
2018
  export async function dispatchSlash(cmd, args, ctx, write) {
1829
2019
  const handler = SLASH_HANDLERS.get(cmd);
1830
- if (!handler) return `unknown slash command: ${cmd} (try /help)`;
2020
+ if (!handler) {
2021
+ const hint = nearest(cmd, [...SLASH_HANDLERS.keys()]);
2022
+ return `unknown slash command: ${cmd}${hint ? ` — did you mean ${hint}?` : ''} (try /help)`;
2023
+ }
1831
2024
  return handler(args || '', ctx || {}, write);
1832
2025
  }