lazyclaw 6.3.1 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +90 -33
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +196 -80
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
package/tui/slash_dispatcher.mjs
CHANGED
|
@@ -26,16 +26,25 @@
|
|
|
26
26
|
// skillsMod, // optional — falls back to dynamic import
|
|
27
27
|
// }
|
|
28
28
|
//
|
|
29
|
-
// Interactive sub-menus
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// LAZYCLAW_NO_INK=1. Re-implementing these as Ink overlays is a v5.5 item.
|
|
29
|
+
// Interactive sub-menus that are readline-coupled in cli.mjs surface a hint in
|
|
30
|
+
// Ink instead of crashing; pass an arg form (`/provider openai`) or set
|
|
31
|
+
// LAZYCLAW_NO_INK=1.
|
|
33
32
|
|
|
34
33
|
import { SLASH_COMMANDS } from './slash_commands.mjs';
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
34
|
+
import { nearest } from '../lib/args.mjs';
|
|
35
|
+
import {
|
|
36
|
+
pickProviderModel,
|
|
37
|
+
pickProviderDrillIn as _pickProviderDrillIn,
|
|
38
|
+
infoFor as _infoFor,
|
|
39
|
+
providerLookup as _providerLookup,
|
|
40
|
+
} from './model_pick.mjs';
|
|
41
|
+
import { renderRecord } from '../lib/render.mjs';
|
|
37
42
|
import { addCustomProvider } from '../providers/custom_provider.mjs';
|
|
38
43
|
import { setAuthKey } from '../providers/auth_store.mjs';
|
|
44
|
+
import { runProviderLogin, loginSlash } from './login_flow.mjs';
|
|
45
|
+
import { hudSlash } from './hud.mjs';
|
|
46
|
+
import { agenticSlash, planSlash, CHAT_MODE_SLASH_COMMANDS } from './chat_mode_slash.mjs';
|
|
47
|
+
import { orchestratorSlash, pickAndSetModel } from './orchestrator_flow.mjs';
|
|
39
48
|
import { attachGoalCron, detachGoalCron } from '../goals_cron.mjs';
|
|
40
49
|
import { loadDotenvIfAny } from '../dotenv_min.mjs';
|
|
41
50
|
import { SUBCOMMAND_GROUPS } from './subcommands.mjs';
|
|
@@ -156,77 +165,18 @@ async function _newReset(_args, ctx) {
|
|
|
156
165
|
return 'cleared — new conversation';
|
|
157
166
|
}
|
|
158
167
|
|
|
159
|
-
function _providerLookup(registry, name) {
|
|
160
|
-
if (typeof registry.lookupProv === 'function') return registry.lookupProv(name);
|
|
161
|
-
return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Pick a provider via the family drill-in (API key / CLI-Local / Mock),
|
|
165
|
-
// mirroring the legacy readline wizard. Single-option steps auto-advance.
|
|
166
|
-
// orchestrator is never listed. Returns a provider id or null on cancel.
|
|
167
|
-
async function _pickProviderDrillIn(ctx, registry) {
|
|
168
|
-
const info = registry.PROVIDER_INFO || {};
|
|
169
|
-
const families = providerFamilies(registry);
|
|
170
|
-
const nonEmpty = Object.values(families).filter((f) => f.members.length > 0);
|
|
171
|
-
if (!nonEmpty.length) return null;
|
|
172
|
-
|
|
173
|
-
// ── Step 1 — auth family (skipped when only one is populated) ──
|
|
174
|
-
let family = nonEmpty[0];
|
|
175
|
-
if (nonEmpty.length > 1) {
|
|
176
|
-
const picked = await ctx.openPicker({
|
|
177
|
-
kind: 'provider-family',
|
|
178
|
-
title: 'select provider — how do you want to auth?',
|
|
179
|
-
subtitle: 'API: bring your own key · CLI/Local: use this machine · Mock: offline',
|
|
180
|
-
items: nonEmpty.map((f) => ({
|
|
181
|
-
id: f.id,
|
|
182
|
-
label: f.label,
|
|
183
|
-
desc: `${f.desc} · ${f.members.slice(0, 3).join(' / ')}${f.members.length > 3 ? ` (+${f.members.length - 3})` : ''}`,
|
|
184
|
-
tag: f.tag,
|
|
185
|
-
})),
|
|
186
|
-
});
|
|
187
|
-
if (!picked || typeof picked !== 'string') return null;
|
|
188
|
-
family = families[picked];
|
|
189
|
-
if (!family || !family.members.length) return null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ── Step 2 — provider in that family (auto-advances on a single member) ──
|
|
193
|
-
// The API-key family also offers "+ add a custom endpoint" so NIM /
|
|
194
|
-
// OpenRouter / vLLM / etc. can be registered without leaving chat. The
|
|
195
|
-
// add-custom row is never auto-advanced past, so don't single-skip it in.
|
|
196
|
-
const items = family.members.map((id) => {
|
|
197
|
-
const meta = info[id] || {};
|
|
198
|
-
let desc = '';
|
|
199
|
-
if (meta.custom) desc = `custom · ${meta.baseUrl || ''}`;
|
|
200
|
-
else if (meta.builtinOpenAICompat) desc = meta.label || meta.baseUrl || '';
|
|
201
|
-
else if (meta.label && meta.label !== id) desc = meta.label;
|
|
202
|
-
return { id, label: id, desc, tag: providerTag(meta) };
|
|
203
|
-
});
|
|
204
|
-
if (family.id === 'api') {
|
|
205
|
-
items.push({
|
|
206
|
-
id: '__add_custom__',
|
|
207
|
-
label: '+ add a custom OpenAI-compatible endpoint…',
|
|
208
|
-
desc: 'NIM · OpenRouter · Together · Groq · vLLM · LM Studio',
|
|
209
|
-
tag: 'new',
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
if (items.length === 1) return items[0].id;
|
|
213
|
-
const picked = await ctx.openPicker({
|
|
214
|
-
kind: 'provider',
|
|
215
|
-
title: `select provider — ${family.label}`,
|
|
216
|
-
subtitle: `current: ${ctx.getActiveProvName()}`,
|
|
217
|
-
items,
|
|
218
|
-
});
|
|
219
|
-
return typeof picked === 'string' ? picked : null;
|
|
220
|
-
}
|
|
221
168
|
|
|
222
169
|
// Single free-text prompt reusing the modal's filter buffer (no dedicated
|
|
223
170
|
// input widget). Returns the typed value, '' (only when allowEmpty), or null
|
|
224
171
|
// on cancel / required-but-empty.
|
|
225
|
-
async function _promptText(ctx, { title, subtitle, allowEmpty } = {}) {
|
|
172
|
+
async function _promptText(ctx, { title, subtitle, allowEmpty, secret } = {}) {
|
|
226
173
|
if (typeof ctx.openPicker !== 'function') return null;
|
|
227
174
|
const picked = await ctx.openPicker({
|
|
228
175
|
kind: 'text',
|
|
229
176
|
title,
|
|
177
|
+
// `secret` masks the typed query on screen (api-key / token entry) while
|
|
178
|
+
// the real value still reaches the caller — the modal picker honors it.
|
|
179
|
+
secret: !!secret,
|
|
230
180
|
subtitle: subtitle || 'type into the filter, then pick the row · Esc cancels',
|
|
231
181
|
items: [{ id: '__text__', label: '✓ use what I typed above', desc: '', pinned: true, freeText: true }],
|
|
232
182
|
});
|
|
@@ -259,7 +209,7 @@ async function _promptConfirm(ctx, { title, subtitle } = {}) {
|
|
|
259
209
|
// Default in-chat approval hook: prompts the operator to confirm each
|
|
260
210
|
// sensitive tool call. Used to drive the fail-closed tool runner from the
|
|
261
211
|
// Ink REPL, where stdin is owned by Ink so a raw readline prompt can't run.
|
|
262
|
-
function _makeInkApprove(ctx) {
|
|
212
|
+
export function _makeInkApprove(ctx) {
|
|
263
213
|
return async function approve({ tool, args, agent }) {
|
|
264
214
|
const raw = typeof args === 'object' ? JSON.stringify(args) : String(args ?? '');
|
|
265
215
|
const summary = redactSecrets(raw).slice(0, 300);
|
|
@@ -310,6 +260,7 @@ async function _maybePromptForKey(ctx, registry, provName) {
|
|
|
310
260
|
const key = await _promptText(ctx, {
|
|
311
261
|
title: `${provName} needs an api key`,
|
|
312
262
|
subtitle: 'paste it now, or Esc to skip (set later via: lazyclaw auth)',
|
|
263
|
+
secret: true,
|
|
313
264
|
});
|
|
314
265
|
if (!key) return;
|
|
315
266
|
const next = setAuthKey({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, provider: provName, key });
|
|
@@ -327,30 +278,30 @@ async function _addCustomFlow(ctx, registry) {
|
|
|
327
278
|
if (!name) return 'cancelled';
|
|
328
279
|
const baseUrl = await _promptText(ctx, { title: `baseUrl for ${name}`, subtitle: 'must start with http(s) and end in /v1' });
|
|
329
280
|
if (!baseUrl) return 'cancelled';
|
|
330
|
-
const apiKey = await _promptText(ctx, { title: `api-key for ${name}`, subtitle: 'leave blank for an auth-less endpoint (e.g. local vLLM)', allowEmpty: true });
|
|
281
|
+
const apiKey = await _promptText(ctx, { title: `api-key for ${name}`, subtitle: 'leave blank for an auth-less endpoint (e.g. local vLLM)', allowEmpty: true, secret: true });
|
|
331
282
|
if (apiKey === null) return 'cancelled';
|
|
332
283
|
return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
|
|
333
284
|
}
|
|
334
285
|
|
|
335
286
|
async function _provider(args, ctx) {
|
|
336
287
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
337
|
-
// `/provider add <name> <baseUrl> [apiKey]` — register a custom OpenAI-
|
|
338
|
-
// compatible endpoint non-interactively.
|
|
288
|
+
// `/provider add <name> <baseUrl> [apiKey]` — register a custom OpenAI-compat endpoint.
|
|
339
289
|
const addMatch = args && args.match(/^add\s+(.+)$/i);
|
|
340
290
|
if (addMatch) {
|
|
341
291
|
const [name, baseUrl, apiKey] = splitWhitespace(addMatch[1]);
|
|
342
292
|
if (!name || !baseUrl) return 'usage: /provider add <name> <baseUrl> [apiKey]';
|
|
343
293
|
return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
|
|
344
294
|
}
|
|
345
|
-
// No arg → drill-in modal picker (family -> provider)
|
|
346
|
-
//
|
|
347
|
-
|
|
295
|
+
// No arg → drill-in modal picker (family -> provider); falls back to a hint
|
|
296
|
+
// string when ctx.openPicker isn't available (non-Ink / picker not settled).
|
|
297
|
+
let _fromPicker = false;
|
|
348
298
|
if (!args) {
|
|
349
299
|
if (typeof ctx.openPicker === 'function') {
|
|
350
300
|
const picked = await _pickProviderDrillIn(ctx, registry);
|
|
351
301
|
if (!picked) return 'cancelled';
|
|
352
302
|
if (picked === '__add_custom__') return _addCustomFlow(ctx, registry);
|
|
353
303
|
args = picked;
|
|
304
|
+
_fromPicker = true;
|
|
354
305
|
// Built-in api-key provider with no key configured → offer to set one.
|
|
355
306
|
await _maybePromptForKey(ctx, registry, args);
|
|
356
307
|
} else {
|
|
@@ -364,190 +315,43 @@ async function _provider(args, ctx) {
|
|
|
364
315
|
}
|
|
365
316
|
if (ctx.setActiveProvName) ctx.setActiveProvName(args);
|
|
366
317
|
if (ctx.setProv) ctx.setProv(next);
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// "model" is the provider name itself, so the model picker would dead-end
|
|
376
|
-
// on a single bogus row. Detect it so we can redirect to a provider pick.
|
|
377
|
-
function _isCompositeProvider(info, provName) {
|
|
378
|
-
if (info && info.composite) return true;
|
|
379
|
-
const s = info && Array.isArray(info.suggestedModels) ? info.suggestedModels : null;
|
|
380
|
-
return !!(s && s.length === 1 && s[0] === provName);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Whether the active provider exposes any selectable model (a suggested
|
|
384
|
-
// model that isn't just the provider name, or a live-fetchable catalogue).
|
|
385
|
-
function _hasRealModels(info, provName) {
|
|
386
|
-
if (supportsLiveFetch(info, provName)) return true;
|
|
387
|
-
const s = info && Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
|
|
388
|
-
return s.some((m) => m && m !== provName);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Build the model-picker rows: suggested + any live-fetched models, deduped,
|
|
392
|
-
// with the provider default tagged, plus pinned sentinel rows for live-fetch
|
|
393
|
-
// (when supported) and free-text custom entry.
|
|
394
|
-
function _buildModelItems(info, provName, dynamicModels) {
|
|
395
|
-
const base = Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
|
|
396
|
-
const all = Array.from(new Set([...(dynamicModels || []), ...base])).filter((m) => m && m !== provName);
|
|
397
|
-
const items = [];
|
|
398
|
-
if (supportsLiveFetch(info, provName)) {
|
|
399
|
-
items.push({
|
|
400
|
-
id: '__fetch_models__',
|
|
401
|
-
label: '↻ fetch live model list',
|
|
402
|
-
desc: 'pull the current catalogue from the provider',
|
|
403
|
-
pinned: true,
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
for (const m of all) {
|
|
407
|
-
items.push({ id: m, label: m, desc: info.defaultModel === m ? '(default)' : '' });
|
|
408
|
-
}
|
|
409
|
-
items.push({
|
|
410
|
-
id: '__custom_model__',
|
|
411
|
-
label: '… type a custom model id',
|
|
412
|
-
desc: 'type the id into the filter above, then pick this row',
|
|
413
|
-
pinned: true,
|
|
414
|
-
freeText: true,
|
|
415
|
-
});
|
|
416
|
-
// Reach another provider's models (e.g. claude-cli's opus) without leaving
|
|
417
|
-
// /model — the active provider isn't the only place to pick a model.
|
|
418
|
-
items.push({
|
|
419
|
-
id: '__switch_provider__',
|
|
420
|
-
label: '⇄ pick a different provider…',
|
|
421
|
-
desc: 'switch provider (e.g. claude-cli for opus/sonnet), then its model',
|
|
422
|
-
pinned: true,
|
|
423
|
-
});
|
|
424
|
-
return items;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Run the model picker for `provName`, looping on the live-fetch row and
|
|
428
|
-
// resolving the free-text row from the typed filter. Returns a concrete
|
|
429
|
-
// model id, or null on cancel.
|
|
430
|
-
async function _pickModelLoop(ctx, registry, provName) {
|
|
431
|
-
const info = _infoFor(registry, provName);
|
|
432
|
-
let dynamic = [];
|
|
433
|
-
let note = '';
|
|
434
|
-
for (let guard = 0; guard < 50; guard++) {
|
|
435
|
-
const items = _buildModelItems(info, provName, dynamic);
|
|
436
|
-
const picked = await ctx.openPicker({
|
|
437
|
-
kind: 'model',
|
|
438
|
-
title: `select model for ${provName}`,
|
|
439
|
-
subtitle: note || `current: ${ctx.getActiveModel() || '(default)'}`,
|
|
440
|
-
items,
|
|
441
|
-
});
|
|
442
|
-
if (picked == null) return null;
|
|
443
|
-
if (typeof picked === 'object') {
|
|
444
|
-
// free-text custom row → { id, query }
|
|
445
|
-
const typed = String(picked.query || '').trim();
|
|
446
|
-
if (!typed) { note = 'type a model id into the filter first'; continue; }
|
|
447
|
-
return typed;
|
|
448
|
-
}
|
|
449
|
-
if (picked === '__fetch_models__') {
|
|
450
|
-
try {
|
|
451
|
-
const fetcher = typeof ctx.fetchModels === 'function'
|
|
452
|
-
? ctx.fetchModels
|
|
453
|
-
: (provId) => fetchModelsForProvider({
|
|
454
|
-
cfg: ctx.cfg,
|
|
455
|
-
registryMod: registry,
|
|
456
|
-
resolveAuthKey: (id) => (ctx.resolveAuthKey ? ctx.resolveAuthKey(id) : ''),
|
|
457
|
-
providerId: provId,
|
|
458
|
-
});
|
|
459
|
-
const fetched = await fetcher(provName);
|
|
460
|
-
if (Array.isArray(fetched) && fetched.length) {
|
|
461
|
-
dynamic = fetched;
|
|
462
|
-
note = `fetched ${fetched.length} model(s) — pick one or type a custom id`;
|
|
463
|
-
} else {
|
|
464
|
-
note = 'no models returned — using the suggested list';
|
|
465
|
-
}
|
|
466
|
-
} catch (e) {
|
|
467
|
-
note = `fetch failed: ${e && e.message ? e.message : e}`;
|
|
468
|
-
}
|
|
469
|
-
continue;
|
|
470
|
-
}
|
|
471
|
-
// Switch to a different provider's models (e.g. claude-cli's opus).
|
|
472
|
-
if (picked === '__switch_provider__') return '__switch_provider__';
|
|
473
|
-
return picked;
|
|
318
|
+
// Keyless CLI not signed in → inline connect menu ('EXIT' = foreground login).
|
|
319
|
+
const r = await runProviderLogin(ctx, args, { promptText: _promptText });
|
|
320
|
+
if (r !== null) return r;
|
|
321
|
+
// Picked from the modal → chain straight into a model pick so provider+model
|
|
322
|
+
// are set together (no separate /model step). Composites have no model list.
|
|
323
|
+
if (_fromPicker && args !== 'orchestrator' && typeof ctx.openPicker === 'function') {
|
|
324
|
+
const mm = await pickAndSetModel(ctx, registry, args);
|
|
325
|
+
if (mm) return `provider → ${args} · ${mm}`;
|
|
474
326
|
}
|
|
475
|
-
return
|
|
327
|
+
return `provider → ${args}`;
|
|
476
328
|
}
|
|
477
329
|
|
|
478
|
-
// Flat provider picker used to escape a composite/model-less active provider
|
|
479
|
-
// or to switch providers from inside /model. Hides composites + mock,
|
|
480
|
-
// mirroring the legacy wizard's filter (cli.mjs:1979).
|
|
481
|
-
async function _pickProviderForModel(ctx, registry, subtitle) {
|
|
482
|
-
const info = registry.PROVIDER_INFO || {};
|
|
483
|
-
const known = Object.keys(registry.PROVIDERS || {})
|
|
484
|
-
.filter((id) => id !== 'mock' && !((info[id] || {}).composite))
|
|
485
|
-
.sort();
|
|
486
|
-
const items = known.map((id) => ({
|
|
487
|
-
id,
|
|
488
|
-
label: id,
|
|
489
|
-
desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
|
|
490
|
-
}));
|
|
491
|
-
const picked = await ctx.openPicker({
|
|
492
|
-
kind: 'provider',
|
|
493
|
-
title: 'select provider (then a model)',
|
|
494
|
-
subtitle: subtitle || `${ctx.getActiveProvName()} has no selectable models — pick a provider`,
|
|
495
|
-
items,
|
|
496
|
-
});
|
|
497
|
-
return typeof picked === 'string' ? picked : null;
|
|
498
|
-
}
|
|
499
330
|
|
|
500
331
|
async function _model(args, ctx) {
|
|
501
332
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
502
333
|
if (!args) {
|
|
503
334
|
if (typeof ctx.openPicker === 'function') {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (ctx.setActiveProvName) ctx.setActiveProvName(pickedProv);
|
|
516
|
-
if (ctx.setProv) ctx.setProv(next);
|
|
517
|
-
switched = true;
|
|
518
|
-
}
|
|
519
|
-
provName = pickedProv;
|
|
520
|
-
info = _infoFor(registry, provName);
|
|
521
|
-
}
|
|
522
|
-
// Model pick loop — the "⇄ pick a different provider" row re-enters the
|
|
523
|
-
// provider picker so models from any provider (e.g. claude-cli's opus)
|
|
524
|
-
// are reachable without leaving /model.
|
|
525
|
-
let model = null;
|
|
526
|
-
for (let guard = 0; guard < 25; guard++) {
|
|
527
|
-
model = await _pickModelLoop(ctx, registry, provName);
|
|
528
|
-
if (model === '__switch_provider__') {
|
|
529
|
-
const np = await _pickProviderForModel(ctx, registry, `current: ${provName} — pick a provider`);
|
|
530
|
-
if (!np) continue; // cancelled the switch → back to the model list
|
|
531
|
-
if (np !== provName) {
|
|
532
|
-
const next = _providerLookup(registry, np);
|
|
533
|
-
if (!next) { continue; }
|
|
534
|
-
if (ctx.setActiveProvName) ctx.setActiveProvName(np);
|
|
535
|
-
if (ctx.setProv) ctx.setProv(next);
|
|
536
|
-
switched = true;
|
|
537
|
-
provName = np;
|
|
538
|
-
info = _infoFor(registry, provName);
|
|
539
|
-
}
|
|
540
|
-
continue;
|
|
541
|
-
}
|
|
542
|
-
break;
|
|
335
|
+
// Shared canonical picker: provider drill-in (only when the active
|
|
336
|
+
// provider has no models) → model loop with the "⇄ switch provider"
|
|
337
|
+
// and custom-id rows. Session-only — /model does not persist to disk.
|
|
338
|
+
const r = await pickProviderModel(ctx, registry, { includeSwitch: true });
|
|
339
|
+
if (!r) return 'cancelled';
|
|
340
|
+
const switched = r.provider !== ctx.getActiveProvName();
|
|
341
|
+
if (switched) {
|
|
342
|
+
const next = _providerLookup(registry, r.provider);
|
|
343
|
+
if (!next) return `unknown provider: ${r.provider}`;
|
|
344
|
+
if (ctx.setActiveProvName) ctx.setActiveProvName(r.provider);
|
|
345
|
+
if (ctx.setProv) ctx.setProv(next);
|
|
543
346
|
}
|
|
544
|
-
|
|
545
|
-
|
|
347
|
+
// Model pick cancelled (null) → keep any provider switch, leave model.
|
|
348
|
+
if (r.model == null) {
|
|
349
|
+
return switched ? `provider → ${r.provider} (model unchanged)` : 'cancelled';
|
|
546
350
|
}
|
|
547
|
-
if (ctx.setActiveModel) ctx.setActiveModel(model);
|
|
351
|
+
if (ctx.setActiveModel) ctx.setActiveModel(r.model);
|
|
548
352
|
return switched
|
|
549
|
-
? `provider → ${
|
|
550
|
-
: `model → ${model}`;
|
|
353
|
+
? `provider → ${r.provider} · model → ${r.model}`
|
|
354
|
+
: `model → ${r.model}`;
|
|
551
355
|
}
|
|
552
356
|
return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
|
|
553
357
|
}
|
|
@@ -569,13 +373,22 @@ async function _model(args, ctx) {
|
|
|
569
373
|
async function _skill(args, ctx) {
|
|
570
374
|
// `/skill name1,name2` — replace the active system message with a
|
|
571
375
|
// composition. No-arg → clear system message. Mirrors cli.mjs:3046.
|
|
572
|
-
|
|
376
|
+
// `clear`/`unset` are not skill names — treat them as the explicit clear verb
|
|
377
|
+
// so they reach the clear branch instead of being composed as a skill.
|
|
378
|
+
const isClear = /^(clear|unset)$/i.test((args || '').trim());
|
|
379
|
+
const names = isClear ? [] : args.split(',').map((s) => s.trim()).filter(Boolean);
|
|
573
380
|
const messages = ctx.getMessages().slice(); // mutable copy
|
|
574
381
|
const sysIdx = messages.findIndex((m) => m.role === 'system');
|
|
575
382
|
const sid = ctx.getSessionId && ctx.getSessionId();
|
|
576
383
|
const sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
|
|
577
384
|
|
|
578
385
|
if (names.length === 0) {
|
|
386
|
+
// Footgun guard: bare `/skill` used to silently wipe the active skills.
|
|
387
|
+
// Now no-arg opens the skill picker; clearing requires explicit /skill clear.
|
|
388
|
+
if (!isClear) {
|
|
389
|
+
if (typeof ctx.openPicker === 'function') return _skillsList('', ctx);
|
|
390
|
+
return 'usage: /skill <name>[,<name>] · /skill clear to unset · /skills to pick';
|
|
391
|
+
}
|
|
579
392
|
if (sysIdx >= 0) messages.splice(sysIdx, 1);
|
|
580
393
|
if (ctx.setMessages) ctx.setMessages(messages);
|
|
581
394
|
if (sid) {
|
|
@@ -619,7 +432,8 @@ async function _skillsList(args, ctx) {
|
|
|
619
432
|
if (!names.length) {
|
|
620
433
|
return [
|
|
621
434
|
'no skills installed.',
|
|
622
|
-
'
|
|
435
|
+
'starter pack: lazyclaw skills starter',
|
|
436
|
+
'install more: lazyclaw skills install <owner>/<repo>',
|
|
623
437
|
'then /skills to pick, or /skill <name>[,<name>] to activate.',
|
|
624
438
|
].join('\n');
|
|
625
439
|
}
|
|
@@ -677,6 +491,18 @@ async function _memory(args, ctx) {
|
|
|
677
491
|
try { mem = await import('../memory.mjs'); }
|
|
678
492
|
catch (e) { return `memory unavailable: ${e?.message || e}`; }
|
|
679
493
|
const tokens = splitWhitespace(args);
|
|
494
|
+
if (!tokens.length && typeof ctx.openPicker === 'function') {
|
|
495
|
+
const picked = await ctx.openPicker({
|
|
496
|
+
kind: 'menu', title: 'Memory', subtitle: 'view a memory store',
|
|
497
|
+
items: [
|
|
498
|
+
{ id: 'core', label: 'Core', desc: 'persistent core memory' },
|
|
499
|
+
{ id: 'recent', label: 'Recent', desc: 'last ~20 messages' },
|
|
500
|
+
{ id: 'episodic', label: 'Episodic', desc: 'consolidated topic files' },
|
|
501
|
+
],
|
|
502
|
+
});
|
|
503
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
504
|
+
return _memory(typeof pid === 'string' && pid ? pid : 'core', ctx);
|
|
505
|
+
}
|
|
680
506
|
const which = tokens[0] || 'core';
|
|
681
507
|
if (which === 'core') {
|
|
682
508
|
const body = mem.loadCore(ctx.cfgDir);
|
|
@@ -740,6 +566,20 @@ async function _agent(args, ctx) {
|
|
|
740
566
|
const rest = tokens.slice(1);
|
|
741
567
|
const aname = rest[0];
|
|
742
568
|
try {
|
|
569
|
+
if (!sub && typeof ctx.openPicker === 'function') {
|
|
570
|
+
const picked = await ctx.openPicker({
|
|
571
|
+
kind: 'menu', title: 'Agents', subtitle: `${agentsMod.listAgents(ctx.cfgDir).length} registered`,
|
|
572
|
+
items: [
|
|
573
|
+
{ id: 'list', label: 'List agents', desc: 'show all' },
|
|
574
|
+
{ id: 'add', label: 'Add agent…', desc: '/agent add <name> [role]' },
|
|
575
|
+
{ id: 'edit', label: 'Edit agent…', desc: 'pick provider + model' },
|
|
576
|
+
{ id: 'show', label: 'Show agent…', desc: 'print one record' },
|
|
577
|
+
{ id: 'remove', label: 'Remove agent…', desc: 'delete a record' },
|
|
578
|
+
],
|
|
579
|
+
});
|
|
580
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
581
|
+
return _agent(typeof pid === 'string' && pid ? pid : 'list', ctx);
|
|
582
|
+
}
|
|
743
583
|
if (!sub || sub === 'list') {
|
|
744
584
|
const agents = agentsMod.listAgents(ctx.cfgDir);
|
|
745
585
|
if (agents.length === 0) return 'no agents registered. /agent add <name> [...] to create.';
|
|
@@ -749,33 +589,63 @@ async function _agent(args, ctx) {
|
|
|
749
589
|
}).join('\n');
|
|
750
590
|
}
|
|
751
591
|
if (sub === 'show') {
|
|
752
|
-
if (!aname) return 'usage: /agent show <name>';
|
|
592
|
+
if (!aname) return 'usage: /agent show <name> [json]';
|
|
753
593
|
const a = agentsMod.getAgent(aname, ctx.cfgDir);
|
|
754
594
|
if (!a) return `no agent "${aname}"`;
|
|
755
|
-
return
|
|
595
|
+
return rest[1] === 'json'
|
|
596
|
+
? JSON.stringify(a, null, 2)
|
|
597
|
+
: renderRecord(a, { fields: ['name', 'displayName', 'provider', 'model', 'role', 'tools', 'tags', 'iconEmoji', 'memoryWrite', 'skillWrite', 'createdAt', 'updatedAt'] });
|
|
756
598
|
}
|
|
757
599
|
if (sub === 'add') {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
600
|
+
let name = aname;
|
|
601
|
+
let roleText = rest.slice(1).join(' ').trim();
|
|
602
|
+
// Guided fill: no name typed + a modal available → prompt for it.
|
|
603
|
+
if (!name && typeof ctx.openPicker === 'function') {
|
|
604
|
+
name = await _promptText(ctx, { title: 'New agent — name', subtitle: 'short id, e.g. scout (Esc cancels)' });
|
|
605
|
+
if (!name) return 'agent add: cancelled';
|
|
606
|
+
if (!roleText) {
|
|
607
|
+
const r = await _promptText(ctx, { title: `Role for ${name}`, subtitle: 'one line describing what this agent does (Esc to skip)', allowEmpty: true });
|
|
608
|
+
roleText = r || '';
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (!name) return 'usage: /agent add <name> [role text…]';
|
|
612
|
+
const a = agentsMod.registerAgent({ name, role: roleText }, ctx.cfgDir);
|
|
613
|
+
return `✓ added agent ${a.name} (tools=${(a.tools || []).join(',')}) — set its model with /agent edit ${a.name}`;
|
|
614
|
+
}
|
|
615
|
+
if (sub === 'edit') {
|
|
616
|
+
if (!aname) return 'usage: /agent edit <name>';
|
|
617
|
+
const existing = agentsMod.getAgent(aname, ctx.cfgDir);
|
|
618
|
+
if (!existing) return `no agent "${aname}"`;
|
|
619
|
+
if (typeof ctx.openPicker !== 'function') {
|
|
620
|
+
return `agent edit: picker unavailable here — use: lazyclaw agent edit ${aname} --provider <p> --model <m>`;
|
|
621
|
+
}
|
|
622
|
+
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
623
|
+
const r = await pickProviderModel(ctx, registry, { pickProvider: true, includeDefault: true, includeSwitch: false });
|
|
624
|
+
if (!r || r.model == null) return 'agent edit: cancelled';
|
|
625
|
+
const patched = agentsMod.patchAgent(aname, { provider: r.provider, model: r.model || '' }, ctx.cfgDir);
|
|
626
|
+
return `✓ ${patched.name} → ${patched.provider}${patched.model ? '/' + patched.model : ''}`;
|
|
762
627
|
}
|
|
763
628
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
764
629
|
if (!aname) return 'usage: /agent remove <name>';
|
|
630
|
+
if (typeof ctx.openPicker === 'function') {
|
|
631
|
+
const ok = await _promptConfirm(ctx, { title: `Remove agent "${aname}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
|
|
632
|
+
if (!ok) return `agent remove: cancelled — "${aname}" not removed`;
|
|
633
|
+
}
|
|
765
634
|
agentsMod.removeAgent(aname, ctx.cfgDir);
|
|
766
635
|
return `✓ removed agent ${aname}`;
|
|
767
636
|
}
|
|
768
|
-
return `/agent: unknown sub "${sub}" — list|show|add|remove`;
|
|
637
|
+
return `/agent: unknown sub "${sub}" — list|show|add|edit|remove`;
|
|
769
638
|
} catch (e) {
|
|
770
639
|
return `/agent error: ${e?.message || e}`;
|
|
771
640
|
}
|
|
772
641
|
}
|
|
773
642
|
|
|
774
643
|
async function _team(args, ctx) {
|
|
775
|
-
let teamsMod, loopMod;
|
|
644
|
+
let teamsMod, loopMod, agentsMod;
|
|
776
645
|
try {
|
|
777
646
|
teamsMod = await import('../teams.mjs');
|
|
778
647
|
loopMod = await import('../loop-engine.mjs');
|
|
648
|
+
agentsMod = await import('../agents.mjs');
|
|
779
649
|
} catch (e) { return `/team unavailable: ${e?.message || e}`; }
|
|
780
650
|
let tokens;
|
|
781
651
|
try { tokens = loopMod.splitArgs(args); }
|
|
@@ -784,6 +654,19 @@ async function _team(args, ctx) {
|
|
|
784
654
|
const rest = tokens.slice(1);
|
|
785
655
|
const tname = rest[0];
|
|
786
656
|
try {
|
|
657
|
+
if (!sub && typeof ctx.openPicker === 'function') {
|
|
658
|
+
const picked = await ctx.openPicker({
|
|
659
|
+
kind: 'menu', title: 'Teams', subtitle: `${teamsMod.listTeams(ctx.cfgDir).length} registered`,
|
|
660
|
+
items: [
|
|
661
|
+
{ id: 'list', label: 'List teams', desc: 'show all' },
|
|
662
|
+
{ id: 'add', label: 'Add team…', desc: '/team add <name> --agents a,b --lead a' },
|
|
663
|
+
{ id: 'show', label: 'Show team…', desc: 'print one record' },
|
|
664
|
+
{ id: 'remove', label: 'Remove team…', desc: 'delete a team' },
|
|
665
|
+
],
|
|
666
|
+
});
|
|
667
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
668
|
+
return _team(typeof pid === 'string' && pid ? pid : 'list', ctx);
|
|
669
|
+
}
|
|
787
670
|
if (!sub || sub === 'list') {
|
|
788
671
|
const teams = teamsMod.listTeams(ctx.cfgDir);
|
|
789
672
|
if (teams.length === 0) return 'no teams registered. /team add <name> --agents a,b --lead a [--channel #x]';
|
|
@@ -793,14 +676,16 @@ async function _team(args, ctx) {
|
|
|
793
676
|
}).join('\n');
|
|
794
677
|
}
|
|
795
678
|
if (sub === 'show') {
|
|
796
|
-
if (!tname) return 'usage: /team show <name>';
|
|
679
|
+
if (!tname) return 'usage: /team show <name> [json]';
|
|
797
680
|
const t = teamsMod.getTeam(tname, ctx.cfgDir);
|
|
798
681
|
if (!t) return `no team "${tname}"`;
|
|
799
|
-
return
|
|
682
|
+
return rest[1] === 'json'
|
|
683
|
+
? JSON.stringify(t, null, 2)
|
|
684
|
+
: renderRecord(t, { fields: ['name', 'displayName', 'lead', 'agents', 'slackChannel', 'createdAt', 'updatedAt'] });
|
|
800
685
|
}
|
|
801
686
|
if (sub === 'add') {
|
|
802
|
-
if (!tname) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
|
|
803
687
|
let agentsCsv = null, lead = null, channel = '';
|
|
688
|
+
let teamName = tname;
|
|
804
689
|
for (let i = 1; i < rest.length; i++) {
|
|
805
690
|
const t = rest[i];
|
|
806
691
|
if (t === '--agents') agentsCsv = rest[++i] || '';
|
|
@@ -808,18 +693,51 @@ async function _team(args, ctx) {
|
|
|
808
693
|
else if (t === '--channel') channel = rest[++i] || '';
|
|
809
694
|
else return `/team error: unknown token "${t}"`;
|
|
810
695
|
}
|
|
811
|
-
|
|
812
|
-
|
|
696
|
+
// Guided fill: no --agents + a modal available → name prompt, then a
|
|
697
|
+
// multi-pick over registered agents, then a lead pick. Typed form
|
|
698
|
+
// (--agents …) and the no-modal path are unchanged.
|
|
699
|
+
let agentsList;
|
|
700
|
+
if (!agentsCsv && typeof ctx.openPicker === 'function') {
|
|
701
|
+
if (!teamName) {
|
|
702
|
+
teamName = await _promptText(ctx, { title: 'New team — name', subtitle: 'short id (Esc cancels)' });
|
|
703
|
+
if (!teamName) return 'team add: cancelled';
|
|
704
|
+
}
|
|
705
|
+
const all = agentsMod ? agentsMod.listAgents(ctx.cfgDir).map((a) => a.name) : [];
|
|
706
|
+
if (!all.length) return 'team add: no agents registered yet — add one with /agent add first';
|
|
707
|
+
const chosen = [];
|
|
708
|
+
for (let guard = 0; guard < 50; guard++) {
|
|
709
|
+
const items = all.filter((n) => !chosen.includes(n)).map((n) => ({ id: n, label: n }));
|
|
710
|
+
if (chosen.length) items.unshift({ id: '__done__', label: `✓ done (${chosen.length} selected)`, pinned: true });
|
|
711
|
+
if (!items.length) break;
|
|
712
|
+
const p = await ctx.openPicker({ kind: 'menu', title: `Team agents — ${chosen.length} picked`, subtitle: 'pick agents one at a time · ✓ done to finish · Esc cancels', items });
|
|
713
|
+
const id = p && typeof p === 'object' ? p.id : p;
|
|
714
|
+
if (!id) { if (chosen.length) break; return 'team add: cancelled'; }
|
|
715
|
+
if (id === '__done__') break;
|
|
716
|
+
chosen.push(id);
|
|
717
|
+
}
|
|
718
|
+
if (!chosen.length) return 'team add: cancelled (no agents picked)';
|
|
719
|
+
const lp = await ctx.openPicker({ kind: 'menu', title: 'Team lead', subtitle: 'who leads this team?', items: chosen.map((n) => ({ id: n, label: n })) });
|
|
720
|
+
lead = (lp && typeof lp === 'object' ? lp.id : lp) || chosen[0];
|
|
721
|
+
agentsList = chosen;
|
|
722
|
+
} else {
|
|
723
|
+
if (!teamName) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
|
|
724
|
+
if (!agentsCsv) return '/team add: --agents is required';
|
|
725
|
+
agentsList = teamsMod.parseListFlag(agentsCsv);
|
|
726
|
+
}
|
|
813
727
|
const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
|
|
814
728
|
botToken: process.env.SLACK_BOT_TOKEN || null,
|
|
815
729
|
apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
|
|
816
730
|
logger: () => {},
|
|
817
731
|
}) : '';
|
|
818
|
-
const team = teamsMod.registerTeam({ name:
|
|
732
|
+
const team = teamsMod.registerTeam({ name: teamName, agents: agentsList, lead, slackChannel: ch }, ctx.cfgDir);
|
|
819
733
|
return `✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})`;
|
|
820
734
|
}
|
|
821
735
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
822
736
|
if (!tname) return 'usage: /team remove <name>';
|
|
737
|
+
if (typeof ctx.openPicker === 'function') {
|
|
738
|
+
const ok = await _promptConfirm(ctx, { title: `Remove team "${tname}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
|
|
739
|
+
if (!ok) return `team remove: cancelled — "${tname}" not removed`;
|
|
740
|
+
}
|
|
823
741
|
teamsMod.removeTeam(tname, ctx.cfgDir);
|
|
824
742
|
return `✓ removed team ${tname}`;
|
|
825
743
|
}
|
|
@@ -945,6 +863,22 @@ async function _goal(args, ctx) {
|
|
|
945
863
|
sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
|
|
946
864
|
} catch (e) { return `/goal unavailable: ${e?.message || e}`; }
|
|
947
865
|
if (!args) {
|
|
866
|
+
if (typeof ctx.openPicker === 'function') {
|
|
867
|
+
const active = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
|
|
868
|
+
const picked = await ctx.openPicker({
|
|
869
|
+
kind: 'menu', title: 'Goals', subtitle: `${active.length} active`,
|
|
870
|
+
items: [
|
|
871
|
+
{ id: 'list', label: 'List goals', desc: 'show all' },
|
|
872
|
+
{ id: 'add', label: 'Add goal…', desc: '/goal add <name> [--desc] [--cron]' },
|
|
873
|
+
{ id: 'show', label: 'Show goal…', desc: 'print one record' },
|
|
874
|
+
{ id: 'close', label: 'Close goal…', desc: 'mark done/abandoned' },
|
|
875
|
+
...active.map((g) => ({ id: g.name, label: `↪ switch: ${g.name}`, desc: g.description || '' })),
|
|
876
|
+
],
|
|
877
|
+
});
|
|
878
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
879
|
+
if (pid && typeof pid === 'string') return _goal(pid, ctx);
|
|
880
|
+
// cancelled → fall through to the text list below
|
|
881
|
+
}
|
|
948
882
|
const items = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
|
|
949
883
|
if (!items.length) return 'no active goals';
|
|
950
884
|
return items.map((g) =>
|
|
@@ -967,6 +901,30 @@ async function _goal(args, ctx) {
|
|
|
967
901
|
else if (!name) name = t;
|
|
968
902
|
else return `goal error: unexpected arg "${t}"`;
|
|
969
903
|
}
|
|
904
|
+
// Guided fill: no name + a modal available → prompt name + desc, and offer
|
|
905
|
+
// a cron preset picker instead of a hand-typed spec. Typed form unchanged.
|
|
906
|
+
if (!name && typeof ctx.openPicker === 'function') {
|
|
907
|
+
name = await _promptText(ctx, { title: 'New goal — name', subtitle: 'short id (Esc cancels)' });
|
|
908
|
+
if (!name) return 'goal add: cancelled';
|
|
909
|
+
if (!desc) {
|
|
910
|
+
const d = await _promptText(ctx, { title: `Goal "${name}" — description`, subtitle: 'what is this goal? (Esc to skip)', allowEmpty: true });
|
|
911
|
+
desc = d || '';
|
|
912
|
+
}
|
|
913
|
+
if (!cron) {
|
|
914
|
+
const cp = await ctx.openPicker({
|
|
915
|
+
kind: 'menu', title: 'Schedule (optional)', subtitle: 'run this goal on a cron?',
|
|
916
|
+
items: [
|
|
917
|
+
{ id: '', label: 'none', desc: 'no schedule' },
|
|
918
|
+
{ id: '0 9 * * *', label: 'daily 09:00', desc: '0 9 * * *' },
|
|
919
|
+
{ id: '0 * * * *', label: 'hourly', desc: '0 * * * *' },
|
|
920
|
+
{ id: '0 9 * * 1', label: 'weekly (Mon 09:00)', desc: '0 9 * * 1' },
|
|
921
|
+
{ id: '__custom__', label: 'custom…', desc: 'type a cron spec', freeText: true },
|
|
922
|
+
],
|
|
923
|
+
});
|
|
924
|
+
if (cp && typeof cp === 'object' && cp.id === '__custom__') cron = String(cp.query || '').trim() || null;
|
|
925
|
+
else { const cid = cp && typeof cp === 'object' ? cp.id : cp; cron = cid && typeof cid === 'string' ? cid : null; }
|
|
926
|
+
}
|
|
927
|
+
}
|
|
970
928
|
if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
|
|
971
929
|
try {
|
|
972
930
|
const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
|
|
@@ -993,10 +951,12 @@ async function _goal(args, ctx) {
|
|
|
993
951
|
if (sub === 'list') return JSON.stringify(goalsMod.listGoals(ctx.cfgDir), null, 2);
|
|
994
952
|
if (sub === 'show') {
|
|
995
953
|
const name = rest[0];
|
|
996
|
-
if (!name) return 'usage: /goal show <name>';
|
|
954
|
+
if (!name) return 'usage: /goal show <name> [json]';
|
|
997
955
|
const g = goalsMod.getGoal(name, ctx.cfgDir);
|
|
998
956
|
if (!g) return `no goal "${name}"`;
|
|
999
|
-
return
|
|
957
|
+
return rest[1] === 'json'
|
|
958
|
+
? JSON.stringify(g, null, 2)
|
|
959
|
+
: renderRecord(g, { fields: ['name', 'status', 'description', 'schedule', 'channels', 'createdAt', 'memoryPath'] });
|
|
1000
960
|
}
|
|
1001
961
|
if (sub === 'close') {
|
|
1002
962
|
const name = rest[0];
|
|
@@ -1014,7 +974,7 @@ async function _goal(args, ctx) {
|
|
|
1014
974
|
if (removed) detachNote = ' (cron detached)';
|
|
1015
975
|
} catch { /* best-effort */ }
|
|
1016
976
|
}
|
|
1017
|
-
return `✓ goal ${g.name} closed (status: ${g.status})${detachNote}`;
|
|
977
|
+
return `✓ goal ${g.name} closed (status: ${g.status})${detachNote} — start a fresh goal with /goal add ${g.name} --desc "..."`;
|
|
1018
978
|
} catch (e) { return `goal error: ${e?.message || e}`; }
|
|
1019
979
|
}
|
|
1020
980
|
// single-arg branch: switch
|
|
@@ -1116,17 +1076,37 @@ async function _personality(args, ctx) {
|
|
|
1116
1076
|
return fs.readFileSync(p, 'utf8');
|
|
1117
1077
|
}
|
|
1118
1078
|
if (sub === 'install') {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
if (!
|
|
1123
|
-
|
|
1124
|
-
|
|
1079
|
+
let nm = a, src = b;
|
|
1080
|
+
// Guided fill: missing name/file + a modal → prompt for them, and retry on
|
|
1081
|
+
// a bad path instead of erroring out (up to a few attempts).
|
|
1082
|
+
if ((!nm || !src) && typeof ctx.openPicker === 'function') {
|
|
1083
|
+
if (!nm) {
|
|
1084
|
+
nm = await _promptText(ctx, { title: 'Install personality — name', subtitle: 'short id to install it as (Esc cancels)' });
|
|
1085
|
+
if (!nm) return 'personality install: cancelled';
|
|
1086
|
+
}
|
|
1087
|
+
if (fs.existsSync(path.join(dir, `${nm}.md`))) return `personality already installed: ${nm}`;
|
|
1088
|
+
for (let attempt = 0; attempt < 3 && !src; attempt++) {
|
|
1089
|
+
const p = await _promptText(ctx, { title: `Source file for ${nm}`, subtitle: attempt ? 'not found — try again (Esc cancels)' : 'path to a .md file (Esc cancels)' });
|
|
1090
|
+
if (!p) return 'personality install: cancelled';
|
|
1091
|
+
if (fs.existsSync(p)) { src = p; break; }
|
|
1092
|
+
}
|
|
1093
|
+
if (!src) return 'personality install: source file not found after 3 tries';
|
|
1094
|
+
}
|
|
1095
|
+
if (!nm || !src) return 'usage: /personality install <name> <file.md>';
|
|
1096
|
+
const dst = path.join(dir, `${nm}.md`);
|
|
1097
|
+
if (fs.existsSync(dst)) return `personality already installed: ${nm}`;
|
|
1098
|
+
if (!fs.existsSync(src)) return `source file not found: ${src}`;
|
|
1099
|
+
fs.writeFileSync(dst, fs.readFileSync(src, 'utf8'));
|
|
1100
|
+
return `installed ${nm}`;
|
|
1125
1101
|
}
|
|
1126
1102
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
1127
1103
|
if (!a) return 'usage: /personality remove <name>';
|
|
1128
1104
|
const p = path.join(dir, `${a}.md`);
|
|
1129
1105
|
if (!fs.existsSync(p)) return `personality not installed: ${a}`;
|
|
1106
|
+
if (typeof ctx.openPicker === 'function') {
|
|
1107
|
+
const ok = await _promptConfirm(ctx, { title: `Remove personality "${a}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
|
|
1108
|
+
if (!ok) return `personality remove: cancelled — "${a}" not removed`;
|
|
1109
|
+
}
|
|
1130
1110
|
fs.unlinkSync(p);
|
|
1131
1111
|
return `removed ${a}`;
|
|
1132
1112
|
}
|
|
@@ -1168,18 +1148,37 @@ async function _task(args, ctx, write) {
|
|
|
1168
1148
|
const id = tokens[1];
|
|
1169
1149
|
|
|
1170
1150
|
try {
|
|
1151
|
+
if (!sub && typeof ctx.openPicker === 'function') {
|
|
1152
|
+
const picked = await ctx.openPicker({
|
|
1153
|
+
kind: 'menu', title: 'Tasks', subtitle: `${tasksMod.listTasks(ctx.cfgDir).length} task(s)`,
|
|
1154
|
+
items: [
|
|
1155
|
+
{ id: 'list', label: 'List tasks', desc: 'show all' },
|
|
1156
|
+
{ id: 'start', label: 'Start task…', desc: 'open a new multi-agent task' },
|
|
1157
|
+
{ id: 'tick', label: 'Tick task…', desc: 'one router turn' },
|
|
1158
|
+
{ id: 'show', label: 'Show task…', desc: 'print a record' },
|
|
1159
|
+
{ id: 'transcript', label: 'Transcript…', desc: 'dump turns' },
|
|
1160
|
+
{ id: 'done', label: 'Mark done…', desc: 'close a task' },
|
|
1161
|
+
{ id: 'abandon', label: 'Abandon…', desc: 'abandon a task' },
|
|
1162
|
+
{ id: 'remove', label: 'Remove…', desc: 'delete a task' },
|
|
1163
|
+
],
|
|
1164
|
+
});
|
|
1165
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
1166
|
+
return _task(typeof pid === 'string' && pid ? pid : 'list', ctx, write);
|
|
1167
|
+
}
|
|
1171
1168
|
if (!sub || sub === 'list') {
|
|
1172
1169
|
const items = tasksMod.listTasks(ctx.cfgDir);
|
|
1173
|
-
if (!items.length) return 'no tasks.
|
|
1170
|
+
if (!items.length) return 'no tasks yet. /task start <team> --title "..." to create one.';
|
|
1174
1171
|
return items.map((t) =>
|
|
1175
1172
|
`• ${t.id} [${t.status || 'unknown'}] ${t.title || '(no title)'}${t.team ? ` — team=${t.team}` : ''}${t.lead ? ` — lead=${t.lead}` : ''}`
|
|
1176
1173
|
).join('\n');
|
|
1177
1174
|
}
|
|
1178
1175
|
if (sub === 'show') {
|
|
1179
|
-
if (!id) return 'usage: /task show <id>';
|
|
1176
|
+
if (!id) return 'usage: /task show <id> [json]';
|
|
1180
1177
|
const t = tasksMod.getTask(id, ctx.cfgDir);
|
|
1181
1178
|
if (!t) return `no task "${id}"`;
|
|
1182
|
-
return
|
|
1179
|
+
return tokens[2] === 'json'
|
|
1180
|
+
? JSON.stringify(t, null, 2)
|
|
1181
|
+
: renderRecord(t, { fields: ['id', 'status', 'title', 'description', 'team', 'lead', 'slackChannel', 'createdAt', 'updatedAt'] });
|
|
1183
1182
|
}
|
|
1184
1183
|
if (sub === 'transcript') {
|
|
1185
1184
|
if (!id) return 'usage: /task transcript <id> [text|md|json]';
|
|
@@ -1218,6 +1217,10 @@ async function _task(args, ctx, write) {
|
|
|
1218
1217
|
}
|
|
1219
1218
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
1220
1219
|
if (!id) return 'usage: /task remove <id>';
|
|
1220
|
+
if (typeof ctx.openPicker === 'function') {
|
|
1221
|
+
const ok = await _promptConfirm(ctx, { title: `Remove task ${id}?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
|
|
1222
|
+
if (!ok) return `task remove: cancelled — ${id} not removed`;
|
|
1223
|
+
}
|
|
1221
1224
|
tasksMod.removeTask(id, ctx.cfgDir);
|
|
1222
1225
|
return `✓ removed task ${id}`;
|
|
1223
1226
|
}
|
|
@@ -1234,6 +1237,25 @@ async function _task(args, ctx, write) {
|
|
|
1234
1237
|
else if (t === '--lead') lead = rest[++i] || null;
|
|
1235
1238
|
else if (!teamName && !t.startsWith('--')) teamName = t;
|
|
1236
1239
|
}
|
|
1240
|
+
// Guided fill: missing team/title + a modal available → pick the team
|
|
1241
|
+
// from the registry and prompt for a title. Typed form unchanged.
|
|
1242
|
+
if ((!teamName || !title) && typeof ctx.openPicker === 'function') {
|
|
1243
|
+
if (!teamName) {
|
|
1244
|
+
const teams = teamsMod.listTeams(ctx.cfgDir);
|
|
1245
|
+
if (!teams.length) return 'task start: no teams yet — create one with /team add first';
|
|
1246
|
+
const tp = await ctx.openPicker({ kind: 'menu', title: 'Start task — pick a team', items: teams.map((t) => ({ id: t.name, label: t.name, desc: `lead=${t.lead || '?'} · agents=${(t.agents || []).join(',')}` })) });
|
|
1247
|
+
teamName = tp && typeof tp === 'object' ? tp.id : tp;
|
|
1248
|
+
if (!teamName || typeof teamName !== 'string') return 'task start: cancelled';
|
|
1249
|
+
}
|
|
1250
|
+
if (!title) {
|
|
1251
|
+
title = await _promptText(ctx, { title: `Task title (team: ${teamName})`, subtitle: 'one line describing the task (Esc cancels)' });
|
|
1252
|
+
if (!title) return 'task start: cancelled';
|
|
1253
|
+
}
|
|
1254
|
+
if (!description) {
|
|
1255
|
+
const d = await _promptText(ctx, { title: 'Task description', subtitle: 'optional detail (Esc to skip)', allowEmpty: true });
|
|
1256
|
+
description = d || '';
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1237
1259
|
if (!teamName || !title) return 'usage: /task start <team> --title "..." [--description "..."] [--lead <name>]';
|
|
1238
1260
|
const team = teamsMod.getTeam(teamName, ctx.cfgDir);
|
|
1239
1261
|
if (!team) return `no team "${teamName}"`;
|
|
@@ -1292,6 +1314,9 @@ async function _task(args, ctx, write) {
|
|
|
1292
1314
|
const userMsg = tokens.slice(2).join(' ').trim();
|
|
1293
1315
|
try {
|
|
1294
1316
|
if (typeof write === 'function') { try { write(' ↻ running task turn…\n'); } catch {} }
|
|
1317
|
+
// Default-on isolation: confine every tool the team runs. Lazy-imported
|
|
1318
|
+
// so the sandbox backends stay off the chat module-load path.
|
|
1319
|
+
const { defaultSandboxSpec } = await import('../sandbox/index.mjs');
|
|
1295
1320
|
const result = await router.runTaskTurn({
|
|
1296
1321
|
task, team, agentsById,
|
|
1297
1322
|
userMessage: userMsg || undefined,
|
|
@@ -1300,6 +1325,7 @@ async function _task(args, ctx, write) {
|
|
|
1300
1325
|
logger: (line) => { if (typeof write === 'function') { try { write(line); } catch {} } },
|
|
1301
1326
|
approve: _makeInkApprove(ctx),
|
|
1302
1327
|
security: ctx.cfg?.security,
|
|
1328
|
+
sandbox: defaultSandboxSpec(ctx.cfg, { cwd: process.cwd(), configDir: ctx.cfgDir }),
|
|
1303
1329
|
});
|
|
1304
1330
|
return `✓ task ${result.task.id} → ${result.task.status} (${result.iterations} agent turn(s)${result.stoppedBy ? `, stopped by ${result.stoppedBy}` : ''})`;
|
|
1305
1331
|
} catch (e) {
|
|
@@ -1315,6 +1341,35 @@ async function _task(args, ctx, write) {
|
|
|
1315
1341
|
async function _trainer(args, ctx) {
|
|
1316
1342
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
1317
1343
|
const tokens = splitWhitespace(args);
|
|
1344
|
+
|
|
1345
|
+
// Bare /trainer with a modal available → an action menu (mirrors the bare
|
|
1346
|
+
// /orchestrator menu). "Set"/"Fallback" re-enter and drill the shared
|
|
1347
|
+
// provider→model picker; "Clear"/"Show" run their subcommands. Typed forms
|
|
1348
|
+
// (/trainer set <p:m>, etc.) still work and skip the menu.
|
|
1349
|
+
if (tokens.length === 0 && typeof ctx.openPicker === 'function') {
|
|
1350
|
+
let cur = '';
|
|
1351
|
+
try {
|
|
1352
|
+
if (typeof registry.resolveTrainer === 'function') {
|
|
1353
|
+
const e = registry.resolveTrainer(ctx.cfg || {});
|
|
1354
|
+
cur = `now: ${e.provider}${e.model ? ':' + e.model : ':(default)'}`;
|
|
1355
|
+
}
|
|
1356
|
+
} catch { /* show menu without the current hint */ }
|
|
1357
|
+
const picked = await ctx.openPicker({
|
|
1358
|
+
kind: 'menu',
|
|
1359
|
+
title: 'Trainer — synthesis / learning model',
|
|
1360
|
+
subtitle: cur || 'pick provider + model for trainer turns',
|
|
1361
|
+
items: [
|
|
1362
|
+
{ id: 'set', label: 'Set trainer…', desc: 'pick provider + model (or auto / provider default)' },
|
|
1363
|
+
{ id: 'fallback', label: 'Set fallback…', desc: 'pick a fallback provider + model' },
|
|
1364
|
+
{ id: 'clear', label: 'Clear', desc: 'unset — mirror the chat provider/model' },
|
|
1365
|
+
{ id: 'show', label: 'Show', desc: 'print the effective + configured trainer' },
|
|
1366
|
+
],
|
|
1367
|
+
});
|
|
1368
|
+
const id = picked && typeof picked === 'object' ? picked.id : picked;
|
|
1369
|
+
if (!id || typeof id !== 'string') return _trainer('show', ctx); // cancelled → show status
|
|
1370
|
+
return _trainer(id, ctx); // re-enter; set/fallback open the picker (no spec)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1318
1373
|
const sub = tokens[0] || 'show';
|
|
1319
1374
|
|
|
1320
1375
|
if (sub === 'show') {
|
|
@@ -1327,7 +1382,7 @@ async function _trainer(args, ctx) {
|
|
|
1327
1382
|
} catch { /* fall through */ }
|
|
1328
1383
|
const configured = (ctx.cfg && ctx.cfg.trainer) || null;
|
|
1329
1384
|
const cfgRender = configured
|
|
1330
|
-
?
|
|
1385
|
+
? renderRecord(configured, { fields: ['provider', 'model', 'fallback'] }).split('\n').map((l) => ' ' + l).join('\n')
|
|
1331
1386
|
: '(unset — trainer mirrors the chat provider/model)';
|
|
1332
1387
|
return [
|
|
1333
1388
|
'trainer (effective):',
|
|
@@ -1339,7 +1394,16 @@ async function _trainer(args, ctx) {
|
|
|
1339
1394
|
}
|
|
1340
1395
|
|
|
1341
1396
|
if (sub === 'set') {
|
|
1342
|
-
|
|
1397
|
+
let spec = tokens[1];
|
|
1398
|
+
let _setFromPicker = false;
|
|
1399
|
+
// No spec + a modal available → drill the shared picker (with an "auto"
|
|
1400
|
+
// row and a "provider default" row) instead of requiring a typed spec.
|
|
1401
|
+
if (!spec && typeof ctx.openPicker === 'function') {
|
|
1402
|
+
const r = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
|
|
1403
|
+
if (!r || r.model == null) return 'trainer set: cancelled';
|
|
1404
|
+
spec = r.provider === 'auto' ? 'auto' : (r.model ? `${r.provider}:${r.model}` : r.provider);
|
|
1405
|
+
_setFromPicker = true;
|
|
1406
|
+
}
|
|
1343
1407
|
if (!spec) return 'usage: /trainer set <provider>[:<model>] (or `auto` for orchestrator-managed)';
|
|
1344
1408
|
const parsed = typeof registry.parseProviderModel === 'function'
|
|
1345
1409
|
? registry.parseProviderModel(spec)
|
|
@@ -1362,6 +1426,17 @@ async function _trainer(args, ctx) {
|
|
|
1362
1426
|
return `/trainer set: unknown provider "${fp.provider}"`;
|
|
1363
1427
|
}
|
|
1364
1428
|
}
|
|
1429
|
+
// Picker-driven set with no --fallback flag → offer an optional fallback
|
|
1430
|
+
// pick (the flag form stays the escape hatch for typed callers).
|
|
1431
|
+
if (_setFromPicker && !fallbackSpec && typeof ctx.openPicker === 'function') {
|
|
1432
|
+
const addFb = await _promptConfirm(ctx, { title: 'Add a fallback trainer?', subtitle: 'used when the primary is unavailable · Esc / deny to skip' });
|
|
1433
|
+
if (addFb) {
|
|
1434
|
+
const fr = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
|
|
1435
|
+
if (fr && fr.model != null) {
|
|
1436
|
+
fallbackSpec = fr.provider === 'auto' ? 'auto' : (fr.model ? `${fr.provider}:${fr.model}` : fr.provider);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1365
1440
|
// Read-merge-write so unrelated cfg keys survive.
|
|
1366
1441
|
const fs = await import('node:fs');
|
|
1367
1442
|
const path = await import('node:path');
|
|
@@ -1379,7 +1454,12 @@ async function _trainer(args, ctx) {
|
|
|
1379
1454
|
}
|
|
1380
1455
|
|
|
1381
1456
|
if (sub === 'fallback') {
|
|
1382
|
-
|
|
1457
|
+
let spec = tokens[1];
|
|
1458
|
+
if (!spec && typeof ctx.openPicker === 'function') {
|
|
1459
|
+
const r = await pickProviderModel(ctx, registry, { includeAuto: true, includeDefault: true });
|
|
1460
|
+
if (!r || r.model == null) return 'trainer fallback: cancelled';
|
|
1461
|
+
spec = r.provider === 'auto' ? 'auto' : (r.model ? `${r.provider}:${r.model}` : r.provider);
|
|
1462
|
+
}
|
|
1383
1463
|
if (!spec) return 'usage: /trainer fallback <provider>[:<model>] | clear';
|
|
1384
1464
|
const fs = await import('node:fs');
|
|
1385
1465
|
const path = await import('node:path');
|
|
@@ -1640,7 +1720,7 @@ async function _dashboard(args) {
|
|
|
1640
1720
|
// behind `lazyclaw menu`. This restores discoverability: browse subcommands
|
|
1641
1721
|
// and get the exact command to run. (Most subcommands own stdout / spawn, so
|
|
1642
1722
|
// they can't safely run inline in the Ink scrollback — we echo the command.)
|
|
1643
|
-
async function _menu(args, ctx) {
|
|
1723
|
+
async function _menu(args, ctx, write) {
|
|
1644
1724
|
if (typeof ctx.openPicker === 'function') {
|
|
1645
1725
|
const items = [];
|
|
1646
1726
|
const seen = new Set();
|
|
@@ -1648,17 +1728,23 @@ async function _menu(args, ctx) {
|
|
|
1648
1728
|
for (const c of cmds) {
|
|
1649
1729
|
if (seen.has(c)) continue;
|
|
1650
1730
|
seen.add(c);
|
|
1651
|
-
|
|
1731
|
+
// Mark which subcommands can run in-chat (a /slash equivalent exists).
|
|
1732
|
+
const inChat = SLASH_HANDLERS.has(`/${c}`);
|
|
1733
|
+
items.push({ id: c, label: c, desc: inChat ? `${group} · runs in chat` : group });
|
|
1652
1734
|
}
|
|
1653
1735
|
}
|
|
1654
1736
|
const picked = await ctx.openPicker({
|
|
1655
1737
|
kind: 'menu',
|
|
1656
1738
|
title: 'lazyclaw subcommands',
|
|
1657
|
-
subtitle: 'Enter shows
|
|
1739
|
+
subtitle: 'Enter runs it in chat (or shows the shell command) · Esc cancels',
|
|
1658
1740
|
items,
|
|
1659
1741
|
});
|
|
1660
1742
|
if (!picked) return 'cancelled';
|
|
1661
1743
|
const cmd = typeof picked === 'string' ? picked : picked.id;
|
|
1744
|
+
// If there's an in-chat slash equivalent, dispatch it directly instead of
|
|
1745
|
+
// telling the user to leave chat.
|
|
1746
|
+
const handler = SLASH_HANDLERS.get(`/${cmd}`);
|
|
1747
|
+
if (handler) return handler('', ctx, write);
|
|
1662
1748
|
return `run from a shell: lazyclaw ${cmd}`;
|
|
1663
1749
|
}
|
|
1664
1750
|
return [
|
|
@@ -1676,7 +1762,86 @@ async function _channels(args, ctx = {}) {
|
|
|
1676
1762
|
const cfgMod = await import('../lib/config.mjs');
|
|
1677
1763
|
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
1678
1764
|
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
1679
|
-
const
|
|
1765
|
+
const toks = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1766
|
+
const [name, action] = toks;
|
|
1767
|
+
|
|
1768
|
+
// `/channels [<name>] setup` — set the channel's credentials (bot token,
|
|
1769
|
+
// homeserver, …) from chat instead of redirecting to /config. Reuses the
|
|
1770
|
+
// masked modal prompt (_promptText) so secrets are never echoed. No modal →
|
|
1771
|
+
// fall back to the readline channel step (same as /config's channel item).
|
|
1772
|
+
const wantSetup = toks.some((t) => /^setup$/i.test(t));
|
|
1773
|
+
if (wantSetup) {
|
|
1774
|
+
const channelMod = await import('../commands/setup_channels.mjs');
|
|
1775
|
+
const picked = toks.find((t) => !/^setup$/i.test(t));
|
|
1776
|
+
if (typeof ctx.openPicker !== 'function') {
|
|
1777
|
+
// Readline path: hand off to the existing channel wizard step.
|
|
1778
|
+
ctx.requestConfigStep = 'channel';
|
|
1779
|
+
return 'EXIT';
|
|
1780
|
+
}
|
|
1781
|
+
let chName = picked && picked.toLowerCase();
|
|
1782
|
+
if (!chName) {
|
|
1783
|
+
const sel = await ctx.openPicker({
|
|
1784
|
+
kind: 'menu',
|
|
1785
|
+
title: 'channel — set credentials',
|
|
1786
|
+
subtitle: 'pick a channel to configure',
|
|
1787
|
+
items: channelMod.CHANNEL_CATALOG.map((c) => ({
|
|
1788
|
+
id: c.name,
|
|
1789
|
+
label: c.label,
|
|
1790
|
+
desc: c.builtin ? '' : `needs: ${(c.deps && c.deps.length) ? c.deps.join(', ') : (c.binary || 'creds only')}`,
|
|
1791
|
+
})),
|
|
1792
|
+
});
|
|
1793
|
+
chName = sel && typeof sel === 'object' ? sel.id : sel;
|
|
1794
|
+
if (!chName || typeof chName !== 'string') return 'channel setup: cancelled';
|
|
1795
|
+
}
|
|
1796
|
+
const spec = channelMod.channelByName(chName);
|
|
1797
|
+
if (!spec) return `unknown channel: ${chName} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
|
|
1798
|
+
if (!spec.fields.length) {
|
|
1799
|
+
// No creds (e.g. http / whatsapp) — just enable it.
|
|
1800
|
+
const cfgDirX = ctx.cfgDir || (await import('node:path')).dirname(cfgMod.configPath());
|
|
1801
|
+
channelMod.persistChannel(cfgDirX, chName, {});
|
|
1802
|
+
if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: true }; }
|
|
1803
|
+
return `channel ${chName} → enabled (no credentials needed)`;
|
|
1804
|
+
}
|
|
1805
|
+
const answers = {};
|
|
1806
|
+
for (const f of spec.fields) {
|
|
1807
|
+
const v = await _promptText(ctx, {
|
|
1808
|
+
title: `${spec.label} — ${f.prompt}`,
|
|
1809
|
+
subtitle: f.optional ? 'optional · Esc to skip' : 'Esc cancels',
|
|
1810
|
+
secret: !!f.secret,
|
|
1811
|
+
allowEmpty: !!f.optional,
|
|
1812
|
+
});
|
|
1813
|
+
if (v === null) {
|
|
1814
|
+
if (f.optional) continue; // Esc on an optional field → skip it
|
|
1815
|
+
return 'channel setup: cancelled';
|
|
1816
|
+
}
|
|
1817
|
+
if (v) answers[f.key] = v;
|
|
1818
|
+
}
|
|
1819
|
+
const path = await import('node:path');
|
|
1820
|
+
const cfgDirX = ctx.cfgDir || path.dirname(cfgMod.configPath());
|
|
1821
|
+
const entry = channelMod.persistChannel(cfgDirX, chName, answers);
|
|
1822
|
+
// Mirror the PERSISTED enabled state onto the in-session cfg so a follow-up
|
|
1823
|
+
// list is fresh — an in-tree channel whose runtime dep is missing stays
|
|
1824
|
+
// disabled (persistChannel gates it) rather than being force-enabled.
|
|
1825
|
+
if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: !!entry.ready }; }
|
|
1826
|
+
const setKeys = Object.keys(answers);
|
|
1827
|
+
let note = '';
|
|
1828
|
+
if (!entry.ready) {
|
|
1829
|
+
if (entry.missingDeps && entry.missingDeps.length) note += `\n(needs ${entry.missingDeps.join(', ')} — run: lazyclaw channels install ${chName})`;
|
|
1830
|
+
if (entry.missingBinary) note += `\n(needs the ${entry.missingBinary} binary on your PATH)`;
|
|
1831
|
+
}
|
|
1832
|
+
return `✓ ${spec.label} credentials saved (${setKeys.join(', ') || 'none'}) → ${entry.ready ? 'channel enabled' : 'saved (enable once the requirement is installed)'}${note}`;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// `/channels <name> test` — verify the stored credentials with a live call.
|
|
1836
|
+
if (name && /^test$/i.test(action || '')) {
|
|
1837
|
+
const channelMod = await import('../commands/setup_channels.mjs');
|
|
1838
|
+
try { loadDotenvIfAny(ctx.cfgDir); } catch { /* best-effort */ }
|
|
1839
|
+
const r = await channelMod.verifyChannel(name.toLowerCase());
|
|
1840
|
+
if (r.ok === true) return `✓ ${name} verified — ${r.detail}`;
|
|
1841
|
+
if (r.ok === null) return `· ${name}: ${r.detail}`;
|
|
1842
|
+
return `✗ ${name}: ${r.detail}${r.hint ? `\n fix: ${r.hint}` : ''}`;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1680
1845
|
if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
|
|
1681
1846
|
const en = /^(on|enable)$/i.test(action);
|
|
1682
1847
|
const cfg = read();
|
|
@@ -1697,70 +1862,56 @@ async function _channels(args, ctx = {}) {
|
|
|
1697
1862
|
if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
|
|
1698
1863
|
cf.channelSetEnabled(ctx.cfg, key, en);
|
|
1699
1864
|
}
|
|
1700
|
-
return
|
|
1865
|
+
return en
|
|
1866
|
+
? `channel ${key} → enabled`
|
|
1867
|
+
: `channel ${key} → disabled (re-enable with /channels ${key} on)`;
|
|
1868
|
+
}
|
|
1869
|
+
// No-arg + modal → an action menu: each row toggles in place, plus a
|
|
1870
|
+
// "set credentials" row. Falls through to the text list when no modal.
|
|
1871
|
+
if (!toks.length && typeof ctx.openPicker === 'function') {
|
|
1872
|
+
const statusRows = cf.channelStatusList(read());
|
|
1873
|
+
const items = statusRows.map((c) => ({
|
|
1874
|
+
id: `toggle:${c.name}`,
|
|
1875
|
+
label: `${c.name} — ${c.enabled ? 'enabled' : 'disabled'}`,
|
|
1876
|
+
desc: c.enabled ? 'Enter to disable' : 'Enter to enable',
|
|
1877
|
+
}));
|
|
1878
|
+
items.push({ id: 'setup', label: '+ Set credentials…', desc: 'pick a channel and enter bot token / homeserver / …' });
|
|
1879
|
+
const picked = await ctx.openPicker({ kind: 'menu', title: 'Channels', subtitle: `${statusRows.length} configured`, items });
|
|
1880
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
1881
|
+
if (!pid || typeof pid !== 'string') return 'cancelled';
|
|
1882
|
+
if (pid === 'setup') return _channels('setup', ctx);
|
|
1883
|
+
if (pid.startsWith('toggle:')) {
|
|
1884
|
+
const nm = pid.slice(7);
|
|
1885
|
+
const cur = statusRows.find((r) => r.name === nm);
|
|
1886
|
+
return _channels(`${nm} ${cur && cur.enabled ? 'off' : 'on'}`, ctx);
|
|
1887
|
+
}
|
|
1701
1888
|
}
|
|
1702
1889
|
const rows = cf.channelStatusList(read());
|
|
1703
|
-
if (!rows.length) return 'no channels configured.
|
|
1890
|
+
if (!rows.length) return 'no channels configured. set credentials with /channels setup (or `lazyclaw setup` for the full wizard).';
|
|
1891
|
+
// Cross-reference each channel's required env creds against the loaded env so
|
|
1892
|
+
// the list flags a channel that's "enabled" but missing its token.
|
|
1893
|
+
let channelMod = null;
|
|
1894
|
+
try {
|
|
1895
|
+
loadDotenvIfAny(ctx.cfgDir);
|
|
1896
|
+
channelMod = await import('../commands/setup_channels.mjs');
|
|
1897
|
+
} catch { /* hint is best-effort */ }
|
|
1898
|
+
const missingFor = (name) => {
|
|
1899
|
+
const spec = channelMod && channelMod.channelByName(name);
|
|
1900
|
+
if (!spec) return [];
|
|
1901
|
+
return spec.fields.filter((f) => !f.optional && !process.env[f.env]).map((f) => f.env);
|
|
1902
|
+
};
|
|
1704
1903
|
const lines = ['configured channels:'];
|
|
1705
|
-
for (const c of rows)
|
|
1706
|
-
|
|
1904
|
+
for (const c of rows) {
|
|
1905
|
+
const miss = missingFor(c.name);
|
|
1906
|
+
const credNote = miss.length ? ` · creds: missing ${miss.join(', ')}` : '';
|
|
1907
|
+
lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}${credNote}`);
|
|
1908
|
+
}
|
|
1909
|
+
lines.push('toggle: /channels <name> on|off · set creds: /channels <name> setup');
|
|
1707
1910
|
return lines.join('\n');
|
|
1708
1911
|
}
|
|
1709
1912
|
|
|
1710
|
-
// /orchestrator —
|
|
1711
|
-
//
|
|
1712
|
-
// lib/config fallback so it works on both REPL paths.
|
|
1713
|
-
async function _orchestrator(args, ctx = {}) {
|
|
1714
|
-
const cf = await import('../config_features.mjs');
|
|
1715
|
-
const cfgMod = await import('../lib/config.mjs');
|
|
1716
|
-
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
1717
|
-
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
1718
|
-
const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
|
|
1719
|
-
const parts = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1720
|
-
const fmt = () => {
|
|
1721
|
-
const s = cf.orchestratorGet(read());
|
|
1722
|
-
return `orchestrator: ${s.active ? 'ON' : 'off'} · planner: ${s.planner || '(default)'} · workers: ${s.workers.length ? s.workers.join(', ') : '(none)'} · maxSubtasks: ${s.maxSubtasks}`;
|
|
1723
|
-
};
|
|
1724
|
-
// Bare `/orchestrator` → arrow-key picker (Ink). Pick ON/OFF/Status instead
|
|
1725
|
-
// of typing the subcommand. Legacy path (no openPicker) shows status text.
|
|
1726
|
-
if (parts.length === 0 && typeof ctx.openPicker === 'function') {
|
|
1727
|
-
const s = cf.orchestratorGet(read());
|
|
1728
|
-
const picked = await ctx.openPicker({
|
|
1729
|
-
title: 'Orchestration',
|
|
1730
|
-
subtitle: `now ${s.active ? 'ON' : 'off'} · planner ${s.planner || '(default)'} · ${s.workers.length} worker(s)`,
|
|
1731
|
-
items: [
|
|
1732
|
-
{ id: 'on', label: 'Turn ON', desc: 'route chats through planner + workers' },
|
|
1733
|
-
{ id: 'off', label: 'Turn OFF', desc: 'back to a single provider' },
|
|
1734
|
-
{ id: 'status', label: 'Status', desc: 'show current config' },
|
|
1735
|
-
],
|
|
1736
|
-
});
|
|
1737
|
-
if (!picked || typeof picked !== 'string') return fmt();
|
|
1738
|
-
return _orchestrator(picked, ctx);
|
|
1739
|
-
}
|
|
1740
|
-
const sub = (parts[0] || 'status').toLowerCase();
|
|
1741
|
-
if (sub === 'status') return fmt();
|
|
1742
|
-
const cfg = read();
|
|
1743
|
-
if (sub === 'on' || sub === 'enable') {
|
|
1744
|
-
if (!cf.orchestratorGet(cfg).planner) {
|
|
1745
|
-
const base = cfg.provider && cfg.provider !== 'orchestrator' ? cfg.provider : 'claude-cli';
|
|
1746
|
-
cf.orchestratorSet(cfg, { planner: base });
|
|
1747
|
-
}
|
|
1748
|
-
cf.orchestratorEnable(cfg, true); persist(cfg);
|
|
1749
|
-
const after = cf.orchestratorGet(read());
|
|
1750
|
-
return after.workers.length ? 'orchestration ON.\n' + fmt() : 'orchestration ON — but no workers yet. Add one: /orchestrator worker add <provider[:model]>';
|
|
1751
|
-
}
|
|
1752
|
-
if (sub === 'off' || sub === 'disable') { cf.orchestratorEnable(cfg, false); persist(cfg); return 'orchestration off. provider → ' + read().provider; }
|
|
1753
|
-
if (sub === 'planner') { if (!parts[1]) return 'usage: /orchestrator planner <provider[:model]>'; cf.orchestratorSet(cfg, { planner: parts[1] }); persist(cfg); return 'planner → ' + parts[1]; }
|
|
1754
|
-
if (sub === 'maxsubtasks') { const n = parseInt(parts[1], 10); if (!Number.isFinite(n)) return 'usage: /orchestrator maxsubtasks <N>'; cf.orchestratorSet(cfg, { maxSubtasks: Math.max(1, Math.min(10, n)) }); persist(cfg); return fmt(); }
|
|
1755
|
-
if (sub === 'worker') {
|
|
1756
|
-
const action = (parts[1] || '').toLowerCase(); const spec = parts[2];
|
|
1757
|
-
const workers = [...cf.orchestratorGet(cfg).workers];
|
|
1758
|
-
if (action === 'add' && spec) { if (!workers.includes(spec)) workers.push(spec); cf.orchestratorSet(cfg, { workers }); persist(cfg); return 'workers: ' + workers.join(', '); }
|
|
1759
|
-
if ((action === 'remove' || action === 'rm') && spec) { const next = workers.filter((w) => w !== spec); cf.orchestratorSet(cfg, { workers: next }); persist(cfg); return 'workers: ' + (next.join(', ') || '(none)'); }
|
|
1760
|
-
return 'usage: /orchestrator worker add|remove <provider[:model]>';
|
|
1761
|
-
}
|
|
1762
|
-
return 'usage: /orchestrator [status|on|off|planner <spec>|worker add|remove <spec>|maxsubtasks <N>]';
|
|
1763
|
-
}
|
|
1913
|
+
// /orchestrator — moved to ./orchestrator_flow.mjs (orchestratorSlash) so the
|
|
1914
|
+
// interactive fetch+pick planner/worker editor can grow off the ratchet.
|
|
1764
1915
|
|
|
1765
1916
|
// /context — view/set the chat history window (turns + token budget). This is
|
|
1766
1917
|
// the sliding history budget sent each turn, NOT the model's hard context
|
|
@@ -1774,6 +1925,32 @@ async function _context(args, ctx = {}) {
|
|
|
1774
1925
|
const parts = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1775
1926
|
const sub = (parts[0] || 'status').toLowerCase();
|
|
1776
1927
|
const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
|
|
1928
|
+
// No-arg + modal → action menu → numeric picker (mirrors orchestrator maxsubtasks).
|
|
1929
|
+
if (!parts.length && typeof ctx.openPicker === 'function') {
|
|
1930
|
+
const w = cf.chatWindowGet(read());
|
|
1931
|
+
const action = await ctx.openPicker({
|
|
1932
|
+
kind: 'menu', title: 'Context window (history budget)', subtitle: `now ${w.turns} turns · ${w.tokens} tokens`,
|
|
1933
|
+
items: [
|
|
1934
|
+
{ id: 'turns', label: 'Set turns…', desc: 'past turns to send' },
|
|
1935
|
+
{ id: 'tokens', label: 'Set tokens…', desc: 'token budget (min 256)' },
|
|
1936
|
+
{ id: 'status', label: 'Status', desc: 'show current' },
|
|
1937
|
+
],
|
|
1938
|
+
});
|
|
1939
|
+
const aid = action && typeof action === 'object' ? action.id : action;
|
|
1940
|
+
if (!aid || typeof aid !== 'string' || aid === 'status') return fmt();
|
|
1941
|
+
if (aid === 'turns') {
|
|
1942
|
+
const np = await ctx.openPicker({ kind: 'menu', title: 'Turns to keep', subtitle: `currently ${w.turns}`, items: [5, 10, 15, 20, 30, 40, 50].map((x) => ({ id: String(x), label: String(x) })) });
|
|
1943
|
+
const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
|
|
1944
|
+
if (!Number.isFinite(v)) return 'context turns: cancelled';
|
|
1945
|
+
const cfg = read(); cf.chatWindowSet(cfg, { turns: v }); persist(cfg); return fmt();
|
|
1946
|
+
}
|
|
1947
|
+
if (aid === 'tokens') {
|
|
1948
|
+
const np = await ctx.openPicker({ kind: 'menu', title: 'Token budget', subtitle: `currently ${w.tokens}`, items: [2000, 4000, 8000, 12000, 16000, 32000].map((x) => ({ id: String(x), label: String(x) })) });
|
|
1949
|
+
const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
|
|
1950
|
+
if (!Number.isFinite(v) || v < 256) return 'context tokens: cancelled';
|
|
1951
|
+
const cfg = read(); cf.chatWindowSet(cfg, { tokens: v }); persist(cfg); return fmt();
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1777
1954
|
if (sub === 'status') return fmt();
|
|
1778
1955
|
const n = parseInt(parts[1], 10);
|
|
1779
1956
|
if (sub === 'turns') { if (!Number.isFinite(n) || n < 1) return 'usage: /context turns <N>'; const cfg = read(); cf.chatWindowSet(cfg, { turns: n }); persist(cfg); return fmt(); }
|
|
@@ -1781,6 +1958,15 @@ async function _context(args, ctx = {}) {
|
|
|
1781
1958
|
return 'usage: /context [status | turns <N> | tokens <N>]';
|
|
1782
1959
|
}
|
|
1783
1960
|
|
|
1961
|
+
// /agentic + /plan live in ./chat_mode_slash.mjs (Group 1) — kept out of this
|
|
1962
|
+
// file (at its size ratchet) so the toggles can grow there. Register their
|
|
1963
|
+
// catalog rows in the shared SLASH_COMMANDS so /help, the popup,
|
|
1964
|
+
// ghost-autocomplete, and the d6 drift-guard see them (idempotent — ESM runs
|
|
1965
|
+
// module init once; appended per the catalog's ordering note).
|
|
1966
|
+
for (const entry of CHAT_MODE_SLASH_COMMANDS) {
|
|
1967
|
+
if (!SLASH_COMMANDS.some((c) => c.cmd === entry.cmd)) SLASH_COMMANDS.push(entry);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1784
1970
|
// ─── dispatch table ──────────────────────────────────────────────────────
|
|
1785
1971
|
|
|
1786
1972
|
export const SLASH_HANDLERS = new Map([
|
|
@@ -1792,6 +1978,10 @@ export const SLASH_HANDLERS = new Map([
|
|
|
1792
1978
|
['/reset', _newReset],
|
|
1793
1979
|
['/clear', _newReset],
|
|
1794
1980
|
['/provider', _provider],
|
|
1981
|
+
['/login', (a, ctx) => loginSlash(a, ctx, { promptText: _promptText })],
|
|
1982
|
+
['/hud', hudSlash],
|
|
1983
|
+
['/agentic', agenticSlash],
|
|
1984
|
+
['/plan', planSlash],
|
|
1795
1985
|
['/model', _model],
|
|
1796
1986
|
['/skill', _skill],
|
|
1797
1987
|
['/skills', _skillsList],
|
|
@@ -1810,7 +2000,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
1810
2000
|
['/dashboard', _dashboard],
|
|
1811
2001
|
['/menu', _menu],
|
|
1812
2002
|
['/channels', _channels],
|
|
1813
|
-
['/orchestrator',
|
|
2003
|
+
['/orchestrator', orchestratorSlash],
|
|
1814
2004
|
['/context', _context],
|
|
1815
2005
|
// /setup — full wizard (every step); /config — pick ONE setting to change
|
|
1816
2006
|
// (in-chat where possible; credential steps unmount, run, re-enter chat).
|
|
@@ -1827,6 +2017,9 @@ export const SLASH_HANDLERS = new Map([
|
|
|
1827
2017
|
*/
|
|
1828
2018
|
export async function dispatchSlash(cmd, args, ctx, write) {
|
|
1829
2019
|
const handler = SLASH_HANDLERS.get(cmd);
|
|
1830
|
-
if (!handler)
|
|
2020
|
+
if (!handler) {
|
|
2021
|
+
const hint = nearest(cmd, [...SLASH_HANDLERS.keys()]);
|
|
2022
|
+
return `unknown slash command: ${cmd}${hint ? ` — did you mean ${hint}?` : ''} (try /help)`;
|
|
2023
|
+
}
|
|
1831
2024
|
return handler(args || '', ctx || {}, write);
|
|
1832
2025
|
}
|