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.
- package/README.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/cli.mjs +10 -21
- package/commands/channels.mjs +106 -107
- package/commands/chat.mjs +81 -45
- package/commands/daemon.mjs +15 -0
- package/commands/gateway.mjs +194 -0
- package/commands/providers.mjs +17 -38
- package/commands/service.mjs +113 -0
- package/commands/setup.mjs +55 -54
- package/commands/setup_channels.mjs +207 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/routes/_deps.mjs +6 -0
- package/daemon/routes/conversation.mjs +68 -4
- package/daemon/routes/ops.mjs +9 -29
- package/dotenv_min.mjs +28 -0
- package/first_run.mjs +9 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +2 -3
- package/providers/codex_cli.mjs +17 -1
- package/providers/compat_vendors.mjs +161 -0
- package/providers/gemini_cli.mjs +19 -3
- package/providers/model_catalogue.mjs +142 -9
- package/providers/probe.mjs +28 -0
- package/providers/registry.mjs +31 -162
- package/tui/editor.mjs +18 -2
- package/tui/pickers.mjs +3 -3
- package/tui/provider_families.mjs +8 -6
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
package/commands/channels.mjs
CHANGED
|
@@ -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,
|
|
4
|
-
import { ensureRegistry
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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]
|
|
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
|
|
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
|
|
115
|
-
const
|
|
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]
|
|
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
|
|
194
|
-
const
|
|
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]
|
|
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:
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/commands/daemon.mjs
CHANGED
|
@@ -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) {
|