jeo-code 0.7.31 → 0.7.32

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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * In-process TypeScript "language service" host (gjc `lsp` parity for TS/JS/TSX/JSX
3
+ * ONLY — no external `typescript-language-server` process, no other languages).
4
+ * Built directly on the `typescript` compiler API's `ts.LanguageService`, which is
5
+ * the exact engine `tsserver`/most TS editor integrations run on top of — so
6
+ * definition/references/hover/rename here match what a real TS language server
7
+ * would report, just without the LSP wire protocol or multi-language support.
8
+ *
9
+ * One `ts.LanguageService` is cached per project root (`cwd`) and reused across
10
+ * calls within a turn. `getScriptVersion` stats the file on every call (no manual
11
+ * invalidation needed) so edits made by `write`/`edit`/`ast_edit` earlier in the
12
+ * same turn are picked up on the next `lsp` call. A file outside the project's
13
+ * `tsconfig.json` "include" set is added to the tracked root set on first use.
14
+ */
15
+ import * as ts from "typescript";
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { resolveTargetFiles } from "./ast-file-scan";
19
+
20
+ interface CachedService {
21
+ service: ts.LanguageService;
22
+ extraFiles: Set<string>;
23
+ }
24
+
25
+ const serviceCache = new Map<string, CachedService>();
26
+
27
+ function fileVersion(absPath: string): string {
28
+ try {
29
+ const st = fs.statSync(absPath);
30
+ return `${st.mtimeMs}:${st.size}`;
31
+ } catch {
32
+ return "missing";
33
+ }
34
+ }
35
+
36
+ const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = {
37
+ target: ts.ScriptTarget.Latest,
38
+ module: ts.ModuleKind.ESNext,
39
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
40
+ jsx: ts.JsxEmit.ReactJSX,
41
+ allowJs: true,
42
+ checkJs: false,
43
+ esModuleInterop: true,
44
+ resolveJsonModule: true,
45
+ strict: false,
46
+ };
47
+
48
+ function loadProjectFiles(cwd: string): { options: ts.CompilerOptions; fileNames: string[] } {
49
+ const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, "tsconfig.json");
50
+ if (configPath) {
51
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
52
+ if (!configFile.error) {
53
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
54
+ if (parsed.fileNames.length > 0) return { options: parsed.options, fileNames: parsed.fileNames };
55
+ }
56
+ }
57
+ return { options: DEFAULT_COMPILER_OPTIONS, fileNames: [] };
58
+ }
59
+
60
+ /** Get (or lazily create) the cached `ts.LanguageService` for a project root. */
61
+ async function getCached(cwd: string): Promise<CachedService> {
62
+ const existing = serviceCache.get(cwd);
63
+ if (existing) return existing;
64
+
65
+ const fromConfig = loadProjectFiles(cwd);
66
+ // No tsconfig.json (or it named zero files) — scan the whole project tree for
67
+ // supported TS/JS files instead of starting with an EMPTY root set. Without this,
68
+ // "references"/"rename" starting from a declaration would only ever see the ONE
69
+ // file explicitly queried: TS's own module resolution pulls in files a root file
70
+ // IMPORTS, never the reverse (files that import a root file), so a project with
71
+ // no tsconfig would silently miss every cross-file usage.
72
+ const fileNames = fromConfig.fileNames.length > 0 ? fromConfig.fileNames : await resolveTargetFiles(["**/*"], cwd);
73
+ const options = fromConfig.options;
74
+ const rootFiles = new Set(fileNames);
75
+ const extraFiles = new Set<string>();
76
+
77
+ const host: ts.LanguageServiceHost = {
78
+ getScriptFileNames: () => [...rootFiles, ...extraFiles],
79
+ getScriptVersion: fileVersion,
80
+ getScriptSnapshot: (fileName) => {
81
+ try {
82
+ return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, "utf-8"));
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ },
87
+ getCurrentDirectory: () => cwd,
88
+ getCompilationSettings: () => options,
89
+ getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
90
+ fileExists: ts.sys.fileExists,
91
+ readFile: ts.sys.readFile,
92
+ readDirectory: ts.sys.readDirectory,
93
+ directoryExists: ts.sys.directoryExists,
94
+ getDirectories: ts.sys.getDirectories,
95
+ };
96
+
97
+ const service = ts.createLanguageService(host, ts.createDocumentRegistry());
98
+ const cached: CachedService = { service, extraFiles };
99
+ serviceCache.set(cwd, cached);
100
+ return cached;
101
+ }
102
+
103
+ /** Get the language service for `cwd`, ensuring `absPath` is tracked (added to the
104
+ * root set if the project's tsconfig didn't already include it). */
105
+ export async function languageServiceFor(cwd: string, absPath: string): Promise<ts.LanguageService> {
106
+ const cached = await getCached(cwd);
107
+ if (!cached.extraFiles.has(absPath)) cached.extraFiles.add(absPath);
108
+ return cached.service;
109
+ }
110
+
111
+ /** Drop a cached language service (tests / long-running sessions that need a
112
+ * clean project graph after large structural changes). */
113
+ export function resetLanguageServiceCache(cwd?: string): void {
114
+ if (cwd) serviceCache.delete(cwd);
115
+ else serviceCache.clear();
116
+ }
117
+
118
+ export interface SymbolSelector { text: string; occurrence: number }
119
+
120
+ /** Parse a loose `"name"` or `"name#N"` selector (1-indexed occurrence, default 1). */
121
+ export function parseSymbolSelector(input: string | undefined): SymbolSelector | undefined {
122
+ if (!input) return undefined;
123
+ const m = /^(.*)#(\d+)$/.exec(input);
124
+ if (m) return { text: m[1]!, occurrence: Math.max(1, parseInt(m[2]!, 10)) };
125
+ return { text: input, occurrence: 1 };
126
+ }
127
+
128
+ export type PositionResult = { position: number } | { error: string };
129
+
130
+ /** Resolve a 1-indexed `line` (+ optional symbol substring selector) to an absolute
131
+ * character offset in `fileText`, using the TS compiler's own line map (so CRLF
132
+ * files are handled correctly, unlike naive `split("\n")` offset arithmetic). */
133
+ export function resolvePosition(fileText: string, line1: number, symbol?: SymbolSelector): PositionResult {
134
+ const sf = ts.createSourceFile("__pos__.tsx", fileText, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
135
+ const lineCount = sf.getLineStarts().length;
136
+ if (line1 < 1 || line1 > lineCount) {
137
+ return { error: `line ${line1} is out of range (file has ${lineCount} lines)` };
138
+ }
139
+ const lineStart = sf.getPositionOfLineAndCharacter(line1 - 1, 0);
140
+ const lineEnd = line1 < lineCount ? sf.getPositionOfLineAndCharacter(line1, 0) : fileText.length;
141
+ const lineText = fileText.slice(lineStart, lineEnd).replace(/\r?\n$/, "");
142
+
143
+ if (!symbol) return { position: lineStart };
144
+
145
+ let idx = -1;
146
+ let from = 0;
147
+ for (let i = 0; i < symbol.occurrence; i++) {
148
+ idx = lineText.indexOf(symbol.text, from);
149
+ if (idx === -1) break;
150
+ from = idx + 1;
151
+ }
152
+ if (idx === -1) {
153
+ return { error: `symbol '${symbol.text}' (occurrence ${symbol.occurrence}) not found on line ${line1}: ${lineText.trim().slice(0, 120)}` };
154
+ }
155
+ return { position: lineStart + idx + Math.max(0, Math.floor(symbol.text.length / 2)) };
156
+ }
157
+
158
+ /** One line of a file, 1-indexed, trimmed for display. */
159
+ export function lineTextAt(fileText: string, line1: number): string {
160
+ const lines = fileText.split(/\r?\n/);
161
+ return (lines[line1 - 1] ?? "").trim();
162
+ }
163
+
164
+ /** Convert a 0-indexed TS `position` in `fileText` to a 1-indexed line number. */
165
+ export function lineOfPosition(fileText: string, position: number): number {
166
+ const sf = ts.createSourceFile("__pos__.tsx", fileText, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
167
+ return sf.getLineAndCharacterOfPosition(position).line + 1;
168
+ }
169
+
170
+ /** A short, uniform `path:line: text` result line. */
171
+ export function formatLocation(relPath: string, line1: number, text: string): string {
172
+ return `${relPath}:${line1}: ${text.length > 160 ? text.slice(0, 159) + "…" : text}`;
173
+ }
@@ -0,0 +1,489 @@
1
+ /**
2
+ * `/agents`, `/roles`, `/fast`, and `/thinking` slash-command handlers
3
+ * extracted from launch.ts.
4
+ *
5
+ * These four commands share mutable REPL state with each other and with the
6
+ * already-extracted `/model` (model-slash.ts): `/agents` and `/roles` read
7
+ * and write `lastPickIndex`; `/fast` and `/thinking` read and write
8
+ * `sessionThinking` (which `/model thinking <level>` also writes). The
9
+ * caller threads the current values in via an explicit context object and
10
+ * reads back a result object instead of these functions closing over REPL
11
+ * state directly.
12
+ */
13
+
14
+ import { readGlobalConfig, saveConfigPatch } from "../../agent/state";
15
+ import {
16
+ describeModel,
17
+ describeAllProviders,
18
+ thinkingMaxTokens,
19
+ flattenModels,
20
+ resolveSelection,
21
+ resolveRoleModel,
22
+ catalogByProvider,
23
+ qualifyModelId,
24
+ } from "../../ai";
25
+ import type { ProviderModelsResult, PickEntry, ProviderName, ThinkLevel } from "../../ai";
26
+ import {
27
+ allSubagentRoles,
28
+ getSubagentRole,
29
+ resolveSubagentModel,
30
+ resolveSubagentMaxSteps,
31
+ resolveSubagentThinking,
32
+ parseMaxSteps,
33
+ withSubagentSetting,
34
+ clearSubagentSetting,
35
+ } from "../../agent/subagents";
36
+ import {
37
+ formatModelLine,
38
+ liveModelKnown,
39
+ formatPickListWithCapabilities,
40
+ formatAgentsPanel,
41
+ formatAgentDetail,
42
+ } from "../../tui/components/config-panel";
43
+ import type { SelectItem } from "../../tui/components/select-list";
44
+ import { openaiCompatDef } from "../../ai/providers/openai-compatible-catalog";
45
+ import { isProviderName, fastThinkingLevelForModel } from "./flags";
46
+
47
+ // Per-provider starting model for provider pinning. Catalog OpenAI-compatible
48
+ // providers supply their own default; built-ins use this map.
49
+ // Pure data — mirrors the identically-named REPL-local const in launch.ts.
50
+ const STATIC_PROVIDER_DEFAULT: Partial<Record<ProviderName, string>> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast", lmstudio: "lmstudio/local-model", xai: "grok-4.3", kimi: "kimi-k2-0711-preview" };
51
+
52
+ // Pure function — mirrors the identically-named REPL-local const in launch.ts.
53
+ function providerDefaultModel(p: ProviderName): string {
54
+ return openaiCompatDef(p)?.defaultModel ?? STATIC_PROVIDER_DEFAULT[p] ?? "";
55
+ }
56
+
57
+ // Pick-list entries for ONE provider, with static fallbacks so the list is
58
+ // never empty. Pure function — mirrors launch.ts's exported `providerPickEntries`
59
+ // (duplicated here rather than imported to avoid a launch.ts <-> launch/*.ts cycle).
60
+ function providerPickEntries(live: ProviderModelsResult[], want: ProviderName): PickEntry[] {
61
+ const fromLive = flattenModels(live.filter(r => r.provider === want));
62
+ if (fromLive.length) return fromLive;
63
+ const catalog = catalogByProvider(want);
64
+ if (catalog.length) {
65
+ return catalog.map((m, i) => ({ index: i + 1, provider: want, model: qualifyModelId(m.providerModel, want) }));
66
+ }
67
+ const def = openaiCompatDef(want);
68
+ if (def) {
69
+ const ids = [def.defaultModel, ...(def.knownModels ?? [])].map(m => qualifyModelId(m, want));
70
+ const seen = new Set<string>();
71
+ const entries: PickEntry[] = [];
72
+ for (const model of ids) {
73
+ if (seen.has(model)) continue;
74
+ seen.add(model);
75
+ entries.push({ index: entries.length + 1, provider: want, model });
76
+ }
77
+ if (entries.length) return entries;
78
+ }
79
+ const fallback = providerDefaultModel(want);
80
+ return fallback ? [{ index: 1, provider: want, model: qualifyModelId(fallback, want) }] : [];
81
+ }
82
+
83
+ // Antigravity with ANY Google OAuth (own login or the gemini-cli fallback) stays
84
+ // SELECTABLE in pickers even when not call-ready: picking the model is how users
85
+ // reach the flow, and the auth layer gives actionable login guidance on the first
86
+ // call if the fallback token is rejected (403). Refusing selection was a dead end.
87
+ // Pure predicate — mirrors the identically-named REPL-local const in launch.ts.
88
+ const selectableThoughNotReady = (st?: { name: string; kind: string }): boolean =>
89
+ !!st && st.name === "antigravity" && st.kind === "oauth";
90
+
91
+ // Pure function — mirrors the identically-named REPL-local const in launch.ts.
92
+ const notReadyWarning = (st: { name: string; label: string }): string =>
93
+ ` ! ${st.name} is not call-ready yet (${st.label}) — run /provider login antigravity before the first turn.`;
94
+
95
+ export interface AgentsSlashCtx {
96
+ sessionModel: string | undefined;
97
+ sessionThinking: ThinkLevel | undefined;
98
+ lastPickIndex: PickEntry[];
99
+ getLiveModels: (force?: boolean) => Promise<ProviderModelsResult[]>;
100
+ /**
101
+ * REPL's interactive live-model picker menu (raw-mode keypress loop +
102
+ * repaint). Not a pure module export — it closes over `runSelectPicker`
103
+ * and `modelPickerAssignments` (which itself reads `sessionModel`), so the
104
+ * caller threads it through like the other REPL hooks.
105
+ */
106
+ pickLiveProviderModel: (
107
+ providerName: string,
108
+ entries: PickEntry[],
109
+ current?: string,
110
+ disabledProviders?: readonly ProviderName[],
111
+ ) => Promise<PickEntry | undefined>;
112
+ /**
113
+ * Sets (or clears) a subagent role's thinking override, printing the
114
+ * confirmation itself. Closes over `readGlobalConfig`/`saveConfigPatch`
115
+ * REPL-local wiring, so the caller threads it through.
116
+ */
117
+ setRoleThinking: (roleId: string, rawLevel: string | undefined) => Promise<boolean>;
118
+ /** Generic arrows+Enter option picker (role / action menus). REPL-local closure. */
119
+ pickFromOptions: (title: string, options: SelectItem<string>[]) => Promise<string | undefined>;
120
+ /** Arrows+Enter thinking-level picker built on `pickFromOptions`. REPL-local closure. */
121
+ pickThinkingLevel: (
122
+ title: string,
123
+ current: ThinkLevel | undefined,
124
+ inheritLabel?: string,
125
+ ) => Promise<ThinkLevel | "inherit" | undefined>;
126
+ }
127
+
128
+ export interface AgentsSlashResult {
129
+ /** Present only when changed. */
130
+ sessionThinking?: ThinkLevel;
131
+ /** Present only when the block builds/refreshes a pick list. */
132
+ lastPickIndex?: PickEntry[];
133
+ }
134
+
135
+ /**
136
+ * Handle `/agents [edit|role] [model|#N|thinking L|maxSteps N|reset]`.
137
+ * Extracted verbatim from launch.ts's inline REPL branch — the caller keeps
138
+ * the `input === "/agents" || input.startsWith("/agents ")` guard and just
139
+ * calls this function.
140
+ */
141
+ export async function runAgentsSlash(input: string, ctx: AgentsSlashCtx): Promise<AgentsSlashResult> {
142
+ const { getLiveModels, pickLiveProviderModel, setRoleThinking, pickFromOptions, pickThinkingLevel } = ctx;
143
+ let lastPickIndex = ctx.lastPickIndex;
144
+ let lastPickIndexChanged = false;
145
+
146
+ const result = (): AgentsSlashResult => ({
147
+ ...(lastPickIndexChanged ? { lastPickIndex } : {}),
148
+ });
149
+
150
+ const agentsCommand = "/agents";
151
+ const tokens = input.substring(agentsCommand.length).trim().split(/\s+/).filter(Boolean);
152
+ const roleArg = tokens[0];
153
+ const modelArg = tokens[1];
154
+ const cfgNow = await readGlobalConfig();
155
+ const subcommand = roleArg?.toLowerCase();
156
+ const printRoster = () => {
157
+ console.log("Subagent roles (used by 'jeo team'):");
158
+ for (const line of formatAgentsPanel(allSubagentRoles(cfgNow), r => ({
159
+ model: resolveSubagentModel(r.id, cfgNow),
160
+ maxSteps: resolveSubagentMaxSteps(r.id, cfgNow),
161
+ thinking: resolveSubagentThinking(r.id, cfgNow),
162
+ }))) console.log(line);
163
+ console.log("Detail: /agents <role> · set model: /agents <role> <model|#N> · provider: /agents <role> provider <name> [model] · thinking: /agents <role> thinking <level|inherit> · steps: /agents <role> maxSteps <N> · picker: /agents edit");
164
+ console.log("Tip: primary model flow: /model → pick model → choose default or subagent role → choose thinking level");
165
+ console.log(`Available: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")} (declare custom roles in config.subagents)`);
166
+ console.log("Subcommands: edit, <role> <model|#N>, <role> thinking <level|inherit>, <role> provider <name> [model], <role> maxSteps <N>, <role> reset");
167
+ };
168
+ if (!roleArg || roleArg === "/" || roleArg === "?" || subcommand === "help") {
169
+ printRoster();
170
+ return result();
171
+ }
172
+ if (subcommand === "edit" || subcommand === "picker") {
173
+ printRoster();
174
+ // Interactive editor (TTY): role picker → action picker → live model /
175
+ // thinking / reset — the arrows+Enter way to CHANGE an existing setting.
176
+ const rolePick = await pickFromOptions(
177
+ "Edit a subagent role (ESC to skip)",
178
+ allSubagentRoles(cfgNow).map(r => ({
179
+ value: r.id,
180
+ label: `${r.id} — ${r.title}`,
181
+ hint: `${resolveSubagentModel(r.id, cfgNow)} · ${resolveSubagentMaxSteps(r.id, cfgNow)} steps${cfgNow.subagents?.[r.id]?.model ? "" : " (default)"}`,
182
+ })),
183
+ );
184
+ const editRole = rolePick ? getSubagentRole(rolePick, cfgNow) : undefined;
185
+ if (!editRole) return result();
186
+ const action = await pickFromOptions(`${editRole.title} — choose action`, [
187
+ { value: "model", label: "change model", hint: resolveSubagentModel(editRole.id, cfgNow) },
188
+ { value: "thinking", label: "change thinking", hint: resolveSubagentThinking(editRole.id, cfgNow) ?? `inherit (${cfgNow.thinkingLevel ?? "medium"})` },
189
+ { value: "reset", label: "reset to defaults", hint: "clears model + maxSteps + thinking override" },
190
+ ]);
191
+ if (action === "reset") {
192
+ await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, editRole.id) }));
193
+ console.log(`${editRole.title} settings reset to defaults → ~/.jeo/config.json`);
194
+ } else if (action === "model") {
195
+ const live = await getLiveModels();
196
+ const entries = flattenModels(live);
197
+ const picked = await pickLiveProviderModel(`${editRole.id}`, entries, resolveSubagentModel(editRole.id, cfgNow));
198
+ if (picked) {
199
+ const pinned = qualifyModelId(picked.model, picked.provider);
200
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, editRole.id, { model: pinned }) }));
201
+ console.log(`Subagent '${editRole.id}' model set to ${pinned} → ~/.jeo/config.json`);
202
+ }
203
+ } else if (action === "thinking") {
204
+ const lvl = await pickThinkingLevel(
205
+ `Reasoning for ${editRole.title}`,
206
+ cfgNow.subagents?.[editRole.id]?.thinking,
207
+ `inherit — follow default (${cfgNow.thinkingLevel ?? "medium"})`,
208
+ );
209
+ if (lvl) await setRoleThinking(editRole.id, lvl);
210
+ }
211
+ return result();
212
+ }
213
+ const role = getSubagentRole(roleArg, cfgNow);
214
+ if (!role) {
215
+ console.log(`Unknown role '${roleArg}'. Known: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")}.`);
216
+ return result();
217
+ }
218
+ if (modelArg?.toLowerCase() === "reset") {
219
+ await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, role.id) }));
220
+ console.log(`${role.title} settings reset to defaults → ~/.jeo/config.json`);
221
+ return result();
222
+ }
223
+ if (modelArg?.toLowerCase() === "maxsteps" || modelArg?.toLowerCase() === "steps") {
224
+ const maxSteps = parseMaxSteps(tokens[2]);
225
+ if (!maxSteps) {
226
+ console.log(`Usage: /agents ${role.id} maxSteps <positive-number>`);
227
+ return result();
228
+ }
229
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { maxSteps }) }));
230
+ console.log(`${role.title} maxSteps set to ${maxSteps} → ~/.jeo/config.json`);
231
+ return result();
232
+ }
233
+ if (modelArg?.toLowerCase() === "thinking" || modelArg?.toLowerCase() === "think") {
234
+ await setRoleThinking(role.id, tokens[2]);
235
+ return result();
236
+ }
237
+ if (modelArg?.toLowerCase() === "provider") {
238
+ const want = (tokens[2] ?? "").toLowerCase();
239
+ if (!isProviderName(want)) {
240
+ console.log(`Usage: /agents ${role.id} provider <name> [model|#N] — e.g. anthropic, openai, gemini, groq, deepseek, openrouter (any configured provider)`);
241
+ return result();
242
+ }
243
+ const st = (await describeAllProviders()).find(s => s.name === want);
244
+ if (st && !st.ready) {
245
+ if (selectableThoughNotReady(st)) {
246
+ console.log(notReadyWarning(st));
247
+ } else {
248
+ console.log(`Cannot pin ${role.title} to ${want}: not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
249
+ return result();
250
+ }
251
+ }
252
+ const live = await getLiveModels();
253
+ const forProvider = providerPickEntries(live, want);
254
+ const liveForProvider = live.some(r => r.ok && r.provider === want && r.models.length > 0);
255
+ const explicit = tokens[3];
256
+ let chosenModel: string;
257
+ if (explicit && forProvider.length) {
258
+ const sel = resolveSelection(forProvider, explicit);
259
+ if (sel.kind === "index" || sel.kind === "match") chosenModel = qualifyModelId(sel.entry.model, want);
260
+ else if (sel.kind === "ambiguous") {
261
+ console.log(`'${explicit}' matches ${sel.matches.length} ${want} models — be more specific:`);
262
+ for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model}`);
263
+ return result();
264
+ } else if (sel.kind === "out-of-range") {
265
+ console.log(`#${explicit.slice(1)} is out of range for ${want} (1-${sel.max}).`);
266
+ return result();
267
+ } else {
268
+ chosenModel = qualifyModelId(explicit, want);
269
+ }
270
+ } else if (explicit) {
271
+ chosenModel = qualifyModelId(explicit, want);
272
+ } else if (forProvider.length) {
273
+ // No model given → the provider's first known model, provider-qualified.
274
+ chosenModel = qualifyModelId(forProvider[0]!.model, want);
275
+ } else {
276
+ chosenModel = providerDefaultModel(want);
277
+ }
278
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
279
+ console.log(`${role.title} pinned to ${want} via model ${chosenModel} — saved to ~/.jeo/config.json`);
280
+ if (forProvider.length) {
281
+ lastPickIndex = forProvider;
282
+ lastPickIndexChanged = true;
283
+ const sourceNote = liveForProvider ? "Live" : "Catalog";
284
+ const tail = liveForProvider ? "" : " (log in to list live models)";
285
+ console.log(`${sourceNote} ${want} models — refine with /agents ${role.id} #N:${tail}`);
286
+ for (const line of formatPickListWithCapabilities(lastPickIndex, { current: chosenModel, cap: 12 })) console.log(line);
287
+ }
288
+ return result();
289
+ }
290
+ if (modelArg) {
291
+ let chosenModel = modelArg;
292
+ let entries = lastPickIndex;
293
+ if (modelArg.startsWith("#") && entries.length === 0) {
294
+ const live = await getLiveModels();
295
+ entries = flattenModels(live);
296
+ }
297
+ if (entries.length) {
298
+ const sel = resolveSelection(entries, modelArg);
299
+ if (sel.kind === "index" || sel.kind === "match") {
300
+ chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
301
+ const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
302
+ if (bad) {
303
+ if (selectableThoughNotReady(bad)) {
304
+ console.log(notReadyWarning(bad));
305
+ } else {
306
+ console.log(`Cannot pin ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad.label}). Set ${bad.envVar ?? "the provider key"} first.`);
307
+ return result();
308
+ }
309
+ }
310
+ } else if (sel.kind === "ambiguous") {
311
+ console.log(`'${modelArg}' matches ${sel.matches.length} live models — be more specific:`);
312
+ for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
313
+ return result();
314
+ } else if (sel.kind === "out-of-range") {
315
+ console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
316
+ return result();
317
+ }
318
+ } else if (modelArg.startsWith("#")) {
319
+ console.log("Use /model first to build the numbered live model list.");
320
+ return result();
321
+ }
322
+ // Persist a per-role model override to ~/.jeo/config.json (consumed by 'jeo team').
323
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
324
+ const { provider } = await describeModel(chosenModel);
325
+ console.log(`${role.title} model set to ${chosenModel} (${provider}) — saved to ~/.jeo/config.json`);
326
+ const live = await getLiveModels();
327
+ if (!liveModelKnown(live, chosenModel)) {
328
+ console.log(` (note: '${chosenModel}' is not in any live model list — verify it is valid for ${provider})`);
329
+ }
330
+ return result();
331
+ }
332
+ for (const line of formatAgentDetail(role, {
333
+ model: resolveSubagentModel(role.id, cfgNow),
334
+ maxSteps: resolveSubagentMaxSteps(role.id, cfgNow),
335
+ thinking: resolveSubagentThinking(role.id, cfgNow),
336
+ })) console.log(line);
337
+ const live = await getLiveModels();
338
+ const agentPick = flattenModels(live);
339
+ if (agentPick.length) {
340
+ lastPickIndex = agentPick;
341
+ lastPickIndexChanged = true;
342
+ console.log(`Live models for ${role.title} — pin with /agents ${role.id} #N:`);
343
+ for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolveSubagentModel(role.id, cfgNow), cap: 20 })) console.log(line);
344
+ }
345
+ return result();
346
+ }
347
+
348
+ const ROLE_TIERS = ["smol", "slow", "plan"] as const;
349
+
350
+ /**
351
+ * Handle `/roles [tier model]`. Extracted verbatim from launch.ts's inline
352
+ * REPL branch — the caller keeps the `input.startsWith("/roles") &&
353
+ * (input === "/roles" || input[6] === " ")` guard and just calls this
354
+ * function.
355
+ */
356
+ export async function runRolesSlash(input: string, ctx: AgentsSlashCtx): Promise<AgentsSlashResult> {
357
+ const { getLiveModels } = ctx;
358
+ let lastPickIndex = ctx.lastPickIndex;
359
+ let lastPickIndexChanged = false;
360
+
361
+ const result = (): AgentsSlashResult => ({
362
+ ...(lastPickIndexChanged ? { lastPickIndex } : {}),
363
+ });
364
+
365
+ const tokens = input.substring(6).trim().split(/\s+/).filter(Boolean);
366
+ const cfgNow = await readGlobalConfig();
367
+ if (tokens.length >= 2 && (ROLE_TIERS as readonly string[]).includes(tokens[0])) {
368
+ const tier = tokens[0] as (typeof ROLE_TIERS)[number];
369
+ let chosenModel = tokens[1]!;
370
+ let entries = lastPickIndex;
371
+ if (chosenModel.startsWith("#") && entries.length === 0) {
372
+ const live = await getLiveModels();
373
+ entries = flattenModels(live);
374
+ }
375
+ if (entries.length) {
376
+ const sel = resolveSelection(entries, chosenModel);
377
+ if (sel.kind === "index" || sel.kind === "match") {
378
+ chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
379
+ const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
380
+ if (bad) {
381
+ console.log(`Cannot set role ${tier} to ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad.label}). Set ${bad.envVar ?? "the provider key"} first.`);
382
+ return result();
383
+ }
384
+ } else if (sel.kind === "ambiguous") {
385
+ console.log(`'${chosenModel}' matches ${sel.matches.length} live models — be more specific:`);
386
+ for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
387
+ return result();
388
+ } else if (sel.kind === "out-of-range") {
389
+ console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
390
+ return result();
391
+ }
392
+ } else if (chosenModel.startsWith("#")) {
393
+ console.log("Use /model first to build the numbered live model list.");
394
+ return result();
395
+ }
396
+ await saveConfigPatch(raw => ({ roles: { ...(raw.roles ?? {}), [tier]: chosenModel } }));
397
+ console.log(`Role '${tier}' model set to ${chosenModel} → ~/.jeo/config.json`);
398
+ return result();
399
+ }
400
+ console.log("Model role tiers (fall back to the default model):");
401
+ for (const tier of ROLE_TIERS) {
402
+ const { provider } = await describeModel(resolveRoleModel(tier, cfgNow));
403
+ console.log(` ${tier.padEnd(5)} ${resolveRoleModel(tier, cfgNow)} (${provider})`);
404
+ }
405
+ console.log("Set a tier: /roles <smol|slow|plan> <model>");
406
+ const live = await getLiveModels();
407
+ const rolePick = flattenModels(live);
408
+ if (rolePick.length) {
409
+ lastPickIndex = rolePick;
410
+ lastPickIndexChanged = true;
411
+ console.log("Live models for role tiers — set with /roles <tier> #N:");
412
+ for (const line of formatPickListWithCapabilities(lastPickIndex, { cap: 15 })) console.log(line);
413
+ }
414
+ return result();
415
+ }
416
+
417
+ /**
418
+ * Handle `/fast [on|off|status]`. Extracted verbatim from launch.ts's inline
419
+ * REPL branch — the caller keeps the `input.startsWith("/fast") && (input
420
+ * === "/fast" || input[5] === " ")` guard and just calls this function.
421
+ */
422
+ export async function runFastSlash(input: string, ctx: AgentsSlashCtx): Promise<AgentsSlashResult> {
423
+ let sessionThinking = ctx.sessionThinking;
424
+ let sessionThinkingChanged = false;
425
+
426
+ const result = (): AgentsSlashResult => ({
427
+ ...(sessionThinkingChanged ? { sessionThinking } : {}),
428
+ });
429
+
430
+ const arg = input.substring(5).trim().toLowerCase() || "status";
431
+ const cfgNow = await readGlobalConfig();
432
+ const currentModel = ctx.sessionModel || cfgNow.defaultModel;
433
+ const { resolved, provider } = await describeModel(currentModel);
434
+ const fastLevel = fastThinkingLevelForModel(resolved);
435
+ const currentThinking = sessionThinking ?? cfgNow.thinkingLevel ?? "medium";
436
+ const status = fastLevel && currentThinking === fastLevel ? "on" : "off";
437
+ if (arg === "status") {
438
+ const support = fastLevel ? `supported (thinking ${fastLevel})` : "unsupported";
439
+ console.log(`Fast mode: ${status} · ${support} · ${formatModelLine({ label: currentModel, resolved, provider })} · current thinking ${currentThinking}`);
440
+ return result();
441
+ }
442
+ if (arg === "on") {
443
+ if (!fastLevel) {
444
+ console.log(`Fast mode is not advertised for ${formatModelLine({ label: currentModel, resolved, provider })}; pick a thinking-capable model with /model.`);
445
+ return result();
446
+ }
447
+ sessionThinking = fastLevel;
448
+ sessionThinkingChanged = true;
449
+ console.log(`Fast mode on: ${formatModelLine({ label: currentModel, resolved, provider })} · thinking ${fastLevel} (~${thinkingMaxTokens(fastLevel)} max tokens/step)`);
450
+ return result();
451
+ }
452
+ if (arg === "off") {
453
+ sessionThinking = cfgNow.thinkingLevel ?? "medium";
454
+ sessionThinkingChanged = true;
455
+ console.log(`Fast mode off: restored thinking ${sessionThinking} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
456
+ return result();
457
+ }
458
+ console.log("Usage: /fast [on|off|status]");
459
+ return result();
460
+ }
461
+
462
+ /**
463
+ * Handle `/thinking [level]`. Extracted verbatim from launch.ts's inline
464
+ * REPL branch — the caller keeps the `input.startsWith("/thinking") &&
465
+ * (input === "/thinking" || input[9] === " ")` guard and just calls this
466
+ * function.
467
+ */
468
+ export async function runThinkingSlash(input: string, ctx: AgentsSlashCtx): Promise<AgentsSlashResult> {
469
+ let sessionThinking = ctx.sessionThinking;
470
+ let sessionThinkingChanged = false;
471
+
472
+ const result = (): AgentsSlashResult => ({
473
+ ...(sessionThinkingChanged ? { sessionThinking } : {}),
474
+ });
475
+
476
+ const arg = input.substring(9).trim().toLowerCase();
477
+ if (!arg) {
478
+ console.log(`Thinking level: ${sessionThinking ?? "medium"} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
479
+ return result();
480
+ }
481
+ if (arg === "minimal" || arg === "low" || arg === "medium" || arg === "high" || arg === "xhigh") {
482
+ sessionThinking = arg;
483
+ sessionThinkingChanged = true;
484
+ console.log(`Thinking set to ${arg} (~${thinkingMaxTokens(arg)} max tokens/step)`);
485
+ } else {
486
+ console.log(`Invalid level '${arg}'. Use: minimal | low | medium | high | xhigh.`);
487
+ }
488
+ return result();
489
+ }