lazyclaw 5.4.3 → 6.0.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 (46) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/cli.mjs +73 -7399
  3. package/daemon.mjs +23 -2085
  4. package/mas/agent_turn.mjs +2 -1
  5. package/mas/index_db.mjs +82 -0
  6. package/mas/learning.mjs +17 -1
  7. package/mas/mention_router.mjs +38 -10
  8. package/mas/provider_adapters.mjs +28 -4
  9. package/mas/scrub_env.mjs +34 -0
  10. package/mas/tool_runner.mjs +23 -7
  11. package/mas/tools/bash.mjs +10 -5
  12. package/mas/tools/browser.mjs +18 -0
  13. package/mas/tools/learning.mjs +24 -14
  14. package/mas/tools/recall.mjs +5 -1
  15. package/mas/tools/web.mjs +47 -11
  16. package/mas/trajectory_store.mjs +7 -4
  17. package/package.json +3 -2
  18. package/providers/auth_store.mjs +22 -0
  19. package/providers/claude_cli.mjs +28 -2
  20. package/providers/claude_cli_detect.mjs +46 -0
  21. package/providers/custom_provider.mjs +70 -0
  22. package/providers/model_catalogue.mjs +86 -0
  23. package/providers/orchestrator.mjs +30 -9
  24. package/providers/rates.mjs +12 -2
  25. package/providers/registry.mjs +10 -7
  26. package/sandbox/confiners/landlock.mjs +14 -8
  27. package/sandbox/confiners/seatbelt.mjs +18 -2
  28. package/scripts/loop-worker.mjs +18 -7
  29. package/scripts/migrate-v5.mjs +5 -61
  30. package/sessions.mjs +0 -0
  31. package/tui/editor.mjs +44 -0
  32. package/tui/modal_filter.mjs +59 -0
  33. package/tui/modal_picker.mjs +12 -37
  34. package/tui/pickers.mjs +917 -0
  35. package/tui/provider_families.mjs +41 -0
  36. package/tui/repl.mjs +67 -36
  37. package/tui/slash_commands.mjs +8 -7
  38. package/tui/slash_dispatcher.mjs +923 -118
  39. package/tui/splash.mjs +5 -12
  40. package/tui/subcommands.mjs +17 -0
  41. package/tui/terminal_approve.mjs +37 -0
  42. package/web/dashboard.css +275 -0
  43. package/web/dashboard.html +2 -1685
  44. package/web/dashboard.js +1406 -0
  45. package/workflow/persistent.mjs +13 -6
  46. 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.
@@ -73,14 +90,19 @@ async function _help() {
73
90
 
74
91
  async function _status(_args, ctx) {
75
92
  const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
76
- const out = {
77
- provider: ctx.getActiveProvName(),
78
- model: ctx.getActiveModel(),
79
- keyMasked: registry.maskApiKey(ctx.cfg && ctx.cfg['api-key']),
80
- messageCount: ctx.getMessages().length,
81
- sessionId: ctx.getSessionId() || null,
82
- };
83
- return JSON.stringify(out);
93
+ const provider = ctx.getActiveProvName();
94
+ const model = ctx.getActiveModel() || '(default)';
95
+ const keyMasked = registry.maskApiKey(ctx.cfg && ctx.cfg['api-key']);
96
+ const messageCount = ctx.getMessages().length;
97
+ const sessionId = ctx.getSessionId() || '(none — in-memory)';
98
+ return [
99
+ 'status:',
100
+ ` provider: ${provider}`,
101
+ ` model: ${model}`,
102
+ ` api key: ${keyMasked}`,
103
+ ` messages: ${messageCount}`,
104
+ ` session: ${sessionId}`,
105
+ ].join('\n');
84
106
  }
85
107
 
86
108
  async function _version(_args, ctx) {
@@ -91,22 +113,33 @@ async function _version(_args, ctx) {
91
113
  async function _usage(_args, ctx) {
92
114
  const msgs = ctx.getMessages();
93
115
  const runningUsage = ctx.getRunningUsage && ctx.getRunningUsage();
94
- const out = {
95
- messageCount: msgs.length,
96
- charsSent: (ctx.getCharsSent && ctx.getCharsSent()) || 0,
97
- };
98
- if (runningUsage) out.tokens = runningUsage;
99
- if (runningUsage && ctx.cfg && ctx.cfg.rates && typeof ctx.cfg.rates === 'object') {
100
- try {
101
- const { costFromUsage } = await import('../providers/rates.mjs');
102
- const r = costFromUsage(
103
- { provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: runningUsage },
104
- ctx.cfg.rates,
105
- );
106
- if (r) out.cost = r;
107
- } catch { /* never let cost-card lookup fail the slash */ }
116
+ const charsSent = (ctx.getCharsSent && ctx.getCharsSent()) || 0;
117
+ const lines = [
118
+ 'usage:',
119
+ ` messages: ${msgs.length}`,
120
+ ` chars sent: ${charsSent.toLocaleString('en-US')}`,
121
+ ];
122
+ if (runningUsage) {
123
+ lines.push(
124
+ ` tokens in: ${(runningUsage.inputTokens || 0).toLocaleString('en-US')}`,
125
+ ` tokens out: ${(runningUsage.outputTokens || 0).toLocaleString('en-US')}`,
126
+ ` tokens tot: ${(runningUsage.totalTokens || 0).toLocaleString('en-US')}`,
127
+ ` turns: ${runningUsage.turnsWithUsage || 0}`,
128
+ );
129
+ if (ctx.cfg && ctx.cfg.rates && typeof ctx.cfg.rates === 'object') {
130
+ try {
131
+ const { costFromUsage } = await import('../providers/rates.mjs');
132
+ const r = costFromUsage(
133
+ { provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: runningUsage },
134
+ ctx.cfg.rates,
135
+ );
136
+ if (r && r.totalUsd != null) {
137
+ lines.push(` cost (USD): $${Number(r.totalUsd).toFixed(4)}`);
138
+ }
139
+ } catch { /* never let cost-card lookup fail the slash */ }
140
+ }
108
141
  }
109
- return JSON.stringify(out);
142
+ return lines.join('\n');
110
143
  }
111
144
 
112
145
  async function _newReset(_args, ctx) {
@@ -130,28 +163,198 @@ function _providerLookup(registry, name) {
130
163
  return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
131
164
  }
132
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
+
133
337
  async function _provider(args, ctx) {
134
338
  const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
135
- // No arg open the Ink modal picker (v5.4.3). Falls back to the
136
- // pre-v5.4.3 hint string when ctx.openPicker isn't available (e.g.
137
- // 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).
138
350
  if (!args) {
139
351
  if (typeof ctx.openPicker === 'function') {
140
- const known = Object.keys(registry.PROVIDERS || {}).sort();
141
- const info = registry.PROVIDER_INFO || {};
142
- const items = known.map((id) => ({
143
- id,
144
- label: id,
145
- desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
146
- }));
147
- const picked = await ctx.openPicker({
148
- kind: 'provider',
149
- title: 'select provider',
150
- subtitle: `current: ${ctx.getActiveProvName()}`,
151
- items,
152
- });
352
+ const picked = await _pickProviderDrillIn(ctx, registry);
153
353
  if (!picked) return 'cancelled';
354
+ if (picked === '__add_custom__') return _addCustomFlow(ctx, registry);
154
355
  args = picked;
356
+ // Built-in api-key provider with no key configured → offer to set one.
357
+ await _maybePromptForKey(ctx, registry, args);
155
358
  } else {
156
359
  return `provider: ${ctx.getActiveProvName()}\n(pass an arg: /provider <name>)`;
157
360
  }
@@ -166,32 +369,189 @@ async function _provider(args, ctx) {
166
369
  return `provider → ${args}`;
167
370
  }
168
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
+
169
502
  async function _model(args, ctx) {
170
503
  const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
171
504
  if (!args) {
172
505
  if (typeof ctx.openPicker === 'function') {
173
- const provName = ctx.getActiveProvName();
174
- const info = (registry.PROVIDER_INFO && registry.PROVIDER_INFO[provName]) || {};
175
- const suggested = Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
176
- if (!suggested.length) {
177
- 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);
178
523
  }
179
- const items = suggested.map((m) => ({
180
- id: m,
181
- label: m,
182
- desc: info.defaultModel === m ? '(default)' : '',
183
- }));
184
- const picked = await ctx.openPicker({
185
- kind: 'model',
186
- title: `select model for ${provName}`,
187
- subtitle: `current: ${ctx.getActiveModel() || '(default)'}`,
188
- items,
189
- });
190
- if (!picked) return 'cancelled';
191
- args = picked;
192
- } else {
193
- 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}`;
194
553
  }
554
+ return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
195
555
  }
196
556
  const { parseSlashProviderModel } = registry;
197
557
  const parsed = typeof parseSlashProviderModel === 'function'
@@ -248,6 +608,36 @@ async function _skill(args, ctx) {
248
608
  }
249
609
  }
250
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
+
251
641
  async function _tools(_args) {
252
642
  let registry;
253
643
  try {
@@ -295,8 +685,15 @@ async function _memory(args, ctx) {
295
685
  return body || '(empty core memory)';
296
686
  }
297
687
  if (which === 'recent') {
298
- const items = mem.loadRecent(20, ctx.cfgDir);
299
- return JSON.stringify(items, null, 2);
688
+ const items = mem.loadRecent(20, ctx.cfgDir) || [];
689
+ if (!items.length) return '(no recent memory)';
690
+ return ['recent memory (last ' + items.length + '):',
691
+ ...items.map((it, i) => {
692
+ const role = it.role || 'msg';
693
+ const content = String(it.content || '').replace(/\s+/g, ' ').slice(0, 80);
694
+ return ` ${String(i + 1).padStart(2)}. [${role}] ${content}${(it.content || '').length > 80 ? '…' : ''}`;
695
+ })
696
+ ].join('\n');
300
697
  }
301
698
  if (which === 'episodic') {
302
699
  const topic = tokens[1];
@@ -304,7 +701,11 @@ async function _memory(args, ctx) {
304
701
  const body = mem.loadEpisodic(topic, ctx.cfgDir);
305
702
  return body || `(no episodic file "${topic}")`;
306
703
  }
307
- return JSON.stringify(mem.listEpisodic(ctx.cfgDir), null, 2);
704
+ const items = mem.listEpisodic(ctx.cfgDir) || [];
705
+ if (!items.length) return '(no episodic files yet — run /dream to consolidate)';
706
+ return ['episodic files:',
707
+ ...items.map((it) => ` • ${typeof it === 'string' ? it : (it.topic || JSON.stringify(it))}`)
708
+ ].join('\n');
308
709
  }
309
710
  return 'usage: /memory [core|recent|episodic [topic]]';
310
711
  }
@@ -440,10 +841,10 @@ async function _loop(args, ctx, write) {
440
841
  catch (e) { return `loop unavailable: ${e?.message || e}`; }
441
842
  if (!args) {
442
843
  return [
443
- 'usage: /loop <prompt> [--max N] [--until "<regex>"]',
844
+ 'usage: /loop <prompt> [--max N] [--until "<regex>"] [--use-memory] [--recall "<q>"]',
444
845
  ` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}`,
445
846
  ` session: ${ctx.getSessionId && ctx.getSessionId() || '(none — turns will not be persisted)'}`,
446
- ' 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.',
447
848
  ].join('\n');
448
849
  }
449
850
  let parsed;
@@ -473,7 +874,31 @@ async function _loop(args, ctx, write) {
473
874
  };
474
875
 
475
876
  const messages = ctx.getMessages();
476
- 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;
477
902
  try {
478
903
  const result = await loopMod.runLoop({
479
904
  prompt: parsed.prompt,
@@ -487,7 +912,8 @@ async function _loop(args, ctx, write) {
487
912
  try { write(` ↻ loop iteration ${i}/${max}\n`); } catch {}
488
913
  }
489
914
  },
490
- signal: ac.signal,
915
+ signal,
916
+ buildSystem,
491
917
  });
492
918
  if (ctx.setCharsSent && ctx.getCharsSent) {
493
919
  ctx.setCharsSent(ctx.getCharsSent() + parsed.prompt.length * result.iterations);
@@ -497,6 +923,19 @@ async function _loop(args, ctx, write) {
497
923
  return `✓ loop done — ${result.iterations}/${parsed.max} iteration(s)${tail}`;
498
924
  } catch (err) {
499
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
+ }
500
939
  }
501
940
  }
502
941
 
@@ -533,9 +972,23 @@ async function _goal(args, ctx) {
533
972
  if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
534
973
  try {
535
974
  const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
536
- // Cron attach is cli.mjs-internal (_attachGoalCron); Ink chat skips it
537
- // and the operator can attach via the `lazyclaw goal add --cron` CLI.
538
- 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
+ }
539
992
  return `✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})${cronNote}`;
540
993
  } catch (e) { return `goal error: ${e?.message || e}`; }
541
994
  }
@@ -553,7 +1006,17 @@ async function _goal(args, ctx) {
553
1006
  if (!name) return 'usage: /goal close <name> [done|abandoned]';
554
1007
  try {
555
1008
  const g = goalsMod.closeGoal(name, outcome, ctx.cfgDir);
556
- 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}`;
557
1020
  } catch (e) { return `goal error: ${e?.message || e}`; }
558
1021
  }
559
1022
  // single-arg branch: switch
@@ -694,11 +1157,11 @@ function _personalityUse(name, ctx, fs, path) {
694
1157
  return `active personality → ${name}`;
695
1158
  }
696
1159
 
697
- async function _task(args, ctx) {
1160
+ async function _task(args, ctx, write) {
698
1161
  let tasksMod, loopMod;
699
1162
  try {
700
- tasksMod = await import('../tasks.mjs');
701
- 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'));
702
1165
  } catch (e) { return `/task unavailable: ${e?.message || e}`; }
703
1166
  let tokens;
704
1167
  try { tokens = loopMod.splitArgs(args); }
@@ -732,18 +1195,120 @@ async function _task(args, ctx) {
732
1195
  }
733
1196
  if (sub === 'abandon' || sub === 'done') {
734
1197
  if (!id) return `usage: /task ${sub} <id>`;
735
- const next = tasksMod.patchTask(id, { status: sub === 'done' ? 'done' : 'abandoned' }, ctx.cfgDir);
736
- 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}`;
737
1220
  }
738
1221
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
739
1222
  if (!id) return 'usage: /task remove <id>';
740
1223
  tasksMod.removeTask(id, ctx.cfgDir);
741
1224
  return `✓ removed task ${id}`;
742
1225
  }
743
- if (sub === 'start' || sub === 'tick') {
744
- 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'})`;
745
1271
  }
746
- 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`;
747
1312
  } catch (e) {
748
1313
  return `/task error: ${e?.message || e}`;
749
1314
  }
@@ -786,6 +1351,19 @@ async function _trainer(args, ctx) {
786
1351
  const next = _providerLookup(registry, parsed.provider);
787
1352
  if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
788
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
+ }
789
1367
  // Read-merge-write so unrelated cfg keys survive.
790
1368
  const fs = await import('node:fs');
791
1369
  const path = await import('node:path');
@@ -795,10 +1373,38 @@ async function _trainer(args, ctx) {
795
1373
  diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
796
1374
  if (parsed.model) diskCfg.trainer.model = parsed.model;
797
1375
  else delete diskCfg.trainer.model;
1376
+ if (fallbackSpec) diskCfg.trainer.fallback = fallbackSpec;
798
1377
  try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
799
1378
  fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
800
1379
  if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
801
- 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}`;
802
1408
  }
803
1409
 
804
1410
  if (sub === 'clear' || sub === 'unset') {
@@ -817,53 +1423,251 @@ async function _trainer(args, ctx) {
817
1423
  return `/trainer: unknown sub "${sub}" — show|set <p:m>|clear`;
818
1424
  }
819
1425
 
820
- // /dashboard — open the lazyclaw web UI. Reuses an already-running
821
- // daemon if one answers /healthz on 19600; otherwise spawns a detached
822
- // `node cli.mjs dashboard --no-open` child so the daemon outlives this
823
- // chat session. Never installs signal handlers / never process.exits —
824
- // Ctrl-C inside chat must NOT touch the dashboard.
825
- async function _dashboard(_args) {
826
- const port = 19600;
827
- const url = `http://127.0.0.1:${port}/dashboard`;
828
- const healthUrl = `http://127.0.0.1:${port}/healthz`;
829
- const { spawn } = await import('node:child_process');
830
- const openBrowser = (u) => {
1426
+ // /dashboard — open the lazyclaw web UI.
1427
+ //
1428
+ // v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
1429
+ // session spawned 20+ daemon children).
1430
+ //
1431
+ // Original implementation:
1432
+ // probe /healthz if !200, spawn detached `lazyclaw dashboard
1433
+ // --no-open` and poll for up to 3s.
1434
+ //
1435
+ // Failure mode that produced the 20+ spawn pile-up:
1436
+ // 1. User types /dashboard. probe fails (no daemon). Spawn child A.
1437
+ // 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
1438
+ // 3. User types /dashboard again BEFORE A is ready. probe still fails.
1439
+ // Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
1440
+ // (cli.mjs:3611) which SIGTERMs child A. B takes over.
1441
+ // 4. Repeat. Each /dashboard kills the previous daemon and starts a
1442
+ // new one. With autorepeat / many slash calls this stacks fast.
1443
+ //
1444
+ // Two-layer guard:
1445
+ // - A module-level _dashboardSpawning latch refuses concurrent spawn
1446
+ // attempts. While a spawn is in flight, /dashboard says so + returns
1447
+ // without firing another child.
1448
+ // - A _dashboardChildPid cache remembers the PID we already spawned;
1449
+ // subsequent calls check kill(pid, 0) to confirm the child is alive
1450
+ // and just open the browser without spawning.
1451
+ //
1452
+ // We probe both /healthz (HTTP) AND a raw net.connect port check so a
1453
+ // slow-starting daemon (binding the listener but not yet answering HTTP)
1454
+ // still counts as "running".
1455
+ let _dashboardSpawning = false;
1456
+ let _dashboardChildPid = null;
1457
+
1458
+ function _portIsListening(port, timeoutMs = 200) {
1459
+ return new Promise((resolve) => {
1460
+ import('node:net').then(({ createConnection }) => {
1461
+ let settled = false;
1462
+ const sock = createConnection({ host: '127.0.0.1', port });
1463
+ const done = (ok) => {
1464
+ if (settled) return;
1465
+ settled = true;
1466
+ try { sock.destroy(); } catch {}
1467
+ resolve(ok);
1468
+ };
1469
+ sock.once('connect', () => done(true));
1470
+ sock.once('error', () => done(false));
1471
+ setTimeout(() => done(false), timeoutMs);
1472
+ }).catch(() => resolve(false));
1473
+ });
1474
+ }
1475
+
1476
+ async function _dashboardProbe(port) {
1477
+ // Fast path — port-level probe. Catches a daemon that has bound the
1478
+ // socket but hasn't finished initializing its HTTP routes.
1479
+ if (await _portIsListening(port, 200)) return true;
1480
+ // Slow path — full /healthz fetch, for defense in depth.
1481
+ if (typeof fetch !== 'function') return false;
1482
+ try {
1483
+ const ac = new AbortController();
1484
+ const t = setTimeout(() => ac.abort(), 250);
1485
+ const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
1486
+ clearTimeout(t);
1487
+ return !!(r && r.ok);
1488
+ } catch { return false; }
1489
+ }
1490
+
1491
+ function _openBrowser(url) {
1492
+ return import('node:child_process').then(({ spawn }) => {
831
1493
  let cmd, args;
832
- if (process.platform === 'darwin') { cmd = 'open'; args = [u]; }
833
- else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', u]; }
834
- else { cmd = 'xdg-open'; args = [u]; }
1494
+ if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
1495
+ else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
1496
+ else { cmd = 'xdg-open'; args = [url]; }
835
1497
  try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
836
- };
837
- const probe = async () => {
838
- if (typeof fetch !== 'function') return false;
1498
+ });
1499
+ }
1500
+
1501
+ async function _dashboardStop(port) {
1502
+ // Best-effort kill of every lazyclaw dashboard daemon on the box.
1503
+ // Used to clean up after the v5.4.3 spawn pile-up bug.
1504
+ if (process.platform === 'win32') {
1505
+ return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
1506
+ }
1507
+ const { spawn } = await import('node:child_process');
1508
+ // Step 1: lsof the port and SIGTERM each PID.
1509
+ const portPids = await new Promise((resolve) => {
839
1510
  try {
840
- const ac = new AbortController();
841
- const t = setTimeout(() => ac.abort(), 300);
842
- const r = await fetch(healthUrl, { signal: ac.signal });
843
- clearTimeout(t);
844
- return !!(r && r.ok);
845
- } catch { return false; }
846
- };
847
- if (await probe()) {
848
- openBrowser(url);
1511
+ const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
1512
+ let buf = '';
1513
+ lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
1514
+ lsof.on('error', () => resolve([]));
1515
+ lsof.on('close', () => resolve(
1516
+ buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
1517
+ ));
1518
+ } catch { resolve([]); }
1519
+ });
1520
+ for (const pid of portPids) {
1521
+ try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
1522
+ }
1523
+ // Step 2: pkill any process whose command line includes "lazyclaw dashboard"
1524
+ // — catches detached children that bound a different (random) port via
1525
+ // cmdDashboard's EADDRINUSE fallback.
1526
+ let pkilled = 0;
1527
+ try {
1528
+ const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
1529
+ pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
1530
+ } catch { /* fine */ }
1531
+ _dashboardChildPid = null;
1532
+ return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
1533
+ }
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
+
1564
+ async function _dashboard(args) {
1565
+ const port = 19600;
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)`;
1570
+ const sub = splitWhitespace(args)[0];
1571
+ if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
1572
+
1573
+ // 1. Already running anywhere on the machine? → reuse.
1574
+ if (await _dashboardProbe(port)) {
1575
+ await _openBrowser(url);
849
1576
  return `✓ dashboard already running — opened ${url}`;
850
1577
  }
851
- // Spawn detached so the daemon outlives this chat process.
1578
+
1579
+ // 2. We spawned in this chat — is that child still alive?
1580
+ if (_dashboardChildPid != null) {
1581
+ try {
1582
+ process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
1583
+ // Child alive but not answering yet. Don't re-spawn; just nudge.
1584
+ await _openBrowser(url);
1585
+ return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
1586
+ } catch {
1587
+ _dashboardChildPid = null; // child died; fall through and respawn.
1588
+ }
1589
+ }
1590
+
1591
+ // 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
1592
+ if (_dashboardSpawning) {
1593
+ await _openBrowser(url);
1594
+ return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
1595
+ }
1596
+
1597
+ // 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
1598
+ // spawn flag in a finally so it always clears.
1599
+ _dashboardSpawning = true;
852
1600
  try {
853
- const child = spawn(process.execPath, [process.argv[1], 'dashboard', '--no-open'], {
854
- detached: true, stdio: 'ignore', cwd: process.cwd(), env: process.env,
855
- });
1601
+ const { spawn } = await import('node:child_process');
1602
+ let child;
1603
+ try {
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,
1608
+ });
1609
+ _dashboardChildPid = child.pid;
1610
+ } catch (e) {
1611
+ return `dashboard error: failed to spawn — ${e?.message || e}`;
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 */ }
856
1620
  child.unref();
857
- } catch (e) {
858
- return `dashboard error: failed to spawn — ${e?.message || e}`;
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.
1626
+ const start = Date.now();
1627
+ while (Date.now() - start < 1500) {
1628
+ if (await _dashboardProbe(port)) {
1629
+ await _openBrowser(url);
1630
+ return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
1631
+ }
1632
+ await new Promise((r) => setTimeout(r, 150));
1633
+ }
1634
+ return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
1635
+ } finally {
1636
+ _dashboardSpawning = false;
859
1637
  }
860
- // Poll /healthz up to ~3s before opening the browser.
861
- const start = Date.now();
862
- while (Date.now() - start < 3000) {
863
- if (await probe()) { openBrowser(url); return `✓ started dashboard opened ${url}`; }
864
- await new Promise((r) => setTimeout(r, 150));
1638
+ }
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}`;
865
1665
  }
866
- return `⚠ dashboard didn't come up within 3s. Try \`lazyclaw dashboard\` from another terminal. URL: ${url}`;
1666
+ return [
1667
+ 'subcommands:',
1668
+ ...SUBCOMMAND_GROUPS.map(([g, cmds]) => ` ${g.padEnd(9)} ${cmds.join(' ')}`),
1669
+ '(run: lazyclaw <subcommand>)',
1670
+ ].join('\n');
867
1671
  }
868
1672
 
869
1673
  // ─── dispatch table ──────────────────────────────────────────────────────
@@ -879,7 +1683,7 @@ export const SLASH_HANDLERS = new Map([
879
1683
  ['/provider', _provider],
880
1684
  ['/model', _model],
881
1685
  ['/skill', _skill],
882
- ['/skills', _skill],
1686
+ ['/skills', _skillsList],
883
1687
  ['/tools', _tools],
884
1688
  ['/recall', _recall],
885
1689
  ['/memory', _memory],
@@ -893,6 +1697,7 @@ export const SLASH_HANDLERS = new Map([
893
1697
  ['/task', _task],
894
1698
  ['/trainer', _trainer],
895
1699
  ['/dashboard', _dashboard],
1700
+ ['/menu', _menu],
896
1701
  ['/exit', async () => 'EXIT'],
897
1702
  ['/quit', async () => 'EXIT'],
898
1703
  ]);