jeo-code 0.7.30 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.ja.md +5 -3
  3. package/README.ko.md +5 -3
  4. package/README.md +5 -3
  5. package/README.zh.md +5 -3
  6. package/package.json +3 -3
  7. package/scripts/install.sh +19 -0
  8. package/scripts/uninstall.sh +2 -2
  9. package/src/agent/AGENTS.md +12 -0
  10. package/src/agent/ast-edit-tool.ts +123 -0
  11. package/src/agent/ast-file-scan.ts +62 -0
  12. package/src/agent/ast-grep-tool.ts +107 -0
  13. package/src/agent/ast-match.ts +241 -0
  14. package/src/agent/compaction.ts +6 -19
  15. package/src/agent/debug-session.ts +435 -0
  16. package/src/agent/debug-tool.ts +89 -0
  17. package/src/agent/engine.ts +103 -18
  18. package/src/agent/goal-tool.ts +57 -0
  19. package/src/agent/irc-tool.ts +71 -0
  20. package/src/agent/job-registry.ts +163 -0
  21. package/src/agent/job-tool.ts +106 -0
  22. package/src/agent/lsp-rename-tool.ts +108 -0
  23. package/src/agent/lsp-tool.ts +177 -0
  24. package/src/agent/memory-graph.ts +6 -1
  25. package/src/agent/memory.ts +73 -52
  26. package/src/agent/session.ts +27 -0
  27. package/src/agent/subagent-registry.ts +40 -4
  28. package/src/agent/subagent-tool.ts +27 -7
  29. package/src/agent/subagents.ts +1 -1
  30. package/src/agent/task-tool.ts +27 -5
  31. package/src/agent/tools.ts +100 -5
  32. package/src/agent/ts-language-service.ts +173 -0
  33. package/src/ai/model-manager.ts +58 -26
  34. package/src/ai/sse.ts +22 -7
  35. package/src/ai/think-tags.ts +10 -1
  36. package/src/commands/deep-interview.ts +49 -1
  37. package/src/commands/launch/agents-slash.ts +489 -0
  38. package/src/commands/launch/flags.ts +27 -9
  39. package/src/commands/launch/model-slash.ts +290 -0
  40. package/src/commands/launch/session-slash.ts +305 -0
  41. package/src/commands/launch/slash-handlers.ts +6 -0
  42. package/src/commands/launch/system-slash.ts +81 -0
  43. package/src/commands/launch/tmux.ts +32 -0
  44. package/src/commands/launch.ts +227 -750
  45. package/src/tui/app.ts +32 -12
  46. package/src/tui/components/evolution.ts +23 -3
  47. package/src/tui/components/width.ts +11 -6
@@ -128,6 +128,13 @@ function freezeReadiness(parsed: SocraticResponse | undefined): { ok: boolean; r
128
128
  return { ok: true };
129
129
  }
130
130
 
131
+ /** Normalize a question for repeat detection: case-, whitespace-, and trailing-
132
+ * punctuation-insensitive so the SAME question re-asked with trivial formatting
133
+ * drift still registers as a repeat. */
134
+ function normalizeQuestion(q: string): string {
135
+ return q.toLowerCase().replace(/\s+/g, " ").replace(/[\s?.!…。?!]+$/g, "").trim();
136
+ }
137
+
131
138
  function slugify(input: string): string {
132
139
  return input
133
140
  .toLowerCase()
@@ -499,7 +506,8 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
499
506
  `}\n` +
500
507
  `Ensure ambiguityScore drops dynamically as more detail is gathered. Do not report ambiguityScore <= ${threshold} unless acceptance_criteria is populated with concrete, testable checks.\n` +
501
508
  `Do not fill an ambiguous point with your own convenient assumption to push the score down — surface that assumption as the nextQuestion instead. Never supply unstated detail that makes the request look simpler or safer than the user actually wrote it.\n` +
502
- `Conversely, when the user's answers genuinely resolve a dimension, lower the score accordingly — do not keep a dimension ambiguous just to prolong the interview. If you notice yourself quietly narrowing the user's idea to make it easier to score, that narrowing is the signal the dimension is still open: ask, don't assume.`
509
+ `Conversely, when the user's answers genuinely resolve a dimension, lower the score accordingly — do not keep a dimension ambiguous just to prolong the interview. If you notice yourself quietly narrowing the user's idea to make it easier to score, that narrowing is the signal the dimension is still open: ask, don't assume.\n` +
510
+ `Never re-ask a question that was already asked and answered — every round must target a NEW gap or a different dimension.`
503
511
  },
504
512
  {
505
513
  role: "user",
@@ -568,6 +576,12 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
568
576
  let round = 1;
569
577
  let ambiguity = state.current_ambiguity ?? 1.0;
570
578
  let lastParsed: SocraticResponse | undefined;
579
+ // Repeat-question loop guard state (deep-interview's descendant of the agent
580
+ // loop's repeat_correct/repeat_stop taxonomy in src/agent/loop-guards.ts): an
581
+ // interviewer that re-asks the SAME question cannot converge by repetition —
582
+ // it just burns rounds and, in interactive mode, the user's patience.
583
+ let lastQuestionNorm = "";
584
+ let questionRepeats = 0;
571
585
 
572
586
  const freezeSeed = async (parsed: SocraticResponse): Promise<boolean> => {
573
587
  const readiness = freezeReadiness(parsed);
@@ -655,6 +669,40 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
655
669
  nextQuestion = interviewLanguage.acceptanceFollowup;
656
670
  }
657
671
 
672
+ // Repeat-question guard: first repeat gets ONE corrective bounce (the model
673
+ // is told the question was already answered and steered to a new gap, WITHOUT
674
+ // re-asking the user); a repeat that survives the correction stops this run
675
+ // instead of looping through the remaining rounds on the same question.
676
+ const qNorm = normalizeQuestion(nextQuestion);
677
+ if (qNorm && qNorm === lastQuestionNorm) {
678
+ questionRepeats++;
679
+ } else {
680
+ lastQuestionNorm = qNorm;
681
+ questionRepeats = 0;
682
+ }
683
+ if (questionRepeats === 1) {
684
+ log(`\n[REPEAT] The interviewer re-asked the same question — nudging it toward a new gap instead of re-asking you.`);
685
+ history.push({ role: "assistant", content: responseText });
686
+ history.push({
687
+ role: "user",
688
+ content:
689
+ `You already asked exactly this question and it was answered above — do not ask it again. ` +
690
+ `Ask a DIFFERENT question that targets the weakest unresolved dimension; or, if the prior answers genuinely resolve it, ` +
691
+ `lower ambiguityScore and populate goal/constraints/acceptance_criteria from what was already said. ` +
692
+ `Reply in the same strict JSON format.`,
693
+ });
694
+ round++;
695
+ continue;
696
+ }
697
+ if (questionRepeats >= 2) {
698
+ log(
699
+ `\n[LOOP GUARD] The interview is stuck re-asking the same question despite a corrective nudge — ` +
700
+ `stopping this run instead of looping. No progress is possible by repetition; ` +
701
+ `resume with 'jeo deep-interview' (adding more detail to the idea helps) to continue.`,
702
+ );
703
+ break;
704
+ }
705
+
658
706
  log(`\nQuestion: ${nextQuestion}`);
659
707
  if (opts.signal?.aborted) {
660
708
  return { ok: false, reason: "aborted" };
@@ -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
+ }
@@ -6,6 +6,9 @@ export interface LaunchFlags {
6
6
  list: boolean;
7
7
  resume: boolean;
8
8
  resumeId?: string;
9
+ /** True when `--resume`/`-r` was given with NO value at cold startup (gjc-parity):
10
+ * triggers the interactive session picker instead of silently resuming latest. */
11
+ resumeInteractive: boolean;
9
12
  noSession: boolean;
10
13
  noTui: boolean;
11
14
  /** Explicit step cap from --max-steps; 0 = dynamic (process-driven budget that
@@ -63,9 +66,9 @@ export function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefin
63
66
  }
64
67
 
65
68
  export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
66
- const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
69
+ const flags: LaunchFlags = { list: false, resume: false, resumeInteractive: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
67
70
  const rest: string[] = [];
68
- const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
71
+
69
72
  for (let i = 0; i < args.length; i++) {
70
73
  const a = args[i];
71
74
  if (a === "--") {
@@ -115,22 +118,37 @@ export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchF
115
118
  i = nextIndex;
116
119
  } else if (a === "--smol" || a === "--slow" || a === "--plan") {
117
120
  flags.modelRole = a.slice(2) as ModelRole;
118
- } else if (a === "--resume" || a === "--continue" || a === "-c") {
121
+ } else if (a === "--continue" || a === "-c") {
122
+ // Always silently resume the MOST RECENT session (gjc-parity: no picker for --continue/-c).
119
123
  flags.resume = true;
124
+ flags.resumeInteractive = false;
120
125
  const next = args[i + 1];
121
- if (next && UUID_REGEX.test(next)) {
126
+ if (next && !next.startsWith("-")) {
122
127
  flags.resumeId = next;
123
128
  i++;
124
129
  }
125
- } else if (a.startsWith("--resume=") || a.startsWith("--continue=") || a.startsWith("-c=")) {
130
+ } else if (a.startsWith("--continue=") || a.startsWith("-c=")) {
126
131
  flags.resume = true;
132
+ flags.resumeInteractive = false;
127
133
  const eqIdx = a.indexOf("=");
128
- const val = a.slice(eqIdx + 1);
129
- if (UUID_REGEX.test(val)) {
130
- flags.resumeId = val;
134
+ flags.resumeId = a.slice(eqIdx + 1);
135
+ } else if (a === "--resume" || a === "-r") {
136
+ // gjc-parity: value present -> resolve as id/prefix directly; no value -> interactive picker.
137
+ flags.resume = true;
138
+ const next = args[i + 1];
139
+ if (next && !next.startsWith("-")) {
140
+ flags.resumeId = next;
141
+ flags.resumeInteractive = false;
142
+ i++;
131
143
  } else {
132
- rest.push(val);
144
+ flags.resumeId = undefined;
145
+ flags.resumeInteractive = true;
133
146
  }
147
+ } else if (a.startsWith("--resume=")) {
148
+ flags.resume = true;
149
+ flags.resumeInteractive = false;
150
+ const eqIdx = a.indexOf("=");
151
+ flags.resumeId = a.slice(eqIdx + 1);
134
152
  } else if (a === "--append-system-prompt" || a.startsWith("--append-system-prompt=")) {
135
153
  const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
136
154
  if (value) flags.appendSystemPromptRaw = value;