lazyclaw 6.4.0 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/agents.mjs +3 -194
- package/commands/agents_registry.mjs +205 -0
- package/commands/automation.mjs +8 -89
- package/commands/automation_loops.mjs +94 -0
- package/commands/chat.mjs +46 -680
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/help_text.mjs +78 -0
- package/commands/setup.mjs +1 -73
- package/daemon/lib/cost.mjs +5 -0
- package/daemon.mjs +1 -1
- package/mas/agent_turn.mjs +7 -0
- package/package.json +1 -1
- package/providers/claude_cli.mjs +1 -1
- package/providers/claude_cli_session.mjs +21 -4
- package/providers/codex_cli.mjs +8 -5
- package/providers/gemini.mjs +4 -1
- package/providers/gemini_cli.mjs +17 -3
- package/providers/tool_use/gemini.mjs +12 -1
- package/providers/tool_use/openai.mjs +7 -0
- package/tui/banner.mjs +72 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- package/tui/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +34 -180
- package/tui/repl.mjs +12 -165
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +12 -640
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_trainer.mjs +173 -0
package/tui/slash_dispatcher.mjs
CHANGED
|
@@ -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
|
-
|
|
1342
|
-
|
|
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
|
-
//
|
|
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
|