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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// tui/repl_altbuffer.mjs — alt-buffer (DEC 1049) mount cluster for the REPL.
|
|
2
|
+
//
|
|
3
|
+
// Extracted verbatim from repl.mjs (file-size gate). Wraps the React tree
|
|
4
|
+
// with mount/unmount side-effects that enable the terminal alternate screen
|
|
5
|
+
// buffer. Three-layer cleanup so the user never gets stranded on the alt
|
|
6
|
+
// canvas:
|
|
7
|
+
// 1. React unmount → useEffect return-fn writes \x1b[?1049l
|
|
8
|
+
// 2. Rude shutdown (SIGINT/SIGTERM/SIGHUP/'exit') → same escape via
|
|
9
|
+
// process-level listeners that we install + remove on unmount.
|
|
10
|
+
// 3. cursor-visible safety on unmount (\x1b[?25h) in case anything
|
|
11
|
+
// below us turned it off.
|
|
12
|
+
//
|
|
13
|
+
// We deliberately do NOT install an uncaughtException handler — Ink
|
|
14
|
+
// already installs one and re-throws; ours would swallow the stack
|
|
15
|
+
// trace (violates §1 Truthfulness / no silent catch).
|
|
16
|
+
//
|
|
17
|
+
// `enabled` is false for non-TTY pipelines, CI, ink-testing-library, and
|
|
18
|
+
// the LAZYCLAW_NO_ALT escape hatch. When false this is a pass-through —
|
|
19
|
+
// no escape sequences leak into stdout.
|
|
20
|
+
import { useEffect } from 'react';
|
|
21
|
+
|
|
22
|
+
export const ALT_BUFFER_ENTER = '\x1b[?1049h';
|
|
23
|
+
export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
|
|
24
|
+
export const CURSOR_VISIBLE = '\x1b[?25h';
|
|
25
|
+
|
|
26
|
+
// Rendering-mode decision. Default = Static scrollback (no flicker; splash
|
|
27
|
+
// prints once + scrolls naturally). Alt-buffer fullscreen is opt-in via
|
|
28
|
+
// LAZYCLAW_ALT=1; LAZYCLAW_NO_ALT=1 forces it off. TTY-only either way.
|
|
29
|
+
export function computeAltEnabled(env, hasTTY) {
|
|
30
|
+
const e = env || {};
|
|
31
|
+
return !!hasTTY && !!e.LAZYCLAW_ALT && !e.LAZYCLAW_NO_ALT;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function FullScreen({ enabled, children }) {
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!enabled) return undefined;
|
|
37
|
+
// Mount: enter alternate screen buffer.
|
|
38
|
+
try { process.stdout.write(ALT_BUFFER_ENTER); } catch { /* swallow — stdout closed */ }
|
|
39
|
+
|
|
40
|
+
// Rude-shutdown listeners. Each writes 1049l + cursor-visible so the
|
|
41
|
+
// terminal is restored even if React never gets a chance to unmount
|
|
42
|
+
// (e.g. parent process kills us with SIGTERM).
|
|
43
|
+
const restore = () => {
|
|
44
|
+
try { process.stdout.write(ALT_BUFFER_LEAVE + CURSOR_VISIBLE); } catch {}
|
|
45
|
+
};
|
|
46
|
+
const onExit = () => { restore(); };
|
|
47
|
+
const onSignal = () => { restore(); };
|
|
48
|
+
process.once('exit', onExit);
|
|
49
|
+
process.once('SIGINT', onSignal);
|
|
50
|
+
process.once('SIGTERM', onSignal);
|
|
51
|
+
process.once('SIGHUP', onSignal);
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
// React unmount: restore primary buffer.
|
|
55
|
+
restore();
|
|
56
|
+
process.removeListener('exit', onExit);
|
|
57
|
+
process.removeListener('SIGINT', onSignal);
|
|
58
|
+
process.removeListener('SIGTERM', onSignal);
|
|
59
|
+
process.removeListener('SIGHUP', onSignal);
|
|
60
|
+
};
|
|
61
|
+
}, [enabled]);
|
|
62
|
+
return children;
|
|
63
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// tui/repl_reducers.mjs — pure state reducers for the REPL host.
|
|
2
|
+
//
|
|
3
|
+
// Extracted verbatim from repl.mjs (file-size gate). These are pure
|
|
4
|
+
// functions: no shared mutable module state, no JSX. They keep their
|
|
5
|
+
// pre-v5.3 shapes — tests/phaseC-repl-interrupt.test.mjs depends on them.
|
|
6
|
+
//
|
|
7
|
+
// Backward-compat contracts (do not break):
|
|
8
|
+
// - makeReplState() — still callable with zero args.
|
|
9
|
+
// - onUserInput, onEscape, onTurnComplete, consumeNextTurnFirstMessage
|
|
10
|
+
// keep their pre-v5.3 shapes.
|
|
11
|
+
|
|
12
|
+
// ─── Pure state ──────────────────────────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// makeReplState stays callable with zero args (existing tests rely on it).
|
|
15
|
+
// The new fields default to empty so legacy callers see no behavior change.
|
|
16
|
+
export function makeReplState(opts) {
|
|
17
|
+
const splashItem = opts && opts.splashItem ? opts.splashItem : null;
|
|
18
|
+
return {
|
|
19
|
+
streaming: false,
|
|
20
|
+
controller: null,
|
|
21
|
+
pendingPrepend: null,
|
|
22
|
+
nextTurnFirstMessage: null,
|
|
23
|
+
history: [],
|
|
24
|
+
scrollback: splashItem ? [splashItem] : [],
|
|
25
|
+
liveAssistant: '',
|
|
26
|
+
turnCounter: 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function onUserInput(state, { text, controller }) {
|
|
31
|
+
if (state.streaming && state.controller) {
|
|
32
|
+
// mid-stream interrupt — abort current turn, queue text for next turn.
|
|
33
|
+
try { state.controller.abort(); } catch {}
|
|
34
|
+
return { ...state, pendingPrepend: text };
|
|
35
|
+
}
|
|
36
|
+
// idle — start a new turn. Append a 'user' entry to scrollback so the
|
|
37
|
+
// sticky-layout caller sees the prompt history above the live stream.
|
|
38
|
+
const id = `u-${state.turnCounter}`;
|
|
39
|
+
return {
|
|
40
|
+
...state,
|
|
41
|
+
streaming: true,
|
|
42
|
+
controller,
|
|
43
|
+
history: [...state.history, text],
|
|
44
|
+
scrollback: [...state.scrollback, { kind: 'user', id, text }],
|
|
45
|
+
turnCounter: state.turnCounter + 1,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function onEscape(state) {
|
|
50
|
+
if (state.streaming && state.controller) {
|
|
51
|
+
try { state.controller.abort(); } catch {}
|
|
52
|
+
}
|
|
53
|
+
// Drop any partial live assistant text on explicit Esc — the user is
|
|
54
|
+
// telling us to discard, not to keep.
|
|
55
|
+
return {
|
|
56
|
+
...state,
|
|
57
|
+
streaming: false,
|
|
58
|
+
controller: null,
|
|
59
|
+
pendingPrepend: null,
|
|
60
|
+
liveAssistant: '',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Stream chunk arrives. Completed lines are committed to the <Static>
|
|
65
|
+
// scrollback immediately (so they scroll up ABOVE the sticky editor), and only
|
|
66
|
+
// the in-progress trailing partial stays in the live region. Without this, a
|
|
67
|
+
// reply taller than the terminal grew the live frame past the viewport and
|
|
68
|
+
// spilled BELOW the input box (long orchestrator replies). Chunks without a
|
|
69
|
+
// newline still just accumulate (the prior behaviour), so short replies and the
|
|
70
|
+
// existing reducer tests are unchanged.
|
|
71
|
+
export function onStreamChunk(state, { chunk }) {
|
|
72
|
+
const buf = state.liveAssistant + chunk;
|
|
73
|
+
const nl = buf.lastIndexOf('\n');
|
|
74
|
+
if (nl < 0) return { ...state, liveAssistant: buf };
|
|
75
|
+
const complete = buf.slice(0, nl); // one or more whole lines
|
|
76
|
+
const remainder = buf.slice(nl + 1); // trailing partial (may be '')
|
|
77
|
+
const id = `as-${state.turnCounter}-${state.scrollback.length}`;
|
|
78
|
+
return {
|
|
79
|
+
...state,
|
|
80
|
+
scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
|
|
81
|
+
liveAssistant: remainder,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function onTurnComplete(state, { reason, error } = {}) {
|
|
86
|
+
const promoted = state.pendingPrepend;
|
|
87
|
+
const suffix = reason === 'aborted' ? ' [aborted]'
|
|
88
|
+
: reason === 'error' ? (error ? ` [error: ${error}]` : ' [error]')
|
|
89
|
+
: '';
|
|
90
|
+
const text = (state.liveAssistant || '') + suffix;
|
|
91
|
+
// Commit any accumulated live text to scrollback. If the turn produced
|
|
92
|
+
// nothing AND wasn't an error/abort, skip the empty append.
|
|
93
|
+
const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
|
|
94
|
+
const id = `a-${state.turnCounter}`;
|
|
95
|
+
const kind = reason === 'error' ? 'error' : 'assistant';
|
|
96
|
+
const nextScrollback = shouldCommit
|
|
97
|
+
? [...state.scrollback, { kind, id, text }]
|
|
98
|
+
: state.scrollback;
|
|
99
|
+
return {
|
|
100
|
+
...state,
|
|
101
|
+
streaming: false,
|
|
102
|
+
controller: null,
|
|
103
|
+
pendingPrepend: null,
|
|
104
|
+
nextTurnFirstMessage: promoted,
|
|
105
|
+
liveAssistant: '',
|
|
106
|
+
scrollback: nextScrollback,
|
|
107
|
+
turnCounter: state.turnCounter + 1,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function consumeNextTurnFirstMessage(state) {
|
|
112
|
+
const msg = state.nextTurnFirstMessage;
|
|
113
|
+
return [{ ...state, nextTurnFirstMessage: null }, msg];
|
|
114
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// tui/slash_channels.mjs — the /channels and /context slash-command handlers,
|
|
2
|
+
// extracted verbatim from slash_dispatcher.mjs. Imports the shared masked-prompt
|
|
3
|
+
// helper from slash_helpers.mjs and the dotenv shim from a leaf lib, never the
|
|
4
|
+
// dispatcher (no cycle).
|
|
5
|
+
|
|
6
|
+
import { loadDotenvIfAny } from '../dotenv_min.mjs';
|
|
7
|
+
import { _promptText } from './slash_helpers.mjs';
|
|
8
|
+
|
|
9
|
+
// /channels — view configured channels and toggle them. `/channels` lists;
|
|
10
|
+
// `/channels <name> on|off` enables/disables. Reads/writes cfg via ctx when
|
|
11
|
+
// available, else lib/config directly, so it works on both REPL paths.
|
|
12
|
+
export async function _channels(args, ctx = {}) {
|
|
13
|
+
const cf = await import('../config_features.mjs');
|
|
14
|
+
const cfgMod = await import('../lib/config.mjs');
|
|
15
|
+
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
16
|
+
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
17
|
+
const toks = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
18
|
+
const [name, action] = toks;
|
|
19
|
+
|
|
20
|
+
// `/channels [<name>] setup` — set the channel's credentials (bot token,
|
|
21
|
+
// homeserver, …) from chat instead of redirecting to /config. Reuses the
|
|
22
|
+
// masked modal prompt (_promptText) so secrets are never echoed. No modal →
|
|
23
|
+
// fall back to the readline channel step (same as /config's channel item).
|
|
24
|
+
const wantSetup = toks.some((t) => /^setup$/i.test(t));
|
|
25
|
+
if (wantSetup) {
|
|
26
|
+
const channelMod = await import('../commands/setup_channels.mjs');
|
|
27
|
+
const picked = toks.find((t) => !/^setup$/i.test(t));
|
|
28
|
+
if (typeof ctx.openPicker !== 'function') {
|
|
29
|
+
// Readline path: hand off to the existing channel wizard step.
|
|
30
|
+
ctx.requestConfigStep = 'channel';
|
|
31
|
+
return 'EXIT';
|
|
32
|
+
}
|
|
33
|
+
let chName = picked && picked.toLowerCase();
|
|
34
|
+
if (!chName) {
|
|
35
|
+
const sel = await ctx.openPicker({
|
|
36
|
+
kind: 'menu',
|
|
37
|
+
title: 'channel — set credentials',
|
|
38
|
+
subtitle: 'pick a channel to configure',
|
|
39
|
+
items: channelMod.CHANNEL_CATALOG.map((c) => ({
|
|
40
|
+
id: c.name,
|
|
41
|
+
label: c.label,
|
|
42
|
+
desc: c.builtin ? '' : `needs: ${(c.deps && c.deps.length) ? c.deps.join(', ') : (c.binary || 'creds only')}`,
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
45
|
+
chName = sel && typeof sel === 'object' ? sel.id : sel;
|
|
46
|
+
if (!chName || typeof chName !== 'string') return 'channel setup: cancelled';
|
|
47
|
+
}
|
|
48
|
+
const spec = channelMod.channelByName(chName);
|
|
49
|
+
if (!spec) return `unknown channel: ${chName} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
|
|
50
|
+
if (!spec.fields.length) {
|
|
51
|
+
// No creds (e.g. http / whatsapp) — just enable it.
|
|
52
|
+
const cfgDirX = ctx.cfgDir || (await import('node:path')).dirname(cfgMod.configPath());
|
|
53
|
+
channelMod.persistChannel(cfgDirX, chName, {});
|
|
54
|
+
if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: true }; }
|
|
55
|
+
return `channel ${chName} → enabled (no credentials needed)`;
|
|
56
|
+
}
|
|
57
|
+
const answers = {};
|
|
58
|
+
for (const f of spec.fields) {
|
|
59
|
+
const v = await _promptText(ctx, {
|
|
60
|
+
title: `${spec.label} — ${f.prompt}`,
|
|
61
|
+
subtitle: f.optional ? 'optional · Esc to skip' : 'Esc cancels',
|
|
62
|
+
secret: !!f.secret,
|
|
63
|
+
allowEmpty: !!f.optional,
|
|
64
|
+
});
|
|
65
|
+
if (v === null) {
|
|
66
|
+
if (f.optional) continue; // Esc on an optional field → skip it
|
|
67
|
+
return 'channel setup: cancelled';
|
|
68
|
+
}
|
|
69
|
+
if (v) answers[f.key] = v;
|
|
70
|
+
}
|
|
71
|
+
const path = await import('node:path');
|
|
72
|
+
const cfgDirX = ctx.cfgDir || path.dirname(cfgMod.configPath());
|
|
73
|
+
const entry = channelMod.persistChannel(cfgDirX, chName, answers);
|
|
74
|
+
// Mirror the PERSISTED enabled state onto the in-session cfg so a follow-up
|
|
75
|
+
// list is fresh — an in-tree channel whose runtime dep is missing stays
|
|
76
|
+
// disabled (persistChannel gates it) rather than being force-enabled.
|
|
77
|
+
if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: !!entry.ready }; }
|
|
78
|
+
const setKeys = Object.keys(answers);
|
|
79
|
+
let note = '';
|
|
80
|
+
if (!entry.ready) {
|
|
81
|
+
if (entry.missingDeps && entry.missingDeps.length) note += `\n(needs ${entry.missingDeps.join(', ')} — run: lazyclaw channels install ${chName})`;
|
|
82
|
+
if (entry.missingBinary) note += `\n(needs the ${entry.missingBinary} binary on your PATH)`;
|
|
83
|
+
}
|
|
84
|
+
return `✓ ${spec.label} credentials saved (${setKeys.join(', ') || 'none'}) → ${entry.ready ? 'channel enabled' : 'saved (enable once the requirement is installed)'}${note}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `/channels <name> test` — verify the stored credentials with a live call.
|
|
88
|
+
if (name && /^test$/i.test(action || '')) {
|
|
89
|
+
const channelMod = await import('../commands/setup_channels.mjs');
|
|
90
|
+
try { loadDotenvIfAny(ctx.cfgDir); } catch { /* best-effort */ }
|
|
91
|
+
const r = await channelMod.verifyChannel(name.toLowerCase());
|
|
92
|
+
if (r.ok === true) return `✓ ${name} verified — ${r.detail}`;
|
|
93
|
+
if (r.ok === null) return `· ${name}: ${r.detail}`;
|
|
94
|
+
return `✗ ${name}: ${r.detail}${r.hint ? `\n fix: ${r.hint}` : ''}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
|
|
98
|
+
const en = /^(on|enable)$/i.test(action);
|
|
99
|
+
const cfg = read();
|
|
100
|
+
const key = name.toLowerCase();
|
|
101
|
+
// Reject unknown names so a typo can't silently create a bogus
|
|
102
|
+
// cfg.channels.<name> section (which would then leak into the list).
|
|
103
|
+
// Stay permissive for pre-existing custom sections.
|
|
104
|
+
const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
105
|
+
if (!cf.KNOWN_CHANNELS.includes(key) && !(key in existing)) {
|
|
106
|
+
return `unknown channel: ${key} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
|
|
107
|
+
}
|
|
108
|
+
cf.channelSetEnabled(cfg, key, en); write(cfg);
|
|
109
|
+
// Legacy fallback path: the readline ctx (_legacyCtx) has no
|
|
110
|
+
// readConfig/writeConfig, so we read/wrote disk above against a fresh
|
|
111
|
+
// cfg object. Mirror the toggle onto the in-session ctx.cfg so a
|
|
112
|
+
// follow-up `/channels` (list) or other in-session read stays
|
|
113
|
+
// consistent instead of showing the stale pre-toggle value.
|
|
114
|
+
if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
|
|
115
|
+
cf.channelSetEnabled(ctx.cfg, key, en);
|
|
116
|
+
}
|
|
117
|
+
return en
|
|
118
|
+
? `channel ${key} → enabled`
|
|
119
|
+
: `channel ${key} → disabled (re-enable with /channels ${key} on)`;
|
|
120
|
+
}
|
|
121
|
+
// No-arg + modal → an action menu: each row toggles in place, plus a
|
|
122
|
+
// "set credentials" row. Falls through to the text list when no modal.
|
|
123
|
+
if (!toks.length && typeof ctx.openPicker === 'function') {
|
|
124
|
+
const statusRows = cf.channelStatusList(read());
|
|
125
|
+
const items = statusRows.map((c) => ({
|
|
126
|
+
id: `toggle:${c.name}`,
|
|
127
|
+
label: `${c.name} — ${c.enabled ? 'enabled' : 'disabled'}`,
|
|
128
|
+
desc: c.enabled ? 'Enter to disable' : 'Enter to enable',
|
|
129
|
+
}));
|
|
130
|
+
items.push({ id: 'setup', label: '+ Set credentials…', desc: 'pick a channel and enter bot token / homeserver / …' });
|
|
131
|
+
const picked = await ctx.openPicker({ kind: 'menu', title: 'Channels', subtitle: `${statusRows.length} configured`, items });
|
|
132
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
133
|
+
if (!pid || typeof pid !== 'string') return 'cancelled';
|
|
134
|
+
if (pid === 'setup') return _channels('setup', ctx);
|
|
135
|
+
if (pid.startsWith('toggle:')) {
|
|
136
|
+
const nm = pid.slice(7);
|
|
137
|
+
const cur = statusRows.find((r) => r.name === nm);
|
|
138
|
+
return _channels(`${nm} ${cur && cur.enabled ? 'off' : 'on'}`, ctx);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const rows = cf.channelStatusList(read());
|
|
142
|
+
if (!rows.length) return 'no channels configured. set credentials with /channels setup (or `lazyclaw setup` for the full wizard).';
|
|
143
|
+
// Cross-reference each channel's required env creds against the loaded env so
|
|
144
|
+
// the list flags a channel that's "enabled" but missing its token.
|
|
145
|
+
let channelMod = null;
|
|
146
|
+
try {
|
|
147
|
+
loadDotenvIfAny(ctx.cfgDir);
|
|
148
|
+
channelMod = await import('../commands/setup_channels.mjs');
|
|
149
|
+
} catch { /* hint is best-effort */ }
|
|
150
|
+
const missingFor = (name) => {
|
|
151
|
+
const spec = channelMod && channelMod.channelByName(name);
|
|
152
|
+
if (!spec) return [];
|
|
153
|
+
return spec.fields.filter((f) => !f.optional && !process.env[f.env]).map((f) => f.env);
|
|
154
|
+
};
|
|
155
|
+
const lines = ['configured channels:'];
|
|
156
|
+
for (const c of rows) {
|
|
157
|
+
const miss = missingFor(c.name);
|
|
158
|
+
const credNote = miss.length ? ` · creds: missing ${miss.join(', ')}` : '';
|
|
159
|
+
lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}${credNote}`);
|
|
160
|
+
}
|
|
161
|
+
lines.push('toggle: /channels <name> on|off · set creds: /channels <name> setup');
|
|
162
|
+
return lines.join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// /context — view/set the chat history window (turns + token budget). This is
|
|
166
|
+
// the sliding history budget sent each turn, NOT the model's hard context
|
|
167
|
+
// limit. ctx-or-lib/config fallback so it works on both REPL paths.
|
|
168
|
+
export async function _context(args, ctx = {}) {
|
|
169
|
+
const cf = await import('../config_features.mjs');
|
|
170
|
+
const cfgMod = await import('../lib/config.mjs');
|
|
171
|
+
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
172
|
+
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
173
|
+
const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
|
|
174
|
+
const parts = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
175
|
+
const sub = (parts[0] || 'status').toLowerCase();
|
|
176
|
+
const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
|
|
177
|
+
// No-arg + modal → action menu → numeric picker (mirrors orchestrator maxsubtasks).
|
|
178
|
+
if (!parts.length && typeof ctx.openPicker === 'function') {
|
|
179
|
+
const w = cf.chatWindowGet(read());
|
|
180
|
+
const action = await ctx.openPicker({
|
|
181
|
+
kind: 'menu', title: 'Context window (history budget)', subtitle: `now ${w.turns} turns · ${w.tokens} tokens`,
|
|
182
|
+
items: [
|
|
183
|
+
{ id: 'turns', label: 'Set turns…', desc: 'past turns to send' },
|
|
184
|
+
{ id: 'tokens', label: 'Set tokens…', desc: 'token budget (min 256)' },
|
|
185
|
+
{ id: 'status', label: 'Status', desc: 'show current' },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
const aid = action && typeof action === 'object' ? action.id : action;
|
|
189
|
+
if (!aid || typeof aid !== 'string' || aid === 'status') return fmt();
|
|
190
|
+
if (aid === 'turns') {
|
|
191
|
+
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) })) });
|
|
192
|
+
const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
|
|
193
|
+
if (!Number.isFinite(v)) return 'context turns: cancelled';
|
|
194
|
+
const cfg = read(); cf.chatWindowSet(cfg, { turns: v }); persist(cfg); return fmt();
|
|
195
|
+
}
|
|
196
|
+
if (aid === 'tokens') {
|
|
197
|
+
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) })) });
|
|
198
|
+
const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
|
|
199
|
+
if (!Number.isFinite(v) || v < 256) return 'context tokens: cancelled';
|
|
200
|
+
const cfg = read(); cf.chatWindowSet(cfg, { tokens: v }); persist(cfg); return fmt();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (sub === 'status') return fmt();
|
|
204
|
+
const n = parseInt(parts[1], 10);
|
|
205
|
+
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(); }
|
|
206
|
+
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(); }
|
|
207
|
+
return 'usage: /context [status | turns <N> | tokens <N>]';
|
|
208
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// tui/slash_dashboard.mjs — the /dashboard slash-command cluster, extracted
|
|
2
|
+
// verbatim from slash_dispatcher.mjs. Self-contained leaf with its own
|
|
3
|
+
// encapsulated module state (_dashboardSpawning / _dashboardChildPid). Imports
|
|
4
|
+
// only the shared leaf helpers, never the dispatcher (no cycle).
|
|
5
|
+
|
|
6
|
+
import { splitWhitespace } from './slash_helpers.mjs';
|
|
7
|
+
|
|
8
|
+
// /dashboard — open the lazyclaw web UI.
|
|
9
|
+
//
|
|
10
|
+
// v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
|
|
11
|
+
// session spawned 20+ daemon children).
|
|
12
|
+
//
|
|
13
|
+
// Original implementation:
|
|
14
|
+
// probe /healthz → if !200, spawn detached `lazyclaw dashboard
|
|
15
|
+
// --no-open` and poll for up to 3s.
|
|
16
|
+
//
|
|
17
|
+
// Failure mode that produced the 20+ spawn pile-up:
|
|
18
|
+
// 1. User types /dashboard. probe fails (no daemon). Spawn child A.
|
|
19
|
+
// 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
|
|
20
|
+
// 3. User types /dashboard again BEFORE A is ready. probe still fails.
|
|
21
|
+
// Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
|
|
22
|
+
// (cli.mjs:3611) which SIGTERMs child A. B takes over.
|
|
23
|
+
// 4. Repeat. Each /dashboard kills the previous daemon and starts a
|
|
24
|
+
// new one. With autorepeat / many slash calls this stacks fast.
|
|
25
|
+
//
|
|
26
|
+
// Two-layer guard:
|
|
27
|
+
// - A module-level _dashboardSpawning latch refuses concurrent spawn
|
|
28
|
+
// attempts. While a spawn is in flight, /dashboard says so + returns
|
|
29
|
+
// without firing another child.
|
|
30
|
+
// - A _dashboardChildPid cache remembers the PID we already spawned;
|
|
31
|
+
// subsequent calls check kill(pid, 0) to confirm the child is alive
|
|
32
|
+
// and just open the browser without spawning.
|
|
33
|
+
//
|
|
34
|
+
// We probe both /healthz (HTTP) AND a raw net.connect port check so a
|
|
35
|
+
// slow-starting daemon (binding the listener but not yet answering HTTP)
|
|
36
|
+
// still counts as "running".
|
|
37
|
+
let _dashboardSpawning = false;
|
|
38
|
+
let _dashboardChildPid = null;
|
|
39
|
+
|
|
40
|
+
function _portIsListening(port, timeoutMs = 200) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
import('node:net').then(({ createConnection }) => {
|
|
43
|
+
let settled = false;
|
|
44
|
+
const sock = createConnection({ host: '127.0.0.1', port });
|
|
45
|
+
const done = (ok) => {
|
|
46
|
+
if (settled) return;
|
|
47
|
+
settled = true;
|
|
48
|
+
try { sock.destroy(); } catch {}
|
|
49
|
+
resolve(ok);
|
|
50
|
+
};
|
|
51
|
+
sock.once('connect', () => done(true));
|
|
52
|
+
sock.once('error', () => done(false));
|
|
53
|
+
setTimeout(() => done(false), timeoutMs);
|
|
54
|
+
}).catch(() => resolve(false));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function _dashboardProbe(port) {
|
|
59
|
+
// Fast path — port-level probe. Catches a daemon that has bound the
|
|
60
|
+
// socket but hasn't finished initializing its HTTP routes.
|
|
61
|
+
if (await _portIsListening(port, 200)) return true;
|
|
62
|
+
// Slow path — full /healthz fetch, for defense in depth.
|
|
63
|
+
if (typeof fetch !== 'function') return false;
|
|
64
|
+
try {
|
|
65
|
+
const ac = new AbortController();
|
|
66
|
+
const t = setTimeout(() => ac.abort(), 250);
|
|
67
|
+
const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
|
|
68
|
+
clearTimeout(t);
|
|
69
|
+
return !!(r && r.ok);
|
|
70
|
+
} catch { return false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _openBrowser(url) {
|
|
74
|
+
return import('node:child_process').then(({ spawn }) => {
|
|
75
|
+
let cmd, args;
|
|
76
|
+
if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
|
|
77
|
+
else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
|
|
78
|
+
else { cmd = 'xdg-open'; args = [url]; }
|
|
79
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function _dashboardStop(port) {
|
|
84
|
+
// Best-effort kill of every lazyclaw dashboard daemon on the box.
|
|
85
|
+
// Used to clean up after the v5.4.3 spawn pile-up bug.
|
|
86
|
+
if (process.platform === 'win32') {
|
|
87
|
+
return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
|
|
88
|
+
}
|
|
89
|
+
const { spawn } = await import('node:child_process');
|
|
90
|
+
// Step 1: lsof the port and SIGTERM each PID.
|
|
91
|
+
const portPids = await new Promise((resolve) => {
|
|
92
|
+
try {
|
|
93
|
+
const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
94
|
+
let buf = '';
|
|
95
|
+
lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
|
|
96
|
+
lsof.on('error', () => resolve([]));
|
|
97
|
+
lsof.on('close', () => resolve(
|
|
98
|
+
buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
|
|
99
|
+
));
|
|
100
|
+
} catch { resolve([]); }
|
|
101
|
+
});
|
|
102
|
+
for (const pid of portPids) {
|
|
103
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
|
|
104
|
+
}
|
|
105
|
+
// Step 2: pkill any process whose command line includes "lazyclaw dashboard"
|
|
106
|
+
// — catches detached children that bound a different (random) port via
|
|
107
|
+
// cmdDashboard's EADDRINUSE fallback.
|
|
108
|
+
let pkilled = 0;
|
|
109
|
+
try {
|
|
110
|
+
const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
111
|
+
pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
|
|
112
|
+
} catch { /* fine */ }
|
|
113
|
+
_dashboardChildPid = null;
|
|
114
|
+
return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Parse the daemon's "listening at <url>" stdout line so /dashboard opens the
|
|
118
|
+
// actually-bound port (the child may fall back to a random port on EADDRINUSE).
|
|
119
|
+
export function parseDashboardUrl(text) {
|
|
120
|
+
const m = String(text || '').match(/listening at\s+(https?:\/\/\S+)/i);
|
|
121
|
+
return m ? m[1] : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Resolve the daemon's real URL from its stdout within `timeoutMs`, or null.
|
|
125
|
+
function _waitForDashboardUrl(child, timeoutMs) {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
if (!child || !child.stdout) { resolve(null); return; }
|
|
128
|
+
let buf = '';
|
|
129
|
+
let done = false;
|
|
130
|
+
const finish = (v) => {
|
|
131
|
+
if (done) return;
|
|
132
|
+
done = true;
|
|
133
|
+
try { child.stdout.off('data', onData); } catch { /* ignore */ }
|
|
134
|
+
resolve(v);
|
|
135
|
+
};
|
|
136
|
+
const onData = (d) => {
|
|
137
|
+
buf += d.toString('utf8');
|
|
138
|
+
const u = parseDashboardUrl(buf);
|
|
139
|
+
if (u) finish(u);
|
|
140
|
+
};
|
|
141
|
+
child.stdout.on('data', onData);
|
|
142
|
+
setTimeout(() => finish(null), timeoutMs);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function _dashboard(args) {
|
|
147
|
+
const port = 19600;
|
|
148
|
+
const url = `http://127.0.0.1:${port}/dashboard`;
|
|
149
|
+
// Under the node:test runner, never launch a real daemon or open a browser
|
|
150
|
+
// (it leaked a background daemon + opened a tab on every test run).
|
|
151
|
+
if (process.env.NODE_TEST_CONTEXT) return `dashboard: ${url} (spawn skipped under test)`;
|
|
152
|
+
const sub = splitWhitespace(args)[0];
|
|
153
|
+
if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
|
|
154
|
+
|
|
155
|
+
// 1. Already running anywhere on the machine? → reuse.
|
|
156
|
+
if (await _dashboardProbe(port)) {
|
|
157
|
+
await _openBrowser(url);
|
|
158
|
+
return `✓ dashboard already running — opened ${url}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 2. We spawned in this chat — is that child still alive?
|
|
162
|
+
if (_dashboardChildPid != null) {
|
|
163
|
+
try {
|
|
164
|
+
process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
|
|
165
|
+
// Child alive but not answering yet. Don't re-spawn; just nudge.
|
|
166
|
+
await _openBrowser(url);
|
|
167
|
+
return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
|
|
168
|
+
} catch {
|
|
169
|
+
_dashboardChildPid = null; // child died; fall through and respawn.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
|
|
174
|
+
if (_dashboardSpawning) {
|
|
175
|
+
await _openBrowser(url);
|
|
176
|
+
return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
|
|
180
|
+
// spawn flag in a finally so it always clears.
|
|
181
|
+
_dashboardSpawning = true;
|
|
182
|
+
try {
|
|
183
|
+
const { spawn } = await import('node:child_process');
|
|
184
|
+
let child;
|
|
185
|
+
try {
|
|
186
|
+
// Pass --port so the child tries 19600 first; pipe stdout so we can read
|
|
187
|
+
// the real bound URL (it may fall back to a random port on EADDRINUSE).
|
|
188
|
+
child = spawn(process.execPath, [process.argv[1], 'dashboard', '--port', String(port), '--no-open'], {
|
|
189
|
+
detached: true, stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd(), env: process.env,
|
|
190
|
+
});
|
|
191
|
+
_dashboardChildPid = child.pid;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return `dashboard error: failed to spawn — ${e?.message || e}`;
|
|
194
|
+
}
|
|
195
|
+
// Prefer the daemon's own "listening at <url>" line — it carries the
|
|
196
|
+
// actual port even after a random-port fallback.
|
|
197
|
+
const boundUrl = await _waitForDashboardUrl(child, 3000);
|
|
198
|
+
// Release the captured stdout pipe so the detached daemon doesn't keep
|
|
199
|
+
// OUR event loop alive (unref, not destroy — destroying would EPIPE the
|
|
200
|
+
// daemon on its next stdout write). Then unref the child itself.
|
|
201
|
+
try { if (child.stdout) { child.stdout.removeAllListeners('data'); child.stdout.unref(); } } catch { /* ignore */ }
|
|
202
|
+
child.unref();
|
|
203
|
+
if (boundUrl) {
|
|
204
|
+
await _openBrowser(boundUrl);
|
|
205
|
+
return `✓ started dashboard (pid ${child.pid}) — opened ${boundUrl}`;
|
|
206
|
+
}
|
|
207
|
+
// Fallback: the line never arrived — poll the default port best-effort.
|
|
208
|
+
const start = Date.now();
|
|
209
|
+
while (Date.now() - start < 1500) {
|
|
210
|
+
if (await _dashboardProbe(port)) {
|
|
211
|
+
await _openBrowser(url);
|
|
212
|
+
return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
|
|
213
|
+
}
|
|
214
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
215
|
+
}
|
|
216
|
+
return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
|
|
217
|
+
} finally {
|
|
218
|
+
_dashboardSpawning = false;
|
|
219
|
+
}
|
|
220
|
+
}
|