lazyclaw 6.0.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.ko.md +88 -25
  2. package/README.md +121 -190
  3. package/channels/handoff.mjs +18 -5
  4. package/channels/matrix.mjs +23 -5
  5. package/channels/slack.mjs +83 -50
  6. package/channels/slack_env.mjs +45 -0
  7. package/channels/telegram.mjs +49 -6
  8. package/cli.mjs +10 -21
  9. package/commands/channels.mjs +106 -107
  10. package/commands/chat.mjs +81 -45
  11. package/commands/daemon.mjs +15 -0
  12. package/commands/gateway.mjs +194 -0
  13. package/commands/providers.mjs +17 -38
  14. package/commands/service.mjs +113 -0
  15. package/commands/setup.mjs +55 -54
  16. package/commands/setup_channels.mjs +207 -0
  17. package/config_features.mjs +106 -0
  18. package/daemon/lib/inbound_dedup.mjs +108 -0
  19. package/daemon/lib/learn_queue.mjs +46 -0
  20. package/daemon/routes/_deps.mjs +6 -0
  21. package/daemon/routes/conversation.mjs +68 -4
  22. package/daemon/routes/ops.mjs +9 -29
  23. package/dotenv_min.mjs +28 -0
  24. package/first_run.mjs +9 -0
  25. package/lib/gateway_guard.mjs +100 -0
  26. package/lib/inbound_client.mjs +108 -0
  27. package/lib/service_install.mjs +207 -0
  28. package/package.json +2 -3
  29. package/providers/codex_cli.mjs +17 -1
  30. package/providers/compat_vendors.mjs +161 -0
  31. package/providers/gemini_cli.mjs +19 -3
  32. package/providers/model_catalogue.mjs +142 -9
  33. package/providers/probe.mjs +28 -0
  34. package/providers/registry.mjs +31 -162
  35. package/tui/editor.mjs +18 -2
  36. package/tui/pickers.mjs +3 -3
  37. package/tui/provider_families.mjs +8 -6
  38. package/tui/repl.mjs +25 -4
  39. package/tui/slash_commands.mjs +4 -0
  40. package/tui/slash_dispatcher.mjs +118 -0
  41. package/tui/splash_props.mjs +52 -0
  42. package/web/dashboard.css +6 -12
  43. package/web/dashboard.html +2 -34
  44. package/web/dashboard.js +3 -3
@@ -1,12 +1,32 @@
1
1
  // Slack / Telegram / Matrix listener commands, extracted from cli.mjs (D3).
2
2
  import path from 'node:path';
3
- import { configPath, readConfig, _resolveAuthKey } from '../lib/config.mjs';
4
- import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
3
+ import { configPath, readConfig, writeConfig } from '../lib/config.mjs';
4
+ import { ensureRegistry } from '../lib/registry_boot.mjs';
5
5
  import { loadDotenvIfAny as _loadDotenvShared } from '../dotenv_min.mjs';
6
+ import { channelStatusList, channelSetEnabled, KNOWN_CHANNELS } from '../config_features.mjs';
7
+ import { assertUnattendedSafe, installCrashHandlers } from '../lib/gateway_guard.mjs';
8
+ import { makeInboundHandler } from '../lib/inbound_client.mjs';
6
9
 
7
10
  // Thin .env loader wrapper kept local so the module stays self-contained.
8
11
  export function _loadDotenvIfAny(cfgDir) { return _loadDotenvShared(cfgDir); }
9
12
 
13
+ // Fail closed before opening a listener socket: refuse to expose a remote
14
+ // inbound surface while the global unattended-sensitive tool override is on.
15
+ function _bootGuard(cfg, surface) {
16
+ try { assertUnattendedSafe(cfg, { surface }); }
17
+ catch (e) { console.error(e.message); process.exit(2); }
18
+ }
19
+
20
+ // Resolve the daemon the listener forwards to. Defaults to the dashboard/
21
+ // daemon loopback port; override with --daemon-url or LAZYCLAW_DAEMON_URL.
22
+ const DEFAULT_DAEMON_URL = 'http://127.0.0.1:19600';
23
+ function _daemonTarget(flags) {
24
+ return {
25
+ daemonUrl: flags['daemon-url'] || process.env.LAZYCLAW_DAEMON_URL || DEFAULT_DAEMON_URL,
26
+ daemonToken: flags['auth-token'] || process.env.LAZYCLAW_AUTH_TOKEN || null,
27
+ };
28
+ }
29
+
10
30
  export async function cmdSlack(sub, positional, flags = {}) {
11
31
  if (sub !== 'listen') {
12
32
  console.error('Usage: lazyclaw slack listen [--provider X] [--model Y]');
@@ -14,73 +34,36 @@ export async function cmdSlack(sub, positional, flags = {}) {
14
34
  }
15
35
  await ensureRegistry();
16
36
  const cfg = readConfig();
37
+ _bootGuard(cfg, 'slack');
17
38
  const cfgDir = path.dirname(configPath());
18
39
 
19
40
  const envInfo = _loadDotenvIfAny(cfgDir);
20
41
  process.stderr.write(`[slack] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
21
42
 
22
- const provName = flags.provider || cfg.provider || 'mock';
23
- const prov = getRegistry().PROVIDERS[provName];
24
- if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
25
- const model = flags.model || cfg.model;
26
-
27
- // Per-thread rolling chat history so multi-turn coherence works
28
- // without committing to on-disk sessions. Capped at MAX_TURNS to
29
- // bound the prompt size.
30
- const threadMsgs = new Map();
31
- const MAX_TURNS = 20;
32
-
33
- const handler = async ({ threadId, text }) => {
34
- const cleaned = String(text || '').replace(/<@[A-Z0-9]+>/g, '').trim();
35
- // Phase 19.2: never post a placeholder ("(empty message)" / "(empty
36
- // reply)") into the thread — those leaked through as visible noise
37
- // when listener self-message echoes happened. Return null and let
38
- // _simulateInbound's guard drop the send. Real provider errors
39
- // still surface so the operator knows something went wrong.
40
- if (!cleaned) {
41
- process.stderr.write('[slack] dropping empty inbound (after mention strip)\n');
42
- return null;
43
- }
44
- const msgs = threadMsgs.get(threadId) || [];
45
- msgs.push({ role: 'user', content: cleaned });
46
- let acc = '';
47
- try {
48
- for await (const chunk of prov.sendMessage(msgs, {
49
- apiKey: _resolveAuthKey(cfg, provName),
50
- model,
51
- })) acc += chunk;
52
- } catch (err) {
53
- msgs.pop();
54
- const why = err?.message || String(err);
55
- process.stderr.write(`[slack] provider error: ${why}\n`);
56
- return `(provider error: ${why})`;
57
- }
58
- msgs.push({ role: 'assistant', content: acc });
59
- if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
60
- threadMsgs.set(threadId, msgs);
61
- if (!acc.trim()) {
62
- process.stderr.write('[slack] provider returned empty text — not posting\n');
63
- return null;
64
- }
65
- return acc;
66
- };
43
+ const { daemonUrl, daemonToken } = _daemonTarget(flags);
44
+ // Bridge every inbound through the daemon's session-bearing /inbound so
45
+ // chat + dashboard + all channels share one session/memory (single agent).
46
+ // Slack captures the sender id (event.user) and forwards it, so a configured
47
+ // pairing allowlist gates Slack the same as telegram/matrix.
48
+ const handler = makeInboundHandler({ channel: 'slack', daemonUrl, daemonToken, provider: flags.provider, model: flags.model });
67
49
 
68
50
  const { SlackChannel } = await import('../channels/slack.mjs');
69
51
  const ch = new SlackChannel();
70
- process.stderr.write(`[slack] provider=${provName} model=${model || '(default)'}\n`);
52
+ process.stderr.write(`[slack] bridging to daemon ${daemonUrl}${flags.provider ? ` (provider=${flags.provider})` : ''}\n`);
71
53
  try {
72
54
  await ch.start(handler);
73
55
  await ch._connectSocketMode({ logger: (line) => process.stderr.write(line) });
74
56
  } catch (err) {
75
57
  if (err?.code === 'SLACK_MISSING_ENV') {
76
58
  console.error(`slack: missing env vars: ${(err.missing || []).join(', ')}`);
77
- console.error(`hint: set them in ${path.join(cfgDir, '.env')} (uncomment SLACK_APP_TOKEN / SLACK_SIGNING_SECRET)`);
59
+ console.error(`hint: set SLACK_APP_TOKEN (xapp-…, Socket Mode) in ${path.join(cfgDir, '.env')}`);
78
60
  } else {
79
61
  console.error(`slack: ${err?.message || err}`);
80
62
  }
81
63
  process.exit(2);
82
64
  }
83
65
  process.stderr.write(`[slack] listening. Ctrl-C to stop.\n`);
66
+ installCrashHandlers({ label: 'slack', stop: () => ch.stop() });
84
67
 
85
68
  await new Promise((resolve) => {
86
69
  const onSig = async () => {
@@ -106,41 +89,17 @@ export async function cmdTelegram(sub, positional, flags = {}) {
106
89
  }
107
90
  await ensureRegistry();
108
91
  const cfg = readConfig();
92
+ _bootGuard(cfg, 'telegram');
109
93
  const cfgDir = path.dirname(configPath());
110
94
 
111
95
  const envInfo = _loadDotenvIfAny(cfgDir);
112
96
  process.stderr.write(`[telegram] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
113
97
 
114
- const provName = flags.provider || cfg.provider || 'mock';
115
- const prov = getRegistry().PROVIDERS[provName];
116
- if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
117
- const model = flags.model || cfg.model;
118
-
119
- const threadMsgs = new Map();
120
- const MAX_TURNS = 20;
121
-
122
- const handler = async ({ threadId, text }) => {
123
- const cleaned = String(text || '').trim();
124
- if (!cleaned) { process.stderr.write('[telegram] dropping empty inbound\n'); return null; }
125
- const msgs = threadMsgs.get(threadId) || [];
126
- msgs.push({ role: 'user', content: cleaned });
127
- let acc = '';
128
- try {
129
- for await (const chunk of prov.sendMessage(msgs, { apiKey: _resolveAuthKey(cfg, provName), model })) acc += chunk;
130
- } catch (err) {
131
- msgs.pop();
132
- const why = err?.message || String(err);
133
- process.stderr.write(`[telegram] provider error: ${why}\n`);
134
- return `(provider error: ${why})`;
135
- }
136
- msgs.push({ role: 'assistant', content: acc });
137
- if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
138
- threadMsgs.set(threadId, msgs);
139
- if (!acc.trim()) { process.stderr.write('[telegram] provider returned empty text — not posting\n'); return null; }
140
- return acc;
141
- };
98
+ const { daemonUrl, daemonToken } = _daemonTarget(flags);
99
+ const handler = makeInboundHandler({ channel: 'telegram', daemonUrl, daemonToken, provider: flags.provider, model: flags.model });
142
100
 
143
- // The pairing allowlist doubles as the Telegram sender allowlist.
101
+ // The pairing allowlist doubles as the Telegram sender allowlist (a
102
+ // channel-level gate complementary to the daemon's /inbound pairing check).
144
103
  const allowlist = (cfg.pairing || []).map((p) => String(p.id));
145
104
  const { TelegramChannel } = await import('../channels/telegram.mjs');
146
105
  let ch;
@@ -150,7 +109,7 @@ export async function cmdTelegram(sub, positional, flags = {}) {
150
109
  console.error(`telegram: ${err?.message || err}`);
151
110
  process.exit(2);
152
111
  }
153
- process.stderr.write(`[telegram] provider=${provName} model=${model || '(default)'} allowlist=${allowlist.length || 'open'}\n`);
112
+ process.stderr.write(`[telegram] bridging to daemon ${daemonUrl} allowlist=${allowlist.length || 'open'}${flags.provider ? ` (provider=${flags.provider})` : ''}\n`);
154
113
  try {
155
114
  await ch.start(handler, { poll: true, logger: (line) => process.stderr.write(line) });
156
115
  } catch (err) {
@@ -163,6 +122,7 @@ export async function cmdTelegram(sub, positional, flags = {}) {
163
122
  process.exit(2);
164
123
  }
165
124
  process.stderr.write(`[telegram] listening. Ctrl-C to stop.\n`);
125
+ installCrashHandlers({ label: 'telegram', stop: () => ch.stop() });
166
126
 
167
127
  await new Promise((resolve) => {
168
128
  const onSig = async () => {
@@ -185,38 +145,14 @@ export async function cmdMatrix(sub, positional, flags = {}) {
185
145
  }
186
146
  await ensureRegistry();
187
147
  const cfg = readConfig();
148
+ _bootGuard(cfg, 'matrix');
188
149
  const cfgDir = path.dirname(configPath());
189
150
 
190
151
  const envInfo = _loadDotenvIfAny(cfgDir);
191
152
  process.stderr.write(`[matrix] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
192
153
 
193
- const provName = flags.provider || cfg.provider || 'mock';
194
- const prov = getRegistry().PROVIDERS[provName];
195
- if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
196
- const model = flags.model || cfg.model;
197
-
198
- const threadMsgs = new Map();
199
- const MAX_TURNS = 20;
200
- const handler = async ({ threadId, text }) => {
201
- const cleaned = String(text || '').trim();
202
- if (!cleaned) { process.stderr.write('[matrix] dropping empty inbound\n'); return null; }
203
- const msgs = threadMsgs.get(threadId) || [];
204
- msgs.push({ role: 'user', content: cleaned });
205
- let acc = '';
206
- try {
207
- for await (const chunk of prov.sendMessage(msgs, { apiKey: _resolveAuthKey(cfg, provName), model })) acc += chunk;
208
- } catch (err) {
209
- msgs.pop();
210
- const why = err?.message || String(err);
211
- process.stderr.write(`[matrix] provider error: ${why}\n`);
212
- return `(provider error: ${why})`;
213
- }
214
- msgs.push({ role: 'assistant', content: acc });
215
- if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
216
- threadMsgs.set(threadId, msgs);
217
- if (!acc.trim()) { process.stderr.write('[matrix] provider returned empty text — not posting\n'); return null; }
218
- return acc;
219
- };
154
+ const { daemonUrl, daemonToken } = _daemonTarget(flags);
155
+ const handler = makeInboundHandler({ channel: 'matrix', daemonUrl, daemonToken, provider: flags.provider, model: flags.model });
220
156
 
221
157
  const allowlist = (cfg.pairing || []).map((p) => String(p.id));
222
158
  const { MatrixChannel } = await import('../channels/matrix.mjs');
@@ -227,7 +163,7 @@ export async function cmdMatrix(sub, positional, flags = {}) {
227
163
  console.error(`matrix: ${err?.message || err}`);
228
164
  process.exit(2);
229
165
  }
230
- process.stderr.write(`[matrix] provider=${provName} model=${model || '(default)'} allowlist=${allowlist.length || 'open'}\n`);
166
+ process.stderr.write(`[matrix] bridging to daemon ${daemonUrl} allowlist=${allowlist.length || 'open'}${flags.provider ? ` (provider=${flags.provider})` : ''}\n`);
231
167
  try {
232
168
  await ch.start(handler, { poll: true, logger: (line) => process.stderr.write(line) });
233
169
  } catch (err) {
@@ -240,6 +176,7 @@ export async function cmdMatrix(sub, positional, flags = {}) {
240
176
  process.exit(2);
241
177
  }
242
178
  process.stderr.write(`[matrix] listening. Ctrl-C to stop.\n`);
179
+ installCrashHandlers({ label: 'matrix', stop: () => ch.stop() });
243
180
 
244
181
  await new Promise((resolve) => {
245
182
  const onSig = async () => {
@@ -252,4 +189,66 @@ export async function cmdMatrix(sub, positional, flags = {}) {
252
189
  });
253
190
  }
254
191
 
192
+ // `lazyclaw channels [list|enable <name>|disable <name>|install <pkg>|remove <pkg>]`
193
+ // list — configured built-in channels (cfg.channels.<name>) + installed plugins.
194
+ // enable/disable — toggle cfg.channels.<name>.enabled (view/edit the setting).
195
+ // install/remove — manage @lazyclaw/channel-* plugin packages.
196
+ export async function cmdChannels(sub, positional = [], flags = {}) {
197
+ const cfgDir = path.dirname(configPath());
198
+ const { createLoader, listInstalled } = await import('../channels/loader.mjs');
199
+ const loader = createLoader({ configDir: cfgDir });
200
+
201
+ if (sub === 'install') {
202
+ const name = positional[0];
203
+ if (!name) { process.stderr.write('usage: lazyclaw channels install <@lazyclaw/channel-name>\n'); process.exit(2); }
204
+ const info = await loader.install(name);
205
+ process.stdout.write(`installed ${info.name}@${info.version}\n`);
206
+ return;
207
+ }
208
+ if (sub === 'remove' || sub === 'uninstall') {
209
+ const name = positional[0];
210
+ if (!name) { process.stderr.write('usage: lazyclaw channels remove <@lazyclaw/channel-name>\n'); process.exit(2); }
211
+ await loader.remove(name);
212
+ process.stdout.write(`removed ${name}\n`);
213
+ return;
214
+ }
215
+ if (sub === 'enable' || sub === 'disable') {
216
+ const name = (positional[0] || '').toLowerCase();
217
+ if (!name) { process.stderr.write(`usage: lazyclaw channels ${sub} <name>\n`); process.exit(2); }
218
+ const cfg = readConfig();
219
+ // Reject unknown names so a typo can't silently create a bogus
220
+ // cfg.channels.<name> section that then leaks into the list view.
221
+ // Stay permissive for pre-existing custom sections.
222
+ const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
223
+ if (!KNOWN_CHANNELS.includes(name) && !(name in existing)) {
224
+ process.stderr.write(`unknown channel: ${name} (known: ${KNOWN_CHANNELS.join(', ')})\n`);
225
+ process.exit(2);
226
+ }
227
+ channelSetEnabled(cfg, name, sub === 'enable');
228
+ writeConfig(cfg);
229
+ process.stdout.write(JSON.stringify({ ok: true, channel: name, enabled: sub === 'enable' }) + '\n');
230
+ return;
231
+ }
232
+
233
+ // list (default): configured built-in channels + installed plugins.
234
+ const cfg = readConfig();
235
+ const configured = channelStatusList(cfg);
236
+ const plugins = listInstalled(cfgDir);
237
+ if (flags.json) {
238
+ process.stdout.write(JSON.stringify({ configured, plugins }, null, 2) + '\n');
239
+ return;
240
+ }
241
+ process.stdout.write('configured channels:\n');
242
+ if (configured.length === 0) {
243
+ process.stdout.write(' (none — run `lazyclaw setup` or `/config` in chat to add one)\n');
244
+ } else {
245
+ for (const ch of configured) {
246
+ const agent = ch.boundAgent ? ` · agent: ${ch.boundAgent}` : '';
247
+ process.stdout.write(` ${ch.name}\t${ch.enabled ? 'enabled' : 'disabled'}${agent}\n`);
248
+ }
249
+ }
250
+ process.stdout.write(`plugins: ${plugins.length ? plugins.map((p) => `${p.name}@${p.version}`).join(', ') : '(none installed)'}\n`);
251
+ process.stdout.write('\ntoggle: lazyclaw channels <enable|disable> <name> · add creds: lazyclaw setup\n');
252
+ }
253
+
255
254
 
package/commands/chat.mjs CHANGED
@@ -15,12 +15,38 @@ import {
15
15
  _pickModelInteractive, _pickProviderInteractive, _printChatBanner,
16
16
  _quickPrompt, _renderBanner, _renderV5Banner,
17
17
  } from '../tui/pickers.mjs';
18
- import { firstRunMode as _firstRunMode } from '../first_run.mjs';
18
+ import { firstRunMode as _firstRunMode, hasConfiguredProvider } from '../first_run.mjs';
19
19
  import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
20
20
  import { makeRunTurn as _chatRunTurnFactory } from '../tui/run_turn.mjs';
21
21
  import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } from '../tui/slash_dispatcher.mjs';
22
22
  import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
23
23
 
24
+ // Legacy (non-Ink) slash routing for dispatcher-style, ctx-only commands.
25
+ // The Ink REPL routes every slash through _dispatchSlash/SLASH_HANDLERS, but
26
+ // the legacy readline path uses a hand-written switch (in cmdChat). Commands
27
+ // like /config only mutate ctx and return a sentinel, so we keep that wiring
28
+ // in one exported helper that BOTH the legacy switch and the regression test
29
+ // drive — this is the seam that proves /config reaches setup on the legacy
30
+ // path (the post-loop guard re-runs cmdSetup when ctx.requestSetup is set).
31
+ // Returns 'EXIT' when the loop must break, or undefined when the command is
32
+ // not one this helper owns (caller falls through to its own handling).
33
+ export function legacySlashRoute(cmd, ctx) {
34
+ switch (cmd) {
35
+ case '/config':
36
+ // Mirror tui/slash_dispatcher.mjs '/config': signal the host, unmount.
37
+ ctx.requestSetup = true;
38
+ return 'EXIT';
39
+ default:
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ // Dispatcher commands the legacy readline path's default branch may delegate to
45
+ // _dispatchSlash. Kept to ctx-safe handlers only (no _inkCtx-only setters /
46
+ // openPicker / version), so legacy doesn't silently degrade. /channels has a
47
+ // lib/config fallback so it's safe; add others only after confirming ctx-safety.
48
+ const LEGACY_DELEGATED_SLASHES = new Set(['/channels', '/orchestrator', '/context']);
49
+
24
50
  export async function cmdChat(flags = {}) {
25
51
  await ensureRegistry();
26
52
  const sessionsMod = await import('../sessions.mjs');
@@ -37,7 +63,7 @@ export async function cmdChat(flags = {}) {
37
63
  // gets the full 5-step guided setup (provider+model, workspace, skills) —
38
64
  // not just the provider picker. `chat --pick` stays a lightweight re-pick.
39
65
  const _mode = _firstRunMode({
40
- hasProvider: !!activeProvName,
66
+ hasProvider: hasConfiguredProvider(activeProvName),
41
67
  flagPick: !!flags.pick,
42
68
  isTTY: !!process.stdin.isTTY,
43
69
  });
@@ -82,38 +108,11 @@ export async function cmdChat(flags = {}) {
82
108
  // narrow-terminal fallback: <60 cols falls back to v4
83
109
  if ((process.stdout.columns || 80) < 60) throw new Error('narrow-terminal');
84
110
 
85
- // Tool groups read the v5 registry and collapse to one row per category.
86
- let toolGroups = [];
87
- try {
88
- const registry = await import('../mas/tools/registry.mjs');
89
- const byCat = registry.byCategory();
90
- toolGroups = Object.entries(byCat).map(([category, items]) => ({
91
- category,
92
- sensitive: items.some(t => t.sensitive),
93
- verbs: items.map(t => t.name.replace(/^[a-z]+_/, '')).slice(0, 6),
94
- })).sort((a, b) => a.category.localeCompare(b.category));
95
- } catch { /* registry unavailable → empty list */ }
96
-
97
- // Skill groups — group installed skills by filename hyphen-prefix
98
- // (canonical C5 fallback: <group>-<name>.md → group; bare names → 'general').
99
- let skillGroups = [];
100
- try {
101
- const { listSkills } = await import('../skills.mjs');
102
- // Use the resolved config dir, not the module default, so a
103
- // LAZYCLAW_CONFIG_DIR override surfaces that install's skills.
104
- const flat = listSkills(path.dirname(configPath()));
105
- const byGroup = new Map();
106
- for (const s of flat) {
107
- const i = s.name.indexOf('-');
108
- const group = i > 0 ? s.name.slice(0, i) : 'general';
109
- const sub = i > 0 ? s.name.slice(i + 1) : s.name;
110
- if (!byGroup.has(group)) byGroup.set(group, []);
111
- byGroup.get(group).push(sub);
112
- }
113
- skillGroups = [...byGroup.entries()]
114
- .map(([group, names]) => ({ group, names: names.slice(0, 6) }))
115
- .sort((a, b) => a.group.localeCompare(b.group));
116
- } catch { /* skills dir unavailable → empty list */ }
111
+ // Tool + skill groups for the splash panel shared with the setup
112
+ // wizard via tui/splash_props.mjs so both surfaces render the same panel.
113
+ const { gatherToolAndSkillGroups } = await import('../tui/splash_props.mjs');
114
+ const { tools: toolGroups, skills: skillGroups } =
115
+ await gatherToolAndSkillGroups(path.dirname(configPath()));
117
116
 
118
117
  const splashProps = {
119
118
  provider: activeProvName, model: activeModel,
@@ -255,14 +254,14 @@ export async function cmdChat(flags = {}) {
255
254
  ? api.openPicker(opts)
256
255
  : Promise.resolve(null);
257
256
  };
258
- // v5.0.10: write streamed chunks straight to process.stdout. Ink
259
- // owns the screen, so interleaved stdout writes can produce some
260
- // visual jank accepted trade for unblocking the chat loop. v5.1
261
- // TODO: route through a ref'd scrollback <Static/> region in
262
- // ReplApp so Ink owns all output.
263
- const _inkRunTurn = _chatRunTurnFactory({
257
+ // Route streamed chunks through ReplApp's injected writeFn: chunks
258
+ // land in React state (liveAssistant) live region committed to
259
+ // the <Static/> scrollback on turn-complete, so Ink owns all output.
260
+ // Writing straight to process.stdout (the old v5.1 hack) put replies
261
+ // in Ink's live frame, which the next render erased — replies vanished.
262
+ const _inkRunTurnFactory = (writeFn) => _chatRunTurnFactory({
264
263
  ctx: _inkCtx,
265
- writeFn: (chunk) => process.stdout.write(chunk),
264
+ writeFn,
266
265
  });
267
266
  // v5.4: full slash-command dispatch via tui/slash_dispatcher.mjs.
268
267
  // Dispatcher returns a string (rendered to scrollback by ReplApp),
@@ -291,13 +290,16 @@ export async function cmdChat(flags = {}) {
291
290
  provider: activeProvName,
292
291
  model: activeModel,
293
292
  ctxUsed: _inkRunningUsage ? _inkRunningUsage.totalTokens : undefined,
294
- ctxTotal: CHAT_WINDOW_TOKEN_BUDGET,
293
+ ctxTotal: Number((cfg.chat || {}).windowTokens) || CHAT_WINDOW_TOKEN_BUDGET,
295
294
  }),
296
- runTurn: _inkRunTurn,
295
+ runTurnFactory: _inkRunTurnFactory,
297
296
  onSlashCommand: _inkSlashHandler,
298
297
  pickerRef: _inkPickerRef,
299
298
  }), { exitOnCtrlC: true, patchConsole: true });
300
299
  await ink.waitUntilExit();
300
+ // /config asks to (re)run the wizard: now that Ink has released stdin,
301
+ // run setup, then return to the shell (re-launch `lazyclaw` to chat).
302
+ if (_inkCtx.requestSetup) await (await import('./setup.mjs')).cmdSetup(undefined, [], {});
301
303
  return;
302
304
  } catch (e) {
303
305
  // Fall through to legacy path on any ink failure (missing import,
@@ -576,7 +578,15 @@ export async function cmdChat(flags = {}) {
576
578
  return true;
577
579
  }
578
580
  case '/new':
579
- case '/reset': {
581
+ case '/reset':
582
+ case '/clear': {
583
+ // /clear is dispatcher-only (no explicit legacy case before this),
584
+ // so without it /clear fell through to `default:` → _dispatchSlash →
585
+ // _newReset, which clears via ctx.set* setters that _legacyCtx does
586
+ // NOT expose — returning 'cleared' while the closure's messages/
587
+ // charsSent/runningUsage stayed intact (a lying no-op). Alias /clear
588
+ // to the /new+/reset direct-mutation body, matching the dispatcher's
589
+ // /clear → /new/reset session-reset aliasing.
580
590
  messages = [];
581
591
  charsSent = 0;
582
592
  runningUsage = null;
@@ -1100,9 +1110,33 @@ export async function cmdChat(flags = {}) {
1100
1110
  } catch { /* /exit must never hang or throw */ }
1101
1111
  return 'EXIT';
1102
1112
  }
1103
- default:
1113
+ case '/config': {
1114
+ // Route through the shared legacySlashRoute helper (covered by
1115
+ // tests/f-config-slash-splash.test.mjs) so the legacy path's /config
1116
+ // wiring is the exact code the regression test exercises. It sets
1117
+ // _legacyCtx.requestSetup and returns 'EXIT'; the post-loop guard at the
1118
+ // bottom of cmdChat re-runs the setup wizard when requestSetup is set.
1119
+ // Without this case the legacy readline path fell through to `default:`
1120
+ // and printed "unknown slash" instead of launching setup.
1121
+ return legacySlashRoute(cmd, _legacyCtx);
1122
+ }
1123
+ default: {
1124
+ // Delegate ONLY ctx-safe dispatcher commands to the shared handler.
1125
+ // _legacyCtx is intentionally thin (no setters / openPicker / version),
1126
+ // so a blanket delegation would silently degrade picker- or setter-
1127
+ // driven handlers (/menu, /clear, /version, …). /channels is ctx-safe
1128
+ // (it has a lib/config fallback); everything else stays an honest
1129
+ // "unknown slash". 'EXIT' breaks the loop; a returned string prints.
1130
+ if (LEGACY_DELEGATED_SLASHES.has(cmd)) {
1131
+ const { args } = _parseSlashLine(line);
1132
+ const r = await _dispatchSlash(cmd, args, _legacyCtx, (s) => process.stdout.write(s));
1133
+ if (r === 'EXIT') return 'EXIT';
1134
+ if (typeof r === 'string' && r.length) process.stdout.write(r + '\n');
1135
+ return true;
1136
+ }
1104
1137
  process.stdout.write(`unknown slash: ${cmd} (try /help)\n`);
1105
1138
  return true;
1139
+ }
1106
1140
  }
1107
1141
  };
1108
1142
 
@@ -1176,6 +1210,8 @@ export async function cmdChat(flags = {}) {
1176
1210
  try { process.stdin.pause(); } catch (_) {}
1177
1211
  try { process.stdin.unref(); } catch (_) {}
1178
1212
  }
1213
+ // /config in the legacy path: re-run the wizard after the readline loop closes.
1214
+ if (_legacyCtx.requestSetup) await (await import('./setup.mjs')).cmdSetup(undefined, [], {});
1179
1215
  }
1180
1216
 
1181
1217
  // Light wrapper around the daemon — meant for users who installed
@@ -3,6 +3,15 @@
3
3
  import path from 'node:path';
4
4
  import { ensureRegistry } from '../lib/registry_boot.mjs';
5
5
  import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
6
+ import { assertUnattendedSafe, installCrashHandlers } from '../lib/gateway_guard.mjs';
7
+
8
+ // Fail closed before binding the HTTP surface: the daemon/dashboard serve
9
+ // POST /inbound + /agent, so the global unattended-sensitive override must
10
+ // not be on while they are exposed.
11
+ function _bootGuard(surface) {
12
+ try { assertUnattendedSafe(readConfig(), { surface }); }
13
+ catch (e) { console.error(e.message); process.exit(2); }
14
+ }
6
15
 
7
16
  export async function _killPortOccupant(port) {
8
17
  if (process.platform === 'win32') return false;
@@ -35,6 +44,7 @@ export async function _killPortOccupant(port) {
35
44
 
36
45
  export async function cmdDashboard(flags = {}) {
37
46
  await ensureRegistry();
47
+ _bootGuard('dashboard');
38
48
  const sessionsMod = await import('../sessions.mjs');
39
49
  const { startDaemon } = await import('../daemon.mjs');
40
50
  const port = flags.port !== undefined ? parseInt(flags.port, 10) : 19600;
@@ -110,6 +120,7 @@ export async function cmdDashboard(flags = {}) {
110
120
  // Forward SIGINT/SIGTERM to a graceful shutdown so Ctrl-C doesn't
111
121
  // strand a port-bound server. Same shape cmdDaemon uses.
112
122
  const { gracefulShutdown } = await import('../daemon.mjs');
123
+ installCrashHandlers({ label: 'dashboard', stop: () => gracefulShutdown(d.server, 5_000) });
113
124
  let shuttingDown = false;
114
125
  const shutdown = async () => {
115
126
  if (shuttingDown) return process.exit(1);
@@ -124,6 +135,7 @@ export async function cmdDashboard(flags = {}) {
124
135
 
125
136
  export async function cmdDaemon(flags) {
126
137
  await ensureRegistry();
138
+ _bootGuard('daemon');
127
139
  const sessionsMod = await import('../sessions.mjs');
128
140
  const { startDaemon } = await import('../daemon.mjs');
129
141
  const port = flags.port !== undefined ? parseInt(flags.port, 10) : 0;
@@ -239,6 +251,9 @@ export async function cmdDaemon(flags) {
239
251
  // mean it" signal.
240
252
  const { gracefulShutdown } = await import('../daemon.mjs');
241
253
  const timeoutMs = flags['shutdown-timeout-ms'] ? parseInt(flags['shutdown-timeout-ms'], 10) : 10_000;
254
+ // Always-on: make an unhandled crash observable + drain sockets, then
255
+ // exit non-zero so a service manager restarts us (vs. silent death).
256
+ installCrashHandlers({ label: 'daemon', logger, stop: () => gracefulShutdown(d.server, timeoutMs) });
242
257
  let shuttingDown = false;
243
258
  const shutdown = async () => {
244
259
  if (shuttingDown) {