lazyclaw 5.4.4 → 6.0.1

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 (91) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/channels-discord/index.mjs +76 -0
  3. package/channels-discord/package.json +14 -0
  4. package/channels-email/index.mjs +109 -0
  5. package/channels-email/package.json +14 -0
  6. package/channels-signal/index.mjs +69 -0
  7. package/channels-signal/package.json +9 -0
  8. package/channels-voice/index.mjs +81 -0
  9. package/channels-voice/package.json +9 -0
  10. package/channels-whatsapp/index.mjs +70 -0
  11. package/channels-whatsapp/package.json +13 -0
  12. package/cli.mjs +73 -7399
  13. package/commands/agents.mjs +669 -0
  14. package/commands/auth_nodes.mjs +323 -0
  15. package/commands/automation.mjs +582 -0
  16. package/commands/channels.mjs +255 -0
  17. package/commands/chat.mjs +1217 -0
  18. package/commands/config.mjs +315 -0
  19. package/commands/daemon.mjs +260 -0
  20. package/commands/misc.mjs +128 -0
  21. package/commands/providers.mjs +511 -0
  22. package/commands/sessions.mjs +343 -0
  23. package/commands/setup.mjs +741 -0
  24. package/commands/skills.mjs +218 -0
  25. package/commands/workflow.mjs +661 -0
  26. package/daemon/lib/auth.mjs +58 -0
  27. package/daemon/lib/cost.mjs +30 -0
  28. package/daemon/lib/provider.mjs +69 -0
  29. package/daemon/lib/respond.mjs +83 -0
  30. package/daemon/route_table.mjs +96 -0
  31. package/daemon/routes/_deps.mjs +36 -0
  32. package/daemon/routes/config.mjs +99 -0
  33. package/daemon/routes/conversation.mjs +371 -0
  34. package/daemon/routes/meta.mjs +239 -0
  35. package/daemon/routes/ops.mjs +185 -0
  36. package/daemon/routes/providers.mjs +211 -0
  37. package/daemon/routes/rates.mjs +90 -0
  38. package/daemon/routes/registry.mjs +223 -0
  39. package/daemon/routes/sessions.mjs +213 -0
  40. package/daemon/routes/skills.mjs +260 -0
  41. package/daemon/routes/workflows.mjs +224 -0
  42. package/daemon.mjs +23 -2085
  43. package/dotenv_min.mjs +23 -0
  44. package/first_run.mjs +15 -0
  45. package/goals_cron.mjs +37 -0
  46. package/lib/args.mjs +216 -0
  47. package/lib/config.mjs +113 -0
  48. package/lib/registry_boot.mjs +55 -0
  49. package/mas/agent_turn.mjs +2 -1
  50. package/mas/index_db.mjs +82 -0
  51. package/mas/learning.mjs +17 -1
  52. package/mas/mention_router.mjs +38 -10
  53. package/mas/provider_adapters.mjs +28 -4
  54. package/mas/scrub_env.mjs +34 -0
  55. package/mas/tool_runner.mjs +23 -7
  56. package/mas/tools/bash.mjs +10 -5
  57. package/mas/tools/browser.mjs +18 -0
  58. package/mas/tools/learning.mjs +24 -14
  59. package/mas/tools/recall.mjs +5 -1
  60. package/mas/tools/web.mjs +47 -11
  61. package/mas/trajectory_store.mjs +7 -4
  62. package/package.json +16 -2
  63. package/providers/auth_store.mjs +22 -0
  64. package/providers/claude_cli.mjs +28 -2
  65. package/providers/claude_cli_detect.mjs +46 -0
  66. package/providers/custom_provider.mjs +70 -0
  67. package/providers/model_catalogue.mjs +86 -0
  68. package/providers/orchestrator.mjs +30 -9
  69. package/providers/rates.mjs +12 -2
  70. package/providers/registry.mjs +10 -7
  71. package/sandbox/confiners/landlock.mjs +14 -8
  72. package/sandbox/confiners/seatbelt.mjs +18 -2
  73. package/scripts/loop-worker.mjs +18 -7
  74. package/scripts/migrate-v5.mjs +5 -61
  75. package/secure_write.mjs +46 -0
  76. package/sessions.mjs +0 -0
  77. package/tui/modal_filter.mjs +59 -0
  78. package/tui/modal_picker.mjs +12 -37
  79. package/tui/pickers.mjs +917 -0
  80. package/tui/provider_families.mjs +41 -0
  81. package/tui/repl.mjs +67 -36
  82. package/tui/slash_commands.mjs +2 -1
  83. package/tui/slash_dispatcher.mjs +717 -58
  84. package/tui/splash.mjs +5 -12
  85. package/tui/subcommands.mjs +17 -0
  86. package/tui/terminal_approve.mjs +37 -0
  87. package/web/dashboard.css +275 -0
  88. package/web/dashboard.html +2 -1685
  89. package/web/dashboard.js +1406 -0
  90. package/workflow/persistent.mjs +13 -6
  91. package/mas/tools/skill_view.mjs +0 -43
@@ -34,6 +34,14 @@
34
34
  // LAZYCLAW_NO_INK=1. Re-implementing these as Ink overlays is a v5.5 item.
35
35
 
36
36
  import { SLASH_COMMANDS } from './slash_commands.mjs';
37
+ import { supportsLiveFetch, fetchModelsForProvider } from '../providers/model_catalogue.mjs';
38
+ import { providerFamilies, providerTag } from './provider_families.mjs';
39
+ import { addCustomProvider } from '../providers/custom_provider.mjs';
40
+ import { setAuthKey } from '../providers/auth_store.mjs';
41
+ import { attachGoalCron, detachGoalCron } from '../goals_cron.mjs';
42
+ import { loadDotenvIfAny } from '../dotenv_min.mjs';
43
+ import { SUBCOMMAND_GROUPS } from './subcommands.mjs';
44
+ import { redactSecrets } from '../mas/redact.mjs';
37
45
 
38
46
  // ─── helpers ─────────────────────────────────────────────────────────────
39
47
 
@@ -55,6 +63,15 @@ function splitWhitespace(s) {
55
63
  return (s || '').split(/\s+/).filter(Boolean);
56
64
  }
57
65
 
66
+ // Parse a "provider[:model]" spec, preferring the registry's parser.
67
+ function _parseProvModel(registry, spec) {
68
+ if (registry && typeof registry.parseProviderModel === 'function') return registry.parseProviderModel(spec);
69
+ const s = String(spec || '');
70
+ const i = s.indexOf(':');
71
+ if (i < 0) return { provider: s || null, model: null };
72
+ return { provider: s.slice(0, i) || null, model: s.slice(i + 1) || null };
73
+ }
74
+
58
75
  // Best-effort dynamic import. Returns the resolved ctx field if the caller
59
76
  // pre-injected it (test hot path), else loads the real module. Throwing is
60
77
  // fine — handlers wrap calls in try/catch where appropriate.
@@ -146,28 +163,198 @@ function _providerLookup(registry, name) {
146
163
  return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
147
164
  }
148
165
 
166
+ // Pick a provider via the family drill-in (API key / CLI-Local / Mock),
167
+ // mirroring the legacy readline wizard. Single-option steps auto-advance.
168
+ // orchestrator is never listed. Returns a provider id or null on cancel.
169
+ async function _pickProviderDrillIn(ctx, registry) {
170
+ const info = registry.PROVIDER_INFO || {};
171
+ const families = providerFamilies(registry);
172
+ const nonEmpty = Object.values(families).filter((f) => f.members.length > 0);
173
+ if (!nonEmpty.length) return null;
174
+
175
+ // ── Step 1 — auth family (skipped when only one is populated) ──
176
+ let family = nonEmpty[0];
177
+ if (nonEmpty.length > 1) {
178
+ const picked = await ctx.openPicker({
179
+ kind: 'provider-family',
180
+ title: 'select provider — how do you want to auth?',
181
+ subtitle: 'API: bring your own key · CLI/Local: use this machine · Mock: offline',
182
+ items: nonEmpty.map((f) => ({
183
+ id: f.id,
184
+ label: f.label,
185
+ desc: `${f.desc} · ${f.members.slice(0, 3).join(' / ')}${f.members.length > 3 ? ` (+${f.members.length - 3})` : ''}`,
186
+ tag: f.tag,
187
+ })),
188
+ });
189
+ if (!picked || typeof picked !== 'string') return null;
190
+ family = families[picked];
191
+ if (!family || !family.members.length) return null;
192
+ }
193
+
194
+ // ── Step 2 — provider in that family (auto-advances on a single member) ──
195
+ // The API-key family also offers "+ add a custom endpoint" so NIM /
196
+ // OpenRouter / vLLM / etc. can be registered without leaving chat. The
197
+ // add-custom row is never auto-advanced past, so don't single-skip it in.
198
+ const items = family.members.map((id) => {
199
+ const meta = info[id] || {};
200
+ let desc = '';
201
+ if (meta.custom) desc = `custom · ${meta.baseUrl || ''}`;
202
+ else if (meta.builtinOpenAICompat) desc = meta.label || meta.baseUrl || '';
203
+ else if (meta.label && meta.label !== id) desc = meta.label;
204
+ return { id, label: id, desc, tag: providerTag(meta) };
205
+ });
206
+ if (family.id === 'api') {
207
+ items.push({
208
+ id: '__add_custom__',
209
+ label: '+ add a custom OpenAI-compatible endpoint…',
210
+ desc: 'NIM · OpenRouter · Together · Groq · vLLM · LM Studio',
211
+ tag: 'new',
212
+ });
213
+ }
214
+ if (items.length === 1) return items[0].id;
215
+ const picked = await ctx.openPicker({
216
+ kind: 'provider',
217
+ title: `select provider — ${family.label}`,
218
+ subtitle: `current: ${ctx.getActiveProvName()}`,
219
+ items,
220
+ });
221
+ return typeof picked === 'string' ? picked : null;
222
+ }
223
+
224
+ // Single free-text prompt reusing the modal's filter buffer (no dedicated
225
+ // input widget). Returns the typed value, '' (only when allowEmpty), or null
226
+ // on cancel / required-but-empty.
227
+ async function _promptText(ctx, { title, subtitle, allowEmpty } = {}) {
228
+ if (typeof ctx.openPicker !== 'function') return null;
229
+ const picked = await ctx.openPicker({
230
+ kind: 'text',
231
+ title,
232
+ subtitle: subtitle || 'type into the filter, then pick the row · Esc cancels',
233
+ items: [{ id: '__text__', label: '✓ use what I typed above', desc: '', pinned: true, freeText: true }],
234
+ });
235
+ if (picked == null) return null;
236
+ if (typeof picked === 'object') {
237
+ const v = String(picked.query || '').trim();
238
+ if (!v && !allowEmpty) return null;
239
+ return v;
240
+ }
241
+ return null;
242
+ }
243
+
244
+ // Yes/no confirmation modal for sensitive-tool approval. Esc (or no modal
245
+ // available) DENIES — approval is never granted by omission.
246
+ async function _promptConfirm(ctx, { title, subtitle } = {}) {
247
+ if (typeof ctx.openPicker !== 'function') return false;
248
+ const picked = await ctx.openPicker({
249
+ kind: 'menu',
250
+ title: title || 'Approve sensitive tool?',
251
+ subtitle: subtitle || 'Enter selects · Esc denies',
252
+ items: [
253
+ { id: 'approve', label: '✓ approve once', desc: 'run this tool call' },
254
+ { id: 'deny', label: '✗ deny', desc: 'block this tool call' },
255
+ ],
256
+ });
257
+ const id = picked && typeof picked === 'object' ? picked.id : picked;
258
+ return id === 'approve';
259
+ }
260
+
261
+ // Default in-chat approval hook: prompts the operator to confirm each
262
+ // sensitive tool call. Used to drive the fail-closed tool runner from the
263
+ // Ink REPL, where stdin is owned by Ink so a raw readline prompt can't run.
264
+ function _makeInkApprove(ctx) {
265
+ return async function approve({ tool, args, agent }) {
266
+ const raw = typeof args === 'object' ? JSON.stringify(args) : String(args ?? '');
267
+ const summary = redactSecrets(raw).slice(0, 300);
268
+ const ok = await _promptConfirm(ctx, {
269
+ title: `Approve ${tool}?`,
270
+ subtitle: `agent ${agent}: ${summary}`,
271
+ });
272
+ return { approved: ok, reason: ok ? 'approved in chat' : 'denied in chat' };
273
+ };
274
+ }
275
+
276
+ // Register a custom OpenAI-compatible endpoint with the given fields, set it
277
+ // active, and return a summary string. Shared by the arg form and the
278
+ // interactive flow.
279
+ async function _registerCustom(ctx, registry, { name, baseUrl, apiKey }) {
280
+ if (typeof ctx.readConfig !== 'function' || typeof ctx.writeConfig !== 'function') {
281
+ return 'add custom: config writer unavailable in this session — use: lazyclaw providers add <name> <baseUrl> [apiKey]';
282
+ }
283
+ try {
284
+ const r = await addCustomProvider({
285
+ registry, readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, name, baseUrl, apiKey,
286
+ });
287
+ const next = _providerLookup(registry, r.name);
288
+ if (next) {
289
+ if (ctx.setActiveProvName) ctx.setActiveProvName(r.name);
290
+ if (ctx.setProv) ctx.setProv(next);
291
+ }
292
+ const probe = r.probe.ok
293
+ ? `✓ reachable — ${r.probe.count} model(s)`
294
+ : `! registered, but /v1/models probe failed: ${r.probe.error}`;
295
+ const override = r.builtinOverride ? `\n(note: overrides built-in "${r.name}")` : '';
296
+ return `custom provider saved → ${r.name} (${r.baseUrl})\n${probe}${override}\nprovider → ${r.name}`;
297
+ } catch (e) {
298
+ return `add custom failed: ${e && e.message ? e.message : e}`;
299
+ }
300
+ }
301
+
302
+ // After an interactive pick of a built-in api-key provider that has no key
303
+ // resolved, prompt for one and persist it (mirrored in-memory so it takes
304
+ // effect this session). No-op for keyless / custom providers, when a key
305
+ // already resolves, or when config writers aren't wired.
306
+ async function _maybePromptForKey(ctx, registry, provName) {
307
+ const meta = _infoFor(registry, provName);
308
+ if (!meta.requiresApiKey || meta.custom) return;
309
+ if (typeof ctx.readConfig !== 'function' || typeof ctx.writeConfig !== 'function') return;
310
+ const existing = typeof ctx.resolveAuthKey === 'function' ? ctx.resolveAuthKey(provName) : '';
311
+ if (existing) return;
312
+ const key = await _promptText(ctx, {
313
+ title: `${provName} needs an api key`,
314
+ subtitle: 'paste it now, or Esc to skip (set later via: lazyclaw auth)',
315
+ });
316
+ if (!key) return;
317
+ const next = setAuthKey({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, provider: provName, key });
318
+ // Mirror onto the in-memory cfg so resolveAuthKey (which closes over it)
319
+ // sees the key on the next turn without a restart.
320
+ if (ctx.cfg && next) {
321
+ ctx.cfg.authProfiles = next.authProfiles;
322
+ ctx.cfg.authActiveProfile = next.authActiveProfile;
323
+ }
324
+ }
325
+
326
+ // Interactive add-custom: collect name/baseUrl/apiKey via sequential prompts.
327
+ async function _addCustomFlow(ctx, registry) {
328
+ const name = await _promptText(ctx, { title: 'custom endpoint — short id', subtitle: 'e.g. nim, openrouter, vllm (Esc cancels)' });
329
+ if (!name) return 'cancelled';
330
+ const baseUrl = await _promptText(ctx, { title: `baseUrl for ${name}`, subtitle: 'must start with http(s) and end in /v1' });
331
+ if (!baseUrl) return 'cancelled';
332
+ const apiKey = await _promptText(ctx, { title: `api-key for ${name}`, subtitle: 'leave blank for an auth-less endpoint (e.g. local vLLM)', allowEmpty: true });
333
+ if (apiKey === null) return 'cancelled';
334
+ return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
335
+ }
336
+
149
337
  async function _provider(args, ctx) {
150
338
  const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
151
- // No arg open the Ink modal picker (v5.4.3). Falls back to the
152
- // pre-v5.4.3 hint string when ctx.openPicker isn't available (e.g.
153
- // non-Ink callers or before the picker ref settles).
339
+ // `/provider add <name> <baseUrl> [apiKey]` register a custom OpenAI-
340
+ // compatible endpoint non-interactively.
341
+ const addMatch = args && args.match(/^add\s+(.+)$/i);
342
+ if (addMatch) {
343
+ const [name, baseUrl, apiKey] = splitWhitespace(addMatch[1]);
344
+ if (!name || !baseUrl) return 'usage: /provider add <name> <baseUrl> [apiKey]';
345
+ return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
346
+ }
347
+ // No arg → drill-in modal picker (family -> provider). Falls back to the
348
+ // pre-v5.4.3 hint string when ctx.openPicker isn't available (e.g. non-Ink
349
+ // callers or before the picker ref settles).
154
350
  if (!args) {
155
351
  if (typeof ctx.openPicker === 'function') {
156
- const known = Object.keys(registry.PROVIDERS || {}).sort();
157
- const info = registry.PROVIDER_INFO || {};
158
- const items = known.map((id) => ({
159
- id,
160
- label: id,
161
- desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
162
- }));
163
- const picked = await ctx.openPicker({
164
- kind: 'provider',
165
- title: 'select provider',
166
- subtitle: `current: ${ctx.getActiveProvName()}`,
167
- items,
168
- });
352
+ const picked = await _pickProviderDrillIn(ctx, registry);
169
353
  if (!picked) return 'cancelled';
354
+ if (picked === '__add_custom__') return _addCustomFlow(ctx, registry);
170
355
  args = picked;
356
+ // Built-in api-key provider with no key configured → offer to set one.
357
+ await _maybePromptForKey(ctx, registry, args);
171
358
  } else {
172
359
  return `provider: ${ctx.getActiveProvName()}\n(pass an arg: /provider <name>)`;
173
360
  }
@@ -182,32 +369,189 @@ async function _provider(args, ctx) {
182
369
  return `provider → ${args}`;
183
370
  }
184
371
 
372
+ function _infoFor(registry, provName) {
373
+ return (registry.PROVIDER_INFO && registry.PROVIDER_INFO[provName]) || {};
374
+ }
375
+
376
+ // A composite provider (orchestrator) has no real model list — its only
377
+ // "model" is the provider name itself, so the model picker would dead-end
378
+ // on a single bogus row. Detect it so we can redirect to a provider pick.
379
+ function _isCompositeProvider(info, provName) {
380
+ if (info && info.composite) return true;
381
+ const s = info && Array.isArray(info.suggestedModels) ? info.suggestedModels : null;
382
+ return !!(s && s.length === 1 && s[0] === provName);
383
+ }
384
+
385
+ // Whether the active provider exposes any selectable model (a suggested
386
+ // model that isn't just the provider name, or a live-fetchable catalogue).
387
+ function _hasRealModels(info, provName) {
388
+ if (supportsLiveFetch(info, provName)) return true;
389
+ const s = info && Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
390
+ return s.some((m) => m && m !== provName);
391
+ }
392
+
393
+ // Build the model-picker rows: suggested + any live-fetched models, deduped,
394
+ // with the provider default tagged, plus pinned sentinel rows for live-fetch
395
+ // (when supported) and free-text custom entry.
396
+ function _buildModelItems(info, provName, dynamicModels) {
397
+ const base = Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
398
+ const all = Array.from(new Set([...(dynamicModels || []), ...base])).filter((m) => m && m !== provName);
399
+ const items = [];
400
+ if (supportsLiveFetch(info, provName)) {
401
+ items.push({
402
+ id: '__fetch_models__',
403
+ label: '↻ fetch live model list',
404
+ desc: 'pull the current catalogue from the provider',
405
+ pinned: true,
406
+ });
407
+ }
408
+ for (const m of all) {
409
+ items.push({ id: m, label: m, desc: info.defaultModel === m ? '(default)' : '' });
410
+ }
411
+ items.push({
412
+ id: '__custom_model__',
413
+ label: '… type a custom model id',
414
+ desc: 'type the id into the filter above, then pick this row',
415
+ pinned: true,
416
+ freeText: true,
417
+ });
418
+ // Reach another provider's models (e.g. claude-cli's opus) without leaving
419
+ // /model — the active provider isn't the only place to pick a model.
420
+ items.push({
421
+ id: '__switch_provider__',
422
+ label: '⇄ pick a different provider…',
423
+ desc: 'switch provider (e.g. claude-cli for opus/sonnet), then its model',
424
+ pinned: true,
425
+ });
426
+ return items;
427
+ }
428
+
429
+ // Run the model picker for `provName`, looping on the live-fetch row and
430
+ // resolving the free-text row from the typed filter. Returns a concrete
431
+ // model id, or null on cancel.
432
+ async function _pickModelLoop(ctx, registry, provName) {
433
+ const info = _infoFor(registry, provName);
434
+ let dynamic = [];
435
+ let note = '';
436
+ for (let guard = 0; guard < 50; guard++) {
437
+ const items = _buildModelItems(info, provName, dynamic);
438
+ const picked = await ctx.openPicker({
439
+ kind: 'model',
440
+ title: `select model for ${provName}`,
441
+ subtitle: note || `current: ${ctx.getActiveModel() || '(default)'}`,
442
+ items,
443
+ });
444
+ if (picked == null) return null;
445
+ if (typeof picked === 'object') {
446
+ // free-text custom row → { id, query }
447
+ const typed = String(picked.query || '').trim();
448
+ if (!typed) { note = 'type a model id into the filter first'; continue; }
449
+ return typed;
450
+ }
451
+ if (picked === '__fetch_models__') {
452
+ try {
453
+ const fetcher = typeof ctx.fetchModels === 'function'
454
+ ? ctx.fetchModels
455
+ : (provId) => fetchModelsForProvider({
456
+ cfg: ctx.cfg,
457
+ registryMod: registry,
458
+ resolveAuthKey: (id) => (ctx.resolveAuthKey ? ctx.resolveAuthKey(id) : ''),
459
+ providerId: provId,
460
+ });
461
+ const fetched = await fetcher(provName);
462
+ if (Array.isArray(fetched) && fetched.length) {
463
+ dynamic = fetched;
464
+ note = `fetched ${fetched.length} model(s) — pick one or type a custom id`;
465
+ } else {
466
+ note = 'no models returned — using the suggested list';
467
+ }
468
+ } catch (e) {
469
+ note = `fetch failed: ${e && e.message ? e.message : e}`;
470
+ }
471
+ continue;
472
+ }
473
+ // Switch to a different provider's models (e.g. claude-cli's opus).
474
+ if (picked === '__switch_provider__') return '__switch_provider__';
475
+ return picked;
476
+ }
477
+ return null;
478
+ }
479
+
480
+ // Flat provider picker used to escape a composite/model-less active provider
481
+ // or to switch providers from inside /model. Hides composites + mock,
482
+ // mirroring the legacy wizard's filter (cli.mjs:1979).
483
+ async function _pickProviderForModel(ctx, registry, subtitle) {
484
+ const info = registry.PROVIDER_INFO || {};
485
+ const known = Object.keys(registry.PROVIDERS || {})
486
+ .filter((id) => id !== 'mock' && !((info[id] || {}).composite))
487
+ .sort();
488
+ const items = known.map((id) => ({
489
+ id,
490
+ label: id,
491
+ desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
492
+ }));
493
+ const picked = await ctx.openPicker({
494
+ kind: 'provider',
495
+ title: 'select provider (then a model)',
496
+ subtitle: subtitle || `${ctx.getActiveProvName()} has no selectable models — pick a provider`,
497
+ items,
498
+ });
499
+ return typeof picked === 'string' ? picked : null;
500
+ }
501
+
185
502
  async function _model(args, ctx) {
186
503
  const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
187
504
  if (!args) {
188
505
  if (typeof ctx.openPicker === 'function') {
189
- const provName = ctx.getActiveProvName();
190
- const info = (registry.PROVIDER_INFO && registry.PROVIDER_INFO[provName]) || {};
191
- const suggested = Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
192
- if (!suggested.length) {
193
- return `no suggested models known for provider "${provName}" pass one: /model <name>`;
506
+ let provName = ctx.getActiveProvName();
507
+ let info = _infoFor(registry, provName);
508
+ let switched = false;
509
+ // Composite (orchestrator) or model-less active provider → pick a
510
+ // provider first so the user is never dead-ended on a single row.
511
+ if (_isCompositeProvider(info, provName) || !_hasRealModels(info, provName)) {
512
+ const pickedProv = await _pickProviderForModel(ctx, registry);
513
+ if (!pickedProv) return 'cancelled';
514
+ if (pickedProv !== provName) {
515
+ const next = _providerLookup(registry, pickedProv);
516
+ if (!next) return `unknown provider: ${pickedProv}`;
517
+ if (ctx.setActiveProvName) ctx.setActiveProvName(pickedProv);
518
+ if (ctx.setProv) ctx.setProv(next);
519
+ switched = true;
520
+ }
521
+ provName = pickedProv;
522
+ info = _infoFor(registry, provName);
194
523
  }
195
- const items = suggested.map((m) => ({
196
- id: m,
197
- label: m,
198
- desc: info.defaultModel === m ? '(default)' : '',
199
- }));
200
- const picked = await ctx.openPicker({
201
- kind: 'model',
202
- title: `select model for ${provName}`,
203
- subtitle: `current: ${ctx.getActiveModel() || '(default)'}`,
204
- items,
205
- });
206
- if (!picked) return 'cancelled';
207
- args = picked;
208
- } else {
209
- return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
524
+ // Model pick loop the "⇄ pick a different provider" row re-enters the
525
+ // provider picker so models from any provider (e.g. claude-cli's opus)
526
+ // are reachable without leaving /model.
527
+ let model = null;
528
+ for (let guard = 0; guard < 25; guard++) {
529
+ model = await _pickModelLoop(ctx, registry, provName);
530
+ if (model === '__switch_provider__') {
531
+ const np = await _pickProviderForModel(ctx, registry, `current: ${provName} — pick a provider`);
532
+ if (!np) continue; // cancelled the switch → back to the model list
533
+ if (np !== provName) {
534
+ const next = _providerLookup(registry, np);
535
+ if (!next) { continue; }
536
+ if (ctx.setActiveProvName) ctx.setActiveProvName(np);
537
+ if (ctx.setProv) ctx.setProv(next);
538
+ switched = true;
539
+ provName = np;
540
+ info = _infoFor(registry, provName);
541
+ }
542
+ continue;
543
+ }
544
+ break;
545
+ }
546
+ if (model == null || model === '__switch_provider__') {
547
+ return switched ? `provider → ${provName} (model unchanged)` : 'cancelled';
548
+ }
549
+ if (ctx.setActiveModel) ctx.setActiveModel(model);
550
+ return switched
551
+ ? `provider → ${provName} · model → ${model}`
552
+ : `model → ${model}`;
210
553
  }
554
+ return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
211
555
  }
212
556
  const { parseSlashProviderModel } = registry;
213
557
  const parsed = typeof parseSlashProviderModel === 'function'
@@ -264,6 +608,36 @@ async function _skill(args, ctx) {
264
608
  }
265
609
  }
266
610
 
611
+ // /skills — list + pick installed skills (the v5.4 alias only forwarded to
612
+ // _skill, which activates/clears but never *shows* what's available). With an
613
+ // arg it activates directly (like /skill); with no arg it opens a picker (or
614
+ // lists, or — when nothing is installed — explains how to install).
615
+ async function _skillsList(args, ctx) {
616
+ if (args && args.trim()) return _skill(args, ctx);
617
+ const skillsMod = await _mod(ctx, 'skillsMod', () => import('../skills.mjs'));
618
+ let names = [];
619
+ try { names = (skillsMod.listSkills(ctx.cfgDir) || []).map((s) => s.name).filter(Boolean); }
620
+ catch (e) { return `skills unavailable: ${e?.message || e}`; }
621
+ if (!names.length) {
622
+ return [
623
+ 'no skills installed.',
624
+ 'install one: lazyclaw skills install <owner>/<repo>',
625
+ 'then /skills to pick, or /skill <name>[,<name>] to activate.',
626
+ ].join('\n');
627
+ }
628
+ if (typeof ctx.openPicker === 'function') {
629
+ const picked = await ctx.openPicker({
630
+ kind: 'skill',
631
+ title: 'activate a skill',
632
+ subtitle: `${names.length} installed · Enter activates · Esc cancels`,
633
+ items: names.map((n) => ({ id: n, label: n, desc: '' })),
634
+ });
635
+ if (!picked) return 'cancelled';
636
+ return _skill(typeof picked === 'string' ? picked : picked.id, ctx);
637
+ }
638
+ return `installed skills (${names.length}):\n${names.map((n) => ` · ${n}`).join('\n')}\n(activate: /skill <name>[,<name>])`;
639
+ }
640
+
267
641
  async function _tools(_args) {
268
642
  let registry;
269
643
  try {
@@ -467,10 +841,10 @@ async function _loop(args, ctx, write) {
467
841
  catch (e) { return `loop unavailable: ${e?.message || e}`; }
468
842
  if (!args) {
469
843
  return [
470
- 'usage: /loop <prompt> [--max N] [--until "<regex>"]',
844
+ 'usage: /loop <prompt> [--max N] [--until "<regex>"] [--use-memory] [--recall "<q>"]',
471
845
  ` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}`,
472
846
  ` session: ${ctx.getSessionId && ctx.getSessionId() || '(none — turns will not be persisted)'}`,
473
- ' note: Ink chat runs /loop without mid-loop Ctrl-C abort; set LAZYCLAW_NO_INK=1 for the full loop UX.',
847
+ ' press Esc to abort a running loop.',
474
848
  ].join('\n');
475
849
  }
476
850
  let parsed;
@@ -500,7 +874,31 @@ async function _loop(args, ctx, write) {
500
874
  };
501
875
 
502
876
  const messages = ctx.getMessages();
503
- const ac = new AbortController();
877
+ // --memory / --recall rebuild the system message from disk every iteration
878
+ // (a parallel writer mutating core.md mid-loop is reflected next call). We
879
+ // capture the chat's prior system so it can be restored after the loop.
880
+ const _sysBefore = (messages.find((m) => m.role === 'system') || {}).content ?? null;
881
+ let memMod = null;
882
+ if (parsed.useMemory || parsed.recall) {
883
+ try { memMod = await import('../memory.mjs'); } catch { memMod = null; }
884
+ }
885
+ const buildSystem = memMod ? (() => {
886
+ const parts = [];
887
+ if (parsed.useMemory) {
888
+ const core = memMod.loadCore(ctx.cfgDir);
889
+ if (core && core.trim()) parts.push(core);
890
+ }
891
+ if (parsed.recall) {
892
+ const text = memMod.recall(parsed.recall, { topN: 3 }, ctx.cfgDir);
893
+ if (text && text.trim()) parts.push(text);
894
+ }
895
+ if (_sysBefore) parts.push(_sysBefore);
896
+ return parts.join('\n\n---\n\n');
897
+ }) : null;
898
+
899
+ // Use the abort signal the REPL threads in (Esc / Ctrl-C aborts the running
900
+ // loop). Falls back to a fresh controller for non-Ink callers.
901
+ const signal = ctx.loopSignal || new AbortController().signal;
504
902
  try {
505
903
  const result = await loopMod.runLoop({
506
904
  prompt: parsed.prompt,
@@ -514,7 +912,8 @@ async function _loop(args, ctx, write) {
514
912
  try { write(` ↻ loop iteration ${i}/${max}\n`); } catch {}
515
913
  }
516
914
  },
517
- signal: ac.signal,
915
+ signal,
916
+ buildSystem,
518
917
  });
519
918
  if (ctx.setCharsSent && ctx.getCharsSent) {
520
919
  ctx.setCharsSent(ctx.getCharsSent() + parsed.prompt.length * result.iterations);
@@ -524,6 +923,19 @@ async function _loop(args, ctx, write) {
524
923
  return `✓ loop done — ${result.iterations}/${parsed.max} iteration(s)${tail}`;
525
924
  } catch (err) {
526
925
  return `loop error: ${err?.message || String(err)}`;
926
+ } finally {
927
+ // Restore the chat's prior system message — the engine overwrote
928
+ // messages[0] with the per-iteration memory composition.
929
+ if (buildSystem) {
930
+ const sysIdx = messages.findIndex((m) => m.role === 'system');
931
+ if (_sysBefore != null) {
932
+ if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: _sysBefore };
933
+ else messages.unshift({ role: 'system', content: _sysBefore });
934
+ } else if (sysIdx >= 0) {
935
+ messages.splice(sysIdx, 1);
936
+ }
937
+ if (ctx.setMessages) ctx.setMessages(messages);
938
+ }
527
939
  }
528
940
  }
529
941
 
@@ -560,9 +972,23 @@ async function _goal(args, ctx) {
560
972
  if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
561
973
  try {
562
974
  const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
563
- // Cron attach is cli.mjs-internal (_attachGoalCron); Ink chat skips it
564
- // and the operator can attach via the `lazyclaw goal add --cron` CLI.
565
- const cronNote = cron ? ' (cron attach via Ink chat is not wired in v5.4 use the CLI form)' : '';
975
+ let cronNote = '';
976
+ if (cron) {
977
+ // Actually attach the schedule (P3 was a stub). Needs the config
978
+ // writers the Ink session wires onto ctx; fall back to a CLI hint if
979
+ // they're absent (non-Ink callers).
980
+ if (typeof ctx.readConfig === 'function' && typeof ctx.writeConfig === 'function') {
981
+ try {
982
+ const cronMod = await import('../cron.mjs');
983
+ const r = await attachGoalCron({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, cron: cronMod, name: g.name, schedule: cron });
984
+ cronNote = r.skipped ? ' (cron recorded; backend install skipped)' : ' (cron scheduled)';
985
+ } catch (ce) {
986
+ cronNote = ` (cron attach failed: ${ce?.message || ce} — use: lazyclaw goal add ${g.name} --cron "${cron}")`;
987
+ }
988
+ } else {
989
+ cronNote = ' (cron recorded — attach via: lazyclaw goal add --cron)';
990
+ }
991
+ }
566
992
  return `✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})${cronNote}`;
567
993
  } catch (e) { return `goal error: ${e?.message || e}`; }
568
994
  }
@@ -580,7 +1006,17 @@ async function _goal(args, ctx) {
580
1006
  if (!name) return 'usage: /goal close <name> [done|abandoned]';
581
1007
  try {
582
1008
  const g = goalsMod.closeGoal(name, outcome, ctx.cfgDir);
583
- return `✓ goal ${g.name} closed (status: ${g.status})`;
1009
+ // Detach any attached cron so a closed goal stops ticking (P3 — the
1010
+ // Ink path used to leave it dangling).
1011
+ let detachNote = '';
1012
+ if (typeof ctx.readConfig === 'function' && typeof ctx.writeConfig === 'function') {
1013
+ try {
1014
+ const cronMod = await import('../cron.mjs');
1015
+ const removed = await detachGoalCron({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, cron: cronMod, name: g.name });
1016
+ if (removed) detachNote = ' (cron detached)';
1017
+ } catch { /* best-effort */ }
1018
+ }
1019
+ return `✓ goal ${g.name} closed (status: ${g.status})${detachNote}`;
584
1020
  } catch (e) { return `goal error: ${e?.message || e}`; }
585
1021
  }
586
1022
  // single-arg branch: switch
@@ -721,11 +1157,11 @@ function _personalityUse(name, ctx, fs, path) {
721
1157
  return `active personality → ${name}`;
722
1158
  }
723
1159
 
724
- async function _task(args, ctx) {
1160
+ async function _task(args, ctx, write) {
725
1161
  let tasksMod, loopMod;
726
1162
  try {
727
- tasksMod = await import('../tasks.mjs');
728
- loopMod = await import('../loop-engine.mjs');
1163
+ tasksMod = await _mod(ctx, 'tasksMod', () => import('../tasks.mjs'));
1164
+ loopMod = await _mod(ctx, 'loopMod', () => import('../loop-engine.mjs'));
729
1165
  } catch (e) { return `/task unavailable: ${e?.message || e}`; }
730
1166
  let tokens;
731
1167
  try { tokens = loopMod.splitArgs(args); }
@@ -759,18 +1195,120 @@ async function _task(args, ctx) {
759
1195
  }
760
1196
  if (sub === 'abandon' || sub === 'done') {
761
1197
  if (!id) return `usage: /task ${sub} <id>`;
762
- const next = tasksMod.patchTask(id, { status: sub === 'done' ? 'done' : 'abandoned' }, ctx.cfgDir);
763
- return `✓ task ${id} ${next?.status || sub}`;
1198
+ const target = sub === 'done' ? 'done' : 'abandoned';
1199
+ const next = tasksMod.patchTask(id, { status: target }, ctx.cfgDir);
1200
+ // Best-effort closing post in the original Slack thread (parity with the
1201
+ // CLI cmdTask), so collaborators see the resolution. Never rolls back
1202
+ // the status change.
1203
+ let slackNote = '';
1204
+ if (next && next.slackChannel && next.slackThreadTs) {
1205
+ try {
1206
+ loadDotenvIfAny(ctx.cfgDir);
1207
+ const SlackChannel = ctx.SlackChannel || (await import('../channels/slack.mjs')).SlackChannel;
1208
+ const slack = new SlackChannel({ requireInbound: false });
1209
+ await slack.start(async () => '', {});
1210
+ const threadId = `${next.slackChannel}:${next.slackThreadTs}`;
1211
+ const msg = target === 'done'
1212
+ ? `:white_check_mark: Task *${next.title}* marked done.`
1213
+ : `:no_entry: Task *${next.title}* abandoned.`;
1214
+ await slack.send(threadId, msg);
1215
+ await slack.stop().catch(() => {});
1216
+ slackNote = ' (posted to Slack thread)';
1217
+ } catch (e) { slackNote = ` (Slack post failed: ${e?.message || e})`; }
1218
+ }
1219
+ return `✓ task ${id} → ${next?.status || target}${slackNote}`;
764
1220
  }
765
1221
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
766
1222
  if (!id) return 'usage: /task remove <id>';
767
1223
  tasksMod.removeTask(id, ctx.cfgDir);
768
1224
  return `✓ removed task ${id}`;
769
1225
  }
770
- if (sub === 'start' || sub === 'tick') {
771
- return `task ${sub}: needs Slack + multi-agent router run from the shell:\n lazyclaw task ${sub} ...`;
1226
+ if (sub === 'start') {
1227
+ // /task start <team> --title "..." [--description "..."] [--lead <name>]
1228
+ const teamsMod = await _mod(ctx, 'teamsMod', () => import('../teams.mjs'));
1229
+ const agentsMod = await _mod(ctx, 'agentsMod', () => import('../agents.mjs'));
1230
+ const rest = tokens.slice(1);
1231
+ let teamName = null, title = '', description = '', lead = null;
1232
+ for (let i = 0; i < rest.length; i++) {
1233
+ const t = rest[i];
1234
+ if (t === '--title') title = rest[++i] || '';
1235
+ else if (t === '--description' || t === '--desc') description = rest[++i] || '';
1236
+ else if (t === '--lead') lead = rest[++i] || null;
1237
+ else if (!teamName && !t.startsWith('--')) teamName = t;
1238
+ }
1239
+ if (!teamName || !title) return 'usage: /task start <team> --title "..." [--description "..."] [--lead <name>]';
1240
+ const team = teamsMod.getTeam(teamName, ctx.cfgDir);
1241
+ if (!team) return `no team "${teamName}"`;
1242
+ const leadName = lead || team.lead;
1243
+ const leadAgent = agentsMod.getAgent(leadName, ctx.cfgDir);
1244
+ const seeded = tasksMod.registerTask(
1245
+ { title, description, team: teamName, lead: leadName, slackChannel: team.slackChannel, status: 'pending' },
1246
+ ctx.cfgDir,
1247
+ );
1248
+ let ts = '';
1249
+ if (team.slackChannel) {
1250
+ try {
1251
+ loadDotenvIfAny(ctx.cfgDir);
1252
+ const SlackChannel = ctx.SlackChannel || (await import('../channels/slack.mjs')).SlackChannel;
1253
+ const slack = new SlackChannel({ requireInbound: false });
1254
+ await slack.start(async () => '', {});
1255
+ const text = tasksMod.buildKickoffMessage({
1256
+ id: seeded.id, title: seeded.title, description: seeded.description,
1257
+ leadDisplayName: (leadAgent && leadAgent.displayName) || leadName,
1258
+ teamDisplayName: team.displayName || team.name,
1259
+ });
1260
+ const res = await slack.send(team.slackChannel, text);
1261
+ ts = (res && res.ts) || '';
1262
+ await slack.stop().catch(() => {});
1263
+ } catch (e) {
1264
+ try { tasksMod.removeTask(seeded.id, ctx.cfgDir); } catch { /* best-effort */ }
1265
+ return `task start: ${e?.message || e}`;
1266
+ }
1267
+ }
1268
+ const turns = ts ? [{ agent: 'system', text: `Task opened by user. Lead: ${leadName}.`, ts }] : [];
1269
+ const finalTask = tasksMod.patchTask(seeded.id, { slackThreadTs: ts, status: ts ? 'running' : 'pending', turns }, ctx.cfgDir);
1270
+ return `✓ task ${finalTask.id} started (status: ${finalTask.status}${ts ? `, slack thread ${ts}` : ', no Slack thread'})`;
772
1271
  }
773
- return `/task: unknown sub "${sub}" — list|show|transcript|abandon|done|remove (start/tick: use CLI)`;
1272
+ if (sub === 'tick') {
1273
+ // /task tick <id> [message] — one multi-agent router turn. The router
1274
+ // emits through a logger callback (not raw stdout), so it runs inline in
1275
+ // the Ink chat with output routed through `write`.
1276
+ if (!id) return 'usage: /task tick <id> [message]';
1277
+ const teamsMod = await _mod(ctx, 'teamsMod', () => import('../teams.mjs'));
1278
+ const agentsMod = await _mod(ctx, 'agentsMod', () => import('../agents.mjs'));
1279
+ const task = tasksMod.getTask(id, ctx.cfgDir);
1280
+ if (!task) return `no task "${id}"`;
1281
+ const team = teamsMod.getTeam(task.team, ctx.cfgDir);
1282
+ if (!team) return `task tick: team "${task.team}" disappeared`;
1283
+ const agentsById = {};
1284
+ for (const name of team.agents) {
1285
+ const rec = agentsMod.getAgent(name, ctx.cfgDir);
1286
+ if (!rec) return `task tick: agent "${name}" disappeared`;
1287
+ agentsById[name] = rec;
1288
+ }
1289
+ loadDotenvIfAny(ctx.cfgDir);
1290
+ const router = await _mod(ctx, 'routerMod', () => import('../mas/mention_router.mjs'));
1291
+ const leadAgent = agentsById[team.lead];
1292
+ const apiKey = ctx.resolveAuthKey ? ctx.resolveAuthKey(leadAgent.provider) : '';
1293
+ const baseUrl = ctx.resolveBaseUrl ? ctx.resolveBaseUrl(leadAgent.provider) : undefined;
1294
+ const userMsg = tokens.slice(2).join(' ').trim();
1295
+ try {
1296
+ if (typeof write === 'function') { try { write(' ↻ running task turn…\n'); } catch {} }
1297
+ const result = await router.runTaskTurn({
1298
+ task, team, agentsById,
1299
+ userMessage: userMsg || undefined,
1300
+ configDir: ctx.cfgDir,
1301
+ apiKey, baseUrl,
1302
+ logger: (line) => { if (typeof write === 'function') { try { write(line); } catch {} } },
1303
+ approve: _makeInkApprove(ctx),
1304
+ security: ctx.cfg?.security,
1305
+ });
1306
+ return `✓ task ${result.task.id} → ${result.task.status} (${result.iterations} agent turn(s)${result.stoppedBy ? `, stopped by ${result.stoppedBy}` : ''})`;
1307
+ } catch (e) {
1308
+ return `task tick: ${e?.message || e}`;
1309
+ }
1310
+ }
1311
+ return `/task: unknown sub "${sub}" — start|tick|list|show|transcript|abandon|done|remove`;
774
1312
  } catch (e) {
775
1313
  return `/task error: ${e?.message || e}`;
776
1314
  }
@@ -813,6 +1351,19 @@ async function _trainer(args, ctx) {
813
1351
  const next = _providerLookup(registry, parsed.provider);
814
1352
  if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
815
1353
  }
1354
+ // Optional `--fallback <provider[:model]>` — resolveTrainer routes here
1355
+ // when opts.useFallback is set. Validate before persisting.
1356
+ let fallbackSpec = null;
1357
+ const fi = tokens.indexOf('--fallback');
1358
+ if (fi >= 0) {
1359
+ fallbackSpec = tokens[fi + 1];
1360
+ if (!fallbackSpec) return 'usage: /trainer set <p:m> --fallback <p:m>';
1361
+ const fp = _parseProvModel(registry, fallbackSpec);
1362
+ if (!fp.provider) return `/trainer set: could not parse fallback "${fallbackSpec}"`;
1363
+ if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
1364
+ return `/trainer set: unknown provider "${fp.provider}"`;
1365
+ }
1366
+ }
816
1367
  // Read-merge-write so unrelated cfg keys survive.
817
1368
  const fs = await import('node:fs');
818
1369
  const path = await import('node:path');
@@ -822,10 +1373,38 @@ async function _trainer(args, ctx) {
822
1373
  diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
823
1374
  if (parsed.model) diskCfg.trainer.model = parsed.model;
824
1375
  else delete diskCfg.trainer.model;
1376
+ if (fallbackSpec) diskCfg.trainer.fallback = fallbackSpec;
825
1377
  try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
826
1378
  fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
827
1379
  if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
828
- return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}`;
1380
+ return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}${fallbackSpec ? ` (fallback: ${fallbackSpec})` : ''}`;
1381
+ }
1382
+
1383
+ if (sub === 'fallback') {
1384
+ const spec = tokens[1];
1385
+ if (!spec) return 'usage: /trainer fallback <provider>[:<model>] | clear';
1386
+ const fs = await import('node:fs');
1387
+ const path = await import('node:path');
1388
+ const cfgPath = path.join(ctx.cfgDir, 'config.json');
1389
+ let diskCfg = {};
1390
+ try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
1391
+ if (spec === 'clear' || spec === 'unset') {
1392
+ if (diskCfg.trainer) delete diskCfg.trainer.fallback;
1393
+ try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1394
+ fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1395
+ if (ctx.cfg && ctx.cfg.trainer) delete ctx.cfg.trainer.fallback;
1396
+ return '✓ trainer fallback cleared';
1397
+ }
1398
+ const fp = _parseProvModel(registry, spec);
1399
+ if (!fp.provider) return `/trainer fallback: could not parse "${spec}"`;
1400
+ if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
1401
+ return `/trainer fallback: unknown provider "${fp.provider}"`;
1402
+ }
1403
+ diskCfg.trainer = { ...(diskCfg.trainer || {}), fallback: spec };
1404
+ try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1405
+ fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1406
+ if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
1407
+ return `✓ trainer fallback → ${spec}`;
829
1408
  }
830
1409
 
831
1410
  if (sub === 'clear' || sub === 'unset') {
@@ -953,9 +1532,41 @@ async function _dashboardStop(port) {
953
1532
  return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
954
1533
  }
955
1534
 
1535
+ // Parse the daemon's "listening at <url>" stdout line so /dashboard opens the
1536
+ // actually-bound port (the child may fall back to a random port on EADDRINUSE).
1537
+ export function parseDashboardUrl(text) {
1538
+ const m = String(text || '').match(/listening at\s+(https?:\/\/\S+)/i);
1539
+ return m ? m[1] : null;
1540
+ }
1541
+
1542
+ // Resolve the daemon's real URL from its stdout within `timeoutMs`, or null.
1543
+ function _waitForDashboardUrl(child, timeoutMs) {
1544
+ return new Promise((resolve) => {
1545
+ if (!child || !child.stdout) { resolve(null); return; }
1546
+ let buf = '';
1547
+ let done = false;
1548
+ const finish = (v) => {
1549
+ if (done) return;
1550
+ done = true;
1551
+ try { child.stdout.off('data', onData); } catch { /* ignore */ }
1552
+ resolve(v);
1553
+ };
1554
+ const onData = (d) => {
1555
+ buf += d.toString('utf8');
1556
+ const u = parseDashboardUrl(buf);
1557
+ if (u) finish(u);
1558
+ };
1559
+ child.stdout.on('data', onData);
1560
+ setTimeout(() => finish(null), timeoutMs);
1561
+ });
1562
+ }
1563
+
956
1564
  async function _dashboard(args) {
957
1565
  const port = 19600;
958
1566
  const url = `http://127.0.0.1:${port}/dashboard`;
1567
+ // Under the node:test runner, never launch a real daemon or open a browser
1568
+ // (it leaked a background daemon + opened a tab on every test run).
1569
+ if (process.env.NODE_TEST_CONTEXT) return `dashboard: ${url} (spawn skipped under test)`;
959
1570
  const sub = splitWhitespace(args)[0];
960
1571
  if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
961
1572
 
@@ -990,16 +1601,30 @@ async function _dashboard(args) {
990
1601
  const { spawn } = await import('node:child_process');
991
1602
  let child;
992
1603
  try {
993
- child = spawn(process.execPath, [process.argv[1], 'dashboard', '--no-open'], {
994
- detached: true, stdio: 'ignore', cwd: process.cwd(), env: process.env,
1604
+ // Pass --port so the child tries 19600 first; pipe stdout so we can read
1605
+ // the real bound URL (it may fall back to a random port on EADDRINUSE).
1606
+ child = spawn(process.execPath, [process.argv[1], 'dashboard', '--port', String(port), '--no-open'], {
1607
+ detached: true, stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd(), env: process.env,
995
1608
  });
996
- child.unref();
997
1609
  _dashboardChildPid = child.pid;
998
1610
  } catch (e) {
999
1611
  return `dashboard error: failed to spawn — ${e?.message || e}`;
1000
1612
  }
1613
+ // Prefer the daemon's own "listening at <url>" line — it carries the
1614
+ // actual port even after a random-port fallback.
1615
+ const boundUrl = await _waitForDashboardUrl(child, 3000);
1616
+ // Release the captured stdout pipe so the detached daemon doesn't keep
1617
+ // OUR event loop alive (unref, not destroy — destroying would EPIPE the
1618
+ // daemon on its next stdout write). Then unref the child itself.
1619
+ try { if (child.stdout) { child.stdout.removeAllListeners('data'); child.stdout.unref(); } } catch { /* ignore */ }
1620
+ child.unref();
1621
+ if (boundUrl) {
1622
+ await _openBrowser(boundUrl);
1623
+ return `✓ started dashboard (pid ${child.pid}) — opened ${boundUrl}`;
1624
+ }
1625
+ // Fallback: the line never arrived — poll the default port best-effort.
1001
1626
  const start = Date.now();
1002
- while (Date.now() - start < 3000) {
1627
+ while (Date.now() - start < 1500) {
1003
1628
  if (await _dashboardProbe(port)) {
1004
1629
  await _openBrowser(url);
1005
1630
  return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
@@ -1012,6 +1637,39 @@ async function _dashboard(args) {
1012
1637
  }
1013
1638
  }
1014
1639
 
1640
+ // /menu — in-chat command palette over the full subcommand catalog. The
1641
+ // no-arg launcher menu used to be the home screen; defaulting to chat hid it
1642
+ // behind `lazyclaw menu`. This restores discoverability: browse subcommands
1643
+ // and get the exact command to run. (Most subcommands own stdout / spawn, so
1644
+ // they can't safely run inline in the Ink scrollback — we echo the command.)
1645
+ async function _menu(args, ctx) {
1646
+ if (typeof ctx.openPicker === 'function') {
1647
+ const items = [];
1648
+ const seen = new Set();
1649
+ for (const [group, cmds] of SUBCOMMAND_GROUPS) {
1650
+ for (const c of cmds) {
1651
+ if (seen.has(c)) continue;
1652
+ seen.add(c);
1653
+ items.push({ id: c, label: c, desc: group });
1654
+ }
1655
+ }
1656
+ const picked = await ctx.openPicker({
1657
+ kind: 'menu',
1658
+ title: 'lazyclaw subcommands',
1659
+ subtitle: 'Enter shows how to run it · Esc cancels',
1660
+ items,
1661
+ });
1662
+ if (!picked) return 'cancelled';
1663
+ const cmd = typeof picked === 'string' ? picked : picked.id;
1664
+ return `run from a shell: lazyclaw ${cmd}`;
1665
+ }
1666
+ return [
1667
+ 'subcommands:',
1668
+ ...SUBCOMMAND_GROUPS.map(([g, cmds]) => ` ${g.padEnd(9)} ${cmds.join(' ')}`),
1669
+ '(run: lazyclaw <subcommand>)',
1670
+ ].join('\n');
1671
+ }
1672
+
1015
1673
  // ─── dispatch table ──────────────────────────────────────────────────────
1016
1674
 
1017
1675
  export const SLASH_HANDLERS = new Map([
@@ -1025,7 +1683,7 @@ export const SLASH_HANDLERS = new Map([
1025
1683
  ['/provider', _provider],
1026
1684
  ['/model', _model],
1027
1685
  ['/skill', _skill],
1028
- ['/skills', _skill],
1686
+ ['/skills', _skillsList],
1029
1687
  ['/tools', _tools],
1030
1688
  ['/recall', _recall],
1031
1689
  ['/memory', _memory],
@@ -1039,6 +1697,7 @@ export const SLASH_HANDLERS = new Map([
1039
1697
  ['/task', _task],
1040
1698
  ['/trainer', _trainer],
1041
1699
  ['/dashboard', _dashboard],
1700
+ ['/menu', _menu],
1042
1701
  ['/exit', async () => 'EXIT'],
1043
1702
  ['/quit', async () => 'EXIT'],
1044
1703
  ]);