lazyclaw 6.3.1 → 6.5.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 +7 -193
- package/commands/agents_registry.mjs +205 -0
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +28 -94
- package/commands/automation_loops.mjs +94 -0
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +127 -704
- package/commands/chat_hardening.mjs +23 -0
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/help_text.mjs +78 -0
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -80
- 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 +41 -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 +27 -3
- 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 +68 -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 +183 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +70 -12
- package/providers/gemini.mjs +104 -3
- package/providers/gemini_cli.mjs +78 -17
- 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 +29 -2
- package/providers/tool_use/openai.mjs +35 -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/banner.mjs +72 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- 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/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +218 -248
- package/tui/repl.mjs +118 -209
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +339 -774
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_popup.mjs +5 -1
- package/tui/slash_trainer.mjs +173 -0
- 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,20 +26,37 @@
|
|
|
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';
|
|
42
51
|
import { redactSecrets } from '../mas/redact.mjs';
|
|
52
|
+
import { splitWhitespace, _mod, _promptText, _promptConfirm } from './slash_helpers.mjs';
|
|
53
|
+
import { _dashboard, parseDashboardUrl } from './slash_dashboard.mjs';
|
|
54
|
+
import { _channels, _context } from './slash_channels.mjs';
|
|
55
|
+
import { _trainer } from './slash_trainer.mjs';
|
|
56
|
+
|
|
57
|
+
// Re-export so callers/tests that import parseDashboardUrl from this module
|
|
58
|
+
// (the dispatcher was its original home) keep resolving after the extraction.
|
|
59
|
+
export { parseDashboardUrl };
|
|
43
60
|
|
|
44
61
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
|
45
62
|
|
|
@@ -55,29 +72,6 @@ export function parseSlashLine(line) {
|
|
|
55
72
|
return { cmd: m[1], args: (m[3] || '').trim() };
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
// Tiny utility — split args on whitespace, drop empties. Used by sub-command
|
|
59
|
-
// handlers that don't need the loop-engine's full quote-aware splitter.
|
|
60
|
-
function splitWhitespace(s) {
|
|
61
|
-
return (s || '').split(/\s+/).filter(Boolean);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Parse a "provider[:model]" spec, preferring the registry's parser.
|
|
65
|
-
function _parseProvModel(registry, spec) {
|
|
66
|
-
if (registry && typeof registry.parseProviderModel === 'function') return registry.parseProviderModel(spec);
|
|
67
|
-
const s = String(spec || '');
|
|
68
|
-
const i = s.indexOf(':');
|
|
69
|
-
if (i < 0) return { provider: s || null, model: null };
|
|
70
|
-
return { provider: s.slice(0, i) || null, model: s.slice(i + 1) || null };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Best-effort dynamic import. Returns the resolved ctx field if the caller
|
|
74
|
-
// pre-injected it (test hot path), else loads the real module. Throwing is
|
|
75
|
-
// fine — handlers wrap calls in try/catch where appropriate.
|
|
76
|
-
async function _mod(ctx, key, importer) {
|
|
77
|
-
if (ctx && ctx[key]) return ctx[key];
|
|
78
|
-
return importer();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
75
|
// ─── handlers ────────────────────────────────────────────────────────────
|
|
82
76
|
|
|
83
77
|
async function _help() {
|
|
@@ -156,110 +150,11 @@ async function _newReset(_args, ctx) {
|
|
|
156
150
|
return 'cleared — new conversation';
|
|
157
151
|
}
|
|
158
152
|
|
|
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
|
-
|
|
222
|
-
// Single free-text prompt reusing the modal's filter buffer (no dedicated
|
|
223
|
-
// input widget). Returns the typed value, '' (only when allowEmpty), or null
|
|
224
|
-
// on cancel / required-but-empty.
|
|
225
|
-
async function _promptText(ctx, { title, subtitle, allowEmpty } = {}) {
|
|
226
|
-
if (typeof ctx.openPicker !== 'function') return null;
|
|
227
|
-
const picked = await ctx.openPicker({
|
|
228
|
-
kind: 'text',
|
|
229
|
-
title,
|
|
230
|
-
subtitle: subtitle || 'type into the filter, then pick the row · Esc cancels',
|
|
231
|
-
items: [{ id: '__text__', label: '✓ use what I typed above', desc: '', pinned: true, freeText: true }],
|
|
232
|
-
});
|
|
233
|
-
if (picked == null) return null;
|
|
234
|
-
if (typeof picked === 'object') {
|
|
235
|
-
const v = String(picked.query || '').trim();
|
|
236
|
-
if (!v && !allowEmpty) return null;
|
|
237
|
-
return v;
|
|
238
|
-
}
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Yes/no confirmation modal for sensitive-tool approval. Esc (or no modal
|
|
243
|
-
// available) DENIES — approval is never granted by omission.
|
|
244
|
-
async function _promptConfirm(ctx, { title, subtitle } = {}) {
|
|
245
|
-
if (typeof ctx.openPicker !== 'function') return false;
|
|
246
|
-
const picked = await ctx.openPicker({
|
|
247
|
-
kind: 'menu',
|
|
248
|
-
title: title || 'Approve sensitive tool?',
|
|
249
|
-
subtitle: subtitle || 'Enter selects · Esc denies',
|
|
250
|
-
items: [
|
|
251
|
-
{ id: 'approve', label: '✓ approve once', desc: 'run this tool call' },
|
|
252
|
-
{ id: 'deny', label: '✗ deny', desc: 'block this tool call' },
|
|
253
|
-
],
|
|
254
|
-
});
|
|
255
|
-
const id = picked && typeof picked === 'object' ? picked.id : picked;
|
|
256
|
-
return id === 'approve';
|
|
257
|
-
}
|
|
258
153
|
|
|
259
154
|
// Default in-chat approval hook: prompts the operator to confirm each
|
|
260
155
|
// sensitive tool call. Used to drive the fail-closed tool runner from the
|
|
261
156
|
// Ink REPL, where stdin is owned by Ink so a raw readline prompt can't run.
|
|
262
|
-
function _makeInkApprove(ctx) {
|
|
157
|
+
export function _makeInkApprove(ctx) {
|
|
263
158
|
return async function approve({ tool, args, agent }) {
|
|
264
159
|
const raw = typeof args === 'object' ? JSON.stringify(args) : String(args ?? '');
|
|
265
160
|
const summary = redactSecrets(raw).slice(0, 300);
|
|
@@ -310,6 +205,7 @@ async function _maybePromptForKey(ctx, registry, provName) {
|
|
|
310
205
|
const key = await _promptText(ctx, {
|
|
311
206
|
title: `${provName} needs an api key`,
|
|
312
207
|
subtitle: 'paste it now, or Esc to skip (set later via: lazyclaw auth)',
|
|
208
|
+
secret: true,
|
|
313
209
|
});
|
|
314
210
|
if (!key) return;
|
|
315
211
|
const next = setAuthKey({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, provider: provName, key });
|
|
@@ -327,30 +223,30 @@ async function _addCustomFlow(ctx, registry) {
|
|
|
327
223
|
if (!name) return 'cancelled';
|
|
328
224
|
const baseUrl = await _promptText(ctx, { title: `baseUrl for ${name}`, subtitle: 'must start with http(s) and end in /v1' });
|
|
329
225
|
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 });
|
|
226
|
+
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
227
|
if (apiKey === null) return 'cancelled';
|
|
332
228
|
return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
|
|
333
229
|
}
|
|
334
230
|
|
|
335
231
|
async function _provider(args, ctx) {
|
|
336
232
|
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.
|
|
233
|
+
// `/provider add <name> <baseUrl> [apiKey]` — register a custom OpenAI-compat endpoint.
|
|
339
234
|
const addMatch = args && args.match(/^add\s+(.+)$/i);
|
|
340
235
|
if (addMatch) {
|
|
341
236
|
const [name, baseUrl, apiKey] = splitWhitespace(addMatch[1]);
|
|
342
237
|
if (!name || !baseUrl) return 'usage: /provider add <name> <baseUrl> [apiKey]';
|
|
343
238
|
return _registerCustom(ctx, registry, { name, baseUrl, apiKey });
|
|
344
239
|
}
|
|
345
|
-
// No arg → drill-in modal picker (family -> provider)
|
|
346
|
-
//
|
|
347
|
-
|
|
240
|
+
// No arg → drill-in modal picker (family -> provider); falls back to a hint
|
|
241
|
+
// string when ctx.openPicker isn't available (non-Ink / picker not settled).
|
|
242
|
+
let _fromPicker = false;
|
|
348
243
|
if (!args) {
|
|
349
244
|
if (typeof ctx.openPicker === 'function') {
|
|
350
245
|
const picked = await _pickProviderDrillIn(ctx, registry);
|
|
351
246
|
if (!picked) return 'cancelled';
|
|
352
247
|
if (picked === '__add_custom__') return _addCustomFlow(ctx, registry);
|
|
353
248
|
args = picked;
|
|
249
|
+
_fromPicker = true;
|
|
354
250
|
// Built-in api-key provider with no key configured → offer to set one.
|
|
355
251
|
await _maybePromptForKey(ctx, registry, args);
|
|
356
252
|
} else {
|
|
@@ -364,190 +260,43 @@ async function _provider(args, ctx) {
|
|
|
364
260
|
}
|
|
365
261
|
if (ctx.setActiveProvName) ctx.setActiveProvName(args);
|
|
366
262
|
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
|
-
});
|
|
263
|
+
// Keyless CLI not signed in → inline connect menu ('EXIT' = foreground login).
|
|
264
|
+
const r = await runProviderLogin(ctx, args, { promptText: _promptText });
|
|
265
|
+
if (r !== null) return r;
|
|
266
|
+
// Picked from the modal → chain straight into a model pick so provider+model
|
|
267
|
+
// are set together (no separate /model step). Composites have no model list.
|
|
268
|
+
if (_fromPicker && args !== 'orchestrator' && typeof ctx.openPicker === 'function') {
|
|
269
|
+
const mm = await pickAndSetModel(ctx, registry, args);
|
|
270
|
+
if (mm) return `provider → ${args} · ${mm}`;
|
|
405
271
|
}
|
|
406
|
-
|
|
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;
|
|
474
|
-
}
|
|
475
|
-
return null;
|
|
272
|
+
return `provider → ${args}`;
|
|
476
273
|
}
|
|
477
274
|
|
|
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
275
|
|
|
500
276
|
async function _model(args, ctx) {
|
|
501
277
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
502
278
|
if (!args) {
|
|
503
279
|
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;
|
|
280
|
+
// Shared canonical picker: provider drill-in (only when the active
|
|
281
|
+
// provider has no models) → model loop with the "⇄ switch provider"
|
|
282
|
+
// and custom-id rows. Session-only — /model does not persist to disk.
|
|
283
|
+
const r = await pickProviderModel(ctx, registry, { includeSwitch: true });
|
|
284
|
+
if (!r) return 'cancelled';
|
|
285
|
+
const switched = r.provider !== ctx.getActiveProvName();
|
|
286
|
+
if (switched) {
|
|
287
|
+
const next = _providerLookup(registry, r.provider);
|
|
288
|
+
if (!next) return `unknown provider: ${r.provider}`;
|
|
289
|
+
if (ctx.setActiveProvName) ctx.setActiveProvName(r.provider);
|
|
290
|
+
if (ctx.setProv) ctx.setProv(next);
|
|
543
291
|
}
|
|
544
|
-
|
|
545
|
-
|
|
292
|
+
// Model pick cancelled (null) → keep any provider switch, leave model.
|
|
293
|
+
if (r.model == null) {
|
|
294
|
+
return switched ? `provider → ${r.provider} (model unchanged)` : 'cancelled';
|
|
546
295
|
}
|
|
547
|
-
if (ctx.setActiveModel) ctx.setActiveModel(model);
|
|
296
|
+
if (ctx.setActiveModel) ctx.setActiveModel(r.model);
|
|
548
297
|
return switched
|
|
549
|
-
? `provider → ${
|
|
550
|
-
: `model → ${model}`;
|
|
298
|
+
? `provider → ${r.provider} · model → ${r.model}`
|
|
299
|
+
: `model → ${r.model}`;
|
|
551
300
|
}
|
|
552
301
|
return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
|
|
553
302
|
}
|
|
@@ -569,13 +318,22 @@ async function _model(args, ctx) {
|
|
|
569
318
|
async function _skill(args, ctx) {
|
|
570
319
|
// `/skill name1,name2` — replace the active system message with a
|
|
571
320
|
// composition. No-arg → clear system message. Mirrors cli.mjs:3046.
|
|
572
|
-
|
|
321
|
+
// `clear`/`unset` are not skill names — treat them as the explicit clear verb
|
|
322
|
+
// so they reach the clear branch instead of being composed as a skill.
|
|
323
|
+
const isClear = /^(clear|unset)$/i.test((args || '').trim());
|
|
324
|
+
const names = isClear ? [] : args.split(',').map((s) => s.trim()).filter(Boolean);
|
|
573
325
|
const messages = ctx.getMessages().slice(); // mutable copy
|
|
574
326
|
const sysIdx = messages.findIndex((m) => m.role === 'system');
|
|
575
327
|
const sid = ctx.getSessionId && ctx.getSessionId();
|
|
576
328
|
const sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
|
|
577
329
|
|
|
578
330
|
if (names.length === 0) {
|
|
331
|
+
// Footgun guard: bare `/skill` used to silently wipe the active skills.
|
|
332
|
+
// Now no-arg opens the skill picker; clearing requires explicit /skill clear.
|
|
333
|
+
if (!isClear) {
|
|
334
|
+
if (typeof ctx.openPicker === 'function') return _skillsList('', ctx);
|
|
335
|
+
return 'usage: /skill <name>[,<name>] · /skill clear to unset · /skills to pick';
|
|
336
|
+
}
|
|
579
337
|
if (sysIdx >= 0) messages.splice(sysIdx, 1);
|
|
580
338
|
if (ctx.setMessages) ctx.setMessages(messages);
|
|
581
339
|
if (sid) {
|
|
@@ -619,7 +377,8 @@ async function _skillsList(args, ctx) {
|
|
|
619
377
|
if (!names.length) {
|
|
620
378
|
return [
|
|
621
379
|
'no skills installed.',
|
|
622
|
-
'
|
|
380
|
+
'starter pack: lazyclaw skills starter',
|
|
381
|
+
'install more: lazyclaw skills install <owner>/<repo>',
|
|
623
382
|
'then /skills to pick, or /skill <name>[,<name>] to activate.',
|
|
624
383
|
].join('\n');
|
|
625
384
|
}
|
|
@@ -677,6 +436,18 @@ async function _memory(args, ctx) {
|
|
|
677
436
|
try { mem = await import('../memory.mjs'); }
|
|
678
437
|
catch (e) { return `memory unavailable: ${e?.message || e}`; }
|
|
679
438
|
const tokens = splitWhitespace(args);
|
|
439
|
+
if (!tokens.length && typeof ctx.openPicker === 'function') {
|
|
440
|
+
const picked = await ctx.openPicker({
|
|
441
|
+
kind: 'menu', title: 'Memory', subtitle: 'view a memory store',
|
|
442
|
+
items: [
|
|
443
|
+
{ id: 'core', label: 'Core', desc: 'persistent core memory' },
|
|
444
|
+
{ id: 'recent', label: 'Recent', desc: 'last ~20 messages' },
|
|
445
|
+
{ id: 'episodic', label: 'Episodic', desc: 'consolidated topic files' },
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
449
|
+
return _memory(typeof pid === 'string' && pid ? pid : 'core', ctx);
|
|
450
|
+
}
|
|
680
451
|
const which = tokens[0] || 'core';
|
|
681
452
|
if (which === 'core') {
|
|
682
453
|
const body = mem.loadCore(ctx.cfgDir);
|
|
@@ -740,6 +511,20 @@ async function _agent(args, ctx) {
|
|
|
740
511
|
const rest = tokens.slice(1);
|
|
741
512
|
const aname = rest[0];
|
|
742
513
|
try {
|
|
514
|
+
if (!sub && typeof ctx.openPicker === 'function') {
|
|
515
|
+
const picked = await ctx.openPicker({
|
|
516
|
+
kind: 'menu', title: 'Agents', subtitle: `${agentsMod.listAgents(ctx.cfgDir).length} registered`,
|
|
517
|
+
items: [
|
|
518
|
+
{ id: 'list', label: 'List agents', desc: 'show all' },
|
|
519
|
+
{ id: 'add', label: 'Add agent…', desc: '/agent add <name> [role]' },
|
|
520
|
+
{ id: 'edit', label: 'Edit agent…', desc: 'pick provider + model' },
|
|
521
|
+
{ id: 'show', label: 'Show agent…', desc: 'print one record' },
|
|
522
|
+
{ id: 'remove', label: 'Remove agent…', desc: 'delete a record' },
|
|
523
|
+
],
|
|
524
|
+
});
|
|
525
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
526
|
+
return _agent(typeof pid === 'string' && pid ? pid : 'list', ctx);
|
|
527
|
+
}
|
|
743
528
|
if (!sub || sub === 'list') {
|
|
744
529
|
const agents = agentsMod.listAgents(ctx.cfgDir);
|
|
745
530
|
if (agents.length === 0) return 'no agents registered. /agent add <name> [...] to create.';
|
|
@@ -749,33 +534,63 @@ async function _agent(args, ctx) {
|
|
|
749
534
|
}).join('\n');
|
|
750
535
|
}
|
|
751
536
|
if (sub === 'show') {
|
|
752
|
-
if (!aname) return 'usage: /agent show <name>';
|
|
537
|
+
if (!aname) return 'usage: /agent show <name> [json]';
|
|
753
538
|
const a = agentsMod.getAgent(aname, ctx.cfgDir);
|
|
754
539
|
if (!a) return `no agent "${aname}"`;
|
|
755
|
-
return
|
|
540
|
+
return rest[1] === 'json'
|
|
541
|
+
? JSON.stringify(a, null, 2)
|
|
542
|
+
: renderRecord(a, { fields: ['name', 'displayName', 'provider', 'model', 'role', 'tools', 'tags', 'iconEmoji', 'memoryWrite', 'skillWrite', 'createdAt', 'updatedAt'] });
|
|
756
543
|
}
|
|
757
544
|
if (sub === 'add') {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
545
|
+
let name = aname;
|
|
546
|
+
let roleText = rest.slice(1).join(' ').trim();
|
|
547
|
+
// Guided fill: no name typed + a modal available → prompt for it.
|
|
548
|
+
if (!name && typeof ctx.openPicker === 'function') {
|
|
549
|
+
name = await _promptText(ctx, { title: 'New agent — name', subtitle: 'short id, e.g. scout (Esc cancels)' });
|
|
550
|
+
if (!name) return 'agent add: cancelled';
|
|
551
|
+
if (!roleText) {
|
|
552
|
+
const r = await _promptText(ctx, { title: `Role for ${name}`, subtitle: 'one line describing what this agent does (Esc to skip)', allowEmpty: true });
|
|
553
|
+
roleText = r || '';
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (!name) return 'usage: /agent add <name> [role text…]';
|
|
557
|
+
const a = agentsMod.registerAgent({ name, role: roleText }, ctx.cfgDir);
|
|
558
|
+
return `✓ added agent ${a.name} (tools=${(a.tools || []).join(',')}) — set its model with /agent edit ${a.name}`;
|
|
559
|
+
}
|
|
560
|
+
if (sub === 'edit') {
|
|
561
|
+
if (!aname) return 'usage: /agent edit <name>';
|
|
562
|
+
const existing = agentsMod.getAgent(aname, ctx.cfgDir);
|
|
563
|
+
if (!existing) return `no agent "${aname}"`;
|
|
564
|
+
if (typeof ctx.openPicker !== 'function') {
|
|
565
|
+
return `agent edit: picker unavailable here — use: lazyclaw agent edit ${aname} --provider <p> --model <m>`;
|
|
566
|
+
}
|
|
567
|
+
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
568
|
+
const r = await pickProviderModel(ctx, registry, { pickProvider: true, includeDefault: true, includeSwitch: false });
|
|
569
|
+
if (!r || r.model == null) return 'agent edit: cancelled';
|
|
570
|
+
const patched = agentsMod.patchAgent(aname, { provider: r.provider, model: r.model || '' }, ctx.cfgDir);
|
|
571
|
+
return `✓ ${patched.name} → ${patched.provider}${patched.model ? '/' + patched.model : ''}`;
|
|
762
572
|
}
|
|
763
573
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
764
574
|
if (!aname) return 'usage: /agent remove <name>';
|
|
575
|
+
if (typeof ctx.openPicker === 'function') {
|
|
576
|
+
const ok = await _promptConfirm(ctx, { title: `Remove agent "${aname}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
|
|
577
|
+
if (!ok) return `agent remove: cancelled — "${aname}" not removed`;
|
|
578
|
+
}
|
|
765
579
|
agentsMod.removeAgent(aname, ctx.cfgDir);
|
|
766
580
|
return `✓ removed agent ${aname}`;
|
|
767
581
|
}
|
|
768
|
-
return `/agent: unknown sub "${sub}" — list|show|add|remove`;
|
|
582
|
+
return `/agent: unknown sub "${sub}" — list|show|add|edit|remove`;
|
|
769
583
|
} catch (e) {
|
|
770
584
|
return `/agent error: ${e?.message || e}`;
|
|
771
585
|
}
|
|
772
586
|
}
|
|
773
587
|
|
|
774
588
|
async function _team(args, ctx) {
|
|
775
|
-
let teamsMod, loopMod;
|
|
589
|
+
let teamsMod, loopMod, agentsMod;
|
|
776
590
|
try {
|
|
777
591
|
teamsMod = await import('../teams.mjs');
|
|
778
592
|
loopMod = await import('../loop-engine.mjs');
|
|
593
|
+
agentsMod = await import('../agents.mjs');
|
|
779
594
|
} catch (e) { return `/team unavailable: ${e?.message || e}`; }
|
|
780
595
|
let tokens;
|
|
781
596
|
try { tokens = loopMod.splitArgs(args); }
|
|
@@ -784,6 +599,19 @@ async function _team(args, ctx) {
|
|
|
784
599
|
const rest = tokens.slice(1);
|
|
785
600
|
const tname = rest[0];
|
|
786
601
|
try {
|
|
602
|
+
if (!sub && typeof ctx.openPicker === 'function') {
|
|
603
|
+
const picked = await ctx.openPicker({
|
|
604
|
+
kind: 'menu', title: 'Teams', subtitle: `${teamsMod.listTeams(ctx.cfgDir).length} registered`,
|
|
605
|
+
items: [
|
|
606
|
+
{ id: 'list', label: 'List teams', desc: 'show all' },
|
|
607
|
+
{ id: 'add', label: 'Add team…', desc: '/team add <name> --agents a,b --lead a' },
|
|
608
|
+
{ id: 'show', label: 'Show team…', desc: 'print one record' },
|
|
609
|
+
{ id: 'remove', label: 'Remove team…', desc: 'delete a team' },
|
|
610
|
+
],
|
|
611
|
+
});
|
|
612
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
613
|
+
return _team(typeof pid === 'string' && pid ? pid : 'list', ctx);
|
|
614
|
+
}
|
|
787
615
|
if (!sub || sub === 'list') {
|
|
788
616
|
const teams = teamsMod.listTeams(ctx.cfgDir);
|
|
789
617
|
if (teams.length === 0) return 'no teams registered. /team add <name> --agents a,b --lead a [--channel #x]';
|
|
@@ -793,14 +621,16 @@ async function _team(args, ctx) {
|
|
|
793
621
|
}).join('\n');
|
|
794
622
|
}
|
|
795
623
|
if (sub === 'show') {
|
|
796
|
-
if (!tname) return 'usage: /team show <name>';
|
|
624
|
+
if (!tname) return 'usage: /team show <name> [json]';
|
|
797
625
|
const t = teamsMod.getTeam(tname, ctx.cfgDir);
|
|
798
626
|
if (!t) return `no team "${tname}"`;
|
|
799
|
-
return
|
|
627
|
+
return rest[1] === 'json'
|
|
628
|
+
? JSON.stringify(t, null, 2)
|
|
629
|
+
: renderRecord(t, { fields: ['name', 'displayName', 'lead', 'agents', 'slackChannel', 'createdAt', 'updatedAt'] });
|
|
800
630
|
}
|
|
801
631
|
if (sub === 'add') {
|
|
802
|
-
if (!tname) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
|
|
803
632
|
let agentsCsv = null, lead = null, channel = '';
|
|
633
|
+
let teamName = tname;
|
|
804
634
|
for (let i = 1; i < rest.length; i++) {
|
|
805
635
|
const t = rest[i];
|
|
806
636
|
if (t === '--agents') agentsCsv = rest[++i] || '';
|
|
@@ -808,18 +638,51 @@ async function _team(args, ctx) {
|
|
|
808
638
|
else if (t === '--channel') channel = rest[++i] || '';
|
|
809
639
|
else return `/team error: unknown token "${t}"`;
|
|
810
640
|
}
|
|
811
|
-
|
|
812
|
-
|
|
641
|
+
// Guided fill: no --agents + a modal available → name prompt, then a
|
|
642
|
+
// multi-pick over registered agents, then a lead pick. Typed form
|
|
643
|
+
// (--agents …) and the no-modal path are unchanged.
|
|
644
|
+
let agentsList;
|
|
645
|
+
if (!agentsCsv && typeof ctx.openPicker === 'function') {
|
|
646
|
+
if (!teamName) {
|
|
647
|
+
teamName = await _promptText(ctx, { title: 'New team — name', subtitle: 'short id (Esc cancels)' });
|
|
648
|
+
if (!teamName) return 'team add: cancelled';
|
|
649
|
+
}
|
|
650
|
+
const all = agentsMod ? agentsMod.listAgents(ctx.cfgDir).map((a) => a.name) : [];
|
|
651
|
+
if (!all.length) return 'team add: no agents registered yet — add one with /agent add first';
|
|
652
|
+
const chosen = [];
|
|
653
|
+
for (let guard = 0; guard < 50; guard++) {
|
|
654
|
+
const items = all.filter((n) => !chosen.includes(n)).map((n) => ({ id: n, label: n }));
|
|
655
|
+
if (chosen.length) items.unshift({ id: '__done__', label: `✓ done (${chosen.length} selected)`, pinned: true });
|
|
656
|
+
if (!items.length) break;
|
|
657
|
+
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 });
|
|
658
|
+
const id = p && typeof p === 'object' ? p.id : p;
|
|
659
|
+
if (!id) { if (chosen.length) break; return 'team add: cancelled'; }
|
|
660
|
+
if (id === '__done__') break;
|
|
661
|
+
chosen.push(id);
|
|
662
|
+
}
|
|
663
|
+
if (!chosen.length) return 'team add: cancelled (no agents picked)';
|
|
664
|
+
const lp = await ctx.openPicker({ kind: 'menu', title: 'Team lead', subtitle: 'who leads this team?', items: chosen.map((n) => ({ id: n, label: n })) });
|
|
665
|
+
lead = (lp && typeof lp === 'object' ? lp.id : lp) || chosen[0];
|
|
666
|
+
agentsList = chosen;
|
|
667
|
+
} else {
|
|
668
|
+
if (!teamName) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
|
|
669
|
+
if (!agentsCsv) return '/team add: --agents is required';
|
|
670
|
+
agentsList = teamsMod.parseListFlag(agentsCsv);
|
|
671
|
+
}
|
|
813
672
|
const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
|
|
814
673
|
botToken: process.env.SLACK_BOT_TOKEN || null,
|
|
815
674
|
apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
|
|
816
675
|
logger: () => {},
|
|
817
676
|
}) : '';
|
|
818
|
-
const team = teamsMod.registerTeam({ name:
|
|
677
|
+
const team = teamsMod.registerTeam({ name: teamName, agents: agentsList, lead, slackChannel: ch }, ctx.cfgDir);
|
|
819
678
|
return `✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})`;
|
|
820
679
|
}
|
|
821
680
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
822
681
|
if (!tname) return 'usage: /team remove <name>';
|
|
682
|
+
if (typeof ctx.openPicker === 'function') {
|
|
683
|
+
const ok = await _promptConfirm(ctx, { title: `Remove team "${tname}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
|
|
684
|
+
if (!ok) return `team remove: cancelled — "${tname}" not removed`;
|
|
685
|
+
}
|
|
823
686
|
teamsMod.removeTeam(tname, ctx.cfgDir);
|
|
824
687
|
return `✓ removed team ${tname}`;
|
|
825
688
|
}
|
|
@@ -945,6 +808,22 @@ async function _goal(args, ctx) {
|
|
|
945
808
|
sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
|
|
946
809
|
} catch (e) { return `/goal unavailable: ${e?.message || e}`; }
|
|
947
810
|
if (!args) {
|
|
811
|
+
if (typeof ctx.openPicker === 'function') {
|
|
812
|
+
const active = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
|
|
813
|
+
const picked = await ctx.openPicker({
|
|
814
|
+
kind: 'menu', title: 'Goals', subtitle: `${active.length} active`,
|
|
815
|
+
items: [
|
|
816
|
+
{ id: 'list', label: 'List goals', desc: 'show all' },
|
|
817
|
+
{ id: 'add', label: 'Add goal…', desc: '/goal add <name> [--desc] [--cron]' },
|
|
818
|
+
{ id: 'show', label: 'Show goal…', desc: 'print one record' },
|
|
819
|
+
{ id: 'close', label: 'Close goal…', desc: 'mark done/abandoned' },
|
|
820
|
+
...active.map((g) => ({ id: g.name, label: `↪ switch: ${g.name}`, desc: g.description || '' })),
|
|
821
|
+
],
|
|
822
|
+
});
|
|
823
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
824
|
+
if (pid && typeof pid === 'string') return _goal(pid, ctx);
|
|
825
|
+
// cancelled → fall through to the text list below
|
|
826
|
+
}
|
|
948
827
|
const items = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
|
|
949
828
|
if (!items.length) return 'no active goals';
|
|
950
829
|
return items.map((g) =>
|
|
@@ -967,6 +846,30 @@ async function _goal(args, ctx) {
|
|
|
967
846
|
else if (!name) name = t;
|
|
968
847
|
else return `goal error: unexpected arg "${t}"`;
|
|
969
848
|
}
|
|
849
|
+
// Guided fill: no name + a modal available → prompt name + desc, and offer
|
|
850
|
+
// a cron preset picker instead of a hand-typed spec. Typed form unchanged.
|
|
851
|
+
if (!name && typeof ctx.openPicker === 'function') {
|
|
852
|
+
name = await _promptText(ctx, { title: 'New goal — name', subtitle: 'short id (Esc cancels)' });
|
|
853
|
+
if (!name) return 'goal add: cancelled';
|
|
854
|
+
if (!desc) {
|
|
855
|
+
const d = await _promptText(ctx, { title: `Goal "${name}" — description`, subtitle: 'what is this goal? (Esc to skip)', allowEmpty: true });
|
|
856
|
+
desc = d || '';
|
|
857
|
+
}
|
|
858
|
+
if (!cron) {
|
|
859
|
+
const cp = await ctx.openPicker({
|
|
860
|
+
kind: 'menu', title: 'Schedule (optional)', subtitle: 'run this goal on a cron?',
|
|
861
|
+
items: [
|
|
862
|
+
{ id: '', label: 'none', desc: 'no schedule' },
|
|
863
|
+
{ id: '0 9 * * *', label: 'daily 09:00', desc: '0 9 * * *' },
|
|
864
|
+
{ id: '0 * * * *', label: 'hourly', desc: '0 * * * *' },
|
|
865
|
+
{ id: '0 9 * * 1', label: 'weekly (Mon 09:00)', desc: '0 9 * * 1' },
|
|
866
|
+
{ id: '__custom__', label: 'custom…', desc: 'type a cron spec', freeText: true },
|
|
867
|
+
],
|
|
868
|
+
});
|
|
869
|
+
if (cp && typeof cp === 'object' && cp.id === '__custom__') cron = String(cp.query || '').trim() || null;
|
|
870
|
+
else { const cid = cp && typeof cp === 'object' ? cp.id : cp; cron = cid && typeof cid === 'string' ? cid : null; }
|
|
871
|
+
}
|
|
872
|
+
}
|
|
970
873
|
if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
|
|
971
874
|
try {
|
|
972
875
|
const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
|
|
@@ -993,10 +896,12 @@ async function _goal(args, ctx) {
|
|
|
993
896
|
if (sub === 'list') return JSON.stringify(goalsMod.listGoals(ctx.cfgDir), null, 2);
|
|
994
897
|
if (sub === 'show') {
|
|
995
898
|
const name = rest[0];
|
|
996
|
-
if (!name) return 'usage: /goal show <name>';
|
|
899
|
+
if (!name) return 'usage: /goal show <name> [json]';
|
|
997
900
|
const g = goalsMod.getGoal(name, ctx.cfgDir);
|
|
998
901
|
if (!g) return `no goal "${name}"`;
|
|
999
|
-
return
|
|
902
|
+
return rest[1] === 'json'
|
|
903
|
+
? JSON.stringify(g, null, 2)
|
|
904
|
+
: renderRecord(g, { fields: ['name', 'status', 'description', 'schedule', 'channels', 'createdAt', 'memoryPath'] });
|
|
1000
905
|
}
|
|
1001
906
|
if (sub === 'close') {
|
|
1002
907
|
const name = rest[0];
|
|
@@ -1014,7 +919,7 @@ async function _goal(args, ctx) {
|
|
|
1014
919
|
if (removed) detachNote = ' (cron detached)';
|
|
1015
920
|
} catch { /* best-effort */ }
|
|
1016
921
|
}
|
|
1017
|
-
return `✓ goal ${g.name} closed (status: ${g.status})${detachNote}`;
|
|
922
|
+
return `✓ goal ${g.name} closed (status: ${g.status})${detachNote} — start a fresh goal with /goal add ${g.name} --desc "..."`;
|
|
1018
923
|
} catch (e) { return `goal error: ${e?.message || e}`; }
|
|
1019
924
|
}
|
|
1020
925
|
// single-arg branch: switch
|
|
@@ -1116,17 +1021,37 @@ async function _personality(args, ctx) {
|
|
|
1116
1021
|
return fs.readFileSync(p, 'utf8');
|
|
1117
1022
|
}
|
|
1118
1023
|
if (sub === 'install') {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
if (!
|
|
1123
|
-
|
|
1124
|
-
|
|
1024
|
+
let nm = a, src = b;
|
|
1025
|
+
// Guided fill: missing name/file + a modal → prompt for them, and retry on
|
|
1026
|
+
// a bad path instead of erroring out (up to a few attempts).
|
|
1027
|
+
if ((!nm || !src) && typeof ctx.openPicker === 'function') {
|
|
1028
|
+
if (!nm) {
|
|
1029
|
+
nm = await _promptText(ctx, { title: 'Install personality — name', subtitle: 'short id to install it as (Esc cancels)' });
|
|
1030
|
+
if (!nm) return 'personality install: cancelled';
|
|
1031
|
+
}
|
|
1032
|
+
if (fs.existsSync(path.join(dir, `${nm}.md`))) return `personality already installed: ${nm}`;
|
|
1033
|
+
for (let attempt = 0; attempt < 3 && !src; attempt++) {
|
|
1034
|
+
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)' });
|
|
1035
|
+
if (!p) return 'personality install: cancelled';
|
|
1036
|
+
if (fs.existsSync(p)) { src = p; break; }
|
|
1037
|
+
}
|
|
1038
|
+
if (!src) return 'personality install: source file not found after 3 tries';
|
|
1039
|
+
}
|
|
1040
|
+
if (!nm || !src) return 'usage: /personality install <name> <file.md>';
|
|
1041
|
+
const dst = path.join(dir, `${nm}.md`);
|
|
1042
|
+
if (fs.existsSync(dst)) return `personality already installed: ${nm}`;
|
|
1043
|
+
if (!fs.existsSync(src)) return `source file not found: ${src}`;
|
|
1044
|
+
fs.writeFileSync(dst, fs.readFileSync(src, 'utf8'));
|
|
1045
|
+
return `installed ${nm}`;
|
|
1125
1046
|
}
|
|
1126
1047
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
1127
1048
|
if (!a) return 'usage: /personality remove <name>';
|
|
1128
1049
|
const p = path.join(dir, `${a}.md`);
|
|
1129
1050
|
if (!fs.existsSync(p)) return `personality not installed: ${a}`;
|
|
1051
|
+
if (typeof ctx.openPicker === 'function') {
|
|
1052
|
+
const ok = await _promptConfirm(ctx, { title: `Remove personality "${a}"?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
|
|
1053
|
+
if (!ok) return `personality remove: cancelled — "${a}" not removed`;
|
|
1054
|
+
}
|
|
1130
1055
|
fs.unlinkSync(p);
|
|
1131
1056
|
return `removed ${a}`;
|
|
1132
1057
|
}
|
|
@@ -1168,18 +1093,37 @@ async function _task(args, ctx, write) {
|
|
|
1168
1093
|
const id = tokens[1];
|
|
1169
1094
|
|
|
1170
1095
|
try {
|
|
1096
|
+
if (!sub && typeof ctx.openPicker === 'function') {
|
|
1097
|
+
const picked = await ctx.openPicker({
|
|
1098
|
+
kind: 'menu', title: 'Tasks', subtitle: `${tasksMod.listTasks(ctx.cfgDir).length} task(s)`,
|
|
1099
|
+
items: [
|
|
1100
|
+
{ id: 'list', label: 'List tasks', desc: 'show all' },
|
|
1101
|
+
{ id: 'start', label: 'Start task…', desc: 'open a new multi-agent task' },
|
|
1102
|
+
{ id: 'tick', label: 'Tick task…', desc: 'one router turn' },
|
|
1103
|
+
{ id: 'show', label: 'Show task…', desc: 'print a record' },
|
|
1104
|
+
{ id: 'transcript', label: 'Transcript…', desc: 'dump turns' },
|
|
1105
|
+
{ id: 'done', label: 'Mark done…', desc: 'close a task' },
|
|
1106
|
+
{ id: 'abandon', label: 'Abandon…', desc: 'abandon a task' },
|
|
1107
|
+
{ id: 'remove', label: 'Remove…', desc: 'delete a task' },
|
|
1108
|
+
],
|
|
1109
|
+
});
|
|
1110
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
1111
|
+
return _task(typeof pid === 'string' && pid ? pid : 'list', ctx, write);
|
|
1112
|
+
}
|
|
1171
1113
|
if (!sub || sub === 'list') {
|
|
1172
1114
|
const items = tasksMod.listTasks(ctx.cfgDir);
|
|
1173
|
-
if (!items.length) return 'no tasks.
|
|
1115
|
+
if (!items.length) return 'no tasks yet. /task start <team> --title "..." to create one.';
|
|
1174
1116
|
return items.map((t) =>
|
|
1175
1117
|
`• ${t.id} [${t.status || 'unknown'}] ${t.title || '(no title)'}${t.team ? ` — team=${t.team}` : ''}${t.lead ? ` — lead=${t.lead}` : ''}`
|
|
1176
1118
|
).join('\n');
|
|
1177
1119
|
}
|
|
1178
1120
|
if (sub === 'show') {
|
|
1179
|
-
if (!id) return 'usage: /task show <id>';
|
|
1121
|
+
if (!id) return 'usage: /task show <id> [json]';
|
|
1180
1122
|
const t = tasksMod.getTask(id, ctx.cfgDir);
|
|
1181
1123
|
if (!t) return `no task "${id}"`;
|
|
1182
|
-
return
|
|
1124
|
+
return tokens[2] === 'json'
|
|
1125
|
+
? JSON.stringify(t, null, 2)
|
|
1126
|
+
: renderRecord(t, { fields: ['id', 'status', 'title', 'description', 'team', 'lead', 'slackChannel', 'createdAt', 'updatedAt'] });
|
|
1183
1127
|
}
|
|
1184
1128
|
if (sub === 'transcript') {
|
|
1185
1129
|
if (!id) return 'usage: /task transcript <id> [text|md|json]';
|
|
@@ -1218,6 +1162,10 @@ async function _task(args, ctx, write) {
|
|
|
1218
1162
|
}
|
|
1219
1163
|
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
1220
1164
|
if (!id) return 'usage: /task remove <id>';
|
|
1165
|
+
if (typeof ctx.openPicker === 'function') {
|
|
1166
|
+
const ok = await _promptConfirm(ctx, { title: `Remove task ${id}?`, subtitle: 'This cannot be undone. Enter selects · Esc cancels' });
|
|
1167
|
+
if (!ok) return `task remove: cancelled — ${id} not removed`;
|
|
1168
|
+
}
|
|
1221
1169
|
tasksMod.removeTask(id, ctx.cfgDir);
|
|
1222
1170
|
return `✓ removed task ${id}`;
|
|
1223
1171
|
}
|
|
@@ -1234,6 +1182,25 @@ async function _task(args, ctx, write) {
|
|
|
1234
1182
|
else if (t === '--lead') lead = rest[++i] || null;
|
|
1235
1183
|
else if (!teamName && !t.startsWith('--')) teamName = t;
|
|
1236
1184
|
}
|
|
1185
|
+
// Guided fill: missing team/title + a modal available → pick the team
|
|
1186
|
+
// from the registry and prompt for a title. Typed form unchanged.
|
|
1187
|
+
if ((!teamName || !title) && typeof ctx.openPicker === 'function') {
|
|
1188
|
+
if (!teamName) {
|
|
1189
|
+
const teams = teamsMod.listTeams(ctx.cfgDir);
|
|
1190
|
+
if (!teams.length) return 'task start: no teams yet — create one with /team add first';
|
|
1191
|
+
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(',')}` })) });
|
|
1192
|
+
teamName = tp && typeof tp === 'object' ? tp.id : tp;
|
|
1193
|
+
if (!teamName || typeof teamName !== 'string') return 'task start: cancelled';
|
|
1194
|
+
}
|
|
1195
|
+
if (!title) {
|
|
1196
|
+
title = await _promptText(ctx, { title: `Task title (team: ${teamName})`, subtitle: 'one line describing the task (Esc cancels)' });
|
|
1197
|
+
if (!title) return 'task start: cancelled';
|
|
1198
|
+
}
|
|
1199
|
+
if (!description) {
|
|
1200
|
+
const d = await _promptText(ctx, { title: 'Task description', subtitle: 'optional detail (Esc to skip)', allowEmpty: true });
|
|
1201
|
+
description = d || '';
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1237
1204
|
if (!teamName || !title) return 'usage: /task start <team> --title "..." [--description "..."] [--lead <name>]';
|
|
1238
1205
|
const team = teamsMod.getTeam(teamName, ctx.cfgDir);
|
|
1239
1206
|
if (!team) return `no team "${teamName}"`;
|
|
@@ -1292,6 +1259,9 @@ async function _task(args, ctx, write) {
|
|
|
1292
1259
|
const userMsg = tokens.slice(2).join(' ').trim();
|
|
1293
1260
|
try {
|
|
1294
1261
|
if (typeof write === 'function') { try { write(' ↻ running task turn…\n'); } catch {} }
|
|
1262
|
+
// Default-on isolation: confine every tool the team runs. Lazy-imported
|
|
1263
|
+
// so the sandbox backends stay off the chat module-load path.
|
|
1264
|
+
const { defaultSandboxSpec } = await import('../sandbox/index.mjs');
|
|
1295
1265
|
const result = await router.runTaskTurn({
|
|
1296
1266
|
task, team, agentsById,
|
|
1297
1267
|
userMessage: userMsg || undefined,
|
|
@@ -1300,6 +1270,7 @@ async function _task(args, ctx, write) {
|
|
|
1300
1270
|
logger: (line) => { if (typeof write === 'function') { try { write(line); } catch {} } },
|
|
1301
1271
|
approve: _makeInkApprove(ctx),
|
|
1302
1272
|
security: ctx.cfg?.security,
|
|
1273
|
+
sandbox: defaultSandboxSpec(ctx.cfg, { cwd: process.cwd(), configDir: ctx.cfgDir }),
|
|
1303
1274
|
});
|
|
1304
1275
|
return `✓ task ${result.task.id} → ${result.task.status} (${result.iterations} agent turn(s)${result.stoppedBy ? `, stopped by ${result.stoppedBy}` : ''})`;
|
|
1305
1276
|
} catch (e) {
|
|
@@ -1312,335 +1283,15 @@ async function _task(args, ctx, write) {
|
|
|
1312
1283
|
}
|
|
1313
1284
|
}
|
|
1314
1285
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
const tokens = splitWhitespace(args);
|
|
1318
|
-
const sub = tokens[0] || 'show';
|
|
1319
|
-
|
|
1320
|
-
if (sub === 'show') {
|
|
1321
|
-
let effective = { provider: ctx.getActiveProvName ? ctx.getActiveProvName() : null,
|
|
1322
|
-
model: ctx.getActiveModel ? ctx.getActiveModel() : null };
|
|
1323
|
-
try {
|
|
1324
|
-
if (typeof registry.resolveTrainer === 'function') {
|
|
1325
|
-
effective = registry.resolveTrainer(ctx.cfg || {});
|
|
1326
|
-
}
|
|
1327
|
-
} catch { /* fall through */ }
|
|
1328
|
-
const configured = (ctx.cfg && ctx.cfg.trainer) || null;
|
|
1329
|
-
const cfgRender = configured
|
|
1330
|
-
? JSON.stringify(configured, null, 2)
|
|
1331
|
-
: '(unset — trainer mirrors the chat provider/model)';
|
|
1332
|
-
return [
|
|
1333
|
-
'trainer (effective):',
|
|
1334
|
-
` provider: ${effective.provider}`,
|
|
1335
|
-
` model: ${effective.model || '(default)'}`,
|
|
1336
|
-
'trainer (configured):',
|
|
1337
|
-
cfgRender,
|
|
1338
|
-
].join('\n');
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
if (sub === 'set') {
|
|
1342
|
-
const spec = tokens[1];
|
|
1343
|
-
if (!spec) return 'usage: /trainer set <provider>[:<model>] (or `auto` for orchestrator-managed)';
|
|
1344
|
-
const parsed = typeof registry.parseProviderModel === 'function'
|
|
1345
|
-
? registry.parseProviderModel(spec)
|
|
1346
|
-
: { provider: spec.split(':')[0], model: spec.split(':')[1] || null };
|
|
1347
|
-
if (!parsed || !parsed.provider) return `/trainer set: could not parse "${spec}"`;
|
|
1348
|
-
if (parsed.provider !== 'auto') {
|
|
1349
|
-
const next = _providerLookup(registry, parsed.provider);
|
|
1350
|
-
if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
|
|
1351
|
-
}
|
|
1352
|
-
// Optional `--fallback <provider[:model]>` — resolveTrainer routes here
|
|
1353
|
-
// when opts.useFallback is set. Validate before persisting.
|
|
1354
|
-
let fallbackSpec = null;
|
|
1355
|
-
const fi = tokens.indexOf('--fallback');
|
|
1356
|
-
if (fi >= 0) {
|
|
1357
|
-
fallbackSpec = tokens[fi + 1];
|
|
1358
|
-
if (!fallbackSpec) return 'usage: /trainer set <p:m> --fallback <p:m>';
|
|
1359
|
-
const fp = _parseProvModel(registry, fallbackSpec);
|
|
1360
|
-
if (!fp.provider) return `/trainer set: could not parse fallback "${fallbackSpec}"`;
|
|
1361
|
-
if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
|
|
1362
|
-
return `/trainer set: unknown provider "${fp.provider}"`;
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
// Read-merge-write so unrelated cfg keys survive.
|
|
1366
|
-
const fs = await import('node:fs');
|
|
1367
|
-
const path = await import('node:path');
|
|
1368
|
-
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
1369
|
-
let diskCfg = {};
|
|
1370
|
-
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
1371
|
-
diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
|
|
1372
|
-
if (parsed.model) diskCfg.trainer.model = parsed.model;
|
|
1373
|
-
else delete diskCfg.trainer.model;
|
|
1374
|
-
if (fallbackSpec) diskCfg.trainer.fallback = fallbackSpec;
|
|
1375
|
-
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
1376
|
-
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
1377
|
-
if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
|
|
1378
|
-
return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}${fallbackSpec ? ` (fallback: ${fallbackSpec})` : ''}`;
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
if (sub === 'fallback') {
|
|
1382
|
-
const spec = tokens[1];
|
|
1383
|
-
if (!spec) return 'usage: /trainer fallback <provider>[:<model>] | clear';
|
|
1384
|
-
const fs = await import('node:fs');
|
|
1385
|
-
const path = await import('node:path');
|
|
1386
|
-
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
1387
|
-
let diskCfg = {};
|
|
1388
|
-
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
1389
|
-
if (spec === 'clear' || spec === 'unset') {
|
|
1390
|
-
if (diskCfg.trainer) delete diskCfg.trainer.fallback;
|
|
1391
|
-
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
1392
|
-
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
1393
|
-
if (ctx.cfg && ctx.cfg.trainer) delete ctx.cfg.trainer.fallback;
|
|
1394
|
-
return '✓ trainer fallback cleared';
|
|
1395
|
-
}
|
|
1396
|
-
const fp = _parseProvModel(registry, spec);
|
|
1397
|
-
if (!fp.provider) return `/trainer fallback: could not parse "${spec}"`;
|
|
1398
|
-
if (fp.provider !== 'auto' && !_providerLookup(registry, fp.provider)) {
|
|
1399
|
-
return `/trainer fallback: unknown provider "${fp.provider}"`;
|
|
1400
|
-
}
|
|
1401
|
-
diskCfg.trainer = { ...(diskCfg.trainer || {}), fallback: spec };
|
|
1402
|
-
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
1403
|
-
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
1404
|
-
if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
|
|
1405
|
-
return `✓ trainer fallback → ${spec}`;
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
if (sub === 'clear' || sub === 'unset') {
|
|
1409
|
-
const fs = await import('node:fs');
|
|
1410
|
-
const path = await import('node:path');
|
|
1411
|
-
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
1412
|
-
let diskCfg = {};
|
|
1413
|
-
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
1414
|
-
delete diskCfg.trainer;
|
|
1415
|
-
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
1416
|
-
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
1417
|
-
if (ctx.cfg) delete ctx.cfg.trainer;
|
|
1418
|
-
return '✓ trainer cleared (will mirror chat provider/model)';
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
return `/trainer: unknown sub "${sub}" — show|set <p:m>|clear`;
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
// /dashboard — open the lazyclaw web UI.
|
|
1425
|
-
//
|
|
1426
|
-
// v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
|
|
1427
|
-
// session spawned 20+ daemon children).
|
|
1428
|
-
//
|
|
1429
|
-
// Original implementation:
|
|
1430
|
-
// probe /healthz → if !200, spawn detached `lazyclaw dashboard
|
|
1431
|
-
// --no-open` and poll for up to 3s.
|
|
1432
|
-
//
|
|
1433
|
-
// Failure mode that produced the 20+ spawn pile-up:
|
|
1434
|
-
// 1. User types /dashboard. probe fails (no daemon). Spawn child A.
|
|
1435
|
-
// 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
|
|
1436
|
-
// 3. User types /dashboard again BEFORE A is ready. probe still fails.
|
|
1437
|
-
// Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
|
|
1438
|
-
// (cli.mjs:3611) which SIGTERMs child A. B takes over.
|
|
1439
|
-
// 4. Repeat. Each /dashboard kills the previous daemon and starts a
|
|
1440
|
-
// new one. With autorepeat / many slash calls this stacks fast.
|
|
1441
|
-
//
|
|
1442
|
-
// Two-layer guard:
|
|
1443
|
-
// - A module-level _dashboardSpawning latch refuses concurrent spawn
|
|
1444
|
-
// attempts. While a spawn is in flight, /dashboard says so + returns
|
|
1445
|
-
// without firing another child.
|
|
1446
|
-
// - A _dashboardChildPid cache remembers the PID we already spawned;
|
|
1447
|
-
// subsequent calls check kill(pid, 0) to confirm the child is alive
|
|
1448
|
-
// and just open the browser without spawning.
|
|
1449
|
-
//
|
|
1450
|
-
// We probe both /healthz (HTTP) AND a raw net.connect port check so a
|
|
1451
|
-
// slow-starting daemon (binding the listener but not yet answering HTTP)
|
|
1452
|
-
// still counts as "running".
|
|
1453
|
-
let _dashboardSpawning = false;
|
|
1454
|
-
let _dashboardChildPid = null;
|
|
1455
|
-
|
|
1456
|
-
function _portIsListening(port, timeoutMs = 200) {
|
|
1457
|
-
return new Promise((resolve) => {
|
|
1458
|
-
import('node:net').then(({ createConnection }) => {
|
|
1459
|
-
let settled = false;
|
|
1460
|
-
const sock = createConnection({ host: '127.0.0.1', port });
|
|
1461
|
-
const done = (ok) => {
|
|
1462
|
-
if (settled) return;
|
|
1463
|
-
settled = true;
|
|
1464
|
-
try { sock.destroy(); } catch {}
|
|
1465
|
-
resolve(ok);
|
|
1466
|
-
};
|
|
1467
|
-
sock.once('connect', () => done(true));
|
|
1468
|
-
sock.once('error', () => done(false));
|
|
1469
|
-
setTimeout(() => done(false), timeoutMs);
|
|
1470
|
-
}).catch(() => resolve(false));
|
|
1471
|
-
});
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
async function _dashboardProbe(port) {
|
|
1475
|
-
// Fast path — port-level probe. Catches a daemon that has bound the
|
|
1476
|
-
// socket but hasn't finished initializing its HTTP routes.
|
|
1477
|
-
if (await _portIsListening(port, 200)) return true;
|
|
1478
|
-
// Slow path — full /healthz fetch, for defense in depth.
|
|
1479
|
-
if (typeof fetch !== 'function') return false;
|
|
1480
|
-
try {
|
|
1481
|
-
const ac = new AbortController();
|
|
1482
|
-
const t = setTimeout(() => ac.abort(), 250);
|
|
1483
|
-
const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
|
|
1484
|
-
clearTimeout(t);
|
|
1485
|
-
return !!(r && r.ok);
|
|
1486
|
-
} catch { return false; }
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
function _openBrowser(url) {
|
|
1490
|
-
return import('node:child_process').then(({ spawn }) => {
|
|
1491
|
-
let cmd, args;
|
|
1492
|
-
if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
|
|
1493
|
-
else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
|
|
1494
|
-
else { cmd = 'xdg-open'; args = [url]; }
|
|
1495
|
-
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
async function _dashboardStop(port) {
|
|
1500
|
-
// Best-effort kill of every lazyclaw dashboard daemon on the box.
|
|
1501
|
-
// Used to clean up after the v5.4.3 spawn pile-up bug.
|
|
1502
|
-
if (process.platform === 'win32') {
|
|
1503
|
-
return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
|
|
1504
|
-
}
|
|
1505
|
-
const { spawn } = await import('node:child_process');
|
|
1506
|
-
// Step 1: lsof the port and SIGTERM each PID.
|
|
1507
|
-
const portPids = await new Promise((resolve) => {
|
|
1508
|
-
try {
|
|
1509
|
-
const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
1510
|
-
let buf = '';
|
|
1511
|
-
lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
|
|
1512
|
-
lsof.on('error', () => resolve([]));
|
|
1513
|
-
lsof.on('close', () => resolve(
|
|
1514
|
-
buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
|
|
1515
|
-
));
|
|
1516
|
-
} catch { resolve([]); }
|
|
1517
|
-
});
|
|
1518
|
-
for (const pid of portPids) {
|
|
1519
|
-
try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
|
|
1520
|
-
}
|
|
1521
|
-
// Step 2: pkill any process whose command line includes "lazyclaw dashboard"
|
|
1522
|
-
// — catches detached children that bound a different (random) port via
|
|
1523
|
-
// cmdDashboard's EADDRINUSE fallback.
|
|
1524
|
-
let pkilled = 0;
|
|
1525
|
-
try {
|
|
1526
|
-
const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
1527
|
-
pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
|
|
1528
|
-
} catch { /* fine */ }
|
|
1529
|
-
_dashboardChildPid = null;
|
|
1530
|
-
return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
// Parse the daemon's "listening at <url>" stdout line so /dashboard opens the
|
|
1534
|
-
// actually-bound port (the child may fall back to a random port on EADDRINUSE).
|
|
1535
|
-
export function parseDashboardUrl(text) {
|
|
1536
|
-
const m = String(text || '').match(/listening at\s+(https?:\/\/\S+)/i);
|
|
1537
|
-
return m ? m[1] : null;
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
// Resolve the daemon's real URL from its stdout within `timeoutMs`, or null.
|
|
1541
|
-
function _waitForDashboardUrl(child, timeoutMs) {
|
|
1542
|
-
return new Promise((resolve) => {
|
|
1543
|
-
if (!child || !child.stdout) { resolve(null); return; }
|
|
1544
|
-
let buf = '';
|
|
1545
|
-
let done = false;
|
|
1546
|
-
const finish = (v) => {
|
|
1547
|
-
if (done) return;
|
|
1548
|
-
done = true;
|
|
1549
|
-
try { child.stdout.off('data', onData); } catch { /* ignore */ }
|
|
1550
|
-
resolve(v);
|
|
1551
|
-
};
|
|
1552
|
-
const onData = (d) => {
|
|
1553
|
-
buf += d.toString('utf8');
|
|
1554
|
-
const u = parseDashboardUrl(buf);
|
|
1555
|
-
if (u) finish(u);
|
|
1556
|
-
};
|
|
1557
|
-
child.stdout.on('data', onData);
|
|
1558
|
-
setTimeout(() => finish(null), timeoutMs);
|
|
1559
|
-
});
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
async function _dashboard(args) {
|
|
1563
|
-
const port = 19600;
|
|
1564
|
-
const url = `http://127.0.0.1:${port}/dashboard`;
|
|
1565
|
-
// Under the node:test runner, never launch a real daemon or open a browser
|
|
1566
|
-
// (it leaked a background daemon + opened a tab on every test run).
|
|
1567
|
-
if (process.env.NODE_TEST_CONTEXT) return `dashboard: ${url} (spawn skipped under test)`;
|
|
1568
|
-
const sub = splitWhitespace(args)[0];
|
|
1569
|
-
if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
|
|
1570
|
-
|
|
1571
|
-
// 1. Already running anywhere on the machine? → reuse.
|
|
1572
|
-
if (await _dashboardProbe(port)) {
|
|
1573
|
-
await _openBrowser(url);
|
|
1574
|
-
return `✓ dashboard already running — opened ${url}`;
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
// 2. We spawned in this chat — is that child still alive?
|
|
1578
|
-
if (_dashboardChildPid != null) {
|
|
1579
|
-
try {
|
|
1580
|
-
process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
|
|
1581
|
-
// Child alive but not answering yet. Don't re-spawn; just nudge.
|
|
1582
|
-
await _openBrowser(url);
|
|
1583
|
-
return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
|
|
1584
|
-
} catch {
|
|
1585
|
-
_dashboardChildPid = null; // child died; fall through and respawn.
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
// 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
|
|
1590
|
-
if (_dashboardSpawning) {
|
|
1591
|
-
await _openBrowser(url);
|
|
1592
|
-
return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
// 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
|
|
1596
|
-
// spawn flag in a finally so it always clears.
|
|
1597
|
-
_dashboardSpawning = true;
|
|
1598
|
-
try {
|
|
1599
|
-
const { spawn } = await import('node:child_process');
|
|
1600
|
-
let child;
|
|
1601
|
-
try {
|
|
1602
|
-
// Pass --port so the child tries 19600 first; pipe stdout so we can read
|
|
1603
|
-
// the real bound URL (it may fall back to a random port on EADDRINUSE).
|
|
1604
|
-
child = spawn(process.execPath, [process.argv[1], 'dashboard', '--port', String(port), '--no-open'], {
|
|
1605
|
-
detached: true, stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd(), env: process.env,
|
|
1606
|
-
});
|
|
1607
|
-
_dashboardChildPid = child.pid;
|
|
1608
|
-
} catch (e) {
|
|
1609
|
-
return `dashboard error: failed to spawn — ${e?.message || e}`;
|
|
1610
|
-
}
|
|
1611
|
-
// Prefer the daemon's own "listening at <url>" line — it carries the
|
|
1612
|
-
// actual port even after a random-port fallback.
|
|
1613
|
-
const boundUrl = await _waitForDashboardUrl(child, 3000);
|
|
1614
|
-
// Release the captured stdout pipe so the detached daemon doesn't keep
|
|
1615
|
-
// OUR event loop alive (unref, not destroy — destroying would EPIPE the
|
|
1616
|
-
// daemon on its next stdout write). Then unref the child itself.
|
|
1617
|
-
try { if (child.stdout) { child.stdout.removeAllListeners('data'); child.stdout.unref(); } } catch { /* ignore */ }
|
|
1618
|
-
child.unref();
|
|
1619
|
-
if (boundUrl) {
|
|
1620
|
-
await _openBrowser(boundUrl);
|
|
1621
|
-
return `✓ started dashboard (pid ${child.pid}) — opened ${boundUrl}`;
|
|
1622
|
-
}
|
|
1623
|
-
// Fallback: the line never arrived — poll the default port best-effort.
|
|
1624
|
-
const start = Date.now();
|
|
1625
|
-
while (Date.now() - start < 1500) {
|
|
1626
|
-
if (await _dashboardProbe(port)) {
|
|
1627
|
-
await _openBrowser(url);
|
|
1628
|
-
return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
|
|
1629
|
-
}
|
|
1630
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
1631
|
-
}
|
|
1632
|
-
return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
|
|
1633
|
-
} finally {
|
|
1634
|
-
_dashboardSpawning = false;
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1286
|
+
// /trainer — moved to ./slash_trainer.mjs (Group 4) so the set/fallback/show/
|
|
1287
|
+
// clear branches can grow off this file's size ratchet.
|
|
1637
1288
|
|
|
1638
1289
|
// /menu — in-chat command palette over the full subcommand catalog. The
|
|
1639
1290
|
// no-arg launcher menu used to be the home screen; defaulting to chat hid it
|
|
1640
1291
|
// behind `lazyclaw menu`. This restores discoverability: browse subcommands
|
|
1641
1292
|
// and get the exact command to run. (Most subcommands own stdout / spawn, so
|
|
1642
1293
|
// they can't safely run inline in the Ink scrollback — we echo the command.)
|
|
1643
|
-
async function _menu(args, ctx) {
|
|
1294
|
+
async function _menu(args, ctx, write) {
|
|
1644
1295
|
if (typeof ctx.openPicker === 'function') {
|
|
1645
1296
|
const items = [];
|
|
1646
1297
|
const seen = new Set();
|
|
@@ -1648,17 +1299,23 @@ async function _menu(args, ctx) {
|
|
|
1648
1299
|
for (const c of cmds) {
|
|
1649
1300
|
if (seen.has(c)) continue;
|
|
1650
1301
|
seen.add(c);
|
|
1651
|
-
|
|
1302
|
+
// Mark which subcommands can run in-chat (a /slash equivalent exists).
|
|
1303
|
+
const inChat = SLASH_HANDLERS.has(`/${c}`);
|
|
1304
|
+
items.push({ id: c, label: c, desc: inChat ? `${group} · runs in chat` : group });
|
|
1652
1305
|
}
|
|
1653
1306
|
}
|
|
1654
1307
|
const picked = await ctx.openPicker({
|
|
1655
1308
|
kind: 'menu',
|
|
1656
1309
|
title: 'lazyclaw subcommands',
|
|
1657
|
-
subtitle: 'Enter shows
|
|
1310
|
+
subtitle: 'Enter runs it in chat (or shows the shell command) · Esc cancels',
|
|
1658
1311
|
items,
|
|
1659
1312
|
});
|
|
1660
1313
|
if (!picked) return 'cancelled';
|
|
1661
1314
|
const cmd = typeof picked === 'string' ? picked : picked.id;
|
|
1315
|
+
// If there's an in-chat slash equivalent, dispatch it directly instead of
|
|
1316
|
+
// telling the user to leave chat.
|
|
1317
|
+
const handler = SLASH_HANDLERS.get(`/${cmd}`);
|
|
1318
|
+
if (handler) return handler('', ctx, write);
|
|
1662
1319
|
return `run from a shell: lazyclaw ${cmd}`;
|
|
1663
1320
|
}
|
|
1664
1321
|
return [
|
|
@@ -1668,117 +1325,18 @@ async function _menu(args, ctx) {
|
|
|
1668
1325
|
].join('\n');
|
|
1669
1326
|
}
|
|
1670
1327
|
|
|
1671
|
-
// /
|
|
1672
|
-
//
|
|
1673
|
-
//
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
const key = name.toLowerCase();
|
|
1684
|
-
// Reject unknown names so a typo can't silently create a bogus
|
|
1685
|
-
// cfg.channels.<name> section (which would then leak into the list).
|
|
1686
|
-
// Stay permissive for pre-existing custom sections.
|
|
1687
|
-
const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
1688
|
-
if (!cf.KNOWN_CHANNELS.includes(key) && !(key in existing)) {
|
|
1689
|
-
return `unknown channel: ${key} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
|
|
1690
|
-
}
|
|
1691
|
-
cf.channelSetEnabled(cfg, key, en); write(cfg);
|
|
1692
|
-
// Legacy fallback path: the readline ctx (_legacyCtx) has no
|
|
1693
|
-
// readConfig/writeConfig, so we read/wrote disk above against a fresh
|
|
1694
|
-
// cfg object. Mirror the toggle onto the in-session ctx.cfg so a
|
|
1695
|
-
// follow-up `/channels` (list) or other in-session read stays
|
|
1696
|
-
// consistent instead of showing the stale pre-toggle value.
|
|
1697
|
-
if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
|
|
1698
|
-
cf.channelSetEnabled(ctx.cfg, key, en);
|
|
1699
|
-
}
|
|
1700
|
-
return `channel ${key} → ${en ? 'enabled' : 'disabled'}`;
|
|
1701
|
-
}
|
|
1702
|
-
const rows = cf.channelStatusList(read());
|
|
1703
|
-
if (!rows.length) return 'no channels configured. add creds with /config (re-runs setup) or `lazyclaw setup`.';
|
|
1704
|
-
const lines = ['configured channels:'];
|
|
1705
|
-
for (const c of rows) lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}`);
|
|
1706
|
-
lines.push('toggle: /channels <name> on|off · add creds: /config');
|
|
1707
|
-
return lines.join('\n');
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
// /orchestrator — view/edit multi-agent config. `status` (default), `on`/`off`,
|
|
1711
|
-
// `planner <spec>`, `worker add|remove <spec>`, `maxsubtasks <N>`. ctx-or-
|
|
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
|
-
}
|
|
1764
|
-
|
|
1765
|
-
// /context — view/set the chat history window (turns + token budget). This is
|
|
1766
|
-
// the sliding history budget sent each turn, NOT the model's hard context
|
|
1767
|
-
// limit. ctx-or-lib/config fallback so it works on both REPL paths.
|
|
1768
|
-
async function _context(args, ctx = {}) {
|
|
1769
|
-
const cf = await import('../config_features.mjs');
|
|
1770
|
-
const cfgMod = await import('../lib/config.mjs');
|
|
1771
|
-
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
1772
|
-
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
1773
|
-
const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
|
|
1774
|
-
const parts = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1775
|
-
const sub = (parts[0] || 'status').toLowerCase();
|
|
1776
|
-
const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
|
|
1777
|
-
if (sub === 'status') return fmt();
|
|
1778
|
-
const n = parseInt(parts[1], 10);
|
|
1779
|
-
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(); }
|
|
1780
|
-
if (sub === 'tokens') { if (!Number.isFinite(n) || n < 256) return 'usage: /context tokens <N> (min 256)'; const cfg = read(); cf.chatWindowSet(cfg, { tokens: n }); persist(cfg); return fmt(); }
|
|
1781
|
-
return 'usage: /context [status | turns <N> | tokens <N>]';
|
|
1328
|
+
// /orchestrator — moved to ./orchestrator_flow.mjs (orchestratorSlash) so the
|
|
1329
|
+
// interactive fetch+pick planner/worker editor can grow off the ratchet.
|
|
1330
|
+
// /channels + /context — moved to ./slash_channels.mjs (Group 3) so they can
|
|
1331
|
+
// grow off this file's size ratchet.
|
|
1332
|
+
|
|
1333
|
+
// /agentic + /plan live in ./chat_mode_slash.mjs (Group 1) — kept out of this
|
|
1334
|
+
// file (at its size ratchet) so the toggles can grow there. Register their
|
|
1335
|
+
// catalog rows in the shared SLASH_COMMANDS so /help, the popup,
|
|
1336
|
+
// ghost-autocomplete, and the d6 drift-guard see them (idempotent — ESM runs
|
|
1337
|
+
// module init once; appended per the catalog's ordering note).
|
|
1338
|
+
for (const entry of CHAT_MODE_SLASH_COMMANDS) {
|
|
1339
|
+
if (!SLASH_COMMANDS.some((c) => c.cmd === entry.cmd)) SLASH_COMMANDS.push(entry);
|
|
1782
1340
|
}
|
|
1783
1341
|
|
|
1784
1342
|
// ─── dispatch table ──────────────────────────────────────────────────────
|
|
@@ -1792,6 +1350,10 @@ export const SLASH_HANDLERS = new Map([
|
|
|
1792
1350
|
['/reset', _newReset],
|
|
1793
1351
|
['/clear', _newReset],
|
|
1794
1352
|
['/provider', _provider],
|
|
1353
|
+
['/login', (a, ctx) => loginSlash(a, ctx, { promptText: _promptText })],
|
|
1354
|
+
['/hud', hudSlash],
|
|
1355
|
+
['/agentic', agenticSlash],
|
|
1356
|
+
['/plan', planSlash],
|
|
1795
1357
|
['/model', _model],
|
|
1796
1358
|
['/skill', _skill],
|
|
1797
1359
|
['/skills', _skillsList],
|
|
@@ -1810,7 +1372,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
1810
1372
|
['/dashboard', _dashboard],
|
|
1811
1373
|
['/menu', _menu],
|
|
1812
1374
|
['/channels', _channels],
|
|
1813
|
-
['/orchestrator',
|
|
1375
|
+
['/orchestrator', orchestratorSlash],
|
|
1814
1376
|
['/context', _context],
|
|
1815
1377
|
// /setup — full wizard (every step); /config — pick ONE setting to change
|
|
1816
1378
|
// (in-chat where possible; credential steps unmount, run, re-enter chat).
|
|
@@ -1827,6 +1389,9 @@ export const SLASH_HANDLERS = new Map([
|
|
|
1827
1389
|
*/
|
|
1828
1390
|
export async function dispatchSlash(cmd, args, ctx, write) {
|
|
1829
1391
|
const handler = SLASH_HANDLERS.get(cmd);
|
|
1830
|
-
if (!handler)
|
|
1392
|
+
if (!handler) {
|
|
1393
|
+
const hint = nearest(cmd, [...SLASH_HANDLERS.keys()]);
|
|
1394
|
+
return `unknown slash command: ${cmd}${hint ? ` — did you mean ${hint}?` : ''} (try /help)`;
|
|
1395
|
+
}
|
|
1831
1396
|
return handler(args || '', ctx || {}, write);
|
|
1832
1397
|
}
|