lazyclaw 6.4.0 → 6.6.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.
@@ -49,6 +49,14 @@ import { attachGoalCron, detachGoalCron } from '../goals_cron.mjs';
49
49
  import { loadDotenvIfAny } from '../dotenv_min.mjs';
50
50
  import { SUBCOMMAND_GROUPS } from './subcommands.mjs';
51
51
  import { redactSecrets } from '../mas/redact.mjs';
52
+ import { splitWhitespace, _mod, _promptText, _promptConfirm } from './slash_helpers.mjs';
53
+ import { _dashboard, parseDashboardUrl } from './slash_dashboard.mjs';
54
+ import { _channels, _context } from './slash_channels.mjs';
55
+ import { _trainer } from './slash_trainer.mjs';
56
+
57
+ // Re-export so callers/tests that import parseDashboardUrl from this module
58
+ // (the dispatcher was its original home) keep resolving after the extraction.
59
+ export { parseDashboardUrl };
52
60
 
53
61
  // ─── helpers ─────────────────────────────────────────────────────────────
54
62
 
@@ -64,29 +72,6 @@ export function parseSlashLine(line) {
64
72
  return { cmd: m[1], args: (m[3] || '').trim() };
65
73
  }
66
74
 
67
- // Tiny utility — split args on whitespace, drop empties. Used by sub-command
68
- // handlers that don't need the loop-engine's full quote-aware splitter.
69
- function splitWhitespace(s) {
70
- return (s || '').split(/\s+/).filter(Boolean);
71
- }
72
-
73
- // Parse a "provider[:model]" spec, preferring the registry's parser.
74
- function _parseProvModel(registry, spec) {
75
- if (registry && typeof registry.parseProviderModel === 'function') return registry.parseProviderModel(spec);
76
- const s = String(spec || '');
77
- const i = s.indexOf(':');
78
- if (i < 0) return { provider: s || null, model: null };
79
- return { provider: s.slice(0, i) || null, model: s.slice(i + 1) || null };
80
- }
81
-
82
- // Best-effort dynamic import. Returns the resolved ctx field if the caller
83
- // pre-injected it (test hot path), else loads the real module. Throwing is
84
- // fine — handlers wrap calls in try/catch where appropriate.
85
- async function _mod(ctx, key, importer) {
86
- if (ctx && ctx[key]) return ctx[key];
87
- return importer();
88
- }
89
-
90
75
  // ─── handlers ────────────────────────────────────────────────────────────
91
76
 
92
77
  async function _help() {
@@ -166,46 +151,6 @@ async function _newReset(_args, ctx) {
166
151
  }
167
152
 
168
153
 
169
- // Single free-text prompt reusing the modal's filter buffer (no dedicated
170
- // input widget). Returns the typed value, '' (only when allowEmpty), or null
171
- // on cancel / required-but-empty.
172
- async function _promptText(ctx, { title, subtitle, allowEmpty, secret } = {}) {
173
- if (typeof ctx.openPicker !== 'function') return null;
174
- const picked = await ctx.openPicker({
175
- kind: 'text',
176
- title,
177
- // `secret` masks the typed query on screen (api-key / token entry) while
178
- // the real value still reaches the caller — the modal picker honors it.
179
- secret: !!secret,
180
- subtitle: subtitle || 'type into the filter, then pick the row · Esc cancels',
181
- items: [{ id: '__text__', label: '✓ use what I typed above', desc: '', pinned: true, freeText: true }],
182
- });
183
- if (picked == null) return null;
184
- if (typeof picked === 'object') {
185
- const v = String(picked.query || '').trim();
186
- if (!v && !allowEmpty) return null;
187
- return v;
188
- }
189
- return null;
190
- }
191
-
192
- // Yes/no confirmation modal for sensitive-tool approval. Esc (or no modal
193
- // available) DENIES — approval is never granted by omission.
194
- async function _promptConfirm(ctx, { title, subtitle } = {}) {
195
- if (typeof ctx.openPicker !== 'function') return false;
196
- const picked = await ctx.openPicker({
197
- kind: 'menu',
198
- title: title || 'Approve sensitive tool?',
199
- subtitle: subtitle || 'Enter selects · Esc denies',
200
- items: [
201
- { id: 'approve', label: '✓ approve once', desc: 'run this tool call' },
202
- { id: 'deny', label: '✗ deny', desc: 'block this tool call' },
203
- ],
204
- });
205
- const id = picked && typeof picked === 'object' ? picked.id : picked;
206
- return id === 'approve';
207
- }
208
-
209
154
  // Default in-chat approval hook: prompts the operator to confirm each
210
155
  // sensitive tool call. Used to drive the fail-closed tool runner from the
211
156
  // Ink REPL, where stdin is owned by Ink so a raw readline prompt can't run.
@@ -1338,382 +1283,8 @@ async function _task(args, ctx, write) {
1338
1283
  }
1339
1284
  }
1340
1285
 
1341
- async function _trainer(args, ctx) {
1342
- const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
1343
- const tokens = splitWhitespace(args);
1344
-
1345
- // Bare /trainer with a modal available → an action menu (mirrors the bare
1346
- // /orchestrator menu). "Set"/"Fallback" re-enter and drill the shared
1347
- // provider→model picker; "Clear"/"Show" run their subcommands. Typed forms
1348
- // (/trainer set <p:m>, etc.) still work and skip the menu.
1349
- if (tokens.length === 0 && typeof ctx.openPicker === 'function') {
1350
- let cur = '';
1351
- try {
1352
- if (typeof registry.resolveTrainer === 'function') {
1353
- const e = registry.resolveTrainer(ctx.cfg || {});
1354
- cur = `now: ${e.provider}${e.model ? ':' + e.model : ':(default)'}`;
1355
- }
1356
- } catch { /* show menu without the current hint */ }
1357
- const picked = await ctx.openPicker({
1358
- kind: 'menu',
1359
- title: 'Trainer — synthesis / learning model',
1360
- subtitle: cur || 'pick provider + model for trainer turns',
1361
- items: [
1362
- { id: 'set', label: 'Set trainer…', desc: 'pick provider + model (or auto / provider default)' },
1363
- { id: 'fallback', label: 'Set fallback…', desc: 'pick a fallback provider + model' },
1364
- { id: 'clear', label: 'Clear', desc: 'unset — mirror the chat provider/model' },
1365
- { id: 'show', label: 'Show', desc: 'print the effective + configured trainer' },
1366
- ],
1367
- });
1368
- const id = picked && typeof picked === 'object' ? picked.id : picked;
1369
- if (!id || typeof id !== 'string') return _trainer('show', ctx); // cancelled → show status
1370
- return _trainer(id, ctx); // re-enter; set/fallback open the picker (no spec)
1371
- }
1372
-
1373
- const sub = tokens[0] || 'show';
1374
-
1375
- if (sub === 'show') {
1376
- let effective = { provider: ctx.getActiveProvName ? ctx.getActiveProvName() : null,
1377
- model: ctx.getActiveModel ? ctx.getActiveModel() : null };
1378
- try {
1379
- if (typeof registry.resolveTrainer === 'function') {
1380
- effective = registry.resolveTrainer(ctx.cfg || {});
1381
- }
1382
- } catch { /* fall through */ }
1383
- const configured = (ctx.cfg && ctx.cfg.trainer) || null;
1384
- const cfgRender = configured
1385
- ? renderRecord(configured, { fields: ['provider', 'model', 'fallback'] }).split('\n').map((l) => ' ' + l).join('\n')
1386
- : '(unset — trainer mirrors the chat provider/model)';
1387
- return [
1388
- 'trainer (effective):',
1389
- ` provider: ${effective.provider}`,
1390
- ` model: ${effective.model || '(default)'}`,
1391
- 'trainer (configured):',
1392
- cfgRender,
1393
- ].join('\n');
1394
- }
1395
-
1396
- if (sub === 'set') {
1397
- let spec = tokens[1];
1398
- let _setFromPicker = false;
1399
- // No spec + a modal available → drill the shared picker (with an "auto"
1400
- // row and a "provider default" row) instead of requiring a typed spec.
1401
- if (!spec && typeof ctx.openPicker === 'function') {
1402
- const r = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
1403
- if (!r || r.model == null) return 'trainer set: cancelled';
1404
- spec = r.provider === 'auto' ? 'auto' : (r.model ? `${r.provider}:${r.model}` : r.provider);
1405
- _setFromPicker = true;
1406
- }
1407
- if (!spec) return 'usage: /trainer set <provider>[:<model>] (or `auto` for orchestrator-managed)';
1408
- const parsed = typeof registry.parseProviderModel === 'function'
1409
- ? registry.parseProviderModel(spec)
1410
- : { provider: spec.split(':')[0], model: spec.split(':')[1] || null };
1411
- if (!parsed || !parsed.provider) return `/trainer set: could not parse "${spec}"`;
1412
- if (parsed.provider !== 'auto') {
1413
- const next = _providerLookup(registry, parsed.provider);
1414
- if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
1415
- }
1416
- // Optional `--fallback <provider[:model]>` — resolveTrainer routes here
1417
- // when opts.useFallback is set. Validate before persisting.
1418
- let fallbackSpec = null;
1419
- const fi = tokens.indexOf('--fallback');
1420
- if (fi >= 0) {
1421
- fallbackSpec = tokens[fi + 1];
1422
- if (!fallbackSpec) return 'usage: /trainer set <p:m> --fallback <p:m>';
1423
- const fp = _parseProvModel(registry, fallbackSpec);
1424
- if (!fp.provider) return `/trainer set: could not parse fallback "${fallbackSpec}"`;
1425
- if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
1426
- return `/trainer set: unknown provider "${fp.provider}"`;
1427
- }
1428
- }
1429
- // Picker-driven set with no --fallback flag → offer an optional fallback
1430
- // pick (the flag form stays the escape hatch for typed callers).
1431
- if (_setFromPicker && !fallbackSpec && typeof ctx.openPicker === 'function') {
1432
- const addFb = await _promptConfirm(ctx, { title: 'Add a fallback trainer?', subtitle: 'used when the primary is unavailable · Esc / deny to skip' });
1433
- if (addFb) {
1434
- const fr = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
1435
- if (fr && fr.model != null) {
1436
- fallbackSpec = fr.provider === 'auto' ? 'auto' : (fr.model ? `${fr.provider}:${fr.model}` : fr.provider);
1437
- }
1438
- }
1439
- }
1440
- // Read-merge-write so unrelated cfg keys survive.
1441
- const fs = await import('node:fs');
1442
- const path = await import('node:path');
1443
- const cfgPath = path.join(ctx.cfgDir, 'config.json');
1444
- let diskCfg = {};
1445
- try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
1446
- diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
1447
- if (parsed.model) diskCfg.trainer.model = parsed.model;
1448
- else delete diskCfg.trainer.model;
1449
- if (fallbackSpec) diskCfg.trainer.fallback = fallbackSpec;
1450
- try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1451
- fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1452
- if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
1453
- return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}${fallbackSpec ? ` (fallback: ${fallbackSpec})` : ''}`;
1454
- }
1455
-
1456
- if (sub === 'fallback') {
1457
- let spec = tokens[1];
1458
- if (!spec && typeof ctx.openPicker === 'function') {
1459
- const r = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
1460
- if (!r || r.model == null) return 'trainer fallback: cancelled';
1461
- spec = r.provider === 'auto' ? 'auto' : (r.model ? `${r.provider}:${r.model}` : r.provider);
1462
- }
1463
- if (!spec) return 'usage: /trainer fallback <provider>[:<model>] | clear';
1464
- const fs = await import('node:fs');
1465
- const path = await import('node:path');
1466
- const cfgPath = path.join(ctx.cfgDir, 'config.json');
1467
- let diskCfg = {};
1468
- try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
1469
- if (spec === 'clear' || spec === 'unset') {
1470
- if (diskCfg.trainer) delete diskCfg.trainer.fallback;
1471
- try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1472
- fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1473
- if (ctx.cfg && ctx.cfg.trainer) delete ctx.cfg.trainer.fallback;
1474
- return '✓ trainer fallback cleared';
1475
- }
1476
- const fp = _parseProvModel(registry, spec);
1477
- if (!fp.provider) return `/trainer fallback: could not parse "${spec}"`;
1478
- if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
1479
- return `/trainer fallback: unknown provider "${fp.provider}"`;
1480
- }
1481
- diskCfg.trainer = { ...(diskCfg.trainer || {}), fallback: spec };
1482
- try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1483
- fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1484
- if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
1485
- return `✓ trainer fallback → ${spec}`;
1486
- }
1487
-
1488
- if (sub === 'clear' || sub === 'unset') {
1489
- const fs = await import('node:fs');
1490
- const path = await import('node:path');
1491
- const cfgPath = path.join(ctx.cfgDir, 'config.json');
1492
- let diskCfg = {};
1493
- try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
1494
- delete diskCfg.trainer;
1495
- try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
1496
- fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
1497
- if (ctx.cfg) delete ctx.cfg.trainer;
1498
- return '✓ trainer cleared (will mirror chat provider/model)';
1499
- }
1500
-
1501
- return `/trainer: unknown sub "${sub}" — show|set <p:m>|clear`;
1502
- }
1503
-
1504
- // /dashboard — open the lazyclaw web UI.
1505
- //
1506
- // v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
1507
- // session spawned 20+ daemon children).
1508
- //
1509
- // Original implementation:
1510
- // probe /healthz → if !200, spawn detached `lazyclaw dashboard
1511
- // --no-open` and poll for up to 3s.
1512
- //
1513
- // Failure mode that produced the 20+ spawn pile-up:
1514
- // 1. User types /dashboard. probe fails (no daemon). Spawn child A.
1515
- // 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
1516
- // 3. User types /dashboard again BEFORE A is ready. probe still fails.
1517
- // Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
1518
- // (cli.mjs:3611) which SIGTERMs child A. B takes over.
1519
- // 4. Repeat. Each /dashboard kills the previous daemon and starts a
1520
- // new one. With autorepeat / many slash calls this stacks fast.
1521
- //
1522
- // Two-layer guard:
1523
- // - A module-level _dashboardSpawning latch refuses concurrent spawn
1524
- // attempts. While a spawn is in flight, /dashboard says so + returns
1525
- // without firing another child.
1526
- // - A _dashboardChildPid cache remembers the PID we already spawned;
1527
- // subsequent calls check kill(pid, 0) to confirm the child is alive
1528
- // and just open the browser without spawning.
1529
- //
1530
- // We probe both /healthz (HTTP) AND a raw net.connect port check so a
1531
- // slow-starting daemon (binding the listener but not yet answering HTTP)
1532
- // still counts as "running".
1533
- let _dashboardSpawning = false;
1534
- let _dashboardChildPid = null;
1535
-
1536
- function _portIsListening(port, timeoutMs = 200) {
1537
- return new Promise((resolve) => {
1538
- import('node:net').then(({ createConnection }) => {
1539
- let settled = false;
1540
- const sock = createConnection({ host: '127.0.0.1', port });
1541
- const done = (ok) => {
1542
- if (settled) return;
1543
- settled = true;
1544
- try { sock.destroy(); } catch {}
1545
- resolve(ok);
1546
- };
1547
- sock.once('connect', () => done(true));
1548
- sock.once('error', () => done(false));
1549
- setTimeout(() => done(false), timeoutMs);
1550
- }).catch(() => resolve(false));
1551
- });
1552
- }
1553
-
1554
- async function _dashboardProbe(port) {
1555
- // Fast path — port-level probe. Catches a daemon that has bound the
1556
- // socket but hasn't finished initializing its HTTP routes.
1557
- if (await _portIsListening(port, 200)) return true;
1558
- // Slow path — full /healthz fetch, for defense in depth.
1559
- if (typeof fetch !== 'function') return false;
1560
- try {
1561
- const ac = new AbortController();
1562
- const t = setTimeout(() => ac.abort(), 250);
1563
- const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
1564
- clearTimeout(t);
1565
- return !!(r && r.ok);
1566
- } catch { return false; }
1567
- }
1568
-
1569
- function _openBrowser(url) {
1570
- return import('node:child_process').then(({ spawn }) => {
1571
- let cmd, args;
1572
- if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
1573
- else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
1574
- else { cmd = 'xdg-open'; args = [url]; }
1575
- try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
1576
- });
1577
- }
1578
-
1579
- async function _dashboardStop(port) {
1580
- // Best-effort kill of every lazyclaw dashboard daemon on the box.
1581
- // Used to clean up after the v5.4.3 spawn pile-up bug.
1582
- if (process.platform === 'win32') {
1583
- return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
1584
- }
1585
- const { spawn } = await import('node:child_process');
1586
- // Step 1: lsof the port and SIGTERM each PID.
1587
- const portPids = await new Promise((resolve) => {
1588
- try {
1589
- const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
1590
- let buf = '';
1591
- lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
1592
- lsof.on('error', () => resolve([]));
1593
- lsof.on('close', () => resolve(
1594
- buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
1595
- ));
1596
- } catch { resolve([]); }
1597
- });
1598
- for (const pid of portPids) {
1599
- try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
1600
- }
1601
- // Step 2: pkill any process whose command line includes "lazyclaw dashboard"
1602
- // — catches detached children that bound a different (random) port via
1603
- // cmdDashboard's EADDRINUSE fallback.
1604
- let pkilled = 0;
1605
- try {
1606
- const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
1607
- pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
1608
- } catch { /* fine */ }
1609
- _dashboardChildPid = null;
1610
- return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
1611
- }
1612
-
1613
- // Parse the daemon's "listening at <url>" stdout line so /dashboard opens the
1614
- // actually-bound port (the child may fall back to a random port on EADDRINUSE).
1615
- export function parseDashboardUrl(text) {
1616
- const m = String(text || '').match(/listening at\s+(https?:\/\/\S+)/i);
1617
- return m ? m[1] : null;
1618
- }
1619
-
1620
- // Resolve the daemon's real URL from its stdout within `timeoutMs`, or null.
1621
- function _waitForDashboardUrl(child, timeoutMs) {
1622
- return new Promise((resolve) => {
1623
- if (!child || !child.stdout) { resolve(null); return; }
1624
- let buf = '';
1625
- let done = false;
1626
- const finish = (v) => {
1627
- if (done) return;
1628
- done = true;
1629
- try { child.stdout.off('data', onData); } catch { /* ignore */ }
1630
- resolve(v);
1631
- };
1632
- const onData = (d) => {
1633
- buf += d.toString('utf8');
1634
- const u = parseDashboardUrl(buf);
1635
- if (u) finish(u);
1636
- };
1637
- child.stdout.on('data', onData);
1638
- setTimeout(() => finish(null), timeoutMs);
1639
- });
1640
- }
1641
-
1642
- async function _dashboard(args) {
1643
- const port = 19600;
1644
- const url = `http://127.0.0.1:${port}/dashboard`;
1645
- // Under the node:test runner, never launch a real daemon or open a browser
1646
- // (it leaked a background daemon + opened a tab on every test run).
1647
- if (process.env.NODE_TEST_CONTEXT) return `dashboard: ${url} (spawn skipped under test)`;
1648
- const sub = splitWhitespace(args)[0];
1649
- if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
1650
-
1651
- // 1. Already running anywhere on the machine? → reuse.
1652
- if (await _dashboardProbe(port)) {
1653
- await _openBrowser(url);
1654
- return `✓ dashboard already running — opened ${url}`;
1655
- }
1656
-
1657
- // 2. We spawned in this chat — is that child still alive?
1658
- if (_dashboardChildPid != null) {
1659
- try {
1660
- process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
1661
- // Child alive but not answering yet. Don't re-spawn; just nudge.
1662
- await _openBrowser(url);
1663
- return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
1664
- } catch {
1665
- _dashboardChildPid = null; // child died; fall through and respawn.
1666
- }
1667
- }
1668
-
1669
- // 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
1670
- if (_dashboardSpawning) {
1671
- await _openBrowser(url);
1672
- return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
1673
- }
1674
-
1675
- // 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
1676
- // spawn flag in a finally so it always clears.
1677
- _dashboardSpawning = true;
1678
- try {
1679
- const { spawn } = await import('node:child_process');
1680
- let child;
1681
- try {
1682
- // Pass --port so the child tries 19600 first; pipe stdout so we can read
1683
- // the real bound URL (it may fall back to a random port on EADDRINUSE).
1684
- child = spawn(process.execPath, [process.argv[1], 'dashboard', '--port', String(port), '--no-open'], {
1685
- detached: true, stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd(), env: process.env,
1686
- });
1687
- _dashboardChildPid = child.pid;
1688
- } catch (e) {
1689
- return `dashboard error: failed to spawn — ${e?.message || e}`;
1690
- }
1691
- // Prefer the daemon's own "listening at <url>" line — it carries the
1692
- // actual port even after a random-port fallback.
1693
- const boundUrl = await _waitForDashboardUrl(child, 3000);
1694
- // Release the captured stdout pipe so the detached daemon doesn't keep
1695
- // OUR event loop alive (unref, not destroy — destroying would EPIPE the
1696
- // daemon on its next stdout write). Then unref the child itself.
1697
- try { if (child.stdout) { child.stdout.removeAllListeners('data'); child.stdout.unref(); } } catch { /* ignore */ }
1698
- child.unref();
1699
- if (boundUrl) {
1700
- await _openBrowser(boundUrl);
1701
- return `✓ started dashboard (pid ${child.pid}) — opened ${boundUrl}`;
1702
- }
1703
- // Fallback: the line never arrived — poll the default port best-effort.
1704
- const start = Date.now();
1705
- while (Date.now() - start < 1500) {
1706
- if (await _dashboardProbe(port)) {
1707
- await _openBrowser(url);
1708
- return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
1709
- }
1710
- await new Promise((r) => setTimeout(r, 150));
1711
- }
1712
- return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
1713
- } finally {
1714
- _dashboardSpawning = false;
1715
- }
1716
- }
1286
+ // /trainer — moved to ./slash_trainer.mjs (Group 4) so the set/fallback/show/
1287
+ // clear branches can grow off this file's size ratchet.
1717
1288
 
1718
1289
  // /menu — in-chat command palette over the full subcommand catalog. The
1719
1290
  // no-arg launcher menu used to be the home screen; defaulting to chat hid it
@@ -1754,209 +1325,10 @@ async function _menu(args, ctx, write) {
1754
1325
  ].join('\n');
1755
1326
  }
1756
1327
 
1757
- // /channels — view configured channels and toggle them. `/channels` lists;
1758
- // `/channels <name> on|off` enables/disables. Reads/writes cfg via ctx when
1759
- // available, else lib/config directly, so it works on both REPL paths.
1760
- async function _channels(args, ctx = {}) {
1761
- const cf = await import('../config_features.mjs');
1762
- const cfgMod = await import('../lib/config.mjs');
1763
- const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
1764
- const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
1765
- const toks = (args || '').trim().split(/\s+/).filter(Boolean);
1766
- const [name, action] = toks;
1767
-
1768
- // `/channels [<name>] setup` — set the channel's credentials (bot token,
1769
- // homeserver, …) from chat instead of redirecting to /config. Reuses the
1770
- // masked modal prompt (_promptText) so secrets are never echoed. No modal →
1771
- // fall back to the readline channel step (same as /config's channel item).
1772
- const wantSetup = toks.some((t) => /^setup$/i.test(t));
1773
- if (wantSetup) {
1774
- const channelMod = await import('../commands/setup_channels.mjs');
1775
- const picked = toks.find((t) => !/^setup$/i.test(t));
1776
- if (typeof ctx.openPicker !== 'function') {
1777
- // Readline path: hand off to the existing channel wizard step.
1778
- ctx.requestConfigStep = 'channel';
1779
- return 'EXIT';
1780
- }
1781
- let chName = picked && picked.toLowerCase();
1782
- if (!chName) {
1783
- const sel = await ctx.openPicker({
1784
- kind: 'menu',
1785
- title: 'channel — set credentials',
1786
- subtitle: 'pick a channel to configure',
1787
- items: channelMod.CHANNEL_CATALOG.map((c) => ({
1788
- id: c.name,
1789
- label: c.label,
1790
- desc: c.builtin ? '' : `needs: ${(c.deps && c.deps.length) ? c.deps.join(', ') : (c.binary || 'creds only')}`,
1791
- })),
1792
- });
1793
- chName = sel && typeof sel === 'object' ? sel.id : sel;
1794
- if (!chName || typeof chName !== 'string') return 'channel setup: cancelled';
1795
- }
1796
- const spec = channelMod.channelByName(chName);
1797
- if (!spec) return `unknown channel: ${chName} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
1798
- if (!spec.fields.length) {
1799
- // No creds (e.g. http / whatsapp) — just enable it.
1800
- const cfgDirX = ctx.cfgDir || (await import('node:path')).dirname(cfgMod.configPath());
1801
- channelMod.persistChannel(cfgDirX, chName, {});
1802
- if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: true }; }
1803
- return `channel ${chName} → enabled (no credentials needed)`;
1804
- }
1805
- const answers = {};
1806
- for (const f of spec.fields) {
1807
- const v = await _promptText(ctx, {
1808
- title: `${spec.label} — ${f.prompt}`,
1809
- subtitle: f.optional ? 'optional · Esc to skip' : 'Esc cancels',
1810
- secret: !!f.secret,
1811
- allowEmpty: !!f.optional,
1812
- });
1813
- if (v === null) {
1814
- if (f.optional) continue; // Esc on an optional field → skip it
1815
- return 'channel setup: cancelled';
1816
- }
1817
- if (v) answers[f.key] = v;
1818
- }
1819
- const path = await import('node:path');
1820
- const cfgDirX = ctx.cfgDir || path.dirname(cfgMod.configPath());
1821
- const entry = channelMod.persistChannel(cfgDirX, chName, answers);
1822
- // Mirror the PERSISTED enabled state onto the in-session cfg so a follow-up
1823
- // list is fresh — an in-tree channel whose runtime dep is missing stays
1824
- // disabled (persistChannel gates it) rather than being force-enabled.
1825
- if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: !!entry.ready }; }
1826
- const setKeys = Object.keys(answers);
1827
- let note = '';
1828
- if (!entry.ready) {
1829
- if (entry.missingDeps && entry.missingDeps.length) note += `\n(needs ${entry.missingDeps.join(', ')} — run: lazyclaw channels install ${chName})`;
1830
- if (entry.missingBinary) note += `\n(needs the ${entry.missingBinary} binary on your PATH)`;
1831
- }
1832
- return `✓ ${spec.label} credentials saved (${setKeys.join(', ') || 'none'}) → ${entry.ready ? 'channel enabled' : 'saved (enable once the requirement is installed)'}${note}`;
1833
- }
1834
-
1835
- // `/channels <name> test` — verify the stored credentials with a live call.
1836
- if (name && /^test$/i.test(action || '')) {
1837
- const channelMod = await import('../commands/setup_channels.mjs');
1838
- try { loadDotenvIfAny(ctx.cfgDir); } catch { /* best-effort */ }
1839
- const r = await channelMod.verifyChannel(name.toLowerCase());
1840
- if (r.ok === true) return `✓ ${name} verified — ${r.detail}`;
1841
- if (r.ok === null) return `· ${name}: ${r.detail}`;
1842
- return `✗ ${name}: ${r.detail}${r.hint ? `\n fix: ${r.hint}` : ''}`;
1843
- }
1844
-
1845
- if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
1846
- const en = /^(on|enable)$/i.test(action);
1847
- const cfg = read();
1848
- const key = name.toLowerCase();
1849
- // Reject unknown names so a typo can't silently create a bogus
1850
- // cfg.channels.<name> section (which would then leak into the list).
1851
- // Stay permissive for pre-existing custom sections.
1852
- const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
1853
- if (!cf.KNOWN_CHANNELS.includes(key) && !(key in existing)) {
1854
- return `unknown channel: ${key} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
1855
- }
1856
- cf.channelSetEnabled(cfg, key, en); write(cfg);
1857
- // Legacy fallback path: the readline ctx (_legacyCtx) has no
1858
- // readConfig/writeConfig, so we read/wrote disk above against a fresh
1859
- // cfg object. Mirror the toggle onto the in-session ctx.cfg so a
1860
- // follow-up `/channels` (list) or other in-session read stays
1861
- // consistent instead of showing the stale pre-toggle value.
1862
- if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
1863
- cf.channelSetEnabled(ctx.cfg, key, en);
1864
- }
1865
- return en
1866
- ? `channel ${key} → enabled`
1867
- : `channel ${key} → disabled (re-enable with /channels ${key} on)`;
1868
- }
1869
- // No-arg + modal → an action menu: each row toggles in place, plus a
1870
- // "set credentials" row. Falls through to the text list when no modal.
1871
- if (!toks.length && typeof ctx.openPicker === 'function') {
1872
- const statusRows = cf.channelStatusList(read());
1873
- const items = statusRows.map((c) => ({
1874
- id: `toggle:${c.name}`,
1875
- label: `${c.name} — ${c.enabled ? 'enabled' : 'disabled'}`,
1876
- desc: c.enabled ? 'Enter to disable' : 'Enter to enable',
1877
- }));
1878
- items.push({ id: 'setup', label: '+ Set credentials…', desc: 'pick a channel and enter bot token / homeserver / …' });
1879
- const picked = await ctx.openPicker({ kind: 'menu', title: 'Channels', subtitle: `${statusRows.length} configured`, items });
1880
- const pid = picked && typeof picked === 'object' ? picked.id : picked;
1881
- if (!pid || typeof pid !== 'string') return 'cancelled';
1882
- if (pid === 'setup') return _channels('setup', ctx);
1883
- if (pid.startsWith('toggle:')) {
1884
- const nm = pid.slice(7);
1885
- const cur = statusRows.find((r) => r.name === nm);
1886
- return _channels(`${nm} ${cur && cur.enabled ? 'off' : 'on'}`, ctx);
1887
- }
1888
- }
1889
- const rows = cf.channelStatusList(read());
1890
- if (!rows.length) return 'no channels configured. set credentials with /channels setup (or `lazyclaw setup` for the full wizard).';
1891
- // Cross-reference each channel's required env creds against the loaded env so
1892
- // the list flags a channel that's "enabled" but missing its token.
1893
- let channelMod = null;
1894
- try {
1895
- loadDotenvIfAny(ctx.cfgDir);
1896
- channelMod = await import('../commands/setup_channels.mjs');
1897
- } catch { /* hint is best-effort */ }
1898
- const missingFor = (name) => {
1899
- const spec = channelMod && channelMod.channelByName(name);
1900
- if (!spec) return [];
1901
- return spec.fields.filter((f) => !f.optional && !process.env[f.env]).map((f) => f.env);
1902
- };
1903
- const lines = ['configured channels:'];
1904
- for (const c of rows) {
1905
- const miss = missingFor(c.name);
1906
- const credNote = miss.length ? ` · creds: missing ${miss.join(', ')}` : '';
1907
- lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}${credNote}`);
1908
- }
1909
- lines.push('toggle: /channels <name> on|off · set creds: /channels <name> setup');
1910
- return lines.join('\n');
1911
- }
1912
-
1913
1328
  // /orchestrator — moved to ./orchestrator_flow.mjs (orchestratorSlash) so the
1914
1329
  // interactive fetch+pick planner/worker editor can grow off the ratchet.
1915
-
1916
- // /context view/set the chat history window (turns + token budget). This is
1917
- // the sliding history budget sent each turn, NOT the model's hard context
1918
- // limit. ctx-or-lib/config fallback so it works on both REPL paths.
1919
- async function _context(args, ctx = {}) {
1920
- const cf = await import('../config_features.mjs');
1921
- const cfgMod = await import('../lib/config.mjs');
1922
- const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
1923
- const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
1924
- const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
1925
- const parts = (args || '').trim().split(/\s+/).filter(Boolean);
1926
- const sub = (parts[0] || 'status').toLowerCase();
1927
- const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
1928
- // No-arg + modal → action menu → numeric picker (mirrors orchestrator maxsubtasks).
1929
- if (!parts.length && typeof ctx.openPicker === 'function') {
1930
- const w = cf.chatWindowGet(read());
1931
- const action = await ctx.openPicker({
1932
- kind: 'menu', title: 'Context window (history budget)', subtitle: `now ${w.turns} turns · ${w.tokens} tokens`,
1933
- items: [
1934
- { id: 'turns', label: 'Set turns…', desc: 'past turns to send' },
1935
- { id: 'tokens', label: 'Set tokens…', desc: 'token budget (min 256)' },
1936
- { id: 'status', label: 'Status', desc: 'show current' },
1937
- ],
1938
- });
1939
- const aid = action && typeof action === 'object' ? action.id : action;
1940
- if (!aid || typeof aid !== 'string' || aid === 'status') return fmt();
1941
- if (aid === 'turns') {
1942
- const np = await ctx.openPicker({ kind: 'menu', title: 'Turns to keep', subtitle: `currently ${w.turns}`, items: [5, 10, 15, 20, 30, 40, 50].map((x) => ({ id: String(x), label: String(x) })) });
1943
- const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
1944
- if (!Number.isFinite(v)) return 'context turns: cancelled';
1945
- const cfg = read(); cf.chatWindowSet(cfg, { turns: v }); persist(cfg); return fmt();
1946
- }
1947
- if (aid === 'tokens') {
1948
- const np = await ctx.openPicker({ kind: 'menu', title: 'Token budget', subtitle: `currently ${w.tokens}`, items: [2000, 4000, 8000, 12000, 16000, 32000].map((x) => ({ id: String(x), label: String(x) })) });
1949
- const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
1950
- if (!Number.isFinite(v) || v < 256) return 'context tokens: cancelled';
1951
- const cfg = read(); cf.chatWindowSet(cfg, { tokens: v }); persist(cfg); return fmt();
1952
- }
1953
- }
1954
- if (sub === 'status') return fmt();
1955
- const n = parseInt(parts[1], 10);
1956
- if (sub === 'turns') { if (!Number.isFinite(n) || n < 1) return 'usage: /context turns <N>'; const cfg = read(); cf.chatWindowSet(cfg, { turns: n }); persist(cfg); return fmt(); }
1957
- if (sub === 'tokens') { if (!Number.isFinite(n) || n < 256) return 'usage: /context tokens <N> (min 256)'; const cfg = read(); cf.chatWindowSet(cfg, { tokens: n }); persist(cfg); return fmt(); }
1958
- return 'usage: /context [status | turns <N> | tokens <N>]';
1959
- }
1330
+ // /channels + /context — moved to ./slash_channels.mjs (Group 3) so they can
1331
+ // grow off this file's size ratchet.
1960
1332
 
1961
1333
  // /agentic + /plan live in ./chat_mode_slash.mjs (Group 1) — kept out of this
1962
1334
  // file (at its size ratchet) so the toggles can grow there. Register their