lazyclaw 5.4.3 → 6.0.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/channels/handoff.mjs +36 -0
- package/cli.mjs +73 -7399
- package/daemon.mjs +23 -2085
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +3 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/sessions.mjs +0 -0
- package/tui/editor.mjs +44 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +8 -7
- package/tui/slash_dispatcher.mjs +923 -118
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- package/mas/tools/skill_view.mjs +0 -43
package/tui/slash_dispatcher.mjs
CHANGED
|
@@ -34,6 +34,14 @@
|
|
|
34
34
|
// LAZYCLAW_NO_INK=1. Re-implementing these as Ink overlays is a v5.5 item.
|
|
35
35
|
|
|
36
36
|
import { SLASH_COMMANDS } from './slash_commands.mjs';
|
|
37
|
+
import { supportsLiveFetch, fetchModelsForProvider } from '../providers/model_catalogue.mjs';
|
|
38
|
+
import { providerFamilies, providerTag } from './provider_families.mjs';
|
|
39
|
+
import { addCustomProvider } from '../providers/custom_provider.mjs';
|
|
40
|
+
import { setAuthKey } from '../providers/auth_store.mjs';
|
|
41
|
+
import { attachGoalCron, detachGoalCron } from '../goals_cron.mjs';
|
|
42
|
+
import { loadDotenvIfAny } from '../dotenv_min.mjs';
|
|
43
|
+
import { SUBCOMMAND_GROUPS } from './subcommands.mjs';
|
|
44
|
+
import { redactSecrets } from '../mas/redact.mjs';
|
|
37
45
|
|
|
38
46
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
|
39
47
|
|
|
@@ -55,6 +63,15 @@ function splitWhitespace(s) {
|
|
|
55
63
|
return (s || '').split(/\s+/).filter(Boolean);
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
// Parse a "provider[:model]" spec, preferring the registry's parser.
|
|
67
|
+
function _parseProvModel(registry, spec) {
|
|
68
|
+
if (registry && typeof registry.parseProviderModel === 'function') return registry.parseProviderModel(spec);
|
|
69
|
+
const s = String(spec || '');
|
|
70
|
+
const i = s.indexOf(':');
|
|
71
|
+
if (i < 0) return { provider: s || null, model: null };
|
|
72
|
+
return { provider: s.slice(0, i) || null, model: s.slice(i + 1) || null };
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
// Best-effort dynamic import. Returns the resolved ctx field if the caller
|
|
59
76
|
// pre-injected it (test hot path), else loads the real module. Throwing is
|
|
60
77
|
// fine — handlers wrap calls in try/catch where appropriate.
|
|
@@ -73,14 +90,19 @@ async function _help() {
|
|
|
73
90
|
|
|
74
91
|
async function _status(_args, ctx) {
|
|
75
92
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
const provider = ctx.getActiveProvName();
|
|
94
|
+
const model = ctx.getActiveModel() || '(default)';
|
|
95
|
+
const keyMasked = registry.maskApiKey(ctx.cfg && ctx.cfg['api-key']);
|
|
96
|
+
const messageCount = ctx.getMessages().length;
|
|
97
|
+
const sessionId = ctx.getSessionId() || '(none — in-memory)';
|
|
98
|
+
return [
|
|
99
|
+
'status:',
|
|
100
|
+
` provider: ${provider}`,
|
|
101
|
+
` model: ${model}`,
|
|
102
|
+
` api key: ${keyMasked}`,
|
|
103
|
+
` messages: ${messageCount}`,
|
|
104
|
+
` session: ${sessionId}`,
|
|
105
|
+
].join('\n');
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
async function _version(_args, ctx) {
|
|
@@ -91,22 +113,33 @@ async function _version(_args, ctx) {
|
|
|
91
113
|
async function _usage(_args, ctx) {
|
|
92
114
|
const msgs = ctx.getMessages();
|
|
93
115
|
const runningUsage = ctx.getRunningUsage && ctx.getRunningUsage();
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
const charsSent = (ctx.getCharsSent && ctx.getCharsSent()) || 0;
|
|
117
|
+
const lines = [
|
|
118
|
+
'usage:',
|
|
119
|
+
` messages: ${msgs.length}`,
|
|
120
|
+
` chars sent: ${charsSent.toLocaleString('en-US')}`,
|
|
121
|
+
];
|
|
122
|
+
if (runningUsage) {
|
|
123
|
+
lines.push(
|
|
124
|
+
` tokens in: ${(runningUsage.inputTokens || 0).toLocaleString('en-US')}`,
|
|
125
|
+
` tokens out: ${(runningUsage.outputTokens || 0).toLocaleString('en-US')}`,
|
|
126
|
+
` tokens tot: ${(runningUsage.totalTokens || 0).toLocaleString('en-US')}`,
|
|
127
|
+
` turns: ${runningUsage.turnsWithUsage || 0}`,
|
|
128
|
+
);
|
|
129
|
+
if (ctx.cfg && ctx.cfg.rates && typeof ctx.cfg.rates === 'object') {
|
|
130
|
+
try {
|
|
131
|
+
const { costFromUsage } = await import('../providers/rates.mjs');
|
|
132
|
+
const r = costFromUsage(
|
|
133
|
+
{ provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: runningUsage },
|
|
134
|
+
ctx.cfg.rates,
|
|
135
|
+
);
|
|
136
|
+
if (r && r.totalUsd != null) {
|
|
137
|
+
lines.push(` cost (USD): $${Number(r.totalUsd).toFixed(4)}`);
|
|
138
|
+
}
|
|
139
|
+
} catch { /* never let cost-card lookup fail the slash */ }
|
|
140
|
+
}
|
|
108
141
|
}
|
|
109
|
-
return
|
|
142
|
+
return lines.join('\n');
|
|
110
143
|
}
|
|
111
144
|
|
|
112
145
|
async function _newReset(_args, ctx) {
|
|
@@ -130,28 +163,198 @@ function _providerLookup(registry, name) {
|
|
|
130
163
|
return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
|
|
131
164
|
}
|
|
132
165
|
|
|
166
|
+
// Pick a provider via the family drill-in (API key / CLI-Local / Mock),
|
|
167
|
+
// mirroring the legacy readline wizard. Single-option steps auto-advance.
|
|
168
|
+
// orchestrator is never listed. Returns a provider id or null on cancel.
|
|
169
|
+
async function _pickProviderDrillIn(ctx, registry) {
|
|
170
|
+
const info = registry.PROVIDER_INFO || {};
|
|
171
|
+
const families = providerFamilies(registry);
|
|
172
|
+
const nonEmpty = Object.values(families).filter((f) => f.members.length > 0);
|
|
173
|
+
if (!nonEmpty.length) return null;
|
|
174
|
+
|
|
175
|
+
// ── Step 1 — auth family (skipped when only one is populated) ──
|
|
176
|
+
let family = nonEmpty[0];
|
|
177
|
+
if (nonEmpty.length > 1) {
|
|
178
|
+
const picked = await ctx.openPicker({
|
|
179
|
+
kind: 'provider-family',
|
|
180
|
+
title: 'select provider — how do you want to auth?',
|
|
181
|
+
subtitle: 'API: bring your own key · CLI/Local: use this machine · Mock: offline',
|
|
182
|
+
items: nonEmpty.map((f) => ({
|
|
183
|
+
id: f.id,
|
|
184
|
+
label: f.label,
|
|
185
|
+
desc: `${f.desc} · ${f.members.slice(0, 3).join(' / ')}${f.members.length > 3 ? ` (+${f.members.length - 3})` : ''}`,
|
|
186
|
+
tag: f.tag,
|
|
187
|
+
})),
|
|
188
|
+
});
|
|
189
|
+
if (!picked || typeof picked !== 'string') return null;
|
|
190
|
+
family = families[picked];
|
|
191
|
+
if (!family || !family.members.length) return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Step 2 — provider in that family (auto-advances on a single member) ──
|
|
195
|
+
// The API-key family also offers "+ add a custom endpoint" so NIM /
|
|
196
|
+
// OpenRouter / vLLM / etc. can be registered without leaving chat. The
|
|
197
|
+
// add-custom row is never auto-advanced past, so don't single-skip it in.
|
|
198
|
+
const items = family.members.map((id) => {
|
|
199
|
+
const meta = info[id] || {};
|
|
200
|
+
let desc = '';
|
|
201
|
+
if (meta.custom) desc = `custom · ${meta.baseUrl || ''}`;
|
|
202
|
+
else if (meta.builtinOpenAICompat) desc = meta.label || meta.baseUrl || '';
|
|
203
|
+
else if (meta.label && meta.label !== id) desc = meta.label;
|
|
204
|
+
return { id, label: id, desc, tag: providerTag(meta) };
|
|
205
|
+
});
|
|
206
|
+
if (family.id === 'api') {
|
|
207
|
+
items.push({
|
|
208
|
+
id: '__add_custom__',
|
|
209
|
+
label: '+ add a custom OpenAI-compatible endpoint…',
|
|
210
|
+
desc: 'NIM · OpenRouter · Together · Groq · vLLM · LM Studio',
|
|
211
|
+
tag: 'new',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (items.length === 1) return items[0].id;
|
|
215
|
+
const picked = await ctx.openPicker({
|
|
216
|
+
kind: 'provider',
|
|
217
|
+
title: `select provider — ${family.label}`,
|
|
218
|
+
subtitle: `current: ${ctx.getActiveProvName()}`,
|
|
219
|
+
items,
|
|
220
|
+
});
|
|
221
|
+
return typeof picked === 'string' ? picked : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Single free-text prompt reusing the modal's filter buffer (no dedicated
|
|
225
|
+
// input widget). Returns the typed value, '' (only when allowEmpty), or null
|
|
226
|
+
// on cancel / required-but-empty.
|
|
227
|
+
async function _promptText(ctx, { title, subtitle, allowEmpty } = {}) {
|
|
228
|
+
if (typeof ctx.openPicker !== 'function') return null;
|
|
229
|
+
const picked = await ctx.openPicker({
|
|
230
|
+
kind: 'text',
|
|
231
|
+
title,
|
|
232
|
+
subtitle: subtitle || 'type into the filter, then pick the row · Esc cancels',
|
|
233
|
+
items: [{ id: '__text__', label: '✓ use what I typed above', desc: '', pinned: true, freeText: true }],
|
|
234
|
+
});
|
|
235
|
+
if (picked == null) return null;
|
|
236
|
+
if (typeof picked === 'object') {
|
|
237
|
+
const v = String(picked.query || '').trim();
|
|
238
|
+
if (!v && !allowEmpty) return null;
|
|
239
|
+
return v;
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Yes/no confirmation modal for sensitive-tool approval. Esc (or no modal
|
|
245
|
+
// available) DENIES — approval is never granted by omission.
|
|
246
|
+
async function _promptConfirm(ctx, { title, subtitle } = {}) {
|
|
247
|
+
if (typeof ctx.openPicker !== 'function') return false;
|
|
248
|
+
const picked = await ctx.openPicker({
|
|
249
|
+
kind: 'menu',
|
|
250
|
+
title: title || 'Approve sensitive tool?',
|
|
251
|
+
subtitle: subtitle || 'Enter selects · Esc denies',
|
|
252
|
+
items: [
|
|
253
|
+
{ id: 'approve', label: '✓ approve once', desc: 'run this tool call' },
|
|
254
|
+
{ id: 'deny', label: '✗ deny', desc: 'block this tool call' },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
const id = picked && typeof picked === 'object' ? picked.id : picked;
|
|
258
|
+
return id === 'approve';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Default in-chat approval hook: prompts the operator to confirm each
|
|
262
|
+
// sensitive tool call. Used to drive the fail-closed tool runner from the
|
|
263
|
+
// Ink REPL, where stdin is owned by Ink so a raw readline prompt can't run.
|
|
264
|
+
function _makeInkApprove(ctx) {
|
|
265
|
+
return async function approve({ tool, args, agent }) {
|
|
266
|
+
const raw = typeof args === 'object' ? JSON.stringify(args) : String(args ?? '');
|
|
267
|
+
const summary = redactSecrets(raw).slice(0, 300);
|
|
268
|
+
const ok = await _promptConfirm(ctx, {
|
|
269
|
+
title: `Approve ${tool}?`,
|
|
270
|
+
subtitle: `agent ${agent}: ${summary}`,
|
|
271
|
+
});
|
|
272
|
+
return { approved: ok, reason: ok ? 'approved in chat' : 'denied in chat' };
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Register a custom OpenAI-compatible endpoint with the given fields, set it
|
|
277
|
+
// active, and return a summary string. Shared by the arg form and the
|
|
278
|
+
// interactive flow.
|
|
279
|
+
async function _registerCustom(ctx, registry, { name, baseUrl, apiKey }) {
|
|
280
|
+
if (typeof ctx.readConfig !== 'function' || typeof ctx.writeConfig !== 'function') {
|
|
281
|
+
return 'add custom: config writer unavailable in this session — use: lazyclaw providers add <name> <baseUrl> [apiKey]';
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const r = await addCustomProvider({
|
|
285
|
+
registry, readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, name, baseUrl, apiKey,
|
|
286
|
+
});
|
|
287
|
+
const next = _providerLookup(registry, r.name);
|
|
288
|
+
if (next) {
|
|
289
|
+
if (ctx.setActiveProvName) ctx.setActiveProvName(r.name);
|
|
290
|
+
if (ctx.setProv) ctx.setProv(next);
|
|
291
|
+
}
|
|
292
|
+
const probe = r.probe.ok
|
|
293
|
+
? `✓ reachable — ${r.probe.count} model(s)`
|
|
294
|
+
: `! registered, but /v1/models probe failed: ${r.probe.error}`;
|
|
295
|
+
const override = r.builtinOverride ? `\n(note: overrides built-in "${r.name}")` : '';
|
|
296
|
+
return `custom provider saved → ${r.name} (${r.baseUrl})\n${probe}${override}\nprovider → ${r.name}`;
|
|
297
|
+
} catch (e) {
|
|
298
|
+
return `add custom failed: ${e && e.message ? e.message : e}`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// After an interactive pick of a built-in api-key provider that has no key
|
|
303
|
+
// resolved, prompt for one and persist it (mirrored in-memory so it takes
|
|
304
|
+
// effect this session). No-op for keyless / custom providers, when a key
|
|
305
|
+
// already resolves, or when config writers aren't wired.
|
|
306
|
+
async function _maybePromptForKey(ctx, registry, provName) {
|
|
307
|
+
const meta = _infoFor(registry, provName);
|
|
308
|
+
if (!meta.requiresApiKey || meta.custom) return;
|
|
309
|
+
if (typeof ctx.readConfig !== 'function' || typeof ctx.writeConfig !== 'function') return;
|
|
310
|
+
const existing = typeof ctx.resolveAuthKey === 'function' ? ctx.resolveAuthKey(provName) : '';
|
|
311
|
+
if (existing) return;
|
|
312
|
+
const key = await _promptText(ctx, {
|
|
313
|
+
title: `${provName} needs an api key`,
|
|
314
|
+
subtitle: 'paste it now, or Esc to skip (set later via: lazyclaw auth)',
|
|
315
|
+
});
|
|
316
|
+
if (!key) return;
|
|
317
|
+
const next = setAuthKey({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, provider: provName, key });
|
|
318
|
+
// Mirror onto the in-memory cfg so resolveAuthKey (which closes over it)
|
|
319
|
+
// sees the key on the next turn without a restart.
|
|
320
|
+
if (ctx.cfg && next) {
|
|
321
|
+
ctx.cfg.authProfiles = next.authProfiles;
|
|
322
|
+
ctx.cfg.authActiveProfile = next.authActiveProfile;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Interactive add-custom: collect name/baseUrl/apiKey via sequential prompts.
|
|
327
|
+
async function _addCustomFlow(ctx, registry) {
|
|
328
|
+
const name = await _promptText(ctx, { title: 'custom endpoint — short id', subtitle: 'e.g. nim, openrouter, vllm (Esc cancels)' });
|
|
329
|
+
if (!name) return 'cancelled';
|
|
330
|
+
const baseUrl = await _promptText(ctx, { title: `baseUrl for ${name}`, subtitle: 'must start with http(s) and end in /v1' });
|
|
331
|
+
if (!baseUrl) return 'cancelled';
|
|
332
|
+
const apiKey = await _promptText(ctx, { title: `api-key for ${name}`, subtitle: 'leave blank for an auth-less endpoint (e.g. local vLLM)', allowEmpty: true });
|
|
333
|
+
if (apiKey === null) return 'cancelled';
|
|
334
|
+
return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
|
|
335
|
+
}
|
|
336
|
+
|
|
133
337
|
async function _provider(args, ctx) {
|
|
134
338
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
339
|
+
// `/provider add <name> <baseUrl> [apiKey]` — register a custom OpenAI-
|
|
340
|
+
// compatible endpoint non-interactively.
|
|
341
|
+
const addMatch = args && args.match(/^add\s+(.+)$/i);
|
|
342
|
+
if (addMatch) {
|
|
343
|
+
const [name, baseUrl, apiKey] = splitWhitespace(addMatch[1]);
|
|
344
|
+
if (!name || !baseUrl) return 'usage: /provider add <name> <baseUrl> [apiKey]';
|
|
345
|
+
return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
|
|
346
|
+
}
|
|
347
|
+
// No arg → drill-in modal picker (family -> provider). Falls back to the
|
|
348
|
+
// pre-v5.4.3 hint string when ctx.openPicker isn't available (e.g. non-Ink
|
|
349
|
+
// callers or before the picker ref settles).
|
|
138
350
|
if (!args) {
|
|
139
351
|
if (typeof ctx.openPicker === 'function') {
|
|
140
|
-
const
|
|
141
|
-
const info = registry.PROVIDER_INFO || {};
|
|
142
|
-
const items = known.map((id) => ({
|
|
143
|
-
id,
|
|
144
|
-
label: id,
|
|
145
|
-
desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
|
|
146
|
-
}));
|
|
147
|
-
const picked = await ctx.openPicker({
|
|
148
|
-
kind: 'provider',
|
|
149
|
-
title: 'select provider',
|
|
150
|
-
subtitle: `current: ${ctx.getActiveProvName()}`,
|
|
151
|
-
items,
|
|
152
|
-
});
|
|
352
|
+
const picked = await _pickProviderDrillIn(ctx, registry);
|
|
153
353
|
if (!picked) return 'cancelled';
|
|
354
|
+
if (picked === '__add_custom__') return _addCustomFlow(ctx, registry);
|
|
154
355
|
args = picked;
|
|
356
|
+
// Built-in api-key provider with no key configured → offer to set one.
|
|
357
|
+
await _maybePromptForKey(ctx, registry, args);
|
|
155
358
|
} else {
|
|
156
359
|
return `provider: ${ctx.getActiveProvName()}\n(pass an arg: /provider <name>)`;
|
|
157
360
|
}
|
|
@@ -166,32 +369,189 @@ async function _provider(args, ctx) {
|
|
|
166
369
|
return `provider → ${args}`;
|
|
167
370
|
}
|
|
168
371
|
|
|
372
|
+
function _infoFor(registry, provName) {
|
|
373
|
+
return (registry.PROVIDER_INFO && registry.PROVIDER_INFO[provName]) || {};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// A composite provider (orchestrator) has no real model list — its only
|
|
377
|
+
// "model" is the provider name itself, so the model picker would dead-end
|
|
378
|
+
// on a single bogus row. Detect it so we can redirect to a provider pick.
|
|
379
|
+
function _isCompositeProvider(info, provName) {
|
|
380
|
+
if (info && info.composite) return true;
|
|
381
|
+
const s = info && Array.isArray(info.suggestedModels) ? info.suggestedModels : null;
|
|
382
|
+
return !!(s && s.length === 1 && s[0] === provName);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Whether the active provider exposes any selectable model (a suggested
|
|
386
|
+
// model that isn't just the provider name, or a live-fetchable catalogue).
|
|
387
|
+
function _hasRealModels(info, provName) {
|
|
388
|
+
if (supportsLiveFetch(info, provName)) return true;
|
|
389
|
+
const s = info && Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
|
|
390
|
+
return s.some((m) => m && m !== provName);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Build the model-picker rows: suggested + any live-fetched models, deduped,
|
|
394
|
+
// with the provider default tagged, plus pinned sentinel rows for live-fetch
|
|
395
|
+
// (when supported) and free-text custom entry.
|
|
396
|
+
function _buildModelItems(info, provName, dynamicModels) {
|
|
397
|
+
const base = Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
|
|
398
|
+
const all = Array.from(new Set([...(dynamicModels || []), ...base])).filter((m) => m && m !== provName);
|
|
399
|
+
const items = [];
|
|
400
|
+
if (supportsLiveFetch(info, provName)) {
|
|
401
|
+
items.push({
|
|
402
|
+
id: '__fetch_models__',
|
|
403
|
+
label: '↻ fetch live model list',
|
|
404
|
+
desc: 'pull the current catalogue from the provider',
|
|
405
|
+
pinned: true,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
for (const m of all) {
|
|
409
|
+
items.push({ id: m, label: m, desc: info.defaultModel === m ? '(default)' : '' });
|
|
410
|
+
}
|
|
411
|
+
items.push({
|
|
412
|
+
id: '__custom_model__',
|
|
413
|
+
label: '… type a custom model id',
|
|
414
|
+
desc: 'type the id into the filter above, then pick this row',
|
|
415
|
+
pinned: true,
|
|
416
|
+
freeText: true,
|
|
417
|
+
});
|
|
418
|
+
// Reach another provider's models (e.g. claude-cli's opus) without leaving
|
|
419
|
+
// /model — the active provider isn't the only place to pick a model.
|
|
420
|
+
items.push({
|
|
421
|
+
id: '__switch_provider__',
|
|
422
|
+
label: '⇄ pick a different provider…',
|
|
423
|
+
desc: 'switch provider (e.g. claude-cli for opus/sonnet), then its model',
|
|
424
|
+
pinned: true,
|
|
425
|
+
});
|
|
426
|
+
return items;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Run the model picker for `provName`, looping on the live-fetch row and
|
|
430
|
+
// resolving the free-text row from the typed filter. Returns a concrete
|
|
431
|
+
// model id, or null on cancel.
|
|
432
|
+
async function _pickModelLoop(ctx, registry, provName) {
|
|
433
|
+
const info = _infoFor(registry, provName);
|
|
434
|
+
let dynamic = [];
|
|
435
|
+
let note = '';
|
|
436
|
+
for (let guard = 0; guard < 50; guard++) {
|
|
437
|
+
const items = _buildModelItems(info, provName, dynamic);
|
|
438
|
+
const picked = await ctx.openPicker({
|
|
439
|
+
kind: 'model',
|
|
440
|
+
title: `select model for ${provName}`,
|
|
441
|
+
subtitle: note || `current: ${ctx.getActiveModel() || '(default)'}`,
|
|
442
|
+
items,
|
|
443
|
+
});
|
|
444
|
+
if (picked == null) return null;
|
|
445
|
+
if (typeof picked === 'object') {
|
|
446
|
+
// free-text custom row → { id, query }
|
|
447
|
+
const typed = String(picked.query || '').trim();
|
|
448
|
+
if (!typed) { note = 'type a model id into the filter first'; continue; }
|
|
449
|
+
return typed;
|
|
450
|
+
}
|
|
451
|
+
if (picked === '__fetch_models__') {
|
|
452
|
+
try {
|
|
453
|
+
const fetcher = typeof ctx.fetchModels === 'function'
|
|
454
|
+
? ctx.fetchModels
|
|
455
|
+
: (provId) => fetchModelsForProvider({
|
|
456
|
+
cfg: ctx.cfg,
|
|
457
|
+
registryMod: registry,
|
|
458
|
+
resolveAuthKey: (id) => (ctx.resolveAuthKey ? ctx.resolveAuthKey(id) : ''),
|
|
459
|
+
providerId: provId,
|
|
460
|
+
});
|
|
461
|
+
const fetched = await fetcher(provName);
|
|
462
|
+
if (Array.isArray(fetched) && fetched.length) {
|
|
463
|
+
dynamic = fetched;
|
|
464
|
+
note = `fetched ${fetched.length} model(s) — pick one or type a custom id`;
|
|
465
|
+
} else {
|
|
466
|
+
note = 'no models returned — using the suggested list';
|
|
467
|
+
}
|
|
468
|
+
} catch (e) {
|
|
469
|
+
note = `fetch failed: ${e && e.message ? e.message : e}`;
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
// Switch to a different provider's models (e.g. claude-cli's opus).
|
|
474
|
+
if (picked === '__switch_provider__') return '__switch_provider__';
|
|
475
|
+
return picked;
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Flat provider picker used to escape a composite/model-less active provider
|
|
481
|
+
// or to switch providers from inside /model. Hides composites + mock,
|
|
482
|
+
// mirroring the legacy wizard's filter (cli.mjs:1979).
|
|
483
|
+
async function _pickProviderForModel(ctx, registry, subtitle) {
|
|
484
|
+
const info = registry.PROVIDER_INFO || {};
|
|
485
|
+
const known = Object.keys(registry.PROVIDERS || {})
|
|
486
|
+
.filter((id) => id !== 'mock' && !((info[id] || {}).composite))
|
|
487
|
+
.sort();
|
|
488
|
+
const items = known.map((id) => ({
|
|
489
|
+
id,
|
|
490
|
+
label: id,
|
|
491
|
+
desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
|
|
492
|
+
}));
|
|
493
|
+
const picked = await ctx.openPicker({
|
|
494
|
+
kind: 'provider',
|
|
495
|
+
title: 'select provider (then a model)',
|
|
496
|
+
subtitle: subtitle || `${ctx.getActiveProvName()} has no selectable models — pick a provider`,
|
|
497
|
+
items,
|
|
498
|
+
});
|
|
499
|
+
return typeof picked === 'string' ? picked : null;
|
|
500
|
+
}
|
|
501
|
+
|
|
169
502
|
async function _model(args, ctx) {
|
|
170
503
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
171
504
|
if (!args) {
|
|
172
505
|
if (typeof ctx.openPicker === 'function') {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
506
|
+
let provName = ctx.getActiveProvName();
|
|
507
|
+
let info = _infoFor(registry, provName);
|
|
508
|
+
let switched = false;
|
|
509
|
+
// Composite (orchestrator) or model-less active provider → pick a
|
|
510
|
+
// provider first so the user is never dead-ended on a single row.
|
|
511
|
+
if (_isCompositeProvider(info, provName) || !_hasRealModels(info, provName)) {
|
|
512
|
+
const pickedProv = await _pickProviderForModel(ctx, registry);
|
|
513
|
+
if (!pickedProv) return 'cancelled';
|
|
514
|
+
if (pickedProv !== provName) {
|
|
515
|
+
const next = _providerLookup(registry, pickedProv);
|
|
516
|
+
if (!next) return `unknown provider: ${pickedProv}`;
|
|
517
|
+
if (ctx.setActiveProvName) ctx.setActiveProvName(pickedProv);
|
|
518
|
+
if (ctx.setProv) ctx.setProv(next);
|
|
519
|
+
switched = true;
|
|
520
|
+
}
|
|
521
|
+
provName = pickedProv;
|
|
522
|
+
info = _infoFor(registry, provName);
|
|
178
523
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
524
|
+
// Model pick loop — the "⇄ pick a different provider" row re-enters the
|
|
525
|
+
// provider picker so models from any provider (e.g. claude-cli's opus)
|
|
526
|
+
// are reachable without leaving /model.
|
|
527
|
+
let model = null;
|
|
528
|
+
for (let guard = 0; guard < 25; guard++) {
|
|
529
|
+
model = await _pickModelLoop(ctx, registry, provName);
|
|
530
|
+
if (model === '__switch_provider__') {
|
|
531
|
+
const np = await _pickProviderForModel(ctx, registry, `current: ${provName} — pick a provider`);
|
|
532
|
+
if (!np) continue; // cancelled the switch → back to the model list
|
|
533
|
+
if (np !== provName) {
|
|
534
|
+
const next = _providerLookup(registry, np);
|
|
535
|
+
if (!next) { continue; }
|
|
536
|
+
if (ctx.setActiveProvName) ctx.setActiveProvName(np);
|
|
537
|
+
if (ctx.setProv) ctx.setProv(next);
|
|
538
|
+
switched = true;
|
|
539
|
+
provName = np;
|
|
540
|
+
info = _infoFor(registry, provName);
|
|
541
|
+
}
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
if (model == null || model === '__switch_provider__') {
|
|
547
|
+
return switched ? `provider → ${provName} (model unchanged)` : 'cancelled';
|
|
548
|
+
}
|
|
549
|
+
if (ctx.setActiveModel) ctx.setActiveModel(model);
|
|
550
|
+
return switched
|
|
551
|
+
? `provider → ${provName} · model → ${model}`
|
|
552
|
+
: `model → ${model}`;
|
|
194
553
|
}
|
|
554
|
+
return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
|
|
195
555
|
}
|
|
196
556
|
const { parseSlashProviderModel } = registry;
|
|
197
557
|
const parsed = typeof parseSlashProviderModel === 'function'
|
|
@@ -248,6 +608,36 @@ async function _skill(args, ctx) {
|
|
|
248
608
|
}
|
|
249
609
|
}
|
|
250
610
|
|
|
611
|
+
// /skills — list + pick installed skills (the v5.4 alias only forwarded to
|
|
612
|
+
// _skill, which activates/clears but never *shows* what's available). With an
|
|
613
|
+
// arg it activates directly (like /skill); with no arg it opens a picker (or
|
|
614
|
+
// lists, or — when nothing is installed — explains how to install).
|
|
615
|
+
async function _skillsList(args, ctx) {
|
|
616
|
+
if (args && args.trim()) return _skill(args, ctx);
|
|
617
|
+
const skillsMod = await _mod(ctx, 'skillsMod', () => import('../skills.mjs'));
|
|
618
|
+
let names = [];
|
|
619
|
+
try { names = (skillsMod.listSkills(ctx.cfgDir) || []).map((s) => s.name).filter(Boolean); }
|
|
620
|
+
catch (e) { return `skills unavailable: ${e?.message || e}`; }
|
|
621
|
+
if (!names.length) {
|
|
622
|
+
return [
|
|
623
|
+
'no skills installed.',
|
|
624
|
+
'install one: lazyclaw skills install <owner>/<repo>',
|
|
625
|
+
'then /skills to pick, or /skill <name>[,<name>] to activate.',
|
|
626
|
+
].join('\n');
|
|
627
|
+
}
|
|
628
|
+
if (typeof ctx.openPicker === 'function') {
|
|
629
|
+
const picked = await ctx.openPicker({
|
|
630
|
+
kind: 'skill',
|
|
631
|
+
title: 'activate a skill',
|
|
632
|
+
subtitle: `${names.length} installed · Enter activates · Esc cancels`,
|
|
633
|
+
items: names.map((n) => ({ id: n, label: n, desc: '' })),
|
|
634
|
+
});
|
|
635
|
+
if (!picked) return 'cancelled';
|
|
636
|
+
return _skill(typeof picked === 'string' ? picked : picked.id, ctx);
|
|
637
|
+
}
|
|
638
|
+
return `installed skills (${names.length}):\n${names.map((n) => ` · ${n}`).join('\n')}\n(activate: /skill <name>[,<name>])`;
|
|
639
|
+
}
|
|
640
|
+
|
|
251
641
|
async function _tools(_args) {
|
|
252
642
|
let registry;
|
|
253
643
|
try {
|
|
@@ -295,8 +685,15 @@ async function _memory(args, ctx) {
|
|
|
295
685
|
return body || '(empty core memory)';
|
|
296
686
|
}
|
|
297
687
|
if (which === 'recent') {
|
|
298
|
-
const items = mem.loadRecent(20, ctx.cfgDir);
|
|
299
|
-
|
|
688
|
+
const items = mem.loadRecent(20, ctx.cfgDir) || [];
|
|
689
|
+
if (!items.length) return '(no recent memory)';
|
|
690
|
+
return ['recent memory (last ' + items.length + '):',
|
|
691
|
+
...items.map((it, i) => {
|
|
692
|
+
const role = it.role || 'msg';
|
|
693
|
+
const content = String(it.content || '').replace(/\s+/g, ' ').slice(0, 80);
|
|
694
|
+
return ` ${String(i + 1).padStart(2)}. [${role}] ${content}${(it.content || '').length > 80 ? '…' : ''}`;
|
|
695
|
+
})
|
|
696
|
+
].join('\n');
|
|
300
697
|
}
|
|
301
698
|
if (which === 'episodic') {
|
|
302
699
|
const topic = tokens[1];
|
|
@@ -304,7 +701,11 @@ async function _memory(args, ctx) {
|
|
|
304
701
|
const body = mem.loadEpisodic(topic, ctx.cfgDir);
|
|
305
702
|
return body || `(no episodic file "${topic}")`;
|
|
306
703
|
}
|
|
307
|
-
|
|
704
|
+
const items = mem.listEpisodic(ctx.cfgDir) || [];
|
|
705
|
+
if (!items.length) return '(no episodic files yet — run /dream to consolidate)';
|
|
706
|
+
return ['episodic files:',
|
|
707
|
+
...items.map((it) => ` • ${typeof it === 'string' ? it : (it.topic || JSON.stringify(it))}`)
|
|
708
|
+
].join('\n');
|
|
308
709
|
}
|
|
309
710
|
return 'usage: /memory [core|recent|episodic [topic]]';
|
|
310
711
|
}
|
|
@@ -440,10 +841,10 @@ async function _loop(args, ctx, write) {
|
|
|
440
841
|
catch (e) { return `loop unavailable: ${e?.message || e}`; }
|
|
441
842
|
if (!args) {
|
|
442
843
|
return [
|
|
443
|
-
'usage: /loop <prompt> [--max N] [--until "<regex>"]',
|
|
844
|
+
'usage: /loop <prompt> [--max N] [--until "<regex>"] [--use-memory] [--recall "<q>"]',
|
|
444
845
|
` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}`,
|
|
445
846
|
` session: ${ctx.getSessionId && ctx.getSessionId() || '(none — turns will not be persisted)'}`,
|
|
446
|
-
'
|
|
847
|
+
' press Esc to abort a running loop.',
|
|
447
848
|
].join('\n');
|
|
448
849
|
}
|
|
449
850
|
let parsed;
|
|
@@ -473,7 +874,31 @@ async function _loop(args, ctx, write) {
|
|
|
473
874
|
};
|
|
474
875
|
|
|
475
876
|
const messages = ctx.getMessages();
|
|
476
|
-
|
|
877
|
+
// --memory / --recall rebuild the system message from disk every iteration
|
|
878
|
+
// (a parallel writer mutating core.md mid-loop is reflected next call). We
|
|
879
|
+
// capture the chat's prior system so it can be restored after the loop.
|
|
880
|
+
const _sysBefore = (messages.find((m) => m.role === 'system') || {}).content ?? null;
|
|
881
|
+
let memMod = null;
|
|
882
|
+
if (parsed.useMemory || parsed.recall) {
|
|
883
|
+
try { memMod = await import('../memory.mjs'); } catch { memMod = null; }
|
|
884
|
+
}
|
|
885
|
+
const buildSystem = memMod ? (() => {
|
|
886
|
+
const parts = [];
|
|
887
|
+
if (parsed.useMemory) {
|
|
888
|
+
const core = memMod.loadCore(ctx.cfgDir);
|
|
889
|
+
if (core && core.trim()) parts.push(core);
|
|
890
|
+
}
|
|
891
|
+
if (parsed.recall) {
|
|
892
|
+
const text = memMod.recall(parsed.recall, { topN: 3 }, ctx.cfgDir);
|
|
893
|
+
if (text && text.trim()) parts.push(text);
|
|
894
|
+
}
|
|
895
|
+
if (_sysBefore) parts.push(_sysBefore);
|
|
896
|
+
return parts.join('\n\n---\n\n');
|
|
897
|
+
}) : null;
|
|
898
|
+
|
|
899
|
+
// Use the abort signal the REPL threads in (Esc / Ctrl-C aborts the running
|
|
900
|
+
// loop). Falls back to a fresh controller for non-Ink callers.
|
|
901
|
+
const signal = ctx.loopSignal || new AbortController().signal;
|
|
477
902
|
try {
|
|
478
903
|
const result = await loopMod.runLoop({
|
|
479
904
|
prompt: parsed.prompt,
|
|
@@ -487,7 +912,8 @@ async function _loop(args, ctx, write) {
|
|
|
487
912
|
try { write(` ↻ loop iteration ${i}/${max}\n`); } catch {}
|
|
488
913
|
}
|
|
489
914
|
},
|
|
490
|
-
signal
|
|
915
|
+
signal,
|
|
916
|
+
buildSystem,
|
|
491
917
|
});
|
|
492
918
|
if (ctx.setCharsSent && ctx.getCharsSent) {
|
|
493
919
|
ctx.setCharsSent(ctx.getCharsSent() + parsed.prompt.length * result.iterations);
|
|
@@ -497,6 +923,19 @@ async function _loop(args, ctx, write) {
|
|
|
497
923
|
return `✓ loop done — ${result.iterations}/${parsed.max} iteration(s)${tail}`;
|
|
498
924
|
} catch (err) {
|
|
499
925
|
return `loop error: ${err?.message || String(err)}`;
|
|
926
|
+
} finally {
|
|
927
|
+
// Restore the chat's prior system message — the engine overwrote
|
|
928
|
+
// messages[0] with the per-iteration memory composition.
|
|
929
|
+
if (buildSystem) {
|
|
930
|
+
const sysIdx = messages.findIndex((m) => m.role === 'system');
|
|
931
|
+
if (_sysBefore != null) {
|
|
932
|
+
if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: _sysBefore };
|
|
933
|
+
else messages.unshift({ role: 'system', content: _sysBefore });
|
|
934
|
+
} else if (sysIdx >= 0) {
|
|
935
|
+
messages.splice(sysIdx, 1);
|
|
936
|
+
}
|
|
937
|
+
if (ctx.setMessages) ctx.setMessages(messages);
|
|
938
|
+
}
|
|
500
939
|
}
|
|
501
940
|
}
|
|
502
941
|
|
|
@@ -533,9 +972,23 @@ async function _goal(args, ctx) {
|
|
|
533
972
|
if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
|
|
534
973
|
try {
|
|
535
974
|
const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
975
|
+
let cronNote = '';
|
|
976
|
+
if (cron) {
|
|
977
|
+
// Actually attach the schedule (P3 — was a stub). Needs the config
|
|
978
|
+
// writers the Ink session wires onto ctx; fall back to a CLI hint if
|
|
979
|
+
// they're absent (non-Ink callers).
|
|
980
|
+
if (typeof ctx.readConfig === 'function' && typeof ctx.writeConfig === 'function') {
|
|
981
|
+
try {
|
|
982
|
+
const cronMod = await import('../cron.mjs');
|
|
983
|
+
const r = await attachGoalCron({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, cron: cronMod, name: g.name, schedule: cron });
|
|
984
|
+
cronNote = r.skipped ? ' (cron recorded; backend install skipped)' : ' (cron scheduled)';
|
|
985
|
+
} catch (ce) {
|
|
986
|
+
cronNote = ` (cron attach failed: ${ce?.message || ce} — use: lazyclaw goal add ${g.name} --cron "${cron}")`;
|
|
987
|
+
}
|
|
988
|
+
} else {
|
|
989
|
+
cronNote = ' (cron recorded — attach via: lazyclaw goal add --cron)';
|
|
990
|
+
}
|
|
991
|
+
}
|
|
539
992
|
return `✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})${cronNote}`;
|
|
540
993
|
} catch (e) { return `goal error: ${e?.message || e}`; }
|
|
541
994
|
}
|
|
@@ -553,7 +1006,17 @@ async function _goal(args, ctx) {
|
|
|
553
1006
|
if (!name) return 'usage: /goal close <name> [done|abandoned]';
|
|
554
1007
|
try {
|
|
555
1008
|
const g = goalsMod.closeGoal(name, outcome, ctx.cfgDir);
|
|
556
|
-
|
|
1009
|
+
// Detach any attached cron so a closed goal stops ticking (P3 — the
|
|
1010
|
+
// Ink path used to leave it dangling).
|
|
1011
|
+
let detachNote = '';
|
|
1012
|
+
if (typeof ctx.readConfig === 'function' && typeof ctx.writeConfig === 'function') {
|
|
1013
|
+
try {
|
|
1014
|
+
const cronMod = await import('../cron.mjs');
|
|
1015
|
+
const removed = await detachGoalCron({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, cron: cronMod, name: g.name });
|
|
1016
|
+
if (removed) detachNote = ' (cron detached)';
|
|
1017
|
+
} catch { /* best-effort */ }
|
|
1018
|
+
}
|
|
1019
|
+
return `✓ goal ${g.name} closed (status: ${g.status})${detachNote}`;
|
|
557
1020
|
} catch (e) { return `goal error: ${e?.message || e}`; }
|
|
558
1021
|
}
|
|
559
1022
|
// single-arg branch: switch
|
|
@@ -694,11 +1157,11 @@ function _personalityUse(name, ctx, fs, path) {
|
|
|
694
1157
|
return `active personality → ${name}`;
|
|
695
1158
|
}
|
|
696
1159
|
|
|
697
|
-
async function _task(args, ctx) {
|
|
1160
|
+
async function _task(args, ctx, write) {
|
|
698
1161
|
let tasksMod, loopMod;
|
|
699
1162
|
try {
|
|
700
|
-
tasksMod = await import('../tasks.mjs');
|
|
701
|
-
loopMod = await import('../loop-engine.mjs');
|
|
1163
|
+
tasksMod = await _mod(ctx, 'tasksMod', () => import('../tasks.mjs'));
|
|
1164
|
+
loopMod = await _mod(ctx, 'loopMod', () => import('../loop-engine.mjs'));
|
|
702
1165
|
} catch (e) { return `/task unavailable: ${e?.message || e}`; }
|
|
703
1166
|
let tokens;
|
|
704
1167
|
try { tokens = loopMod.splitArgs(args); }
|
|
@@ -732,18 +1195,120 @@ async function _task(args, ctx) {
|
|
|
732
1195
|
}
|
|
733
1196
|
if (sub === 'abandon' || sub === 'done') {
|
|
734
1197
|
if (!id) return `usage: /task ${sub} <id>`;
|
|
735
|
-
const
|
|
736
|
-
|
|
1198
|
+
const target = sub === 'done' ? 'done' : 'abandoned';
|
|
1199
|
+
const next = tasksMod.patchTask(id, { status: target }, ctx.cfgDir);
|
|
1200
|
+
// Best-effort closing post in the original Slack thread (parity with the
|
|
1201
|
+
// CLI cmdTask), so collaborators see the resolution. Never rolls back
|
|
1202
|
+
// the status change.
|
|
1203
|
+
let slackNote = '';
|
|
1204
|
+
if (next && next.slackChannel && next.slackThreadTs) {
|
|
1205
|
+
try {
|
|
1206
|
+
loadDotenvIfAny(ctx.cfgDir);
|
|
1207
|
+
const SlackChannel = ctx.SlackChannel || (await import('../channels/slack.mjs')).SlackChannel;
|
|
1208
|
+
const slack = new SlackChannel({ requireInbound: false });
|
|
1209
|
+
await slack.start(async () => '', {});
|
|
1210
|
+
const threadId = `${next.slackChannel}:${next.slackThreadTs}`;
|
|
1211
|
+
const msg = target === 'done'
|
|
1212
|
+
? `:white_check_mark: Task *${next.title}* marked done.`
|
|
1213
|
+
: `:no_entry: Task *${next.title}* abandoned.`;
|
|
1214
|
+
await slack.send(threadId, msg);
|
|
1215
|
+
await slack.stop().catch(() => {});
|
|
1216
|
+
slackNote = ' (posted to Slack thread)';
|
|
1217
|
+
} catch (e) { slackNote = ` (Slack post failed: ${e?.message || e})`; }
|
|
1218
|
+
}
|
|
1219
|
+
return `✓ task ${id} → ${next?.status || target}${slackNote}`;
|
|
737
1220
|
}
|
|
738
1221
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
739
1222
|
if (!id) return 'usage: /task remove <id>';
|
|
740
1223
|
tasksMod.removeTask(id, ctx.cfgDir);
|
|
741
1224
|
return `✓ removed task ${id}`;
|
|
742
1225
|
}
|
|
743
|
-
if (sub === 'start'
|
|
744
|
-
|
|
1226
|
+
if (sub === 'start') {
|
|
1227
|
+
// /task start <team> --title "..." [--description "..."] [--lead <name>]
|
|
1228
|
+
const teamsMod = await _mod(ctx, 'teamsMod', () => import('../teams.mjs'));
|
|
1229
|
+
const agentsMod = await _mod(ctx, 'agentsMod', () => import('../agents.mjs'));
|
|
1230
|
+
const rest = tokens.slice(1);
|
|
1231
|
+
let teamName = null, title = '', description = '', lead = null;
|
|
1232
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1233
|
+
const t = rest[i];
|
|
1234
|
+
if (t === '--title') title = rest[++i] || '';
|
|
1235
|
+
else if (t === '--description' || t === '--desc') description = rest[++i] || '';
|
|
1236
|
+
else if (t === '--lead') lead = rest[++i] || null;
|
|
1237
|
+
else if (!teamName && !t.startsWith('--')) teamName = t;
|
|
1238
|
+
}
|
|
1239
|
+
if (!teamName || !title) return 'usage: /task start <team> --title "..." [--description "..."] [--lead <name>]';
|
|
1240
|
+
const team = teamsMod.getTeam(teamName, ctx.cfgDir);
|
|
1241
|
+
if (!team) return `no team "${teamName}"`;
|
|
1242
|
+
const leadName = lead || team.lead;
|
|
1243
|
+
const leadAgent = agentsMod.getAgent(leadName, ctx.cfgDir);
|
|
1244
|
+
const seeded = tasksMod.registerTask(
|
|
1245
|
+
{ title, description, team: teamName, lead: leadName, slackChannel: team.slackChannel, status: 'pending' },
|
|
1246
|
+
ctx.cfgDir,
|
|
1247
|
+
);
|
|
1248
|
+
let ts = '';
|
|
1249
|
+
if (team.slackChannel) {
|
|
1250
|
+
try {
|
|
1251
|
+
loadDotenvIfAny(ctx.cfgDir);
|
|
1252
|
+
const SlackChannel = ctx.SlackChannel || (await import('../channels/slack.mjs')).SlackChannel;
|
|
1253
|
+
const slack = new SlackChannel({ requireInbound: false });
|
|
1254
|
+
await slack.start(async () => '', {});
|
|
1255
|
+
const text = tasksMod.buildKickoffMessage({
|
|
1256
|
+
id: seeded.id, title: seeded.title, description: seeded.description,
|
|
1257
|
+
leadDisplayName: (leadAgent && leadAgent.displayName) || leadName,
|
|
1258
|
+
teamDisplayName: team.displayName || team.name,
|
|
1259
|
+
});
|
|
1260
|
+
const res = await slack.send(team.slackChannel, text);
|
|
1261
|
+
ts = (res && res.ts) || '';
|
|
1262
|
+
await slack.stop().catch(() => {});
|
|
1263
|
+
} catch (e) {
|
|
1264
|
+
try { tasksMod.removeTask(seeded.id, ctx.cfgDir); } catch { /* best-effort */ }
|
|
1265
|
+
return `task start: ${e?.message || e}`;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
const turns = ts ? [{ agent: 'system', text: `Task opened by user. Lead: ${leadName}.`, ts }] : [];
|
|
1269
|
+
const finalTask = tasksMod.patchTask(seeded.id, { slackThreadTs: ts, status: ts ? 'running' : 'pending', turns }, ctx.cfgDir);
|
|
1270
|
+
return `✓ task ${finalTask.id} started (status: ${finalTask.status}${ts ? `, slack thread ${ts}` : ', no Slack thread'})`;
|
|
745
1271
|
}
|
|
746
|
-
|
|
1272
|
+
if (sub === 'tick') {
|
|
1273
|
+
// /task tick <id> [message] — one multi-agent router turn. The router
|
|
1274
|
+
// emits through a logger callback (not raw stdout), so it runs inline in
|
|
1275
|
+
// the Ink chat with output routed through `write`.
|
|
1276
|
+
if (!id) return 'usage: /task tick <id> [message]';
|
|
1277
|
+
const teamsMod = await _mod(ctx, 'teamsMod', () => import('../teams.mjs'));
|
|
1278
|
+
const agentsMod = await _mod(ctx, 'agentsMod', () => import('../agents.mjs'));
|
|
1279
|
+
const task = tasksMod.getTask(id, ctx.cfgDir);
|
|
1280
|
+
if (!task) return `no task "${id}"`;
|
|
1281
|
+
const team = teamsMod.getTeam(task.team, ctx.cfgDir);
|
|
1282
|
+
if (!team) return `task tick: team "${task.team}" disappeared`;
|
|
1283
|
+
const agentsById = {};
|
|
1284
|
+
for (const name of team.agents) {
|
|
1285
|
+
const rec = agentsMod.getAgent(name, ctx.cfgDir);
|
|
1286
|
+
if (!rec) return `task tick: agent "${name}" disappeared`;
|
|
1287
|
+
agentsById[name] = rec;
|
|
1288
|
+
}
|
|
1289
|
+
loadDotenvIfAny(ctx.cfgDir);
|
|
1290
|
+
const router = await _mod(ctx, 'routerMod', () => import('../mas/mention_router.mjs'));
|
|
1291
|
+
const leadAgent = agentsById[team.lead];
|
|
1292
|
+
const apiKey = ctx.resolveAuthKey ? ctx.resolveAuthKey(leadAgent.provider) : '';
|
|
1293
|
+
const baseUrl = ctx.resolveBaseUrl ? ctx.resolveBaseUrl(leadAgent.provider) : undefined;
|
|
1294
|
+
const userMsg = tokens.slice(2).join(' ').trim();
|
|
1295
|
+
try {
|
|
1296
|
+
if (typeof write === 'function') { try { write(' ↻ running task turn…\n'); } catch {} }
|
|
1297
|
+
const result = await router.runTaskTurn({
|
|
1298
|
+
task, team, agentsById,
|
|
1299
|
+
userMessage: userMsg || undefined,
|
|
1300
|
+
configDir: ctx.cfgDir,
|
|
1301
|
+
apiKey, baseUrl,
|
|
1302
|
+
logger: (line) => { if (typeof write === 'function') { try { write(line); } catch {} } },
|
|
1303
|
+
approve: _makeInkApprove(ctx),
|
|
1304
|
+
security: ctx.cfg?.security,
|
|
1305
|
+
});
|
|
1306
|
+
return `✓ task ${result.task.id} → ${result.task.status} (${result.iterations} agent turn(s)${result.stoppedBy ? `, stopped by ${result.stoppedBy}` : ''})`;
|
|
1307
|
+
} catch (e) {
|
|
1308
|
+
return `task tick: ${e?.message || e}`;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return `/task: unknown sub "${sub}" — start|tick|list|show|transcript|abandon|done|remove`;
|
|
747
1312
|
} catch (e) {
|
|
748
1313
|
return `/task error: ${e?.message || e}`;
|
|
749
1314
|
}
|
|
@@ -786,6 +1351,19 @@ async function _trainer(args, ctx) {
|
|
|
786
1351
|
const next = _providerLookup(registry, parsed.provider);
|
|
787
1352
|
if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
|
|
788
1353
|
}
|
|
1354
|
+
// Optional `--fallback <provider[:model]>` — resolveTrainer routes here
|
|
1355
|
+
// when opts.useFallback is set. Validate before persisting.
|
|
1356
|
+
let fallbackSpec = null;
|
|
1357
|
+
const fi = tokens.indexOf('--fallback');
|
|
1358
|
+
if (fi >= 0) {
|
|
1359
|
+
fallbackSpec = tokens[fi + 1];
|
|
1360
|
+
if (!fallbackSpec) return 'usage: /trainer set <p:m> --fallback <p:m>';
|
|
1361
|
+
const fp = _parseProvModel(registry, fallbackSpec);
|
|
1362
|
+
if (!fp.provider) return `/trainer set: could not parse fallback "${fallbackSpec}"`;
|
|
1363
|
+
if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
|
|
1364
|
+
return `/trainer set: unknown provider "${fp.provider}"`;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
789
1367
|
// Read-merge-write so unrelated cfg keys survive.
|
|
790
1368
|
const fs = await import('node:fs');
|
|
791
1369
|
const path = await import('node:path');
|
|
@@ -795,10 +1373,38 @@ async function _trainer(args, ctx) {
|
|
|
795
1373
|
diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
|
|
796
1374
|
if (parsed.model) diskCfg.trainer.model = parsed.model;
|
|
797
1375
|
else delete diskCfg.trainer.model;
|
|
1376
|
+
if (fallbackSpec) diskCfg.trainer.fallback = fallbackSpec;
|
|
798
1377
|
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
799
1378
|
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
800
1379
|
if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
|
|
801
|
-
return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}`;
|
|
1380
|
+
return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}${fallbackSpec ? ` (fallback: ${fallbackSpec})` : ''}`;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
if (sub === 'fallback') {
|
|
1384
|
+
const spec = tokens[1];
|
|
1385
|
+
if (!spec) return 'usage: /trainer fallback <provider>[:<model>] | clear';
|
|
1386
|
+
const fs = await import('node:fs');
|
|
1387
|
+
const path = await import('node:path');
|
|
1388
|
+
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
1389
|
+
let diskCfg = {};
|
|
1390
|
+
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
1391
|
+
if (spec === 'clear' || spec === 'unset') {
|
|
1392
|
+
if (diskCfg.trainer) delete diskCfg.trainer.fallback;
|
|
1393
|
+
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
1394
|
+
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
1395
|
+
if (ctx.cfg && ctx.cfg.trainer) delete ctx.cfg.trainer.fallback;
|
|
1396
|
+
return '✓ trainer fallback cleared';
|
|
1397
|
+
}
|
|
1398
|
+
const fp = _parseProvModel(registry, spec);
|
|
1399
|
+
if (!fp.provider) return `/trainer fallback: could not parse "${spec}"`;
|
|
1400
|
+
if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
|
|
1401
|
+
return `/trainer fallback: unknown provider "${fp.provider}"`;
|
|
1402
|
+
}
|
|
1403
|
+
diskCfg.trainer = { ...(diskCfg.trainer || {}), fallback: spec };
|
|
1404
|
+
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
1405
|
+
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
1406
|
+
if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
|
|
1407
|
+
return `✓ trainer fallback → ${spec}`;
|
|
802
1408
|
}
|
|
803
1409
|
|
|
804
1410
|
if (sub === 'clear' || sub === 'unset') {
|
|
@@ -817,53 +1423,251 @@ async function _trainer(args, ctx) {
|
|
|
817
1423
|
return `/trainer: unknown sub "${sub}" — show|set <p:m>|clear`;
|
|
818
1424
|
}
|
|
819
1425
|
|
|
820
|
-
// /dashboard — open the lazyclaw web UI.
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1426
|
+
// /dashboard — open the lazyclaw web UI.
|
|
1427
|
+
//
|
|
1428
|
+
// v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
|
|
1429
|
+
// session spawned 20+ daemon children).
|
|
1430
|
+
//
|
|
1431
|
+
// Original implementation:
|
|
1432
|
+
// probe /healthz → if !200, spawn detached `lazyclaw dashboard
|
|
1433
|
+
// --no-open` and poll for up to 3s.
|
|
1434
|
+
//
|
|
1435
|
+
// Failure mode that produced the 20+ spawn pile-up:
|
|
1436
|
+
// 1. User types /dashboard. probe fails (no daemon). Spawn child A.
|
|
1437
|
+
// 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
|
|
1438
|
+
// 3. User types /dashboard again BEFORE A is ready. probe still fails.
|
|
1439
|
+
// Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
|
|
1440
|
+
// (cli.mjs:3611) which SIGTERMs child A. B takes over.
|
|
1441
|
+
// 4. Repeat. Each /dashboard kills the previous daemon and starts a
|
|
1442
|
+
// new one. With autorepeat / many slash calls this stacks fast.
|
|
1443
|
+
//
|
|
1444
|
+
// Two-layer guard:
|
|
1445
|
+
// - A module-level _dashboardSpawning latch refuses concurrent spawn
|
|
1446
|
+
// attempts. While a spawn is in flight, /dashboard says so + returns
|
|
1447
|
+
// without firing another child.
|
|
1448
|
+
// - A _dashboardChildPid cache remembers the PID we already spawned;
|
|
1449
|
+
// subsequent calls check kill(pid, 0) to confirm the child is alive
|
|
1450
|
+
// and just open the browser without spawning.
|
|
1451
|
+
//
|
|
1452
|
+
// We probe both /healthz (HTTP) AND a raw net.connect port check so a
|
|
1453
|
+
// slow-starting daemon (binding the listener but not yet answering HTTP)
|
|
1454
|
+
// still counts as "running".
|
|
1455
|
+
let _dashboardSpawning = false;
|
|
1456
|
+
let _dashboardChildPid = null;
|
|
1457
|
+
|
|
1458
|
+
function _portIsListening(port, timeoutMs = 200) {
|
|
1459
|
+
return new Promise((resolve) => {
|
|
1460
|
+
import('node:net').then(({ createConnection }) => {
|
|
1461
|
+
let settled = false;
|
|
1462
|
+
const sock = createConnection({ host: '127.0.0.1', port });
|
|
1463
|
+
const done = (ok) => {
|
|
1464
|
+
if (settled) return;
|
|
1465
|
+
settled = true;
|
|
1466
|
+
try { sock.destroy(); } catch {}
|
|
1467
|
+
resolve(ok);
|
|
1468
|
+
};
|
|
1469
|
+
sock.once('connect', () => done(true));
|
|
1470
|
+
sock.once('error', () => done(false));
|
|
1471
|
+
setTimeout(() => done(false), timeoutMs);
|
|
1472
|
+
}).catch(() => resolve(false));
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
async function _dashboardProbe(port) {
|
|
1477
|
+
// Fast path — port-level probe. Catches a daemon that has bound the
|
|
1478
|
+
// socket but hasn't finished initializing its HTTP routes.
|
|
1479
|
+
if (await _portIsListening(port, 200)) return true;
|
|
1480
|
+
// Slow path — full /healthz fetch, for defense in depth.
|
|
1481
|
+
if (typeof fetch !== 'function') return false;
|
|
1482
|
+
try {
|
|
1483
|
+
const ac = new AbortController();
|
|
1484
|
+
const t = setTimeout(() => ac.abort(), 250);
|
|
1485
|
+
const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
|
|
1486
|
+
clearTimeout(t);
|
|
1487
|
+
return !!(r && r.ok);
|
|
1488
|
+
} catch { return false; }
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function _openBrowser(url) {
|
|
1492
|
+
return import('node:child_process').then(({ spawn }) => {
|
|
831
1493
|
let cmd, args;
|
|
832
|
-
if (process.platform === 'darwin') { cmd = 'open'; args = [
|
|
833
|
-
else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""',
|
|
834
|
-
else { cmd = 'xdg-open'; args = [
|
|
1494
|
+
if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
|
|
1495
|
+
else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
|
|
1496
|
+
else { cmd = 'xdg-open'; args = [url]; }
|
|
835
1497
|
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
|
|
836
|
-
};
|
|
837
|
-
|
|
838
|
-
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
async function _dashboardStop(port) {
|
|
1502
|
+
// Best-effort kill of every lazyclaw dashboard daemon on the box.
|
|
1503
|
+
// Used to clean up after the v5.4.3 spawn pile-up bug.
|
|
1504
|
+
if (process.platform === 'win32') {
|
|
1505
|
+
return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
|
|
1506
|
+
}
|
|
1507
|
+
const { spawn } = await import('node:child_process');
|
|
1508
|
+
// Step 1: lsof the port and SIGTERM each PID.
|
|
1509
|
+
const portPids = await new Promise((resolve) => {
|
|
839
1510
|
try {
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1511
|
+
const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
1512
|
+
let buf = '';
|
|
1513
|
+
lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
|
|
1514
|
+
lsof.on('error', () => resolve([]));
|
|
1515
|
+
lsof.on('close', () => resolve(
|
|
1516
|
+
buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
|
|
1517
|
+
));
|
|
1518
|
+
} catch { resolve([]); }
|
|
1519
|
+
});
|
|
1520
|
+
for (const pid of portPids) {
|
|
1521
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
|
|
1522
|
+
}
|
|
1523
|
+
// Step 2: pkill any process whose command line includes "lazyclaw dashboard"
|
|
1524
|
+
// — catches detached children that bound a different (random) port via
|
|
1525
|
+
// cmdDashboard's EADDRINUSE fallback.
|
|
1526
|
+
let pkilled = 0;
|
|
1527
|
+
try {
|
|
1528
|
+
const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
1529
|
+
pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
|
|
1530
|
+
} catch { /* fine */ }
|
|
1531
|
+
_dashboardChildPid = null;
|
|
1532
|
+
return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// Parse the daemon's "listening at <url>" stdout line so /dashboard opens the
|
|
1536
|
+
// actually-bound port (the child may fall back to a random port on EADDRINUSE).
|
|
1537
|
+
export function parseDashboardUrl(text) {
|
|
1538
|
+
const m = String(text || '').match(/listening at\s+(https?:\/\/\S+)/i);
|
|
1539
|
+
return m ? m[1] : null;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Resolve the daemon's real URL from its stdout within `timeoutMs`, or null.
|
|
1543
|
+
function _waitForDashboardUrl(child, timeoutMs) {
|
|
1544
|
+
return new Promise((resolve) => {
|
|
1545
|
+
if (!child || !child.stdout) { resolve(null); return; }
|
|
1546
|
+
let buf = '';
|
|
1547
|
+
let done = false;
|
|
1548
|
+
const finish = (v) => {
|
|
1549
|
+
if (done) return;
|
|
1550
|
+
done = true;
|
|
1551
|
+
try { child.stdout.off('data', onData); } catch { /* ignore */ }
|
|
1552
|
+
resolve(v);
|
|
1553
|
+
};
|
|
1554
|
+
const onData = (d) => {
|
|
1555
|
+
buf += d.toString('utf8');
|
|
1556
|
+
const u = parseDashboardUrl(buf);
|
|
1557
|
+
if (u) finish(u);
|
|
1558
|
+
};
|
|
1559
|
+
child.stdout.on('data', onData);
|
|
1560
|
+
setTimeout(() => finish(null), timeoutMs);
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
async function _dashboard(args) {
|
|
1565
|
+
const port = 19600;
|
|
1566
|
+
const url = `http://127.0.0.1:${port}/dashboard`;
|
|
1567
|
+
// Under the node:test runner, never launch a real daemon or open a browser
|
|
1568
|
+
// (it leaked a background daemon + opened a tab on every test run).
|
|
1569
|
+
if (process.env.NODE_TEST_CONTEXT) return `dashboard: ${url} (spawn skipped under test)`;
|
|
1570
|
+
const sub = splitWhitespace(args)[0];
|
|
1571
|
+
if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
|
|
1572
|
+
|
|
1573
|
+
// 1. Already running anywhere on the machine? → reuse.
|
|
1574
|
+
if (await _dashboardProbe(port)) {
|
|
1575
|
+
await _openBrowser(url);
|
|
849
1576
|
return `✓ dashboard already running — opened ${url}`;
|
|
850
1577
|
}
|
|
851
|
-
|
|
1578
|
+
|
|
1579
|
+
// 2. We spawned in this chat — is that child still alive?
|
|
1580
|
+
if (_dashboardChildPid != null) {
|
|
1581
|
+
try {
|
|
1582
|
+
process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
|
|
1583
|
+
// Child alive but not answering yet. Don't re-spawn; just nudge.
|
|
1584
|
+
await _openBrowser(url);
|
|
1585
|
+
return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
|
|
1586
|
+
} catch {
|
|
1587
|
+
_dashboardChildPid = null; // child died; fall through and respawn.
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
|
|
1592
|
+
if (_dashboardSpawning) {
|
|
1593
|
+
await _openBrowser(url);
|
|
1594
|
+
return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
|
|
1598
|
+
// spawn flag in a finally so it always clears.
|
|
1599
|
+
_dashboardSpawning = true;
|
|
852
1600
|
try {
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
1601
|
+
const { spawn } = await import('node:child_process');
|
|
1602
|
+
let child;
|
|
1603
|
+
try {
|
|
1604
|
+
// Pass --port so the child tries 19600 first; pipe stdout so we can read
|
|
1605
|
+
// the real bound URL (it may fall back to a random port on EADDRINUSE).
|
|
1606
|
+
child = spawn(process.execPath, [process.argv[1], 'dashboard', '--port', String(port), '--no-open'], {
|
|
1607
|
+
detached: true, stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd(), env: process.env,
|
|
1608
|
+
});
|
|
1609
|
+
_dashboardChildPid = child.pid;
|
|
1610
|
+
} catch (e) {
|
|
1611
|
+
return `dashboard error: failed to spawn — ${e?.message || e}`;
|
|
1612
|
+
}
|
|
1613
|
+
// Prefer the daemon's own "listening at <url>" line — it carries the
|
|
1614
|
+
// actual port even after a random-port fallback.
|
|
1615
|
+
const boundUrl = await _waitForDashboardUrl(child, 3000);
|
|
1616
|
+
// Release the captured stdout pipe so the detached daemon doesn't keep
|
|
1617
|
+
// OUR event loop alive (unref, not destroy — destroying would EPIPE the
|
|
1618
|
+
// daemon on its next stdout write). Then unref the child itself.
|
|
1619
|
+
try { if (child.stdout) { child.stdout.removeAllListeners('data'); child.stdout.unref(); } } catch { /* ignore */ }
|
|
856
1620
|
child.unref();
|
|
857
|
-
|
|
858
|
-
|
|
1621
|
+
if (boundUrl) {
|
|
1622
|
+
await _openBrowser(boundUrl);
|
|
1623
|
+
return `✓ started dashboard (pid ${child.pid}) — opened ${boundUrl}`;
|
|
1624
|
+
}
|
|
1625
|
+
// Fallback: the line never arrived — poll the default port best-effort.
|
|
1626
|
+
const start = Date.now();
|
|
1627
|
+
while (Date.now() - start < 1500) {
|
|
1628
|
+
if (await _dashboardProbe(port)) {
|
|
1629
|
+
await _openBrowser(url);
|
|
1630
|
+
return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
|
|
1631
|
+
}
|
|
1632
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
1633
|
+
}
|
|
1634
|
+
return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
|
|
1635
|
+
} finally {
|
|
1636
|
+
_dashboardSpawning = false;
|
|
859
1637
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// /menu — in-chat command palette over the full subcommand catalog. The
|
|
1641
|
+
// no-arg launcher menu used to be the home screen; defaulting to chat hid it
|
|
1642
|
+
// behind `lazyclaw menu`. This restores discoverability: browse subcommands
|
|
1643
|
+
// and get the exact command to run. (Most subcommands own stdout / spawn, so
|
|
1644
|
+
// they can't safely run inline in the Ink scrollback — we echo the command.)
|
|
1645
|
+
async function _menu(args, ctx) {
|
|
1646
|
+
if (typeof ctx.openPicker === 'function') {
|
|
1647
|
+
const items = [];
|
|
1648
|
+
const seen = new Set();
|
|
1649
|
+
for (const [group, cmds] of SUBCOMMAND_GROUPS) {
|
|
1650
|
+
for (const c of cmds) {
|
|
1651
|
+
if (seen.has(c)) continue;
|
|
1652
|
+
seen.add(c);
|
|
1653
|
+
items.push({ id: c, label: c, desc: group });
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
const picked = await ctx.openPicker({
|
|
1657
|
+
kind: 'menu',
|
|
1658
|
+
title: 'lazyclaw subcommands',
|
|
1659
|
+
subtitle: 'Enter shows how to run it · Esc cancels',
|
|
1660
|
+
items,
|
|
1661
|
+
});
|
|
1662
|
+
if (!picked) return 'cancelled';
|
|
1663
|
+
const cmd = typeof picked === 'string' ? picked : picked.id;
|
|
1664
|
+
return `run from a shell: lazyclaw ${cmd}`;
|
|
865
1665
|
}
|
|
866
|
-
return
|
|
1666
|
+
return [
|
|
1667
|
+
'subcommands:',
|
|
1668
|
+
...SUBCOMMAND_GROUPS.map(([g, cmds]) => ` ${g.padEnd(9)} ${cmds.join(' ')}`),
|
|
1669
|
+
'(run: lazyclaw <subcommand>)',
|
|
1670
|
+
].join('\n');
|
|
867
1671
|
}
|
|
868
1672
|
|
|
869
1673
|
// ─── dispatch table ──────────────────────────────────────────────────────
|
|
@@ -879,7 +1683,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
879
1683
|
['/provider', _provider],
|
|
880
1684
|
['/model', _model],
|
|
881
1685
|
['/skill', _skill],
|
|
882
|
-
['/skills',
|
|
1686
|
+
['/skills', _skillsList],
|
|
883
1687
|
['/tools', _tools],
|
|
884
1688
|
['/recall', _recall],
|
|
885
1689
|
['/memory', _memory],
|
|
@@ -893,6 +1697,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
893
1697
|
['/task', _task],
|
|
894
1698
|
['/trainer', _trainer],
|
|
895
1699
|
['/dashboard', _dashboard],
|
|
1700
|
+
['/menu', _menu],
|
|
896
1701
|
['/exit', async () => 'EXIT'],
|
|
897
1702
|
['/quit', async () => 'EXIT'],
|
|
898
1703
|
]);
|