lazyclaw 5.4.4 → 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/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 +2 -1
- package/tui/slash_dispatcher.mjs +717 -58
- 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.
|
|
@@ -146,28 +163,198 @@ function _providerLookup(registry, name) {
|
|
|
146
163
|
return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
|
|
147
164
|
}
|
|
148
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
|
+
|
|
149
337
|
async function _provider(args, ctx) {
|
|
150
338
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
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).
|
|
154
350
|
if (!args) {
|
|
155
351
|
if (typeof ctx.openPicker === 'function') {
|
|
156
|
-
const
|
|
157
|
-
const info = registry.PROVIDER_INFO || {};
|
|
158
|
-
const items = known.map((id) => ({
|
|
159
|
-
id,
|
|
160
|
-
label: id,
|
|
161
|
-
desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
|
|
162
|
-
}));
|
|
163
|
-
const picked = await ctx.openPicker({
|
|
164
|
-
kind: 'provider',
|
|
165
|
-
title: 'select provider',
|
|
166
|
-
subtitle: `current: ${ctx.getActiveProvName()}`,
|
|
167
|
-
items,
|
|
168
|
-
});
|
|
352
|
+
const picked = await _pickProviderDrillIn(ctx, registry);
|
|
169
353
|
if (!picked) return 'cancelled';
|
|
354
|
+
if (picked === '__add_custom__') return _addCustomFlow(ctx, registry);
|
|
170
355
|
args = picked;
|
|
356
|
+
// Built-in api-key provider with no key configured → offer to set one.
|
|
357
|
+
await _maybePromptForKey(ctx, registry, args);
|
|
171
358
|
} else {
|
|
172
359
|
return `provider: ${ctx.getActiveProvName()}\n(pass an arg: /provider <name>)`;
|
|
173
360
|
}
|
|
@@ -182,32 +369,189 @@ async function _provider(args, ctx) {
|
|
|
182
369
|
return `provider → ${args}`;
|
|
183
370
|
}
|
|
184
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
|
+
|
|
185
502
|
async function _model(args, ctx) {
|
|
186
503
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
187
504
|
if (!args) {
|
|
188
505
|
if (typeof ctx.openPicker === 'function') {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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);
|
|
194
523
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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}`;
|
|
210
553
|
}
|
|
554
|
+
return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
|
|
211
555
|
}
|
|
212
556
|
const { parseSlashProviderModel } = registry;
|
|
213
557
|
const parsed = typeof parseSlashProviderModel === 'function'
|
|
@@ -264,6 +608,36 @@ async function _skill(args, ctx) {
|
|
|
264
608
|
}
|
|
265
609
|
}
|
|
266
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
|
+
|
|
267
641
|
async function _tools(_args) {
|
|
268
642
|
let registry;
|
|
269
643
|
try {
|
|
@@ -467,10 +841,10 @@ async function _loop(args, ctx, write) {
|
|
|
467
841
|
catch (e) { return `loop unavailable: ${e?.message || e}`; }
|
|
468
842
|
if (!args) {
|
|
469
843
|
return [
|
|
470
|
-
'usage: /loop <prompt> [--max N] [--until "<regex>"]',
|
|
844
|
+
'usage: /loop <prompt> [--max N] [--until "<regex>"] [--use-memory] [--recall "<q>"]',
|
|
471
845
|
` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}`,
|
|
472
846
|
` session: ${ctx.getSessionId && ctx.getSessionId() || '(none — turns will not be persisted)'}`,
|
|
473
|
-
'
|
|
847
|
+
' press Esc to abort a running loop.',
|
|
474
848
|
].join('\n');
|
|
475
849
|
}
|
|
476
850
|
let parsed;
|
|
@@ -500,7 +874,31 @@ async function _loop(args, ctx, write) {
|
|
|
500
874
|
};
|
|
501
875
|
|
|
502
876
|
const messages = ctx.getMessages();
|
|
503
|
-
|
|
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;
|
|
504
902
|
try {
|
|
505
903
|
const result = await loopMod.runLoop({
|
|
506
904
|
prompt: parsed.prompt,
|
|
@@ -514,7 +912,8 @@ async function _loop(args, ctx, write) {
|
|
|
514
912
|
try { write(` ↻ loop iteration ${i}/${max}\n`); } catch {}
|
|
515
913
|
}
|
|
516
914
|
},
|
|
517
|
-
signal
|
|
915
|
+
signal,
|
|
916
|
+
buildSystem,
|
|
518
917
|
});
|
|
519
918
|
if (ctx.setCharsSent && ctx.getCharsSent) {
|
|
520
919
|
ctx.setCharsSent(ctx.getCharsSent() + parsed.prompt.length * result.iterations);
|
|
@@ -524,6 +923,19 @@ async function _loop(args, ctx, write) {
|
|
|
524
923
|
return `✓ loop done — ${result.iterations}/${parsed.max} iteration(s)${tail}`;
|
|
525
924
|
} catch (err) {
|
|
526
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
|
+
}
|
|
527
939
|
}
|
|
528
940
|
}
|
|
529
941
|
|
|
@@ -560,9 +972,23 @@ async function _goal(args, ctx) {
|
|
|
560
972
|
if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
|
|
561
973
|
try {
|
|
562
974
|
const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
}
|
|
566
992
|
return `✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})${cronNote}`;
|
|
567
993
|
} catch (e) { return `goal error: ${e?.message || e}`; }
|
|
568
994
|
}
|
|
@@ -580,7 +1006,17 @@ async function _goal(args, ctx) {
|
|
|
580
1006
|
if (!name) return 'usage: /goal close <name> [done|abandoned]';
|
|
581
1007
|
try {
|
|
582
1008
|
const g = goalsMod.closeGoal(name, outcome, ctx.cfgDir);
|
|
583
|
-
|
|
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}`;
|
|
584
1020
|
} catch (e) { return `goal error: ${e?.message || e}`; }
|
|
585
1021
|
}
|
|
586
1022
|
// single-arg branch: switch
|
|
@@ -721,11 +1157,11 @@ function _personalityUse(name, ctx, fs, path) {
|
|
|
721
1157
|
return `active personality → ${name}`;
|
|
722
1158
|
}
|
|
723
1159
|
|
|
724
|
-
async function _task(args, ctx) {
|
|
1160
|
+
async function _task(args, ctx, write) {
|
|
725
1161
|
let tasksMod, loopMod;
|
|
726
1162
|
try {
|
|
727
|
-
tasksMod = await import('../tasks.mjs');
|
|
728
|
-
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'));
|
|
729
1165
|
} catch (e) { return `/task unavailable: ${e?.message || e}`; }
|
|
730
1166
|
let tokens;
|
|
731
1167
|
try { tokens = loopMod.splitArgs(args); }
|
|
@@ -759,18 +1195,120 @@ async function _task(args, ctx) {
|
|
|
759
1195
|
}
|
|
760
1196
|
if (sub === 'abandon' || sub === 'done') {
|
|
761
1197
|
if (!id) return `usage: /task ${sub} <id>`;
|
|
762
|
-
const
|
|
763
|
-
|
|
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}`;
|
|
764
1220
|
}
|
|
765
1221
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
766
1222
|
if (!id) return 'usage: /task remove <id>';
|
|
767
1223
|
tasksMod.removeTask(id, ctx.cfgDir);
|
|
768
1224
|
return `✓ removed task ${id}`;
|
|
769
1225
|
}
|
|
770
|
-
if (sub === 'start'
|
|
771
|
-
|
|
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'})`;
|
|
772
1271
|
}
|
|
773
|
-
|
|
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`;
|
|
774
1312
|
} catch (e) {
|
|
775
1313
|
return `/task error: ${e?.message || e}`;
|
|
776
1314
|
}
|
|
@@ -813,6 +1351,19 @@ async function _trainer(args, ctx) {
|
|
|
813
1351
|
const next = _providerLookup(registry, parsed.provider);
|
|
814
1352
|
if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
|
|
815
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
|
+
}
|
|
816
1367
|
// Read-merge-write so unrelated cfg keys survive.
|
|
817
1368
|
const fs = await import('node:fs');
|
|
818
1369
|
const path = await import('node:path');
|
|
@@ -822,10 +1373,38 @@ async function _trainer(args, ctx) {
|
|
|
822
1373
|
diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
|
|
823
1374
|
if (parsed.model) diskCfg.trainer.model = parsed.model;
|
|
824
1375
|
else delete diskCfg.trainer.model;
|
|
1376
|
+
if (fallbackSpec) diskCfg.trainer.fallback = fallbackSpec;
|
|
825
1377
|
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
826
1378
|
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
827
1379
|
if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
|
|
828
|
-
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}`;
|
|
829
1408
|
}
|
|
830
1409
|
|
|
831
1410
|
if (sub === 'clear' || sub === 'unset') {
|
|
@@ -953,9 +1532,41 @@ async function _dashboardStop(port) {
|
|
|
953
1532
|
return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
|
|
954
1533
|
}
|
|
955
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
|
+
|
|
956
1564
|
async function _dashboard(args) {
|
|
957
1565
|
const port = 19600;
|
|
958
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)`;
|
|
959
1570
|
const sub = splitWhitespace(args)[0];
|
|
960
1571
|
if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
|
|
961
1572
|
|
|
@@ -990,16 +1601,30 @@ async function _dashboard(args) {
|
|
|
990
1601
|
const { spawn } = await import('node:child_process');
|
|
991
1602
|
let child;
|
|
992
1603
|
try {
|
|
993
|
-
child
|
|
994
|
-
|
|
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,
|
|
995
1608
|
});
|
|
996
|
-
child.unref();
|
|
997
1609
|
_dashboardChildPid = child.pid;
|
|
998
1610
|
} catch (e) {
|
|
999
1611
|
return `dashboard error: failed to spawn — ${e?.message || e}`;
|
|
1000
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 */ }
|
|
1620
|
+
child.unref();
|
|
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.
|
|
1001
1626
|
const start = Date.now();
|
|
1002
|
-
while (Date.now() - start <
|
|
1627
|
+
while (Date.now() - start < 1500) {
|
|
1003
1628
|
if (await _dashboardProbe(port)) {
|
|
1004
1629
|
await _openBrowser(url);
|
|
1005
1630
|
return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
|
|
@@ -1012,6 +1637,39 @@ async function _dashboard(args) {
|
|
|
1012
1637
|
}
|
|
1013
1638
|
}
|
|
1014
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}`;
|
|
1665
|
+
}
|
|
1666
|
+
return [
|
|
1667
|
+
'subcommands:',
|
|
1668
|
+
...SUBCOMMAND_GROUPS.map(([g, cmds]) => ` ${g.padEnd(9)} ${cmds.join(' ')}`),
|
|
1669
|
+
'(run: lazyclaw <subcommand>)',
|
|
1670
|
+
].join('\n');
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1015
1673
|
// ─── dispatch table ──────────────────────────────────────────────────────
|
|
1016
1674
|
|
|
1017
1675
|
export const SLASH_HANDLERS = new Map([
|
|
@@ -1025,7 +1683,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
1025
1683
|
['/provider', _provider],
|
|
1026
1684
|
['/model', _model],
|
|
1027
1685
|
['/skill', _skill],
|
|
1028
|
-
['/skills',
|
|
1686
|
+
['/skills', _skillsList],
|
|
1029
1687
|
['/tools', _tools],
|
|
1030
1688
|
['/recall', _recall],
|
|
1031
1689
|
['/memory', _memory],
|
|
@@ -1039,6 +1697,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
1039
1697
|
['/task', _task],
|
|
1040
1698
|
['/trainer', _trainer],
|
|
1041
1699
|
['/dashboard', _dashboard],
|
|
1700
|
+
['/menu', _menu],
|
|
1042
1701
|
['/exit', async () => 'EXIT'],
|
|
1043
1702
|
['/quit', async () => 'EXIT'],
|
|
1044
1703
|
]);
|