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
@@ -9,6 +9,10 @@ import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agen
9
9
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
10
10
  import { SubagentRegistry } from "../agent/subagent-registry";
11
11
  import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
12
+ import { createJobTool, JOB_TOOL_PROTOCOL_LINE } from "../agent/job-tool";
13
+ import { JobRegistry } from "../agent/job-registry";
14
+ import { createIrcTool, IRC_TOOL_PROTOCOL_LINE } from "../agent/irc-tool";
15
+ import { createGoalTool, GOAL_TOOL_PROTOCOL_LINE } from "../agent/goal-tool";
12
16
  import { LaunchTui } from "../tui/app";
13
17
  import { runDeepInterviewEngine, type DeepInterviewEngineOptions } from "./deep-interview";
14
18
  import { runRalplanEngine, type RalplanEngineOptions } from "./ralplan";
@@ -46,7 +50,7 @@ import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/op
46
50
 
47
51
  import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting, applyTargetChoices } from "../agent/subagents";
48
52
  import { SelectList, renderSelectList, type SelectItem } from "../tui/components/select-list";
49
- import { SessionPicker, renderSessionPicker } from "../tui/components/session-picker";
53
+
50
54
  import {
51
55
  formatModelLine,
52
56
  formatProviderPanel,
@@ -70,7 +74,7 @@ import { renderStatusBar } from "../tui/components/status";
70
74
  import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
71
75
  import { readClipboardImage } from "../util/clipboard-image";
72
76
  import { attachImagePaths, insertImageTag } from "../util/file-attachment";
73
- import { formatTranscript } from "../tui/components/transcript";
77
+
74
78
  import { copyTextToClipboard, readClipboardText } from "../tui/clipboard";
75
79
  import { loadInputHistory, appendInputHistory } from "../agent/input-history";
76
80
  import type { ImageAttachment } from "../ai/types";
@@ -95,11 +99,9 @@ import {
95
99
  listSessions,
96
100
  latestSessionId,
97
101
  exportSession,
98
- renameSession,
99
102
  updateSessionModel,
100
- deleteSession,
101
- sessionPath,
102
103
  appendCompaction,
104
+ resolveSessionRef,
103
105
  } from "../agent/session";
104
106
  import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearVisible, clearToEnd, enableModifyOtherKeys, disableModifyOtherKeys, enableKittyKeyboard, disableKittyKeyboard } from "../tui/terminal";
105
107
 
@@ -126,6 +128,9 @@ import {
126
128
  resolveWorktree,
127
129
  shellQuote,
128
130
  reapStaleTmuxSessions,
131
+ callerTmuxTerminalSize,
132
+ tmuxNewSessionSizeArgs,
133
+ type TmuxTerminalSize,
129
134
  type TmuxCreateResult,
130
135
  type TmuxProfileCommand,
131
136
  } from "./launch/tmux";
@@ -194,7 +199,11 @@ import {
194
199
  handleSearchSlash,
195
200
  } from "./launch/code-slash";
196
201
  import { handleUndoSlash } from "./launch/git-slash";
202
+ import { runSessionSlash } from "./launch/session-slash";
203
+ import { runModelSlash } from "./launch/model-slash";
204
+ import { runAgentsSlash, runRolesSlash, runFastSlash, runThinkingSlash } from "./launch/agents-slash";
197
205
  import { wikiRootPromptLine, decideWikiSlash } from "./launch/wiki-slash";
206
+ import { decideThemeSlash, buildConfigPanelLines, runEvolveSimulation } from "./launch/system-slash";
198
207
 
199
208
 
200
209
 
@@ -218,6 +227,9 @@ export {
218
227
  resolveWorktree,
219
228
  shellQuote,
220
229
  reapStaleTmuxSessions,
230
+ callerTmuxTerminalSize,
231
+ tmuxNewSessionSizeArgs,
232
+ type TmuxTerminalSize,
221
233
  type TmuxCreateResult,
222
234
  type TmuxProfileCommand,
223
235
  type InFlightAbortHarness,
@@ -261,6 +273,12 @@ export function normalizeSlashAlias(input: string): string {
261
273
  if (input === "/subagent" || input.startsWith("/subagent ")) return `/agents${input.slice("/subagent".length)}`;
262
274
  if (input === "/subagents" || input.startsWith("/subagents ")) return `/agents${input.slice("/subagents".length)}`;
263
275
  if (input === "/resume" || input.startsWith("/resume ")) return `/session resume${input.slice("/resume".length)}`;
276
+ // gjc-parity: /new, /drop, /rename, /sessions are top-level palette aliases for
277
+ // their /session <sub> implementations (same pattern as /resume above).
278
+ if (input === "/new") return "/session new";
279
+ if (input === "/drop") return "/session drop";
280
+ if (input === "/rename" || input.startsWith("/rename ")) return `/session rename${input.slice("/rename".length)}`;
281
+ if (input === "/sessions") return "/session list";
264
282
  return input;
265
283
  }
266
284
 
@@ -390,8 +408,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
390
408
  const innerCmd = `exec env JEO_TMUX_LAUNCHED=1 ${[...cmd, "launch", ...innerArgs].map(shellQuote).join(" ")}`;
391
409
 
392
410
  // Create a fresh, independent session (race-safe: the create is the guard).
411
+ // Size the DETACHED session to the caller terminal (gjc launch-tmux parity
412
+ // #1376): without -x/-y tmux creates it 80x24 and the inner jeo's first
413
+ // frames render mis-wrapped into the pane scrollback before attach resizes.
414
+ const termSize = callerTmuxTerminalSize();
393
415
  const alloc = allocateTmuxSession(sessionBase, name => {
394
- const created = Bun.spawnSync([tmuxBin, "new-session", "-d", "-s", name, "-c", cwd, innerCmd]);
416
+ const created = Bun.spawnSync([tmuxBin, "new-session", "-d", ...tmuxNewSessionSizeArgs(termSize), "-s", name, "-c", cwd, innerCmd]);
395
417
  if (created.exitCode === 0) return "ok";
396
418
  const err = created.stderr.toString().trim();
397
419
  if (/duplicate session/i.test(err)) return "taken"; // another jeo grabbed this name
@@ -429,6 +451,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
429
451
  : `Starting new independent tmux session: ${sessionName} (another live jeo session already owns ${sessionBase}; reattach later with: tmux attach -t ${sessionName})`,
430
452
  );
431
453
 
454
+ // Re-assert the caller dimensions right before attach (best-effort): profile
455
+ // commands or a slow first frame can land while the window is still detached.
456
+ if (termSize) {
457
+ try {
458
+ Bun.spawnSync([tmuxBin, "resize-window", "-t", `=${sessionName}`, "-x", String(termSize.columns), "-y", String(termSize.rows)]);
459
+ } catch { /* best-effort — old tmux without resize-window is fine */ }
460
+ }
461
+
432
462
  const attach = Bun.spawn([tmuxBin, "attach-session", "-t", `=${sessionName}`], {
433
463
  stdin: "inherit",
434
464
  stdout: "inherit",
@@ -495,7 +525,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
495
525
  // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
496
526
  const contextFiles = await loadProjectContext(cwd);
497
527
 
498
- const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent"]);
528
+ const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug"]);
499
529
  let allowedTools = new Set(KNOWN_TOOLS);
500
530
 
501
531
  if (flags.noTools) {
@@ -566,6 +596,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
566
596
  (allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
567
597
  (allowedTools.has("subagent") ? "\n\nDetached subagents: " + SUBAGENT_TOOL_PROTOCOL_LINE +
568
598
  " Launch background work with task {\"detached\": true, \"role\": <role>, \"task\": <assignment>}; it returns a subagent id immediately so you can keep working and collect the result later." : "") +
599
+ (allowedTools.has("job") ? "\n\nBackground jobs: " + JOB_TOOL_PROTOCOL_LINE : "") +
600
+ (allowedTools.has("irc") ? "\n\nPeer messaging: " + IRC_TOOL_PROTOCOL_LINE : "") +
601
+ (allowedTools.has("goal") ? "\n\nGoal tracking: " + GOAL_TOOL_PROTOCOL_LINE : "") +
569
602
  (effectiveNoSkills ? "" :
570
603
  "\n\nJEO workflow routing:\n" +
571
604
  "- Answer the user's request DIRECTLY. Never reply with a catalog, list, or summary of skills unless the user explicitly asks what skills exist.\n" +
@@ -692,9 +725,50 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
692
725
  let sessionId: string | undefined;
693
726
  let compactionSeq = 0;
694
727
  if (!flags.noSession) {
695
- if (flags.resume) {
696
- const id = flags.resumeId ?? (await latestSessionId(cwd));
697
- if (!id) {
728
+ if (flags.resumeInteractive) {
729
+ // Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): the interactive
730
+ // session picker — added right before the REPL loop starts, below — takes over
731
+ // and replaces this session before the user ever sees it, but ONLY in a TTY.
732
+ // Start with a fresh session here as the safe default either way.
733
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
734
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
735
+ // No TTY means no picker is possible — fall back to the same silent-latest
736
+ // behavior as `--continue` rather than silently doing nothing.
737
+ const id = await latestSessionId(cwd);
738
+ if (id) {
739
+ try {
740
+ const { header, messages } = await loadSession(id, cwd);
741
+ for (const m of messages) history.push(m);
742
+ sessionId = id;
743
+ if (!initialSessionModel && header.model) sessionModel = header.model;
744
+ const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
745
+ console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
746
+ } catch (err) {
747
+ console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
748
+ }
749
+ }
750
+ }
751
+ } else if (flags.resume) {
752
+ let id: string | undefined;
753
+ let skipResume = false;
754
+ if (flags.resumeId) {
755
+ // gjc-parity: resolve as a full id OR a short id prefix.
756
+ const resolved = await resolveSessionRef(flags.resumeId, cwd);
757
+ if (resolved.kind === "ok") {
758
+ id = resolved.id;
759
+ } else if (resolved.kind === "ambiguous") {
760
+ console.log(`Ambiguous session id '${flags.resumeId}' — matches: ${resolved.matches.slice(0, 5).join(", ")}${resolved.matches.length > 5 ? " …" : ""}. Use more characters or run with --resume (no value) to pick interactively.`);
761
+ skipResume = true;
762
+ } else {
763
+ console.log(`Session '${flags.resumeId}' not found. Starting a new one.`);
764
+ skipResume = true;
765
+ }
766
+ } else {
767
+ id = await latestSessionId(cwd);
768
+ }
769
+ if (skipResume) {
770
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
771
+ } else if (!id) {
698
772
  console.log("No session to resume. Starting a new one.");
699
773
  sessionId = (await createSession(cwd, undefined, sessionModel)).id;
700
774
  } else {
@@ -946,6 +1020,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
946
1020
  // #9: per-turn registry for DETACHED subagents (task{detached:true}); the
947
1021
  // `subagent` tool controls them and cancelAll() in finally prevents orphans.
948
1022
  const subagentRegistry = new SubagentRegistry();
1023
+ const jobRegistry = new JobRegistry();
949
1024
  try {
950
1025
  // Per-turn todo snapshot: drives the done-time reconciliation gate (the
951
1026
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
@@ -1007,6 +1082,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1007
1082
  }),
1008
1083
  todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
1009
1084
  subagent: createSubagentTool(subagentRegistry),
1085
+ job: createJobTool(jobRegistry),
1086
+ irc: createIrcTool(subagentRegistry),
1087
+ goal: createGoalTool(),
1010
1088
  };
1011
1089
  const tools = filterToolMap(fullTools, Array.from(allowedTools));
1012
1090
  // Opik observability (opt-in via JEO_OPIK): one trace per turn, spans per
@@ -1065,6 +1143,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1065
1143
  } finally {
1066
1144
  harness.dispose();
1067
1145
  subagentRegistry.cancelAll(); // #9: no detached run leaks past the turn
1146
+ jobRegistry.cancelAll(); // background jobs are turn-scoped too — no orphaned processes
1068
1147
  // Steering typed but never drained (e.g. entered just after the final step)
1069
1148
  // must not be lost — fold it into the next prompt draft so it runs next.
1070
1149
  const leftover = steerInbox.splice(0, steerInbox.length).map(s => s.trim()).filter(Boolean);
@@ -1199,6 +1278,74 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1199
1278
  console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
1200
1279
  return;
1201
1280
  }
1281
+ if (cmd === "/usage") {
1282
+ const cfg = await readGlobalConfig();
1283
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
1284
+ const result = handleUsage(ctx, sessionUsage);
1285
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
1286
+ return;
1287
+ }
1288
+ if (cmd === "/context") {
1289
+ const cfg = await readGlobalConfig();
1290
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
1291
+ const result = await handleContext(ctx);
1292
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
1293
+ return;
1294
+ }
1295
+ if (cmd === "/tools") {
1296
+ const cfg = await readGlobalConfig();
1297
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
1298
+ const result = await handleTools(ctx);
1299
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
1300
+ return;
1301
+ }
1302
+ if (cmd === "/hotkeys") {
1303
+ const cfg = await readGlobalConfig();
1304
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
1305
+ const result = handleHotkeys(ctx);
1306
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
1307
+ return;
1308
+ }
1309
+ if (cmd === "/theme" || cmd.startsWith("/theme ")) {
1310
+ const decision = decideThemeSlash(cmd, listThemes(), resolveTheme().name);
1311
+ for (const line of decision.lines) console.log(line);
1312
+ if (decision.kind === "set") {
1313
+ process.env.JEO_TUI_THEME = decision.themeName!;
1314
+ await saveConfigPatch(raw => ({ theme: decision.themeName }));
1315
+ // No refreshUiTheme() here — one-shot mode returns immediately after this
1316
+ // block, so there is no live TUI paint state to re-resolve (and
1317
+ // refreshUiTheme is declared later in this closure, out of scope here).
1318
+ }
1319
+ return;
1320
+ }
1321
+ if (cmd === "/wiki" || cmd.startsWith("/wiki ")) {
1322
+ const decision = decideWikiSlash(
1323
+ cmd,
1324
+ resolveWikiRoot(await readGlobalConfig()),
1325
+ !!jeoEnv("WIKI_ROOT"),
1326
+ );
1327
+ for (const line of decision.lines) console.log(line);
1328
+ if (decision.kind === "clear") {
1329
+ await saveConfigPatch(() => ({ wikiRoot: undefined }));
1330
+ delete process.env.JEO_WIKI_ROOT;
1331
+ sessionWikiRoot = undefined;
1332
+ await refreshSessionMemory("");
1333
+ } else if (decision.kind === "set") {
1334
+ await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
1335
+ process.env.JEO_WIKI_ROOT = decision.root;
1336
+ sessionWikiRoot = decision.root;
1337
+ await refreshSessionMemory("");
1338
+ }
1339
+ return;
1340
+ }
1341
+ if (cmd === "/config" || cmd === "/settings") {
1342
+ for (const line of await buildConfigPanelLines({ sessionModel, sessionThinking, sessionId })) console.log(line);
1343
+ return;
1344
+ }
1345
+ if (cmd === "/evolve") {
1346
+ await runEvolveSimulation(animateAsciiArt);
1347
+ return;
1348
+ }
1202
1349
  }
1203
1350
  // One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
1204
1351
  // single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
@@ -2361,14 +2508,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2361
2508
  }
2362
2509
  };
2363
2510
 
2364
- // Antigravity with ANY Google OAuth (own login or the gemini-cli fallback) stays
2365
- // SELECTABLE in pickers even when not call-ready: picking the model is how users
2366
- // reach the flow, and the auth layer gives actionable login guidance on the first
2367
- // call if the fallback token is rejected (403). Refusing selection was a dead end.
2368
- const selectableThoughNotReady = (st?: { name: string; kind: string }): boolean =>
2369
- !!st && st.name === "antigravity" && st.kind === "oauth";
2370
- const notReadyWarning = (st: { name: string; label: string }): string =>
2371
- ` ! ${st.name} is not call-ready yet (${st.label}) — run /provider login antigravity before the first turn.`;
2372
2511
 
2373
2512
 
2374
2513
  const MODEL_BADGE_ROLE_ORDER = ["planner", "architect", "executor", "critic"] as const;
@@ -3070,6 +3209,21 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3070
3209
  });
3071
3210
  }
3072
3211
 
3212
+ // Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): show the SAME
3213
+ // interactive session picker `/session resume` (no arg) uses, in place of the
3214
+ // fresh session created above. TTY-only — non-TTY already fell back to
3215
+ // silent-latest resume in the startup block above.
3216
+ if (flags.resumeInteractive && !flags.noSession && process.stdin.isTTY && process.stdout.isTTY) {
3217
+ const pickResult = await runSessionSlash("/session resume", {
3218
+ cwd, history, noSession: flags.noSession, sessionId, sessionModel, rl,
3219
+ advanceSessionBoxColor, disarmPreview, clearScreen, freshWelcomeLines, logLines, runSelectPicker,
3220
+ });
3221
+ sessionId = pickResult.sessionId;
3222
+ if (pickResult.sessionModel !== undefined) sessionModel = pickResult.sessionModel;
3223
+ if (pickResult.lastUserInput !== undefined) lastUserInput = pickResult.lastUserInput;
3224
+ if (pickResult.lastReply !== undefined) lastReply = pickResult.lastReply;
3225
+ }
3226
+
3073
3227
  while (true) {
3074
3228
  drainPendingTtyInput();
3075
3229
  // "New input first": queued FULL lines from the previous turn are folded
@@ -3209,10 +3363,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3209
3363
  const res = await maybeCompact(history, { model: activeModel, force: true, contextTokens });
3210
3364
  if (res.error) {
3211
3365
  console.error(chalk.red(res.error));
3212
- } else if (res.compacted && sessionId && res.replacesThrough !== undefined) {
3213
- const touchedNote = res.touchedFiles?.length ? ` Files touched: ${res.touchedFiles.join(", ")}.` : "";
3214
- const summaryText = res.summary ?? `[Earlier conversation omitted: ${res.removed} messages — summary unavailable.${touchedNote}]`;
3215
- await appendCompaction(sessionId, ++compactionSeq, summaryText, res.replacesThrough, cwd);
3366
+ } else if (res.compacted) {
3367
+ if (sessionId && res.replacesThrough !== undefined) {
3368
+ const touchedNote = res.touchedFiles?.length ? ` Files touched: ${res.touchedFiles.join(", ")}.` : "";
3369
+ const summaryText = res.summary ?? `[Earlier conversation omitted: ${res.removed} messages — summary unavailable.${touchedNote}]`;
3370
+ await appendCompaction(sessionId, ++compactionSeq, summaryText, res.replacesThrough, cwd);
3371
+ }
3216
3372
  console.log(`(compacted ${res.removed} older messages${res.touchedFiles?.length ? `; kept ${res.touchedFiles.length} file refs` : ""})`);
3217
3373
  } else {
3218
3374
  console.log("(nothing to compact)");
@@ -3220,226 +3376,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3220
3376
  continue;
3221
3377
  }
3222
3378
  if (input === "/session" || input.startsWith("/session ")) {
3223
- const tokens = input.substring(8).trim().split(/\s+/).filter(Boolean);
3224
- const sub = (tokens[0] ?? "").toLowerCase();
3225
-
3226
- const startFreshSession = async (verb: string): Promise<void> => {
3227
- history.length = 1;
3228
- if (!flags.noSession) {
3229
- sessionId = (await createSession(cwd, undefined, sessionModel)).id;
3230
- advanceSessionBoxColor(); // distinct input-box hue per newly opened session
3231
- console.log(`(${verb} — new session ${sessionId})`);
3232
- } else {
3233
- sessionId = undefined;
3234
- console.log(`(${verb} — sessions disabled)`);
3235
- }
3236
- };
3237
-
3238
- if (sub === "new") {
3239
- await startFreshSession("started fresh");
3240
- continue;
3241
- }
3242
- if (sub === "drop" || sub === "delete") {
3243
- if (sessionId) {
3244
- const removed = await deleteSession(sessionId, cwd);
3245
- console.log(removed ? `(deleted session ${sessionId})` : `(session ${sessionId} already gone)`);
3246
- }
3247
- await startFreshSession("dropped");
3248
- continue;
3249
- }
3250
- if (sub === "rename") {
3251
- const title = tokens.slice(1).join(" ").trim();
3252
- if (!title) {
3253
- console.log("Usage: /session rename <title>");
3254
- continue;
3255
- }
3256
- if (!sessionId) {
3257
- console.log("(sessions are disabled — nothing to rename)");
3258
- continue;
3259
- }
3260
- try {
3261
- await renameSession(sessionId, title, cwd);
3262
- console.log(`(session renamed to '${title}')`);
3263
- } catch (err) {
3264
- console.log(`! rename failed: ${(err as Error).message}`);
3265
- }
3266
- continue;
3267
- }
3268
- if (sub === "resume") {
3269
- const arg = tokens.slice(1).join(" ").trim();
3270
- const applyResume = async (rid: string): Promise<void> => {
3271
- try {
3272
- const { header, messages } = await loadSession(rid, cwd);
3273
- history.length = 1;
3274
- for (const m of messages) history.push(m);
3275
- sessionId = rid;
3276
- // Restore the model this session was last using (per-session model).
3277
- if (header.model) sessionModel = header.model;
3278
- // Seed /retry + reply marker from the last user/assistant turn.
3279
- lastUserInput = ""; lastReply = "";
3280
- for (let k = history.length - 1; k >= 1; k--) {
3281
- if (history[k]!.role === "user" && !lastUserInput) lastUserInput = String(history[k]!.content ?? "");
3282
- if (history[k]!.role === "assistant" && !lastReply) lastReply = String(history[k]!.content ?? "");
3283
- if (lastUserInput && lastReply) break;
3284
- }
3285
- // Seed readline's input history so ↑ in the prompt recalls THIS session's
3286
- // prior prompts (not just lines typed in the current run). readline history
3287
- // is newest-first; unshift in chronological order so the session's newest
3288
- // prompt lands at the front (first ↑). Skip injected/framed messages.
3289
- const rli = rl as unknown as { history?: string[] };
3290
- if (Array.isArray(rli.history)) {
3291
- const priorPrompts = history
3292
- .filter(m => m.role === "user")
3293
- .map(m => String(m.content ?? "").trim())
3294
- .filter(c => c && !c.startsWith("Tool [") && !c.startsWith("[mid-turn steering") && !c.startsWith("[Earlier conversation summary]"));
3295
- for (const p of priorPrompts) {
3296
- if (rli.history[0] !== p) rli.history.unshift(p);
3297
- }
3298
- }
3299
- // Clean restore: wipe the screen + scrollback BEFORE replaying the
3300
- // transcript so it can't collide with picker remnants, the prior
3301
- // conversation, or the live input frame — the "/resume corrupts the
3302
- // TUI" fix. Same proven path as /clear; re-render the welcome banner
3303
- // so the resumed view reads like a fresh, intact screen.
3304
- if (process.stdout.isTTY) {
3305
- disarmPreview();
3306
- process.stdout.write(clearScreen());
3307
- console.log(freshWelcomeLines().join("\n"));
3308
- }
3309
- const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
3310
- logLines([
3311
- sep,
3312
- `resumed session ${rid} · ${messages.length} message(s) (/history all for the full transcript)`,
3313
- sep,
3314
- ...formatTranscript(history, { maxTurns: 6, color: true, unicode: true }),
3315
- sep,
3316
- ]);
3317
- } catch (err) {
3318
- console.log(`! ${(err as Error).message}`);
3319
- }
3320
- };
3321
- if (arg) { await applyResume(arg); continue; }
3322
- // No id → only sessions with a real conversation are resumable (every launch
3323
- // creates an empty session; those are noise).
3324
- let pool = (await listSessions(cwd)).filter(s => s.messageCount > 0);
3325
- if (pool.length === 0) {
3326
- console.log("(no saved sessions with history)");
3327
- continue;
3328
- }
3329
- // Interactive gjc-style picker on a TTY: type to filter, ↑↓/PgUp/PgDn to
3330
- // move, Enter resumes, Del deletes (press Del twice to confirm), Esc cancels.
3331
- if (process.stdin.isTTY && process.stdout.isTTY) {
3332
- // Loop so a delete refreshes the list and re-opens the picker in place.
3333
- for (;;) {
3334
- const picker = new SessionPicker(pool);
3335
- let action: { kind: "resume" | "delete"; id: string } | undefined;
3336
- let confirmDeleteId: string | undefined;
3337
- await runSelectPicker(
3338
- (cols, rows) => renderSessionPicker(picker, {
3339
- title: "Resume a session",
3340
- cols,
3341
- rows: Math.max(8, rows),
3342
- unicode: true,
3343
- color: true,
3344
- confirmDeleteId,
3345
- }),
3346
- (ch, key) => {
3347
- if (key?.name === "up") { confirmDeleteId = undefined; picker.up(); return false; }
3348
- if (key?.name === "down") { confirmDeleteId = undefined; picker.down(); return false; }
3349
- if (key?.name === "pageup") { confirmDeleteId = undefined; picker.page(-1); return false; }
3350
- if (key?.name === "pagedown") { confirmDeleteId = undefined; picker.page(1); return false; }
3351
- if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
3352
- if (key?.name === "delete") {
3353
- const sel = picker.selected();
3354
- if (!sel) return false;
3355
- if (confirmDeleteId === sel.id) { action = { kind: "delete", id: sel.id }; return true; }
3356
- confirmDeleteId = sel.id;
3357
- return false;
3358
- }
3359
- if (key?.name === "return" || key?.name === "enter") {
3360
- const sel = picker.selected();
3361
- if (sel) { action = { kind: "resume", id: sel.id }; return true; }
3362
- return false;
3363
- }
3364
- confirmDeleteId = undefined;
3365
- if (key?.name === "backspace") { picker.backspace(); return false; }
3366
- if (ch && ch >= " " && !key?.ctrl && !key?.meta) picker.typeChar(ch);
3367
- return false;
3368
- },
3369
- );
3370
- if (!action) { console.log("(resume cancelled)"); break; }
3371
- if (action.kind === "resume") { await applyResume(action.id); break; }
3372
- // Delete: drop the file, refresh the pool, and re-open the picker.
3373
- const delId = action.id;
3374
- try {
3375
- const removed = await deleteSession(delId, cwd);
3376
- console.log(removed ? `(deleted session ${delId})` : `(session ${delId} already gone)`);
3377
- } catch (err) {
3378
- console.log(`! delete failed: ${(err as Error).message}`);
3379
- }
3380
- if (delId === sessionId) await startFreshSession("dropped current session");
3381
- pool = pool.filter(s => s.id !== delId);
3382
- if (pool.length === 0) { console.log("(no saved sessions with history)"); break; }
3383
- }
3384
- continue;
3385
- }
3386
- // Non-TTY fallback: static list (resume with /session resume <id>).
3387
- console.log("Saved sessions — resume with /session resume <id>:");
3388
- for (const s of pool.slice(0, 15)) {
3389
- const marker = s.id === sessionId ? "*" : " ";
3390
- console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${s.title ? `[${s.title}] ` : ""}${s.preview}`);
3391
- }
3392
- continue;
3393
- }
3394
- if (sub === "list") {
3395
- const sessions = await listSessions(cwd);
3396
- if (sessions.length === 0) console.log("(no saved sessions)");
3397
- for (const s of sessions) {
3398
- const marker = s.id === sessionId ? "*" : " ";
3399
- const title = s.title ? `[${s.title}] ` : "";
3400
- console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${title}${s.preview}`);
3401
- }
3402
- continue;
3403
- }
3404
- if (sub === "info") {
3405
- if (!sessionId) {
3406
- console.log("Session: disabled (--no-session)");
3407
- continue;
3408
- }
3409
- const all = await listSessions(cwd);
3410
- const current = all.find(s => s.id === sessionId);
3411
- console.log("Session info:");
3412
- console.log(` id ${sessionId}`);
3413
- if (current?.title) console.log(` title ${current.title}`);
3414
- console.log(` file ${sessionPath(sessionId, cwd)}`);
3415
- console.log(` started ${current?.timestamp ?? "(this run)"}`);
3416
- console.log(` messages ${current?.messageCount ?? Math.max(0, history.length - 1)} persisted · ${history.length - 1} in context`);
3417
- console.log(` workspace ${cwd}`);
3418
- continue;
3419
- }
3420
- if (sub && sub !== "info") {
3421
- console.log("Usage: /session [list|info|new|drop|rename <title>|resume [id]]");
3422
- continue;
3423
- }
3424
-
3425
- // Default: list sessions AND show current session info
3426
- const sessions = await listSessions(cwd);
3427
- if (sessions.length === 0) {
3428
- console.log("(no saved sessions)");
3429
- } else {
3430
- console.log("Saved sessions:");
3431
- for (const s of sessions) {
3432
- const marker = s.id === sessionId ? "*" : " ";
3433
- const title = s.title ? `[${s.title}] ` : "";
3434
- console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${title}${s.preview}`);
3435
- }
3436
- }
3437
- if (sessionId) {
3438
- const current = sessions.find(s => s.id === sessionId);
3439
- console.log(`\nCurrent session: ${sessionId}${current?.title ? ` [${current.title}]` : ""}`);
3440
- } else {
3441
- console.log("\nCurrent session: disabled (--no-session)");
3442
- }
3379
+ const result = await runSessionSlash(input, {
3380
+ cwd, history, noSession: flags.noSession, sessionId, sessionModel, rl,
3381
+ advanceSessionBoxColor, disarmPreview, clearScreen, freshWelcomeLines, logLines, runSelectPicker,
3382
+ });
3383
+ sessionId = result.sessionId;
3384
+ if (result.sessionModel !== undefined) sessionModel = result.sessionModel;
3385
+ if (result.lastUserInput !== undefined) lastUserInput = result.lastUserInput;
3386
+ if (result.lastReply !== undefined) lastReply = result.lastReply;
3443
3387
  continue;
3444
3388
  }
3445
3389
  if (input === "/retry") {
@@ -3614,22 +3558,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3614
3558
  continue;
3615
3559
  }
3616
3560
  if (input === "/theme" || input.startsWith("/theme ")) {
3617
- const want = input.substring(6).trim().toLowerCase();
3618
- const themes = listThemes();
3619
- if (!want) {
3620
- const active = resolveTheme().name;
3621
- console.log("TUI themes (set with /theme <name>, persists via ~/.jeo/config.json):");
3622
- for (const t of themes) console.log(` ${t.name === active ? "*" : " "} ${t.name.padEnd(10)} ${t.description}`);
3623
- continue;
3624
- }
3625
- if (!themes.some(t => t.name === want)) {
3626
- console.log(`Unknown theme '${want}'. Known: ${themes.map(t => t.name).join(", ")}.`);
3627
- continue;
3561
+ const decision = decideThemeSlash(input, listThemes(), resolveTheme().name);
3562
+ for (const line of decision.lines) console.log(line);
3563
+ if (decision.kind === "set") {
3564
+ process.env.JEO_TUI_THEME = decision.themeName!;
3565
+ await saveConfigPatch(raw => ({ theme: decision.themeName }));
3566
+ refreshUiTheme(); // re-resolve the keystroke-hot theme handle immediately
3628
3567
  }
3629
- process.env.JEO_TUI_THEME = want;
3630
- await saveConfigPatch(raw => ({ theme: want }));
3631
- refreshUiTheme(); // re-resolve the keystroke-hot theme handle immediately
3632
- console.log(`Theme set to ${want} — saved to ~/.jeo/config.json`);
3633
3568
  continue;
3634
3569
  }
3635
3570
  if (input === "/wiki" || input.startsWith("/wiki ")) {
@@ -3653,12 +3588,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3653
3588
  continue;
3654
3589
  }
3655
3590
  if (input === "/evolve") {
3656
- console.log("=== Initiating Evolutionary Simulation ===");
3657
- for (const stage of EVOLUTION_STAGES) {
3658
- console.log(`\nStage: ${stage.name}`);
3659
- await animateAsciiArt(stage, { delayMs: 40 });
3660
- }
3661
- console.log("\n=== Evolved to Singularity! ===");
3591
+ await runEvolveSimulation(animateAsciiArt);
3662
3592
  continue;
3663
3593
  }
3664
3594
  if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
@@ -3929,508 +3859,55 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3929
3859
  await refreshLiveModelsCache();
3930
3860
  continue;
3931
3861
  }
3932
- const agentsCommand =
3933
- input === "/agents" || input.startsWith("/agents ") ? "/agents" :
3934
- undefined;
3935
- if (agentsCommand) {
3936
- const tokens = input.substring(agentsCommand.length).trim().split(/\s+/).filter(Boolean);
3937
- const roleArg = tokens[0];
3938
- const modelArg = tokens[1];
3939
- const cfgNow = await readGlobalConfig();
3940
- const subcommand = roleArg?.toLowerCase();
3941
- const printRoster = () => {
3942
- console.log("Subagent roles (used by 'jeo team'):");
3943
- for (const line of formatAgentsPanel(allSubagentRoles(cfgNow), r => ({
3944
- model: resolveSubagentModel(r.id, cfgNow),
3945
- maxSteps: resolveSubagentMaxSteps(r.id, cfgNow),
3946
- thinking: resolveSubagentThinking(r.id, cfgNow),
3947
- }))) console.log(line);
3948
- 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");
3949
- console.log("Tip: primary model flow: /model → pick model → choose default or subagent role → choose thinking level");
3950
- console.log(`Available: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")} (declare custom roles in config.subagents)`);
3951
- console.log("Subcommands: edit, <role> <model|#N>, <role> thinking <level|inherit>, <role> provider <name> [model], <role> maxSteps <N>, <role> reset");
3952
- };
3953
- if (!roleArg || roleArg === "/" || roleArg === "?" || subcommand === "help") {
3954
- printRoster();
3955
- continue;
3956
- }
3957
- if (subcommand === "edit" || subcommand === "picker") {
3958
- printRoster();
3959
- // Interactive editor (TTY): role picker → action picker → live model /
3960
- // thinking / reset — the arrows+Enter way to CHANGE an existing setting.
3961
- const rolePick = await pickFromOptions(
3962
- "Edit a subagent role (ESC to skip)",
3963
- allSubagentRoles(cfgNow).map(r => ({
3964
- value: r.id,
3965
- label: `${r.id} — ${r.title}`,
3966
- hint: `${resolveSubagentModel(r.id, cfgNow)} · ${resolveSubagentMaxSteps(r.id, cfgNow)} steps${cfgNow.subagents?.[r.id]?.model ? "" : " (default)"}`,
3967
- })),
3968
- );
3969
- const editRole = rolePick ? getSubagentRole(rolePick, cfgNow) : undefined;
3970
- if (!editRole) continue;
3971
- const action = await pickFromOptions(`${editRole.title} — choose action`, [
3972
- { value: "model", label: "change model", hint: resolveSubagentModel(editRole.id, cfgNow) },
3973
- { value: "thinking", label: "change thinking", hint: resolveSubagentThinking(editRole.id, cfgNow) ?? `inherit (${cfgNow.thinkingLevel ?? "medium"})` },
3974
- { value: "reset", label: "reset to defaults", hint: "clears model + maxSteps + thinking override" },
3975
- ]);
3976
- if (action === "reset") {
3977
- await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, editRole.id) }));
3978
- console.log(`${editRole.title} settings reset to defaults → ~/.jeo/config.json`);
3979
- } else if (action === "model") {
3980
- const live = await getLiveModels();
3981
- const entries = flattenModels(live);
3982
- const picked = await pickLiveProviderModel(`${editRole.id}`, entries, resolveSubagentModel(editRole.id, cfgNow));
3983
- if (picked) {
3984
- const pinned = qualifyModelId(picked.model, picked.provider);
3985
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, editRole.id, { model: pinned }) }));
3986
- console.log(`Subagent '${editRole.id}' model set to ${pinned} → ~/.jeo/config.json`);
3987
- }
3988
- } else if (action === "thinking") {
3989
- const lvl = await pickThinkingLevel(
3990
- `Reasoning for ${editRole.title}`,
3991
- cfgNow.subagents?.[editRole.id]?.thinking,
3992
- `inherit — follow default (${cfgNow.thinkingLevel ?? "medium"})`,
3993
- );
3994
- if (lvl) await setRoleThinking(editRole.id, lvl);
3995
- }
3996
- continue;
3997
- }
3998
- const role = getSubagentRole(roleArg, cfgNow);
3999
- if (!role) {
4000
- console.log(`Unknown role '${roleArg}'. Known: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")}.`);
4001
- continue;
4002
- }
4003
- if (modelArg?.toLowerCase() === "reset") {
4004
- await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, role.id) }));
4005
- console.log(`${role.title} settings reset to defaults → ~/.jeo/config.json`);
4006
- continue;
4007
- }
4008
- if (modelArg?.toLowerCase() === "maxsteps" || modelArg?.toLowerCase() === "steps") {
4009
- const maxSteps = parseMaxSteps(tokens[2]);
4010
- if (!maxSteps) {
4011
- console.log(`Usage: /agents ${role.id} maxSteps <positive-number>`);
4012
- continue;
4013
- }
4014
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { maxSteps }) }));
4015
- console.log(`${role.title} maxSteps set to ${maxSteps} → ~/.jeo/config.json`);
4016
- continue;
4017
- }
4018
- if (modelArg?.toLowerCase() === "thinking" || modelArg?.toLowerCase() === "think") {
4019
- await setRoleThinking(role.id, tokens[2]);
4020
- continue;
4021
- }
4022
- if (modelArg?.toLowerCase() === "provider") {
4023
- const want = (tokens[2] ?? "").toLowerCase();
4024
- if (!isProviderName(want)) {
4025
- console.log(`Usage: /agents ${role.id} provider <name> [model|#N] — e.g. anthropic, openai, gemini, groq, deepseek, openrouter (any configured provider)`);
4026
- continue;
4027
- }
4028
- const st = (await describeAllProviders()).find(s => s.name === want);
4029
- if (st && !st.ready) {
4030
- if (selectableThoughNotReady(st)) {
4031
- console.log(notReadyWarning(st));
4032
- } else {
4033
- console.log(`Cannot pin ${role.title} to ${want}: not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
4034
- continue;
4035
- }
4036
- }
4037
- const live = await getLiveModels();
4038
- const forProvider = providerPickEntries(live, want);
4039
- const liveForProvider = live.some(r => r.ok && r.provider === want && r.models.length > 0);
4040
- const explicit = tokens[3];
4041
- let chosenModel: string;
4042
- if (explicit && forProvider.length) {
4043
- const sel = resolveSelection(forProvider, explicit);
4044
- if (sel.kind === "index" || sel.kind === "match") chosenModel = qualifyModelId(sel.entry.model, want);
4045
- else if (sel.kind === "ambiguous") {
4046
- console.log(`'${explicit}' matches ${sel.matches.length} ${want} models — be more specific:`);
4047
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model}`);
4048
- continue;
4049
- } else if (sel.kind === "out-of-range") {
4050
- console.log(`#${explicit.slice(1)} is out of range for ${want} (1-${sel.max}).`);
4051
- continue;
4052
- } else {
4053
- chosenModel = qualifyModelId(explicit, want);
4054
- }
4055
- } else if (explicit) {
4056
- chosenModel = qualifyModelId(explicit, want);
4057
- } else if (forProvider.length) {
4058
- // No model given → the provider's first known model, provider-qualified.
4059
- chosenModel = qualifyModelId(forProvider[0]!.model, want);
4060
- } else {
4061
- chosenModel = providerDefaultModel(want);
4062
- }
4063
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
4064
- console.log(`${role.title} pinned to ${want} via model ${chosenModel} — saved to ~/.jeo/config.json`);
4065
- if (forProvider.length) {
4066
- lastPickIndex = forProvider;
4067
- const sourceNote = liveForProvider ? "Live" : "Catalog";
4068
- const tail = liveForProvider ? "" : " (log in to list live models)";
4069
- console.log(`${sourceNote} ${want} models — refine with /agents ${role.id} #N:${tail}`);
4070
- for (const line of formatPickListWithCapabilities(lastPickIndex, { current: chosenModel, cap: 12 })) console.log(line);
4071
- }
4072
- continue;
4073
- }
4074
- if (modelArg) {
4075
- let chosenModel = modelArg;
4076
- let entries = lastPickIndex;
4077
- if (modelArg.startsWith("#") && entries.length === 0) {
4078
- const live = await getLiveModels();
4079
- entries = flattenModels(live);
4080
- }
4081
- if (entries.length) {
4082
- const sel = resolveSelection(entries, modelArg);
4083
- if (sel.kind === "index" || sel.kind === "match") {
4084
- chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
4085
- const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
4086
- if (bad) {
4087
- if (selectableThoughNotReady(bad)) {
4088
- console.log(notReadyWarning(bad));
4089
- } else {
4090
- console.log(`Cannot pin ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad.label}). Set ${bad.envVar ?? "the provider key"} first.`);
4091
- continue;
4092
- }
4093
- }
4094
- } else if (sel.kind === "ambiguous") {
4095
- console.log(`'${modelArg}' matches ${sel.matches.length} live models — be more specific:`);
4096
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4097
- continue;
4098
- } else if (sel.kind === "out-of-range") {
4099
- console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4100
- continue;
4101
- }
4102
- } else if (modelArg.startsWith("#")) {
4103
- console.log("Use /model first to build the numbered live model list.");
4104
- continue;
4105
- }
4106
- // Persist a per-role model override to ~/.jeo/config.json (consumed by 'jeo team').
4107
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
4108
- const { provider } = await describeModel(chosenModel);
4109
- console.log(`${role.title} model set to ${chosenModel} (${provider}) — saved to ~/.jeo/config.json`);
4110
- const live = await getLiveModels();
4111
- if (!liveModelKnown(live, chosenModel)) {
4112
- console.log(` (note: '${chosenModel}' is not in any live model list — verify it is valid for ${provider})`);
4113
- }
4114
- continue;
4115
- }
4116
- for (const line of formatAgentDetail(role, {
4117
- model: resolveSubagentModel(role.id, cfgNow),
4118
- maxSteps: resolveSubagentMaxSteps(role.id, cfgNow),
4119
- thinking: resolveSubagentThinking(role.id, cfgNow),
4120
- })) console.log(line);
4121
- const live = await getLiveModels();
4122
- const agentPick = flattenModels(live);
4123
- if (agentPick.length) {
4124
- lastPickIndex = agentPick;
4125
- console.log(`Live models for ${role.title} — pin with /agents ${role.id} #N:`);
4126
- for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolveSubagentModel(role.id, cfgNow), cap: 20 })) console.log(line);
4127
- }
3862
+ if (input === "/agents" || input.startsWith("/agents ")) {
3863
+ const agentsResult = await runAgentsSlash(input, {
3864
+ sessionModel, sessionThinking, lastPickIndex,
3865
+ getLiveModels, pickLiveProviderModel, setRoleThinking, pickFromOptions, pickThinkingLevel,
3866
+ });
3867
+ if (agentsResult.sessionThinking !== undefined) sessionThinking = agentsResult.sessionThinking;
3868
+ if (agentsResult.lastPickIndex !== undefined) lastPickIndex = agentsResult.lastPickIndex;
4128
3869
  continue;
4129
3870
  }
4130
3871
  if (input === "/config") {
4131
- const cfgNow = await readGlobalConfig();
4132
- const label = sessionModel || cfgNow.defaultModel;
4133
- const { resolved, provider } = await describeModel(label);
4134
- console.log("Effective runtime config:");
4135
- for (const line of formatConfigPanel({
4136
- model: label,
4137
- resolved,
4138
- provider,
4139
- thinkingLevel: sessionThinking ?? cfgNow.thinkingLevel ?? "medium",
4140
- ollamaBaseUrl: cfgNow.ollamaBaseUrl,
4141
- openaiBaseUrl: cfgNow.openaiBaseUrl,
4142
- requestMaxRetries: cfgNow.retry?.requestMaxRetries,
4143
- sessionId,
4144
- })) console.log(line);
3872
+ for (const line of await buildConfigPanelLines({ sessionModel, sessionThinking, sessionId })) console.log(line);
4145
3873
  continue;
4146
3874
  }
4147
3875
  if (input.startsWith("/roles") && (input === "/roles" || input[6] === " ")) {
4148
- const tokens = input.substring(6).trim().split(/\s+/).filter(Boolean);
4149
- const cfgNow = await readGlobalConfig();
4150
- const TIERS = ["smol", "slow", "plan"] as const;
4151
- if (tokens.length >= 2 && (TIERS as readonly string[]).includes(tokens[0])) {
4152
- const tier = tokens[0] as (typeof TIERS)[number];
4153
- let chosenModel = tokens[1]!;
4154
- let entries = lastPickIndex;
4155
- if (chosenModel.startsWith("#") && entries.length === 0) {
4156
- const live = await getLiveModels();
4157
- entries = flattenModels(live);
4158
- }
4159
- if (entries.length) {
4160
- const sel = resolveSelection(entries, chosenModel);
4161
- if (sel.kind === "index" || sel.kind === "match") {
4162
- chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
4163
- const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
4164
- if (bad) {
4165
- 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.`);
4166
- continue;
4167
- }
4168
- } else if (sel.kind === "ambiguous") {
4169
- console.log(`'${chosenModel}' matches ${sel.matches.length} live models — be more specific:`);
4170
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4171
- continue;
4172
- } else if (sel.kind === "out-of-range") {
4173
- console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4174
- continue;
4175
- }
4176
- } else if (chosenModel.startsWith("#")) {
4177
- console.log("Use /model first to build the numbered live model list.");
4178
- continue;
4179
- }
4180
- await saveConfigPatch(raw => ({ roles: { ...(raw.roles ?? {}), [tier]: chosenModel } }));
4181
- console.log(`Role '${tier}' model set to ${chosenModel} → ~/.jeo/config.json`);
4182
- continue;
4183
- }
4184
- console.log("Model role tiers (fall back to the default model):");
4185
- for (const tier of TIERS) {
4186
- const { provider } = await describeModel(resolveRoleModel(tier, cfgNow));
4187
- console.log(` ${tier.padEnd(5)} ${resolveRoleModel(tier, cfgNow)} (${provider})`);
4188
- }
4189
- console.log("Set a tier: /roles <smol|slow|plan> <model>");
4190
- const live = await getLiveModels();
4191
- const rolePick = flattenModels(live);
4192
- if (rolePick.length) {
4193
- lastPickIndex = rolePick;
4194
- console.log("Live models for role tiers — set with /roles <tier> #N:");
4195
- for (const line of formatPickListWithCapabilities(lastPickIndex, { cap: 15 })) console.log(line);
4196
- }
3876
+ const rolesResult = await runRolesSlash(input, {
3877
+ sessionModel, sessionThinking, lastPickIndex,
3878
+ getLiveModels, pickLiveProviderModel, setRoleThinking, pickFromOptions, pickThinkingLevel,
3879
+ });
3880
+ if (rolesResult.sessionThinking !== undefined) sessionThinking = rolesResult.sessionThinking;
3881
+ if (rolesResult.lastPickIndex !== undefined) lastPickIndex = rolesResult.lastPickIndex;
4197
3882
  continue;
4198
3883
  }
4199
3884
  if (input.startsWith("/fast") && (input === "/fast" || input[5] === " ")) {
4200
- const arg = input.substring(5).trim().toLowerCase() || "status";
4201
- const cfgNow = await readGlobalConfig();
4202
- const currentModel = sessionModel || cfgNow.defaultModel;
4203
- const { resolved, provider } = await describeModel(currentModel);
4204
- const fastLevel = fastThinkingLevelForModel(resolved);
4205
- const currentThinking = sessionThinking ?? cfgNow.thinkingLevel ?? "medium";
4206
- const status = fastLevel && currentThinking === fastLevel ? "on" : "off";
4207
- if (arg === "status") {
4208
- const support = fastLevel ? `supported (thinking ${fastLevel})` : "unsupported";
4209
- console.log(`Fast mode: ${status} · ${support} · ${formatModelLine({ label: currentModel, resolved, provider })} · current thinking ${currentThinking}`);
4210
- continue;
4211
- }
4212
- if (arg === "on") {
4213
- if (!fastLevel) {
4214
- console.log(`Fast mode is not advertised for ${formatModelLine({ label: currentModel, resolved, provider })}; pick a thinking-capable model with /model.`);
4215
- continue;
4216
- }
4217
- sessionThinking = fastLevel;
4218
- console.log(`Fast mode on: ${formatModelLine({ label: currentModel, resolved, provider })} · thinking ${fastLevel} (~${thinkingMaxTokens(fastLevel)} max tokens/step)`);
4219
- continue;
4220
- }
4221
- if (arg === "off") {
4222
- sessionThinking = cfgNow.thinkingLevel ?? "medium";
4223
- console.log(`Fast mode off: restored thinking ${sessionThinking} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
4224
- continue;
4225
- }
4226
- console.log("Usage: /fast [on|off|status]");
3885
+ const fastResult = await runFastSlash(input, {
3886
+ sessionModel, sessionThinking, lastPickIndex,
3887
+ getLiveModels, pickLiveProviderModel, setRoleThinking, pickFromOptions, pickThinkingLevel,
3888
+ });
3889
+ if (fastResult.sessionThinking !== undefined) sessionThinking = fastResult.sessionThinking;
3890
+ if (fastResult.lastPickIndex !== undefined) lastPickIndex = fastResult.lastPickIndex;
4227
3891
  continue;
4228
3892
  }
4229
3893
  if (input.startsWith("/thinking") && (input === "/thinking" || input[9] === " ")) {
4230
- const arg = input.substring(9).trim().toLowerCase();
4231
- if (!arg) {
4232
- console.log(`Thinking level: ${sessionThinking ?? "medium"} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
4233
- continue;
4234
- }
4235
- if (arg === "minimal" || arg === "low" || arg === "medium" || arg === "high" || arg === "xhigh") {
4236
- sessionThinking = arg;
4237
- console.log(`Thinking set to ${arg} (~${thinkingMaxTokens(arg)} max tokens/step)`);
4238
- } else {
4239
- console.log(`Invalid level '${arg}'. Use: minimal | low | medium | high | xhigh.`);
4240
- }
3894
+ const thinkingResult = await runThinkingSlash(input, {
3895
+ sessionModel, sessionThinking, lastPickIndex,
3896
+ getLiveModels, pickLiveProviderModel, setRoleThinking, pickFromOptions, pickThinkingLevel,
3897
+ });
3898
+ if (thinkingResult.sessionThinking !== undefined) sessionThinking = thinkingResult.sessionThinking;
3899
+ if (thinkingResult.lastPickIndex !== undefined) lastPickIndex = thinkingResult.lastPickIndex;
4241
3900
  continue;
4242
3901
  }
4243
3902
  if (input.startsWith("/model") && (input === "/model" || input[6] === " ")) {
4244
- let arg = input.substring(6).trim();
4245
- // `/model save [id]` → persist the (session or given) model as the config default.
4246
- if (arg === "save" || arg.startsWith("save ")) {
4247
- let toSave = arg.slice(4).trim();
4248
- // Resolve `#N`/fuzzy through the same pick-list logic as `/model #N`, so we never
4249
- // persist a literal token like "#2" as defaultModel (which then fails to route).
4250
- if (toSave && lastPickIndex.length) {
4251
- const sel = resolveSelection(lastPickIndex, toSave);
4252
- if (sel.kind === "index" || sel.kind === "match") toSave = qualifyModelId(sel.entry.model, sel.entry.provider);
4253
- else if (sel.kind === "ambiguous") {
4254
- console.log(`'${toSave}' matches ${sel.matches.length} models — be more specific:`);
4255
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4256
- continue;
4257
- } else if (sel.kind === "out-of-range") {
4258
- console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4259
- continue;
4260
- }
4261
- // kind "none" → treat `toSave` as a literal model id/alias.
4262
- } else if (toSave.startsWith("#")) {
4263
- console.log("Use /model first to build the numbered list.");
4264
- continue;
4265
- }
4266
- // Fall back to the FRESH on-disk default (not the stale session-start snapshot) so a
4267
- // bare `/model save` after a prior `/model save <id>` never reverts the saved default.
4268
- const finalSave = toSave || sessionModel || (await readGlobalConfig()).defaultModel;
4269
- await saveConfigPatch(raw => rememberModelPatch(raw, finalSave));
4270
- const { resolved, provider } = await describeModel(finalSave);
4271
- console.log(`Default model saved: ${formatModelLine({ label: finalSave, resolved, provider })} → ~/.jeo/config.json`);
4272
- continue;
4273
- }
4274
- const modelThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(arg);
4275
- if (modelThinking) {
4276
- const level = (modelThinking[1] ?? "").toLowerCase();
4277
- if (!isThinkingLevel(level)) {
4278
- console.log("Usage: /model thinking <minimal|low|medium|high|xhigh>");
4279
- continue;
4280
- }
4281
- sessionThinking = level;
4282
- await saveConfigPatch(() => ({ thinkingLevel: level }));
4283
- console.log(`Default thinking set to ${level} (~${thinkingMaxTokens(level)} max tokens/step) → ~/.jeo/config.json`);
4284
- continue;
4285
- }
4286
- const statuses = await describeAllProviders();
4287
- const disabledModelProviders = statuses.filter(s => !s.ready && !selectableThoughNotReady(s)).map(s => s.name);
4288
- const roleMatch = /^(subagent|role)\s+(\S+)(?:\s+(.+))?$/i.exec(arg);
4289
- if (roleMatch) {
4290
- const role = getSubagentRole(roleMatch[2] ?? "", await readGlobalConfig());
4291
- if (!role) {
4292
- console.log("Usage: /model subagent <executor|planner|architect|critic> [model|#N]");
4293
- continue;
4294
- }
4295
- let roleModelArg = (roleMatch[3] ?? "").trim();
4296
- const roleThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(roleModelArg);
4297
- if (roleThinking) {
4298
- console.log(`Subagent thinking is set via /agents — try: /agents ${role.id} thinking ${roleThinking[1] ?? "<level|inherit>"} (or /agents edit). /model only sets the default thinking.`);
4299
- continue;
4300
- }
4301
-
4302
- if (!roleModelArg && process.stdin.isTTY && process.stdout.isTTY) {
4303
- const live = await getLiveModels();
4304
- lastPickIndex = flattenModels(live);
4305
- if (lastPickIndex.length) {
4306
- const currentResolved = (await describeModel(resolveSubagentModel(role.id, await readGlobalConfig()))).resolved;
4307
- const picked = await pickLiveProviderModel(role.id, lastPickIndex, currentResolved, disabledModelProviders);
4308
- if (!picked) {
4309
- console.log("(cancelled)");
4310
- continue;
4311
- }
4312
- roleModelArg = qualifyModelId(picked.model, picked.provider);
4313
-
4314
- }
4315
- }
4316
- if (roleModelArg && lastPickIndex.length) {
4317
- const sel = resolveSelection(lastPickIndex, roleModelArg);
4318
- if (sel.kind === "index" || sel.kind === "match") {
4319
- if (disabledModelProviders.includes(sel.entry.provider)) {
4320
- const bad = statuses.find(s => s.name === sel.entry.provider);
4321
- console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
4322
- continue;
4323
- }
4324
- roleModelArg = qualifyModelId(sel.entry.model, sel.entry.provider);
4325
-
4326
- } else if (sel.kind === "ambiguous") {
4327
- console.log(`'${roleModelArg}' matches ${sel.matches.length} models — be more specific:`);
4328
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4329
- continue;
4330
- } else if (sel.kind === "out-of-range") {
4331
- console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4332
- continue;
4333
- }
4334
- } else if (roleModelArg.startsWith("#")) {
4335
- console.log("Use /model first to build the numbered list.");
4336
- continue;
4337
- }
4338
- if (roleModelArg) {
4339
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: roleModelArg }) }));
4340
- const { provider } = await describeModel(roleModelArg);
4341
- console.log(`${role.title} model set to ${roleModelArg} (${provider}) — saved to ~/.jeo/config.json. Set its thinking via /agents ${role.id} thinking <level> (or /agents edit).`);
4342
- } else {
4343
- const current = resolveSubagentModel(role.id, await readGlobalConfig());
4344
- const { resolved, provider } = await describeModel(current);
4345
- console.log(`${role.title} model: ${formatModelLine({ label: current, resolved, provider })}`);
4346
- const live = await getLiveModels();
4347
- lastPickIndex = flattenModels(live);
4348
- if (lastPickIndex.length) {
4349
- console.log(`Live models for ${role.title} — set with /model subagent ${role.id} #N:`);
4350
- for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
4351
- }
4352
- }
4353
- continue;
4354
- }
4355
- let modelPickedFromSelector = false;
4356
- if (!arg && process.stdin.isTTY && process.stdout.isTTY) {
4357
- const live = await getLiveModels();
4358
- lastPickIndex = flattenModels(live);
4359
- if (lastPickIndex.length) {
4360
- const currentResolved = (await describeModel(sessionModel || defaultModel)).resolved;
4361
- const picked = await pickLiveProviderModel("live", lastPickIndex, currentResolved, disabledModelProviders);
4362
- if (!picked) {
4363
- console.log("(cancelled)");
4364
- continue;
4365
- }
4366
- arg = qualifyModelId(picked.model, picked.provider);
4367
- modelPickedFromSelector = true;
4368
- }
4369
- }
4370
- // Selection from the last numbered pick list (`#N`) or a fuzzy substring.
4371
- if (arg && lastPickIndex.length) {
4372
- const sel = resolveSelection(lastPickIndex, arg);
4373
- if (sel.kind === "index" || sel.kind === "match") {
4374
- if (disabledModelProviders.includes(sel.entry.provider)) {
4375
- const bad = statuses.find(s => s.name === sel.entry.provider);
4376
- console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
4377
- continue;
4378
- }
4379
- arg = qualifyModelId(sel.entry.model, sel.entry.provider);
4380
- modelPickedFromSelector = true;
4381
- } else if (sel.kind === "ambiguous") {
4382
- console.log(`'${arg}' matches ${sel.matches.length} models — be more specific:`);
4383
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4384
- continue;
4385
- } else if (sel.kind === "out-of-range") {
4386
- console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4387
- continue;
4388
- }
4389
- // kind "none" → fall through and treat `arg` as a literal model id/alias.
4390
- } else if (arg.startsWith("#")) {
4391
- console.log("Use /model first to build the numbered list.");
4392
- continue;
4393
- }
4394
- const label = arg || (sessionModel || defaultModel);
4395
- if (arg && modelPickedFromSelector && await applyPickedModelWithTarget(arg)) {
4396
- continue;
4397
- }
4398
- if (arg) {
4399
- sessionModel = arg;
4400
- // MRU persistence: picking a model IS saving it — the newest pick wins
4401
- // as the global default; recents keep the rotation for every session.
4402
- await saveConfigPatch(raw => rememberModelPatch(raw, arg));
4403
- await persistSessionModel();
4404
- }
4405
- const { resolved, provider } = await describeModel(label);
4406
- const st = statuses.find(s => s.name === provider);
4407
- console.log(`${arg ? "Model set to" : "Current model"}: ${formatModelLine({ label, resolved, provider, ready: st?.ready })}${arg ? " — saved as default" : ""}`);
4408
- if (st && !st.ready) console.log(` ! ${provider} is not ready (${st.label}) — set ${st.envVar ?? "the provider key"} or run 'jeo setup'.`);
4409
- // ChatGPT OAuth only serves the Codex models; warn before the turn fails if the user
4410
- // pins a non-Codex id with no local base URL to fall back to (gjc-parity readiness guard).
4411
- if (arg && provider === "openai" && st?.kind === "oauth" && !CODEX_MODELS.includes(resolved)) {
4412
- const hasLocalBase = !!((await readGlobalConfig()).openaiBaseUrl || process.env.OPENAI_BASE_URL);
4413
- if (!hasLocalBase) {
4414
- console.log(` ! ChatGPT OAuth serves only Codex models (${CODEX_MODELS.join(", ")}); '${resolved}' will be rejected at runtime — pick one of those, or set OPENAI_API_KEY / OPENAI_BASE_URL.`);
4415
- }
4416
- }
4417
- if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
4418
- console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model to pick a valid id)`);
4419
- }
4420
- const meta = catalogMetadata(resolved);
4421
- if (meta) console.log(` ${formatCapabilityLine(meta)}`);
4422
- if (!arg) {
4423
- const recents = recentModelsForDisplay(await readGlobalConfig());
4424
- if (recents.length > 1) {
4425
- console.log("Recent models (newest first):");
4426
- recents.slice(0, 5).forEach((m, i) => console.log(` ${i + 1}. ${m}${i === 0 ? " ◀ default" : ""}`));
4427
- }
4428
- const live = await getLiveModels();
4429
- lastPickIndex = flattenModels(live);
4430
- console.log("Live models (logged-in providers) — set with /model #N:");
4431
- for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
4432
- }
4433
- console.log(" (model picks persist automatically — newest selection is the default everywhere)");
3903
+ const modelResult = await runModelSlash(input, {
3904
+ sessionModel, sessionThinking, defaultModel, lastPickIndex, liveModelsCache,
3905
+ isTTY: !!(process.stdin.isTTY && process.stdout.isTTY),
3906
+ getLiveModels, applyPickedModelWithTarget, persistSessionModel, pickLiveProviderModel,
3907
+ });
3908
+ if (modelResult.sessionModel !== undefined) sessionModel = modelResult.sessionModel;
3909
+ if (modelResult.sessionThinking !== undefined) sessionThinking = modelResult.sessionThinking;
3910
+ if (modelResult.lastPickIndex !== undefined) lastPickIndex = modelResult.lastPickIndex;
4434
3911
  continue;
4435
3912
  }
4436
3913
  if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {