jeo-code 0.1.0 → 0.4.5

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 (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +808 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +624 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -1,67 +1,757 @@
1
1
  import { createInterface } from "node:readline/promises";
2
- import { runAgentLoop, executorSystemPrompt } from "../agent/engine";
2
+ import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, type AgentLoopEvents } from "../agent/engine";
3
+ import { initialDynamicStepLimit } from "../agent/step-budget";
4
+ import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
5
+ import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
6
+ import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
3
7
  import { LaunchTui } from "../tui/app";
4
- import { skillsPromptSection } from "../skills/catalog";
5
- import { matchSlash, isSlashAttempt } from "../tui/components/slash";
6
- import { staticCompletionContext, readlineCompleter, type CompletionContext } from "../tui/components/autocomplete";
7
- import { EVOLUTION_STAGES, renderAsciiArt, animateAsciiArt } from "../tui/components/ascii-art";
8
+ import { runDeepInterviewEngine } from "./deep-interview";
9
+ import { runRalplanEngine } from "./ralplan";
10
+ import { runTeamEngine } from "./team";
11
+ import { runUltragoalEngine } from "./ultragoal";
12
+ import { skillsPromptSection, loadSkills, formatSkill, buildSkillTask, getSkillFrom, skillSlashAliases, workflowSkillsForPrompt, parseSkillInvocation, looksLikeSkillEcho, skillInvocationCard, type SkillDoc } from "../skills/catalog";
13
+ import { formatForgeBox } from "../tui/components/forge";
14
+ import { interactiveOAuthLogin } from "./auth";
15
+ import { logoutOAuth } from "../auth";
16
+ import type { AuthProvider } from "../auth";
17
+ import { matchSlash, isSlashAttempt, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
18
+ import { staticCompletionContext, readlineCompleter, formatCompletionPreview, tokenize, type CompletionContext } from "../tui/components/autocomplete";
19
+ import { EVOLUTION_STAGES, animateAsciiArt } from "../tui/components/ascii-art";
8
20
  import { getEvolutionTip } from "../tui/components/evolution";
21
+ import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
22
+ import { checkForUpdate } from "../util/update-check";
23
+ import { jeoEnv } from "../util/env";
24
+ import { renderUpdateBox } from "../tui/components/update-box";
25
+ import { supportsUnicode } from "../tui/components/capability";
26
+ import pkg from "../../package.json";
9
27
  import chalk from "chalk";
10
- import type { Message } from "../agent/loop";
11
- import { readGlobalConfig, saveGlobalConfig } from "../agent/state";
12
- import { describeModel, describeAllProviders, thinkingMaxTokens, discoverModels, flattenModels, resolveSelection, catalogMetadata, resolveRoleModel, enrichAll, sortByCapability } from "../ai";
13
- import type { ProviderModelsResult, PickEntry } from "../ai";
28
+ import { callLlm, type Message } from "../agent/loop";
29
+ import { friendlyProviderError } from "../util/provider-error";
30
+ import { readGlobalConfig, saveConfigPatch } from "../agent/state";
31
+ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
32
+ import { describeModel, describeAllProviders, thinkingMaxTokens, discoverModels, flattenModels, resolveSelection, catalogMetadata, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
33
+ import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
14
34
 
15
35
  import { listAliases } from "../ai/model-registry";
16
36
 
17
- import { SUBAGENT_ROLES, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps } from "../agent/subagents";
37
+ import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting } from "../agent/subagents";
38
+ import { SelectList, renderSelectList, type SelectItem } from "../tui/components/select-list";
18
39
  import {
19
40
  formatModelLine,
20
- formatAliasLines,
21
41
  formatProviderPanel,
22
42
  formatAgentsPanel,
23
43
  formatAgentDetail,
24
44
  formatConfigPanel,
25
45
 
26
46
  liveModelKnown,
27
- formatPickList,
47
+ formatPickListWithCapabilities,
28
48
  formatCapabilityLine,
29
- formatEnrichedModels,
30
49
  } from "../tui/components/config-panel";
31
- import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff } from "../tui/components/code-view";
50
+ import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
51
+ import { skillPicker, renderSkillPicker } from "../tui/components/skill-picker";
52
+ import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
53
+ import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
54
+ import { categoryBadge } from "../tui/components/category-index";
55
+ import { renderInputFrame } from "../tui/components/input-box";
56
+ import { renderStatusBar } from "../tui/components/status";
57
+ import { detectColorLevel, ColorLevel } from "../tui/components/color";
58
+ import { readClipboardImage } from "../util/clipboard-image";
59
+ import { formatTranscript } from "../tui/components/transcript";
60
+ import type { ImageAttachment } from "../ai/types";
61
+ import { renderMarkdownTables } from "../tui/components/markdown-table";
62
+
63
+ import { stripMarkdown } from "../tui/components/markdown-text";
64
+ import { summarizeForgeInvocation } from "../tui/components/forge";
65
+ import { formatDuration, formatUsage } from "../tui/components/duration";
66
+
32
67
  import { findTool, searchTool } from "../agent/tools";
33
68
  import { loadProjectContext, withProjectContext } from "../agent/context-files";
34
- import { maybeCompact } from "../agent/compaction";
69
+ import { maybeCompact, historyTokens } from "../agent/compaction";
35
70
  import * as path from "node:path";
36
71
  import * as fs from "node:fs";
72
+ import { listThemes, resolveTheme, themeGradient, accentPaint, accentShadowPaint } from "../tui/components/themes";
37
73
  import {
38
74
  createSession,
39
75
  appendMessage,
76
+ appendMessages,
40
77
  loadSession,
41
78
  listSessions,
42
79
  latestSessionId,
80
+ exportSession,
81
+ renameSession,
82
+ deleteSession,
83
+ sessionPath,
84
+ appendCompaction,
43
85
  } from "../agent/session";
86
+ import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking } from "../tui/terminal";
44
87
 
45
- interface LaunchFlags {
88
+ export interface LaunchFlags {
46
89
  list: boolean;
47
90
  resume: boolean;
48
91
  resumeId?: string;
49
92
  noSession: boolean;
50
93
  noTui: boolean;
94
+ /** Explicit step cap from --max-steps; 0 = dynamic (process-driven budget that
95
+ * keeps extending while the turn shows progress — no hardcoded step ceiling). */
51
96
  maxSteps: number;
52
97
  message: string;
53
98
  tmux: boolean;
54
99
  worktree?: string;
100
+ model?: string;
101
+ provider?: ProviderName;
102
+ modelRole?: ModelRole;
103
+ thinking?: ThinkLevel;
104
+ errors: string[];
105
+ print?: boolean;
106
+ appendSystemPromptRaw?: string;
107
+ appendSystemPrompt?: string;
108
+ noSkills: boolean;
109
+ skills?: string;
110
+ noTools: boolean;
111
+ tools?: string;
112
+ systemPromptRaw?: string;
113
+ systemPrompt?: string;
114
+ }
115
+
116
+ const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
117
+
118
+ function takeValue(args: string[], index: number, inlinePrefix: string): { value?: string; nextIndex: number } {
119
+ const current = args[index]!;
120
+ if (current.startsWith(inlinePrefix)) return { value: current.slice(inlinePrefix.length), nextIndex: index };
121
+ const next = args[index + 1];
122
+ if (next && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
123
+ return { nextIndex: index };
124
+ }
125
+
126
+ function isProviderName(input: string | undefined): input is ProviderName {
127
+ return input === "anthropic" || input === "openai" || input === "gemini" || input === "antigravity" || input === "ollama";
128
+ }
129
+
130
+ function isThinkingLevel(input: string | undefined): input is ThinkLevel {
131
+ return input === "minimal" || input === "low" || input === "medium" || input === "high" || input === "xhigh";
132
+ }
133
+
134
+ function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
135
+ const supported = catalogMetadata(modelId)?.thinking ?? [];
136
+ if (supported.includes("minimal")) return "minimal";
137
+ if (supported.includes("low")) return "low";
138
+ return undefined;
139
+ }
140
+
141
+ function hashString(input: string): string {
142
+ let hash = 2166136261;
143
+ for (let i = 0; i < input.length; i++) {
144
+ hash ^= input.charCodeAt(i);
145
+ hash = Math.imul(hash, 16777619);
146
+ }
147
+ return (hash >>> 0).toString(36).padStart(6, "0").slice(0, 6);
148
+ }
149
+
150
+ function tmuxSafeNamePart(input: string, max = 32): string {
151
+ const safe = input.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "value";
152
+ if (safe.length <= max) return safe;
153
+ return `${safe.slice(0, Math.max(1, max - 7))}-${hashString(input)}`;
154
+ }
155
+
156
+ function tmuxRuntimeSuffix(flags: LaunchFlags): string {
157
+ const parts: string[] = [];
158
+ if (flags.provider) parts.push(`provider-${flags.provider}`);
159
+ if (flags.model) parts.push(`model-${tmuxSafeNamePart(flags.model)}`);
160
+ else if (flags.modelRole) parts.push(flags.modelRole);
161
+ if (flags.thinking) parts.push(`think-${flags.thinking}`);
162
+ // Only an EXPLICIT --max-steps cap names the session; the dynamic default (0) adds nothing.
163
+ if (flags.maxSteps > 0) parts.push(`steps-${flags.maxSteps}`);
164
+ if (parts.length === 0) return "";
165
+ const joined = parts.join("-");
166
+ const suffix = joined.length <= 72 ? joined : `${joined.slice(0, 65)}-${hashString(joined)}`;
167
+ return `-${suffix}`;
168
+ }
169
+
170
+ /**
171
+ * Base tmux session name for `jeo --tmux`. Keyed on the working DIRECTORY (not just the
172
+ * git branch) so two different projects/worktrees on the same branch (e.g. `main`)
173
+ * never share a base. {@link uniqueTmuxSessionName} then makes each concurrent invocation
174
+ * fully independent, so a second `jeo --tmux` never attaches to (and mirrors) the first.
175
+ */
176
+ export function tmuxSessionName(cwd: string, branch: string, flags: LaunchFlags): string {
177
+ const dirTag = `${tmuxSafeNamePart(path.basename(cwd) || "root", 16)}-${hashString(cwd)}`;
178
+ const base = branch ? `jeo-${branch}-${dirTag}` : `jeo-${dirTag}`;
179
+ return base + tmuxRuntimeSuffix(flags);
180
+ }
181
+
182
+ /**
183
+ * Count uncommitted git entries for the `⑂ <branch> ?N` footer dirty flag (gjc parity).
184
+ * One `git status --porcelain` spawn per CALL; callers invoke it once per turn start, not
185
+ * per render. Returns undefined when not a repo / git absent / clean.
186
+ */
187
+ export function gitDirtyCount(cwd: string): number | undefined {
188
+ try {
189
+ const res = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
190
+ if (res.exitCode !== 0) return undefined;
191
+ const n = res.stdout.toString().split("\n").filter(l => l.trim().length > 0).length;
192
+ return n || undefined;
193
+ } catch {
194
+ return undefined;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Allocate + create an INDEPENDENT tmux session from a base name. Each separate,
200
+ * concurrent `jeo --tmux` invocation gets its OWN session instead of attaching to (and
201
+ * mirroring) one another process already created: try `base`, then `base-2`, `base-3`, …
202
+ * The create itself is the guard, so this is race-safe — two processes starting at the
203
+ * same instant can't both win `base`. `tryCreate` must attempt to create the named session
204
+ * and return `"ok"` (created — it's ours), `"taken"` (name already live / lost the race →
205
+ * try the next suffix), or `"error:<msg>"` (a real failure → abort). Sessions die with
206
+ * their jeo process, so a sequential re-run reuses the clean base; only live overlap is
207
+ * suffixed.
208
+ */
209
+ export type TmuxCreateResult = "ok" | "taken" | `error:${string}`;
210
+ export function allocateTmuxSession(
211
+ base: string,
212
+ tryCreate: (name: string) => TmuxCreateResult,
213
+ ): { name: string } | { error: string } {
214
+ for (let n = 1; n <= 1000; n++) {
215
+ const candidate = n === 1 ? base : `${base}-${n}`;
216
+ const result = tryCreate(candidate);
217
+ if (result === "ok") return { name: candidate };
218
+ if (result === "taken") continue;
219
+ return { error: result.slice("error:".length) };
220
+ }
221
+ return { error: `could not allocate a free tmux session name for ${base} (1000 already live?)` };
222
+ }
223
+
224
+ function shellQuote(arg: string): string {
225
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
226
+ }
227
+
228
+ /**
229
+ * True when `jeo --tmux` runs INSIDE an existing tmux session and should enable
230
+ * session-scoped mouse mode for the CURRENT session: no jeo-owned session is created
231
+ * on this path, so without `mouse on` tmux ignores the wheel entirely and the
232
+ * mid-turn scrollback (ledger lines flushed above the live frame) is unreachable.
233
+ * Skipped for jeo-spawned sessions (JEO_TMUX_LAUNCHED=1 — the creator already set
234
+ * it) and when JEO_TMUX_MOUSE=0 opts out.
235
+ */
236
+ export function shouldEnableCurrentTmuxMouse(env: Record<string, string | undefined>): boolean {
237
+ return !!env.TMUX
238
+ && (env.JEO_TMUX_LAUNCHED ?? env.JEO_TMUX_LAUNCHED) !== "1"
239
+ && (env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0";
240
+ }
241
+
242
+ /**
243
+ * The runnable command for the INNER `jeo launch` a `--tmux` session executes.
244
+ * Pure — testable. Three runtime shapes:
245
+ * - compiled standalone binary: `argv[1]` is a Bun VIRTUAL path (`/$bunfs/…`)
246
+ * that does not exist on disk; the binary itself (`execPath`) is the
247
+ * entrypoint. Passing the virtual path made the inner command crash on
248
+ * spawn, so `tmux new-session` died instantly and the follow-up attach
249
+ * failed with "can't find session".
250
+ * - source run (`bun src/cli.ts`): re-run the script through the runtime.
251
+ * - anything else (a shim/binary path on disk): run it directly.
252
+ */
253
+ export function tmuxLaunchCommand(argv1: string | undefined, execPath: string, cwd: string): string[] {
254
+ const entrypoint = argv1 ?? "";
255
+ if (entrypoint === "" || entrypoint.startsWith("/$bunfs/") || entrypoint.startsWith("B:\\~BUN\\")) {
256
+ return [execPath];
257
+ }
258
+ const resolved = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(cwd, entrypoint);
259
+ if (/\.(ts|js|mjs)$/.test(entrypoint)) return [execPath, resolved];
260
+ return [resolved];
261
+ }
262
+
263
+ /** One tmux configuration step applied to a jeo-owned session after creation. */
264
+ export interface TmuxProfileCommand {
265
+ description: string;
266
+ args: string[];
267
+ }
268
+
269
+ /**
270
+ * gjc-parity tmux profile for jeo-OWNED sessions (mirrors gjc's
271
+ * `buildGjcTmuxProfileCommands`). Applied right after `new-session`, before attach:
272
+ * - `mouse on` (session-scoped): wheel-up enters copy-mode over the REAL pane
273
+ * history — this is what makes the mid-turn scrollback (ledger lines flushed
274
+ * above the inline live frame) reachable with the mouse wheel. Wheel-down at
275
+ * the bottom drops back out. `JEO_TMUX_MOUSE=0` opts out.
276
+ * - ownership/identity markers (`@jeo-profile`, `@jeo-branch`, `@jeo-project`):
277
+ * lets tooling tell jeo-owned sessions apart from user sessions (gjc parity
278
+ * with `@gjc-*`). Never applied to foreign sessions.
279
+ * - `set-clipboard on` + a readable copy-mode `mode-style`: text selected while
280
+ * wheel-scrolled back is visibly highlighted and lands on the system clipboard
281
+ * (OSC52). `JEO_TMUX_PROFILE=0` opts out of these cosmetic extras while keeping
282
+ * mouse + markers.
283
+ */
284
+ export function tmuxProfileCommands(
285
+ target: string,
286
+ env: Record<string, string | undefined>,
287
+ meta: { branch?: string; project?: string } = {},
288
+ ): TmuxProfileCommand[] {
289
+ // Exact-name session target. `=name:` (explicit session:window form), NOT bare
290
+ // `=name`: tmux 3.6 rejects bare `=name` for set-option/show-options with
291
+ // "no such session" even while the session is live (has-session/attach accept
292
+ // it). The trailing colon makes cmd-find parse `=name` as the session part —
293
+ // exact-matched, never prefix-matched — and resolves the session's current
294
+ // window for set-window-option. This was the silent failure that left
295
+ // `mouse on` unset, killing wheel-up scrollback in jeo-owned sessions.
296
+ const t = `=${target}:`;
297
+ const commands: TmuxProfileCommand[] = [];
298
+ if ((env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0") {
299
+ commands.push({
300
+ description: "enable tmux mouse scrolling (wheel-up → copy-mode over real history)",
301
+ args: ["set-option", "-t", t, "mouse", "on"],
302
+ });
303
+ }
304
+ commands.push({
305
+ description: "mark jeo tmux ownership",
306
+ args: ["set-option", "-t", t, "@jeo-profile", "1"],
307
+ });
308
+ if (meta.branch) {
309
+ commands.push({
310
+ description: "record jeo branch identity",
311
+ args: ["set-option", "-t", t, "@jeo-branch", meta.branch],
312
+ });
313
+ }
314
+ if (meta.project) {
315
+ commands.push({
316
+ description: "record jeo project identity",
317
+ args: ["set-option", "-t", t, "@jeo-project", meta.project],
318
+ });
319
+ }
320
+ if ((env.JEO_TMUX_PROFILE ?? env.JEO_TMUX_PROFILE) !== "0") {
321
+ commands.push(
322
+ {
323
+ description: "enable tmux clipboard integration",
324
+ args: ["set-option", "-t", t, "set-clipboard", "on"],
325
+ },
326
+ {
327
+ description: "make copy-mode selection readable",
328
+ args: ["set-window-option", "-t", t, "mode-style", "fg=colour231,bg=colour60"],
329
+ },
330
+ );
331
+ }
332
+ return commands;
333
+ }
334
+
335
+ /**
336
+ * A `process.stdout` view whose visible-output methods become no-ops while `gated()` is
337
+ * true. Used as readline's `output` so that, while the boxed slash-preview footer is armed,
338
+ * readline's OWN prompt/echo is suppressed and only our box is visible — no duplicated raw
339
+ * `jeo>` line. The previous approach monkeypatched `rl._writeToOutput`, a Node internal Bun
340
+ * does not expose (so on Bun both inputs showed at once). Gating the shared `output` stream
341
+ * works on both runtimes. Our footer is written straight to `process.stdout`, never through
342
+ * this proxy, so it always renders. Geometry/everything else is forwarded unchanged.
343
+ */
344
+ const GATED_OUTPUT_METHODS = new Set(["write", "cursorTo", "moveCursor", "clearLine", "clearScreenDown", "_write", "_writev"]);
345
+ export function gatedStdout(real: NodeJS.WriteStream, gated: () => boolean): NodeJS.WriteStream {
346
+ return new Proxy(real, {
347
+ get(target, prop, _receiver) {
348
+ if (typeof prop === "string" && GATED_OUTPUT_METHODS.has(prop)) {
349
+ return (...args: any[]) => {
350
+ if (gated()) {
351
+ const cb = args[args.length - 1]; // honor readline's write callback so it never stalls
352
+ if (typeof cb === "function") cb();
353
+ return true;
354
+ }
355
+ return (target as any)[prop](...args);
356
+ };
357
+ }
358
+ const value = Reflect.get(target, prop, target);
359
+ return typeof value === "function" ? value.bind(target) : value;
360
+ },
361
+ }) as unknown as NodeJS.WriteStream;
362
+ }
363
+
364
+ function firstOutputLine(output: string | undefined): string {
365
+ if (!output) return "";
366
+ const line = String(output)
367
+ .split("\n")
368
+ .map(l => l.trim())
369
+ .find(l => l.length > 0);
370
+ return line ? line.replace(/\s+/g, " ").slice(0, 140) : "";
371
+ }
372
+
373
+ function streamResultSuffix(tool: string, ok: boolean, output: string | undefined): string {
374
+ const summary = firstOutputLine(output);
375
+ if (!summary) return "";
376
+ if (!ok || tool === "task") return ` — ${summary}`;
377
+ return "";
378
+ }
379
+
380
+ export function formatTaskSubEvent(e: TaskSubEvent): string {
381
+ const role = e.role || "subagent";
382
+ const roleLabel = role.toUpperCase();
383
+ const detail = firstOutputLine(e.detail);
384
+ const summary = e.summary ? ` — ${e.summary}` : "";
385
+ // No ` step N/M` marker — step counters carry no meaning under the dynamic
386
+ // budget (user feedback); a tree prefix makes nested subagent activity scan as
387
+ // one readable branch in plain logs and TUI scrollback.
388
+ const badge = categoryBadge("subagent");
389
+ if (e.kind === "start") return `${badge} ${chalk.magenta(`▸ ${roleLabel}`)} · ${detail}`.slice(0, 240);
390
+ if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
391
+ if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
392
+ if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
393
+ return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
394
+ }
395
+
396
+ function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
397
+ log(formatTaskSubEvent(e));
398
+ }
399
+
400
+
401
+ /**
402
+ * Plain (non-TTY / `--no-tui`) progress sink — the cmd-mode equivalent of the live TUI, and
403
+ * the gjc-parity fix for "I typed a request but saw no steps/results". The old sink only
404
+ * logged tool RESULTS, so a turn that finished without a tool call (or before the first
405
+ * result) printed nothing but the final reply. This surfaces every STEP, the tool it is about
406
+ * to run (with the real file/command target via `summarizeForgeInvocation`), and each result —
407
+ * tracking the current step + pending invocation across the engine's
408
+ * onStep → onAssistant → onToolResult sequence.
409
+ */
410
+ export function createStreamEvents(
411
+ _maxSteps: number,
412
+ log: (line: string) => void = (s: string) => console.log(s),
413
+ now: () => number = Date.now,
414
+ ): AgentLoopEvents {
415
+ let pending = "";
416
+ let latestUsage: { inputTokens: number; outputTokens: number } | undefined;
417
+ const startTime = now();
418
+
419
+ return {
420
+ // Lazy header: nothing is printed until the tool call is known (onAssistant).
421
+ // A `done` / invalid reply therefore emits no progress line at all. The step
422
+ // NUMBER itself is never shown — meaningless under the dynamic budget.
423
+ onStep: () => {},
424
+ onAssistant: (_raw: string, invocation: { tool?: string; arguments?: unknown } | null) => {
425
+ const tool = typeof invocation?.tool === "string" ? invocation.tool.trim() : "";
426
+ if (!tool || tool === "done") return;
427
+ pending = summarizeForgeInvocation(tool, invocation?.arguments).title;
428
+ // gjc-style live status unit: step header + tool target + elapsed + token usage.
429
+ const elapsedMs = now() - startTime;
430
+ let suffix = "";
431
+ if (elapsedMs >= 1000) suffix += ` · ${formatDuration(elapsedMs)}`;
432
+ if (latestUsage) suffix += ` · ${formatUsage(latestUsage)}`;
433
+ log(`${categoryBadge("progress")} ${pending}${suffix ? chalk.dim(suffix) : ""}`);
434
+ },
435
+ onToolResult: (tool: string, ok: boolean, output?: string) => {
436
+ const label = pending || tool;
437
+ const mark = ok ? chalk.green("✓") : chalk.red("✗");
438
+ log(` ${categoryBadge(ok ? "done" : "error")} ${mark} ${label}${streamResultSuffix(tool, ok, output)}`);
439
+ pending = "";
440
+ },
441
+ onNotice: (msg: string) => log(` ${categoryBadge("progress")} ${chalk.yellow(msg)}`),
442
+ onBudget: (_limit: number, reason: string) => {
443
+ log(` ${categoryBadge("progress")} ${chalk.yellow(reason)}`);
444
+ },
445
+ onUsage: (usage: { inputTokens: number; outputTokens: number }) => {
446
+ latestUsage = usage;
447
+ },
448
+ };
449
+ }
450
+
451
+ export function shouldUseOneShotTui(noTui: boolean): boolean {
452
+ return LaunchTui.usable(noTui);
453
+ }
454
+
455
+ export interface InFlightAbortHarness {
456
+ controller: AbortController;
457
+ handleSigint(): void;
458
+ handleData(chunk: string | Uint8Array): void;
459
+ dispose(): void;
460
+ }
461
+
462
+ interface AbortHarnessOptions {
463
+ controller?: AbortController;
464
+ captureEsc?: boolean;
465
+ stdin?: {
466
+ isTTY?: boolean;
467
+ isRaw?: boolean;
468
+ setRawMode?(raw: boolean): void;
469
+ resume?(): void;
470
+ on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
471
+ off(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
472
+ };
473
+ onAbortNotice?: (message: string) => void;
474
+ onHardExit?: () => void;
475
+ /** Invoked when stray escape-sequence noise (wheel scroll etc.) arrives mid-turn. */
476
+ onNoise?: () => void;
477
+ /** Invoked when Ctrl+O (\u000f) is pressed mid-turn — the detail-view binding.
478
+ * Without this hook the byte would be swallowed into the buffered input queue,
479
+ * which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
480
+ onDetailKey?: () => void;
481
+ /** Invoked with printable keyboard input received while the live turn owns stdin. */
482
+ onBufferedInput?: (chunk: string) => void;
483
+ /** True while the input queue is inside a bracketed paste (mid-paste chunks
484
+ * carry no marker and must keep routing to the queue, not the noise path). */
485
+ pasteActive?: () => boolean;
486
+ }
487
+
488
+ /** Bracketed-paste markers (DECSET 2004): terminals wrap pasted text in these so
489
+ * an app can treat the paste as DATA instead of keystrokes — the prompt_toolkit
490
+ * paste contract. jeo enables the mode for the REPL TTY so a multi-line paste
491
+ * arrives atomically and executes one command per line, in order. */
492
+ export const PASTE_START = "\u001b[200~";
493
+ export const PASTE_END = "\u001b[201~";
494
+
495
+ export interface PromptInputQueue {
496
+ pendingLines: string[];
497
+ partial: string;
498
+ /** Complete lines that arrived inside a bracketed PASTE: intentional batch
499
+ * commands, served one per prompt in order. Never folded into the typed-line
500
+ * prefill (that contract is for keystrokes typed during a live turn). */
501
+ pastedLines: string[];
502
+ /** True while a bracketed paste spans chunks (between \x1b[200~ and \x1b[201~). */
503
+ inPaste: boolean;
504
+ }
505
+
506
+ /** Typed (non-paste) keystrokes: printable chars build the partial, Enter promotes
507
+ * it to pendingLines, backspace edits — ESC/ctrl noise segments are rejected. */
508
+ function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
509
+ if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
510
+ let accepted = false;
511
+ const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
512
+ for (const ch of Array.from(normalized)) {
513
+ if (ch === "\n") {
514
+ if (state.partial.length > 0) accepted = true;
515
+ state.pendingLines.push(state.partial);
516
+ state.partial = "";
517
+ } else if (ch === "\u007f" || ch === "\b") {
518
+ const chars = Array.from(state.partial);
519
+ chars.pop();
520
+ state.partial = chars.join("");
521
+ accepted = true;
522
+ } else if (ch === "\t" || ch >= " ") {
523
+ state.partial += ch;
524
+ accepted = true;
525
+ }
526
+ }
527
+ return accepted;
528
+ }
529
+
530
+ /** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
531
+ * partial stays editable, and control bytes (incl. any stray ESC from copied ANSI
532
+ * text) are dropped instead of being interpreted as keystrokes. */
533
+ function feedPasteBody(state: PromptInputQueue, body: string): boolean {
534
+ if (!body) return false;
535
+ let accepted = false;
536
+ const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
537
+ for (const ch of Array.from(normalized)) {
538
+ if (ch === "\n") {
539
+ state.pastedLines.push(state.partial);
540
+ state.partial = "";
541
+ accepted = true;
542
+ } else if (ch === "\t" || ch >= " ") {
543
+ state.partial += ch;
544
+ accepted = true;
545
+ }
546
+ }
547
+ return accepted;
548
+ }
549
+
550
+ export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
551
+ if (!chunk) return false;
552
+ let accepted = false;
553
+ let rest = chunk;
554
+ while (rest.length > 0) {
555
+ if (state.inPaste) {
556
+ const end = rest.indexOf(PASTE_END);
557
+ const body = end === -1 ? rest : rest.slice(0, end);
558
+ if (end !== -1) state.inPaste = false;
559
+ rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
560
+ if (feedPasteBody(state, body)) accepted = true;
561
+ } else {
562
+ const start = rest.indexOf(PASTE_START);
563
+ const plain = start === -1 ? rest : rest.slice(0, start);
564
+ if (start !== -1) state.inPaste = true;
565
+ rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
566
+ if (feedTypedSegment(state, plain)) accepted = true;
567
+ }
568
+ }
569
+ return accepted;
570
+ }
571
+
572
+ /** Live-turn prompt capture: printable input edits the SAME next-prompt line the
573
+ * idle footer will show after the turn finishes. Enter does NOT promote a hidden
574
+ * queue entry; it merely marks the current line as ready, so the existing input
575
+ * box stays the single source of truth and the user presses Enter once more at
576
+ * the real prompt to run it. */
577
+ function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolean {
578
+ if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
579
+ let accepted = false;
580
+ const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
581
+ for (const ch of Array.from(normalized)) {
582
+ if (ch === "\n") {
583
+ accepted = true;
584
+ } else if (ch === "\u007f" || ch === "\b") {
585
+ const chars = Array.from(state.partial);
586
+ chars.pop();
587
+ state.partial = chars.join("");
588
+ accepted = true;
589
+ } else if (ch === "\t" || ch >= " ") {
590
+ state.partial += ch;
591
+ accepted = true;
592
+ }
593
+ }
594
+ return accepted;
595
+ }
596
+
597
+ function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
598
+ if (!body) return false;
599
+ const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
600
+ const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
601
+ if (!flattened) return false;
602
+ state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
603
+ return true;
604
+ }
605
+
606
+ export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
607
+ if (!chunk) return false;
608
+ let accepted = false;
609
+ let rest = chunk;
610
+ while (rest.length > 0) {
611
+ if (state.inPaste) {
612
+ const end = rest.indexOf(PASTE_END);
613
+ const body = end === -1 ? rest : rest.slice(0, end);
614
+ if (end !== -1) state.inPaste = false;
615
+ rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
616
+ if (feedLivePromptPasteBody(state, body)) accepted = true;
617
+ } else {
618
+ const start = rest.indexOf(PASTE_START);
619
+ const plain = start === -1 ? rest : rest.slice(0, start);
620
+ if (start !== -1) state.inPaste = true;
621
+ rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
622
+ if (feedLivePromptSegment(state, plain)) accepted = true;
623
+ }
624
+ }
625
+ return accepted;
626
+ }
627
+
628
+ /**
629
+ * TTY "new input first" contract: fold any queued FULL lines (stray
630
+ * Enter-terminated buffer noise, or older persisted queues) into the editable
631
+ * prompt prefill instead of leaving them to auto-execute as the next prompt.
632
+ * Without this, stale queued lines ran BEFORE the user's fresh input — jeo
633
+ * appeared to "continue the previous work first". Returns the number of lines
634
+ * folded. Pure over the queue object — piped/non-TTY callers must NOT use this
635
+ * (scripted stdin relies on in-order line execution).
636
+ */
637
+ export function restoreQueuedLinesToPrefill(state: PromptInputQueue): number {
638
+ const lines = state.pendingLines.splice(0, state.pendingLines.length).map(l => l.trim()).filter(Boolean);
639
+ if (lines.length === 0) return 0;
640
+ const restored = lines.join(" ");
641
+ state.partial = state.partial ? `${restored} ${state.partial}`.trim() : restored;
642
+ return lines.length;
643
+ }
644
+
645
+ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFlightAbortHarness {
646
+ const controller = opts.controller ?? new AbortController();
647
+ const stdin = opts.stdin ?? process.stdin;
648
+ const captureEsc = opts.captureEsc === true && !!stdin.isTTY;
649
+ const wasRaw = !!stdin.isRaw;
650
+ let rawChanged = false;
651
+
652
+ const abortNow = (message: string) => {
653
+ if (controller.signal.aborted) return false;
654
+ opts.onAbortNotice?.(message);
655
+ controller.abort();
656
+ return true;
657
+ };
658
+
659
+ const handleSigint = () => {
660
+ // Ctrl+C is a hard terminal break. Older jeo softened the first press into
661
+ // "abort current run; press again to exit", which left users trapped in raw
662
+ // TTY/TUI states when they expected the terminal to stop. Abort the controller
663
+ // for cleanup observers, then invoke the hard-exit hook immediately.
664
+ if (!controller.signal.aborted) controller.abort();
665
+ opts.onHardExit?.();
666
+ };
667
+
668
+ const handleData = (chunk: string | Uint8Array) => {
669
+ if (!captureEsc || controller.signal.aborted) return;
670
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
671
+ // Bracketed paste is DATA, not keystrokes (the prompt_toolkit contract):
672
+ // marker-carrying and mid-paste chunks go straight to the input queue BEFORE
673
+ // any ESC interpretation — the markers themselves contain ESC, and a paste
674
+ // must never be classified as cancel/noise.
675
+ if (text.includes(PASTE_START) || opts.pasteActive?.()) {
676
+ opts.onBufferedInput?.(text);
677
+ return;
678
+ }
679
+ // Ctrl+O — detail view. Exact-match like the lone-ESC cancel: a raw \u000f
680
+ // keystroke arrives as its own chunk; embedded \u000f inside pasted/streamed
681
+ // data must NOT trigger the view.
682
+ if (text === "\u000f") {
683
+ opts.onDetailKey?.();
684
+ return;
685
+ }
686
+ const escAt = text.indexOf("\u001b");
687
+ const sigintAt = text.indexOf("\u0003");
688
+ const controlAt =
689
+ escAt === -1 ? sigintAt :
690
+ sigintAt === -1 ? escAt :
691
+ Math.min(escAt, sigintAt);
692
+ if (controlAt >= 0) {
693
+ const printablePrefix = text.slice(0, controlAt);
694
+ if (printablePrefix) opts.onBufferedInput?.(printablePrefix);
695
+ if (text[controlAt] === "\u0003") {
696
+ handleSigint();
697
+ return;
698
+ }
699
+ if (text === "\u001b") {
700
+ abortNow("ESC pressed — cancelling current run…");
701
+ return;
702
+ }
703
+ opts.onNoise?.();
704
+ return;
705
+ }
706
+ opts.onBufferedInput?.(text);
707
+ };
708
+
709
+ process.on("SIGINT", handleSigint);
710
+ if (captureEsc) {
711
+ stdin.on("data", handleData);
712
+ if (stdin.setRawMode && !wasRaw) {
713
+ stdin.setRawMode(true);
714
+ rawChanged = true;
715
+ }
716
+ stdin.resume?.();
717
+ }
718
+
719
+ return {
720
+ controller,
721
+ handleSigint,
722
+ handleData,
723
+ dispose() {
724
+ process.removeListener("SIGINT", handleSigint);
725
+ if (captureEsc) {
726
+ stdin.off("data", handleData);
727
+ if (rawChanged) stdin.setRawMode?.(false);
728
+ }
729
+ },
730
+ };
55
731
  }
56
732
 
57
- function parseFlags(args: string[]): LaunchFlags {
58
- const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 25, message: "", tmux: false };
733
+ /** The exact resume command printed on REPL exit (and testable in isolation)
734
+ * same convention as the `--list` handler's hint. */
735
+ export function formatResumeHint(sessionId: string): string {
736
+ return `Resume with: jeo launch --resume ${sessionId}`;
737
+ }
738
+ export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
739
+ // maxSteps 0 = dynamic: the engine's process-driven budget extends itself while the
740
+ // turn shows progress instead of stopping at a hardcoded count (old default: 100).
741
+ const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
59
742
  const rest: string[] = [];
60
743
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
61
744
  for (let i = 0; i < args.length; i++) {
62
745
  const a = args[i];
746
+ if (a === "--") {
747
+ rest.push(...args.slice(i + 1));
748
+ break;
749
+ }
63
750
  if (a === "--list") {
64
751
  flags.list = true;
752
+ } else if (a === "-p" || a === "--print") {
753
+ flags.print = true;
754
+ flags.noTui = true;
65
755
  } else if (a === "--tmux") {
66
756
  flags.tmux = true;
67
757
  } else if (a === "--worktree") {
@@ -85,28 +775,190 @@ function parseFlags(args: string[]): LaunchFlags {
85
775
  } else if (a.startsWith("--max-steps=")) {
86
776
  const n = parseInt(a.slice(12), 10);
87
777
  if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
88
- } else if (a === "--resume") {
778
+ } else if (a === "--model") {
779
+ const { value, nextIndex } = takeValue(args, i, "--model=");
780
+ if (value) flags.model = value;
781
+ else flags.errors.push("--model requires a value");
782
+ i = nextIndex;
783
+ } else if (a.startsWith("--model=")) {
784
+ const { value } = takeValue(args, i, "--model=");
785
+ if (value) flags.model = value;
786
+ else flags.errors.push("--model requires a value");
787
+ } else if (a === "--provider") {
788
+ const { value, nextIndex } = takeValue(args, i, "--provider=");
789
+ const normalized = value?.toLowerCase();
790
+ if (isProviderName(normalized)) flags.provider = normalized;
791
+ else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
792
+ i = nextIndex;
793
+ } else if (a.startsWith("--provider=")) {
794
+ const { value } = takeValue(args, i, "--provider=");
795
+ const normalized = value?.toLowerCase();
796
+ if (isProviderName(normalized)) flags.provider = normalized;
797
+ else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
798
+ } else if (a === "--thinking") {
799
+ const { value, nextIndex } = takeValue(args, i, "--thinking=");
800
+ const normalized = value?.toLowerCase();
801
+ if (isThinkingLevel(normalized)) flags.thinking = normalized;
802
+ else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
803
+ i = nextIndex;
804
+ } else if (a.startsWith("--thinking=")) {
805
+ const { value } = takeValue(args, i, "--thinking=");
806
+ const normalized = value?.toLowerCase();
807
+ if (isThinkingLevel(normalized)) flags.thinking = normalized;
808
+ else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
809
+ } else if (a === "--smol" || a === "--slow" || a === "--plan") {
810
+ flags.modelRole = a.slice(2) as ModelRole;
811
+ } else if (a === "--resume" || a === "--continue" || a === "-c") {
89
812
  flags.resume = true;
90
813
  const next = args[i + 1];
91
814
  if (next && UUID_REGEX.test(next)) {
92
815
  flags.resumeId = next;
93
816
  i++;
94
817
  }
95
- } else if (a.startsWith("--resume=")) {
818
+ } else if (a.startsWith("--resume=") || a.startsWith("--continue=") || a.startsWith("-c=")) {
96
819
  flags.resume = true;
97
- const val = a.slice(9);
820
+ const eqIdx = a.indexOf("=");
821
+ const val = a.slice(eqIdx + 1);
98
822
  if (UUID_REGEX.test(val)) {
99
823
  flags.resumeId = val;
100
824
  } else {
101
825
  rest.push(val);
102
826
  }
827
+ } else if (a === "--append-system-prompt") {
828
+ const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
829
+ if (value) {
830
+ flags.appendSystemPromptRaw = value;
831
+ } else {
832
+ flags.errors.push("--append-system-prompt requires a value");
833
+ }
834
+ i = nextIndex;
835
+ } else if (a.startsWith("--append-system-prompt=")) {
836
+ const { value } = takeValue(args, i, "--append-system-prompt=");
837
+ if (value) {
838
+ flags.appendSystemPromptRaw = value;
839
+ } else {
840
+ flags.errors.push("--append-system-prompt requires a value");
841
+ }
842
+ } else if (a === "--no-skills") {
843
+ flags.noSkills = true;
844
+ } else if (a === "--skills") {
845
+ const { value, nextIndex } = takeValue(args, i, "--skills=");
846
+ if (value) flags.skills = value;
847
+ else flags.errors.push("--skills requires a value");
848
+ i = nextIndex;
849
+ } else if (a.startsWith("--skills=")) {
850
+ const { value } = takeValue(args, i, "--skills=");
851
+ if (value) flags.skills = value;
852
+ else flags.errors.push("--skills requires a value");
853
+ } else if (a === "--no-tools") {
854
+ flags.noTools = true;
855
+ } else if (a === "--tools") {
856
+ const { value, nextIndex } = takeValue(args, i, "--tools=");
857
+ if (value) flags.tools = value;
858
+ else flags.errors.push("--tools requires a value");
859
+ i = nextIndex;
860
+ } else if (a.startsWith("--tools=")) {
861
+ const { value } = takeValue(args, i, "--tools=");
862
+ if (value) flags.tools = value;
863
+ else flags.errors.push("--tools requires a value");
864
+ } else if (a === "--system-prompt") {
865
+ const { value, nextIndex } = takeValue(args, i, "--system-prompt=");
866
+ if (value) flags.systemPromptRaw = value;
867
+ else flags.errors.push("--system-prompt requires a value");
868
+ i = nextIndex;
869
+ } else if (a.startsWith("--system-prompt=")) {
870
+ const { value } = takeValue(args, i, "--system-prompt=");
871
+ if (value) flags.systemPromptRaw = value;
872
+ else flags.errors.push("--system-prompt requires a value");
103
873
  } else {
104
874
  rest.push(a);
105
875
  }
106
876
  }
107
877
  flags.message = rest.join(" ").trim();
878
+
879
+ if (flags.print && !flags.message) {
880
+ flags.errors.push("-p/--print requires a message argument");
881
+ }
882
+
883
+ if (flags.appendSystemPromptRaw) {
884
+ if (flags.appendSystemPromptRaw.startsWith("@")) {
885
+ const filePath = flags.appendSystemPromptRaw.slice(1);
886
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
887
+ try {
888
+ flags.appendSystemPrompt = fs.readFileSync(absPath, "utf8");
889
+ } catch (err) {
890
+ flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
891
+ }
892
+ } else {
893
+ flags.appendSystemPrompt = flags.appendSystemPromptRaw;
894
+ }
895
+ }
896
+ if (flags.systemPromptRaw) {
897
+ if (flags.systemPromptRaw.startsWith("@")) {
898
+ const filePath = flags.systemPromptRaw.slice(1);
899
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
900
+ try {
901
+ flags.systemPrompt = fs.readFileSync(absPath, "utf8");
902
+ } catch (err) {
903
+ flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
904
+ }
905
+ } else {
906
+ flags.systemPrompt = flags.systemPromptRaw;
907
+ }
908
+ }
909
+
108
910
  return flags;
109
911
  }
912
+ export function matchSkillGlob(pattern: string, name: string): boolean {
913
+ const p = pattern.toLowerCase();
914
+ const n = name.toLowerCase();
915
+ if (!p.includes("*")) {
916
+ return p === n;
917
+ }
918
+ const escaped = p.replace(/[.+^${}()|[\]\\]/g, "\\$&");
919
+ const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
920
+ const regex = new RegExp(regexStr);
921
+ return regex.test(n);
922
+ }
923
+
924
+ export function filterToolMap(
925
+ tools: Record<string, any>,
926
+ allowlist: string[]
927
+ ): Record<string, any> {
928
+ const result: Record<string, any> = {};
929
+ for (const name of allowlist) {
930
+ if (name in tools) {
931
+ result[name] = tools[name];
932
+ }
933
+ }
934
+ return result;
935
+ }
936
+ export const TOOL_DESCRIPTIONS: Record<string, string> = {
937
+ read: "read {filePath, lineRange?, raw?} — read a file; lines are prefixed `LINEhh|` (hh = 2-char content anchor; the | is a separator, not file bytes)",
938
+ write: "write {filePath, content} — create/overwrite a file",
939
+ edit: "edit {filePath, editBlock} — ≔A..B replace lines (append read anchors for safety: ≔12ab..15cd — rejected with fresh content if the lines changed); ≔A+ insert after line A; ≔$ append EOF (payload on next line). NEVER copy the `LINEhh|` prefixes into SEARCH blocks or payloads",
940
+ bash: "bash {command, timeoutMs?, cwd?, env?} — run a shell command (cwd: subdir; env: extra vars)",
941
+ find: "find {globPattern} — find files by name",
942
+ search: "search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
943
+ ls: "ls {dirPath} — list a directory's entries (dirs first)",
944
+ };
945
+
946
+ export function buildToolProtocol(allowedTools: Set<string>): string {
947
+ const lines: string[] = ["You have these tools (call exactly ONE per step):"];
948
+ let num = 1;
949
+ for (const name of ["read", "write", "edit", "bash", "find", "search", "ls"]) {
950
+ if (allowedTools.has(name)) {
951
+ lines.push(`${num}. ${TOOL_DESCRIPTIONS[name]}`);
952
+ num++;
953
+ }
954
+ }
955
+ lines.push(`${num}. done {reason?} — call when the task is fully implemented AND verified`);
956
+ lines.push("");
957
+ lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
958
+ lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
959
+ lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
960
+ return lines.join("\n");
961
+ }
110
962
 
111
963
  /**
112
964
  * Resolve a git worktree path (gjc `--worktree <path>` parity). If the path
@@ -120,7 +972,7 @@ function resolveWorktree(cwd: string, wt: string): string {
120
972
  console.error("error: --worktree requires git on PATH");
121
973
  process.exit(1);
122
974
  }
123
- const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "joc-wt");
975
+ const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "jeo-wt");
124
976
  const withBranch = Bun.spawnSync(["git", "worktree", "add", "-b", branch, abs], {
125
977
  cwd,
126
978
  stdout: "pipe",
@@ -145,18 +997,52 @@ function resolveWorktree(cwd: string, wt: string): string {
145
997
 
146
998
  export async function runLaunchCommand(args: string[]): Promise<void> {
147
999
  let cwd = process.cwd();
148
- const flags = parseFlags(args);
1000
+ const flags = parseFlags(args, cwd);
1001
+ if (flags.errors.length) {
1002
+ for (const err of flags.errors) {
1003
+ console.error(`error: ${err}`);
1004
+ }
1005
+ process.exitCode = 1;
1006
+ return;
1007
+ }
149
1008
 
150
1009
  if (flags.worktree) {
151
1010
  const wt = resolveWorktree(cwd, flags.worktree);
152
1011
  if (wt !== cwd) {
153
1012
  process.chdir(wt);
154
1013
  cwd = wt;
155
- if (process.env.JOC_TMUX_LAUNCHED !== "1") console.log(`Using worktree: ${wt}`);
1014
+ if (jeoEnv("TMUX_LAUNCHED") !== "1") console.log(`Using worktree: ${wt}`);
156
1015
  }
157
1016
  }
1017
+ let branch: string | undefined;
1018
+ try {
1019
+ // Same git invocation the tmux session-naming path uses (symbolic-ref is
1020
+ // quiet + fails cleanly on detached HEAD, so no "HEAD" placeholder leaks).
1021
+ const gitRes = Bun.spawnSync(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], {
1022
+ cwd,
1023
+ stdout: "pipe",
1024
+ stderr: "ignore",
1025
+ });
1026
+ if (gitRes.exitCode === 0) {
1027
+ const out = gitRes.stdout.toString().trim();
1028
+ branch = out || undefined;
1029
+ }
1030
+ } catch {}
1031
+ const cfg = await readGlobalConfig();
1032
+ const defaultModel = cfg.defaultModel;
1033
+ const initialSessionModel =
1034
+ flags.model ??
1035
+ (flags.modelRole ? resolveRoleModel(flags.modelRole, cfg) : flags.provider ? PROVIDER_DEFAULT[flags.provider] : undefined);
1036
+ if (flags.provider && initialSessionModel) {
1037
+ const { provider } = await describeModel(initialSessionModel);
1038
+ if (provider !== flags.provider) {
1039
+ console.log(`error: selected model '${initialSessionModel}' resolves to ${provider}, not requested provider ${flags.provider}.`);
1040
+ return;
1041
+ }
1042
+ }
1043
+
158
1044
  if (flags.tmux) {
159
- if (!process.env.TMUX && process.env.JOC_TMUX_LAUNCHED !== "1") {
1045
+ if (!process.env.TMUX && jeoEnv("TMUX_LAUNCHED") !== "1") {
160
1046
  const tmuxBin = Bun.which("tmux");
161
1047
  if (tmuxBin) {
162
1048
  let branch = "";
@@ -170,7 +1056,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
170
1056
  branch = gitRes.stdout.toString().trim().replace(/[^a-zA-Z0-9_-]/g, "-");
171
1057
  }
172
1058
  } catch {}
173
- const sessionName = branch ? `joc-${branch}` : "joc-session";
1059
+ const sessionBase = tmuxSessionName(cwd, branch, flags);
174
1060
 
175
1061
  // Strip orchestration flags: the worktree is already the tmux session
176
1062
  // cwd (`-c cwd` below), so the inner process inherits it directly.
@@ -182,44 +1068,36 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
182
1068
  if (a.startsWith("--worktree=")) continue;
183
1069
  innerArgs.push(a);
184
1070
  }
185
- const entrypoint = process.argv[1] || "joc";
186
- const resolvedEntrypoint = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(cwd, entrypoint);
187
- let cmd: string[] = [];
188
- if (entrypoint.endsWith(".ts") || entrypoint.endsWith(".js") || entrypoint.endsWith(".mjs")) {
189
- cmd = [process.execPath, resolvedEntrypoint];
190
- } else {
191
- cmd = [resolvedEntrypoint];
192
- }
193
-
194
- const innerCmd = `exec env JOC_TMUX_LAUNCHED=1 ${[...cmd, "launch", ...innerArgs].map(a => `"${a.replace(/"/g, '\\"')}"`).join(" ")}`;
1071
+ const cmd = tmuxLaunchCommand(process.argv[1], process.execPath, cwd);
195
1072
 
196
- const hasSession = Bun.spawnSync([tmuxBin, "has-session", "-t", `=${sessionName}`]);
197
- if (hasSession.exitCode === 0) {
198
- console.log(`Attaching to existing tmux session: ${sessionName}`);
199
- const proc = Bun.spawn([tmuxBin, "attach-session", "-t", `=${sessionName}`], {
200
- stdin: "inherit",
201
- stdout: "inherit",
202
- stderr: "inherit",
203
- });
204
- await proc.exited;
205
- return;
206
- }
1073
+ const innerCmd = `exec env JEO_TMUX_LAUNCHED=1 ${[...cmd, "launch", ...innerArgs].map(shellQuote).join(" ")}`;
207
1074
 
208
- console.log(`Starting new tmux session: ${sessionName}`);
209
- const createSession = Bun.spawnSync([
210
- tmuxBin,
211
- "new-session",
212
- "-d",
213
- "-s",
214
- sessionName,
215
- "-c",
216
- cwd,
217
- innerCmd
218
- ]);
219
- if (createSession.exitCode !== 0) {
220
- console.error(`Error: Failed to create tmux session: ${createSession.stderr.toString()}`);
1075
+ // Create a fresh, independent session (race-safe: the create is the guard).
1076
+ const alloc = allocateTmuxSession(sessionBase, name => {
1077
+ const created = Bun.spawnSync([tmuxBin, "new-session", "-d", "-s", name, "-c", cwd, innerCmd]);
1078
+ if (created.exitCode === 0) return "ok";
1079
+ const err = created.stderr.toString().trim();
1080
+ if (/duplicate session/i.test(err)) return "taken"; // another jeo grabbed this name
1081
+ return `error:${err || `tmux new-session exited ${created.exitCode}`}`;
1082
+ });
1083
+ if ("error" in alloc) {
1084
+ console.error(`Error: Failed to create tmux session: ${alloc.error}`);
221
1085
  process.exit(1);
222
1086
  }
1087
+ const sessionName = alloc.name;
1088
+ // gjc-parity session profile (mouse / clipboard / copy-mode style / @jeo-*
1089
+ // markers): wheel-up enters copy-mode over the REAL pane history, so the
1090
+ // mid-turn scrollback (ledger lines flushed above the live frame) is
1091
+ // reachable with the mouse wheel. Session-scoped (`-t =name`, never -g).
1092
+ // Best-effort per command: an old tmux missing an option is fine.
1093
+ for (const profileCmd of tmuxProfileCommands(sessionName, process.env, { branch: branch || undefined, project: cwd })) {
1094
+ try { Bun.spawnSync([tmuxBin, ...profileCmd.args]); } catch { /* best-effort */ }
1095
+ }
1096
+ console.log(
1097
+ sessionName === sessionBase
1098
+ ? `Starting new tmux session: ${sessionName}`
1099
+ : `Starting new independent tmux session: ${sessionName} (another live jeo session already owns ${sessionBase}; reattach later with: tmux attach -t ${sessionName})`,
1100
+ );
223
1101
 
224
1102
  const attach = Bun.spawn([tmuxBin, "attach-session", "-t", `=${sessionName}`], {
225
1103
  stdin: "inherit",
@@ -231,54 +1109,161 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
231
1109
  } else {
232
1110
  console.warn("warning: tmux is not available on PATH. Launching directly...");
233
1111
  }
1112
+ } else if (shouldEnableCurrentTmuxMouse(process.env)) {
1113
+ // `jeo --tmux` INSIDE an existing tmux session: no new session is created, but
1114
+ // wheel scrolling still needs tmux mouse mode — without it tmux ignores the
1115
+ // wheel entirely, so the live-turn scrollback contract (ledger lines flushed
1116
+ // above the inline frame) is unreachable ("scroll doesn't work"). Session-
1117
+ // scoped (never -g), best-effort; JEO_TMUX_MOUSE=0 opts out.
1118
+ const tmuxBin = Bun.which("tmux");
1119
+ if (tmuxBin) {
1120
+ try { Bun.spawnSync([tmuxBin, "set-option", "mouse", "on"]); } catch { /* best-effort */ }
1121
+ }
234
1122
  }
235
1123
  }
236
1124
 
237
- const cfg = await readGlobalConfig();
238
- const defaultModel = cfg.defaultModel;
239
1125
 
240
1126
  // --list: print persisted sessions and exit.
241
1127
  if (flags.list) {
242
1128
  const sessions = await listSessions(cwd);
243
1129
  if (sessions.length === 0) {
244
- console.log("No saved sessions in .joc/sessions/.");
1130
+ console.log("No saved sessions in .jeo/sessions/.");
245
1131
  return;
246
1132
  }
247
1133
  console.log("Saved sessions (newest first):");
248
1134
  for (const s of sessions) {
249
1135
  console.log(` ${s.id} ${s.timestamp} (${s.messageCount} msgs) ${s.preview}`);
250
1136
  }
251
- console.log("\nResume with: joc launch --resume <id>");
1137
+ console.log("\nResume with: jeo launch --resume <id>");
252
1138
  return;
253
1139
  }
254
1140
 
255
- // pi-style: load project context (JEO.md / AGENTS.md / .joc/context.md / CLAUDE.md) into the prompt.
1141
+ // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
256
1142
  const contextFiles = await loadProjectContext(cwd);
1143
+
1144
+ const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo"]);
1145
+ let allowedTools = new Set(KNOWN_TOOLS);
1146
+
1147
+ if (flags.noTools) {
1148
+ allowedTools = new Set();
1149
+ } else if (flags.tools) {
1150
+ const list = flags.tools.split(",").map(t => t.trim()).filter(Boolean);
1151
+ const valid: string[] = [];
1152
+ for (const name of list) {
1153
+ if (KNOWN_TOOLS.has(name)) {
1154
+ valid.push(name);
1155
+ } else {
1156
+ console.error(`Warning: Unknown tool name ignored: ${name}`);
1157
+ }
1158
+ }
1159
+ allowedTools = new Set(valid);
1160
+ }
1161
+
1162
+ let resolvedSkills: SkillDoc[] = [];
1163
+ if (!flags.noSkills) {
1164
+ const loaded = await loadSkills(cwd);
1165
+ if (flags.skills) {
1166
+ const patterns = flags.skills.split(",").map(p => p.trim()).filter(Boolean);
1167
+ resolvedSkills = loaded.filter(s => patterns.some(p => matchSkillGlob(p, s.name)));
1168
+ } else {
1169
+ resolvedSkills = loaded;
1170
+ }
1171
+ }
1172
+
1173
+ const effectiveNoSkills = flags.noSkills || resolvedSkills.length === 0;
1174
+
1175
+ const workflowSkills = workflowSkillsForPrompt(resolvedSkills);
1176
+ const resolvedSkillNames = resolvedSkills.map(s => s.name);
1177
+ const skillSlashDetails: SlashCommandInfo[] = resolvedSkills.flatMap(skill =>
1178
+ skillSlashAliases(skill).map(alias => ({
1179
+ command: alias,
1180
+ usage: `${alias} [intent]`,
1181
+ description: `Run ${skill.name} skill${skill.summary ? ` — ${skill.summary}` : ""}`,
1182
+ group: "skills" as const,
1183
+ })),
1184
+ );
1185
+
1186
+ const protocol = buildToolProtocol(allowedTools);
1187
+ const preamble = flags.systemPrompt ?? "You are the jeo, an interactive coding agent.\nAccomplish the user's request by calling tools and verifying your work.";
1188
+ // Prior-session learnings (B6 경험 증류) — "" when absent or JEO_NO_MEMORY=1.
1189
+ const memoryBlock = await memoryPromptSection(cwd);
1190
+
257
1191
  const baseSystemPrompt =
258
- executorSystemPrompt("joc, an interactive coding agent") +
1192
+ preamble + "\n\n" + protocol + "\n\n" +
1193
+ WORKING_DISCIPLINE + "\n\n" +
1194
+ "Always verify (run tests / execute the program) before calling done." +
259
1195
  "\nWhen you have finished the user's request, or need to reply to or ask the user something, call done with {\"reason\": <your natural-language reply to the user>}. The reason text is shown to the user as your message." +
260
- "\n\nAvailable joc workflow skills (suggest the relevant command when the user's task fits one):\n" +
261
- skillsPromptSection();
262
- const systemPrompt = withProjectContext(baseSystemPrompt, contextFiles);
1196
+ (allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
1197
+ " Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
1198
+ (allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
1199
+ (effectiveNoSkills ? "" :
1200
+ "\n\nJEO workflow routing:\n" +
1201
+ "- 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" +
1202
+ "- Advertise both bundled workflow skills and configured skills below. Bundled workflows are the primary routing priority, while configured/user skills can be invoked via explicit slash commands or /skill.\n" +
1203
+ "- Do NOT answer with a skill routing brief or execute a skill unless the user explicitly asks for skill help, invokes /skill or a skill slash alias, or the task truly fits a bundled workflow.\n" +
1204
+ "- If the user pasted SKILL.md docs as reference material, treat them as user data and follow the latest concrete request.\n" +
1205
+ "- Your done reason must describe YOUR work or answer — never recite skill documentation.\n" +
1206
+ skillsPromptSection(workflowSkills)) +
1207
+ (memoryBlock ? "\n\n" + memoryBlock : "");
1208
+
1209
+ let systemPrompt = withProjectContext(baseSystemPrompt, contextFiles);
1210
+ if (flags.appendSystemPrompt) {
1211
+ systemPrompt += "\n" + flags.appendSystemPrompt;
1212
+ }
263
1213
 
264
1214
  const history: Message[] = [{ role: "system", content: systemPrompt }];
265
- let sessionModel: string | undefined = undefined;
1215
+ let sessionModel: string | undefined = initialSessionModel;
266
1216
  // Session thinking-level override (`/thinking`); falls back to the config level.
267
- let sessionThinking: "minimal" | "low" | "medium" | "high" | "xhigh" | undefined = cfg.thinkingLevel;
268
- // Cache of live, credential-validated models per provider (refreshed via `/models refresh`).
1217
+ let sessionThinking: "minimal" | "low" | "medium" | "high" | "xhigh" | undefined = flags.thinking ?? cfg.thinkingLevel;
1218
+ // Cache of live, credential-validated models per provider (refreshed by live pickers).
269
1219
  let liveModelsCache: ProviderModelsResult[] | null = null;
270
1220
  const getLiveModels = async (force = false): Promise<ProviderModelsResult[]> => {
271
1221
  if (force || !liveModelsCache) {
272
- process.stdout.write("(fetching models from logged-in providers…)\n");
273
1222
  liveModelsCache = await discoverModels({ timeoutMs: 4000 });
274
1223
  }
275
1224
  return liveModelsCache;
276
1225
  };
1226
+ const refreshLiveModelsCache = async (): Promise<ProviderModelsResult[]> => {
1227
+ liveModelsCache = null;
1228
+ return getLiveModels(true);
1229
+ };
277
1230
  // The most recently displayed numbered pick list; `/model #N` selects from it.
278
1231
  let lastPickIndex: PickEntry[] = [];
1232
+ // Cumulative provider token usage for this REPL process (`/usage`, gjc parity).
1233
+ const sessionUsage = { inputTokens: 0, outputTokens: 0, turns: 0 };
1234
+ // The last user request sent to the agent loop (`/retry`).
1235
+ let lastUserInput = "";
1236
+ // Full untruncated text of the last assistant reply — surfaced in detail by Ctrl+O.
1237
+ let lastReply = "";
1238
+ // Full untruncated output of the most recent tool call — the clipped forge
1239
+ // card's `⟦Ctrl+O for more⟧` hint resolves here.
1240
+ let lastToolDetail: { tool: string; output: string } | null = null;
1241
+ /** Wrap turn events so EVERY sink (TUI or plain stream) records the last full
1242
+ * tool output for the Ctrl+O detail view. */
1243
+ const withToolDetailCapture = (base: ReturnType<LaunchTui["events"]>): ReturnType<LaunchTui["events"]> => ({
1244
+ ...base,
1245
+ onToolResult: (tool, success, output) => {
1246
+ lastToolDetail = { tool, output };
1247
+ base.onToolResult?.(tool, success, output);
1248
+ },
1249
+ });
1250
+ /** The Ctrl+O detail block (shared by the prompt-time keypress handler and the
1251
+ * mid-turn TUI binding): full last reply + full last tool output. */
1252
+ const composeDetailLines = (): string[] => {
1253
+ if (!lastReply && !lastToolDetail) return [];
1254
+ const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
1255
+ const toolDetail = lastToolDetail
1256
+ ? [sep, `detail · full last tool output (${lastToolDetail.tool})`, sep, ...lastToolDetail.output.split("\n").slice(0, 2000).map(sanitizeForTerminal)]
1257
+ : [];
1258
+ const replyDetail = lastReply
1259
+ ? [sep, "detail · full last response (ctrl+o)", sep, ...renderMarkdownTables(lastReply).split("\n")]
1260
+ : [];
1261
+ return [...replyDetail, ...toolDetail, sep];
1262
+ };
279
1263
 
280
1264
  // pi-style session persistence: resume an existing session or create a new one.
281
1265
  let sessionId: string | undefined;
1266
+ let compactionSeq = 0;
282
1267
  if (!flags.noSession) {
283
1268
  if (flags.resume) {
284
1269
  const id = flags.resumeId ?? (await latestSessionId(cwd));
@@ -301,59 +1286,206 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
301
1286
  }
302
1287
  }
303
1288
 
304
- const streamEvents = {
305
- onToolResult: (tool: string, ok: boolean) => console.log(` └─ stream:${ok ? "complete" : "error"} tool ${tool}`),
306
- onError: (msg: string) => console.log(` └─ stream:error ${msg}`),
307
- };
1289
+ // `step N/M` display seed: the explicit --max-steps cap, else the dynamic budget's
1290
+ // rolling base the engine's onBudget event keeps the denominator honest as it grows.
1291
+ const initialStepLimit = flags.maxSteps > 0 ? flags.maxSteps : initialDynamicStepLimit();
1292
+ // Plain (non-TTY / --no-tui) progress sink — the cmd-mode equivalent of the live TUI.
1293
+ const streamEvents = createStreamEvents(initialStepLimit);
1294
+ let queueBusyInput: ((chunk: string) => boolean) | undefined;
1295
+ let queueBusyPasteActive: (() => boolean) | undefined;
1296
+ // Live snapshot of the busy-turn prompt draft — feeds the TUI's normal input
1297
+ // box during a running turn so typed text stays in the same query surface
1298
+ // instead of a separate queued row.
1299
+ let queueBusySnapshot: (() => { text: string }) | undefined;
1300
+ let interactiveTurnActive = false;
1301
+
308
1302
 
309
1303
  // Run one conversational turn: compact, persist user msg, run the loop, persist + return the reply.
310
1304
  // When `useTui`, a live TUI renders the turn and prints the final reply itself (rendered=true).
311
1305
  const runTurn = async (
312
1306
  userInput: string,
313
- useTui: boolean
1307
+ useTui: boolean,
1308
+ images?: ImageAttachment[]
314
1309
  ): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
315
- await maybeCompact(history, { model: sessionModel });
1310
+ const turnConfig = await readGlobalConfig();
1311
+ const activeModel = sessionModel || turnConfig.defaultModel;
1312
+ const contextTokens = catalogMetadata(activeModel)?.contextTokens;
1313
+
1314
+ const compRes = await maybeCompact(history, {
1315
+ model: sessionModel,
1316
+ contextTokens,
1317
+ });
1318
+
1319
+ if (compRes.error) {
1320
+ throw new Error(compRes.error);
1321
+ }
1322
+
1323
+ if (compRes.compacted && sessionId && compRes.replacesThrough !== undefined) {
1324
+ const touchedNote = compRes.touchedFiles?.length ? ` Files touched: ${compRes.touchedFiles.join(", ")}.` : "";
1325
+ const summaryText = compRes.summary ?? `[Earlier conversation omitted: ${compRes.removed} messages — summary unavailable.${touchedNote}]`;
1326
+ await appendCompaction(sessionId, ++compactionSeq, summaryText, compRes.replacesThrough, cwd);
1327
+ }
1328
+
316
1329
  const beforeLen = history.length;
317
- history.push({ role: "user", content: userInput });
1330
+ if (images?.length && catalogMetadata(activeModel)?.images === false) {
1331
+ console.log(`! ${activeModel} does not advertise image input — sending the attachment anyway.`);
1332
+ }
1333
+ history.push(images?.length ? { role: "user", content: userInput, images } : { role: "user", content: userInput });
318
1334
 
319
- const activeModel = sessionModel || defaultModel;
1335
+ // `turnConfig` was read before compaction so both the compactor and delegated
1336
+ // task tool see mid-session config changes (e.g. `/agents <role> <model>`).
320
1337
  const { provider: activeProvider } = await describeModel(activeModel);
321
- const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: flags.maxSteps }) : null;
322
- if (tui) tui.start();
1338
+ // Dirty count is recomputed at each turn start (gjc parity P1.B5: per-turn, not
1339
+ // per-render) so `?N` grows as the agent edits files; one spawn/turn, not per frame.
1340
+ const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
1341
+ const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: sessionThinking }) : null;
1342
+ tui?.setContextUsage(historyTokens(history), contextTokens);
1343
+ tui?.setTurnTitle(userInput); // gjc-parity turn title → HUD + tmux pane title (no LLM call)
323
1344
  let result;
324
- const ac = new AbortController();
325
- const onSigint = () => ac.abort();
326
- process.once("SIGINT", onSigint);
327
1345
  try {
328
- result = await runAgentLoop(history, {
329
- cwd,
330
- maxSteps: flags.maxSteps,
331
- model: sessionModel,
332
- maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
333
- signal: ac.signal,
334
- events: tui ? tui.events() : streamEvents,
1346
+ if (tui) {
1347
+ interactiveTurnActive = true;
1348
+ tui.start();
1349
+ }
1350
+ const harness = createInFlightAbortHarness({
1351
+ captureEsc: !!tui,
1352
+ onNoise: () => tui?.repaint(),
1353
+ pasteActive: () => queueBusyPasteActive?.() ?? false,
1354
+ // Ctrl+O mid-turn: flush the detail view into the TUI panel. Always
1355
+ // useful — even before the first reply/tool detail exists, the panel
1356
+ // shows the turn's timestamped recent-activity tail, so Ctrl+O answers
1357
+ // "what has been happening" instead of silently doing nothing.
1358
+ onDetailKey: () => {
1359
+ if (!tui) return;
1360
+ const lines = [...composeDetailLines()];
1361
+ const activity = tui.recentActivity(20);
1362
+ if (activity.length) {
1363
+ const sep = "─".repeat(40);
1364
+ lines.push(`activity · last ${activity.length} event(s) this turn`, sep, ...activity, sep);
1365
+ }
1366
+ if (lines.length === 0) return;
1367
+ tui.showDetail(lines);
1368
+ },
1369
+ onBufferedInput: chunk => {
1370
+ if (!tui) return;
1371
+ const captured = queueBusyInput?.(chunk) ?? false;
1372
+ // Keep the SAME query input box visible during a live turn. Printable
1373
+ // keystrokes edit the next prompt draft; Enter does not create a hidden
1374
+ // queue entry, so there is no separate "queued input" surface.
1375
+ if (captured) tui.setLivePromptInput(queueBusySnapshot?.().text ?? "");
1376
+ },
1377
+ onAbortNotice: msg => {
1378
+ if (tui) tui.events().onNotice?.(msg);
1379
+ else console.log(msg);
1380
+ },
1381
+ onHardExit: () => {
1382
+ if (tui) tui.finish("Cancelled.");
1383
+ process.exit(130);
1384
+ },
335
1385
  });
1386
+ const ac = harness.controller;
1387
+ try {
1388
+ // Per-turn todo snapshot: drives the done-time reconciliation gate (the
1389
+ // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
1390
+ // because nothing ever forced the model to update item statuses).
1391
+ let turnTodos: { title: string; status: string }[] = [];
1392
+ const onBeforeDone = (): string | null => {
1393
+ const unfinished = turnTodos.filter(t => t.status !== "done");
1394
+ if (turnTodos.length === 0 || unfinished.length === 0) return null;
1395
+ return (
1396
+ `Your todo list still shows ${unfinished.length} unfinished item(s): ${unfinished.map(t => `"${t.title}"`).join(", ")}. ` +
1397
+ `Reconcile the plan first — call the todo tool resending the FULL list with every actually-completed item marked "done" ` +
1398
+ `(drop items that no longer apply), then call done again.`
1399
+ );
1400
+ };
1401
+ const fullTools = {
1402
+ ...DEFAULT_TOOLS,
1403
+ task: createTaskTool({
1404
+ config: { ...turnConfig, defaultModel: activeModel },
1405
+ signal: ac.signal,
1406
+ onEvent: useTui
1407
+ ? (e => tui?.onSubagentEvent(e))
1408
+ : (e => logTaskSubEvent(e)),
1409
+ }),
1410
+ todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
1411
+ };
1412
+ const tools = filterToolMap(fullTools, Array.from(allowedTools));
1413
+ result = await runAgentLoop(history, {
1414
+ cwd,
1415
+ tools,
1416
+ maxSteps: flags.maxSteps,
1417
+ model: sessionModel,
1418
+ maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
1419
+ signal: ac.signal,
1420
+ events: { ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone },
1421
+ });
1422
+ if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
1423
+ history.push({
1424
+ role: "user",
1425
+ content:
1426
+ "Your previous reply was skill-document content, not an answer. Answer my actual request directly now — " +
1427
+ "use tools if needed, then call done with a concise reply in your own words. Do not quote skill docs.",
1428
+ });
1429
+ const retry = await runAgentLoop(history, {
1430
+ cwd,
1431
+ tools,
1432
+ maxSteps: Math.min(6, flags.maxSteps > 0 ? flags.maxSteps : 6),
1433
+ budget: { maxExtensions: 0 },
1434
+ model: sessionModel,
1435
+ maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
1436
+ signal: ac.signal,
1437
+ events: withToolDetailCapture(tui ? tui.events() : streamEvents),
1438
+ });
1439
+ const usage =
1440
+ result.usage && retry.usage
1441
+ ? {
1442
+ inputTokens: result.usage.inputTokens + retry.usage.inputTokens,
1443
+ outputTokens: result.usage.outputTokens + retry.usage.outputTokens,
1444
+ }
1445
+ : retry.usage ?? result.usage;
1446
+ result = { ...retry, steps: result.steps + retry.steps, usage };
1447
+ }
1448
+ } finally {
1449
+ harness.dispose();
1450
+ }
336
1451
  } catch (err) {
337
- if (tui) tui.finish(`! ${(err as Error).message}`);
1452
+ if (tui) {
1453
+ tui.finish(`! ${friendlyProviderError(err)}`);
1454
+ interactiveTurnActive = false;
1455
+ }
338
1456
  throw err;
339
- } finally {
340
- process.removeListener("SIGINT", onSigint);
341
1457
  }
342
- const reply = result.doneReason || `(reached the ${result.steps}-step limit without signaling done)`;
1458
+ // A completed turn with an empty done-reason must NOT masquerade as a step-limit
1459
+ // failure ("reached the 3-step limit" after the model called done at step 3).
1460
+ const reply = result.doneReason
1461
+ || (result.done
1462
+ ? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
1463
+ : `(reached the ${result.steps}-step limit without signaling done)`);
343
1464
  // Full-fidelity persistence: append every message the engine added this turn
344
1465
  // (user prompt + intermediate tool-call/tool-result turns), then the final reply.
345
- if (sessionId) {
346
- for (const m of history.slice(beforeLen)) await appendMessage(sessionId, m, cwd);
1466
+ try {
1467
+ if (sessionId) {
1468
+ // One batched fs append for the whole turn (was: one awaited append per message).
1469
+ await appendMessages(sessionId, history.slice(beforeLen), cwd);
1470
+ }
1471
+ history.push({ role: "assistant", content: reply });
1472
+ if (sessionId) await appendMessage(sessionId, { role: "assistant", content: reply }, cwd);
1473
+ if (tui) tui.finish(reply);
1474
+ } finally {
1475
+ if (tui) interactiveTurnActive = false;
1476
+ }
1477
+ if (result.usage) {
1478
+ sessionUsage.inputTokens += result.usage.inputTokens;
1479
+ sessionUsage.outputTokens += result.usage.outputTokens;
347
1480
  }
348
- history.push({ role: "assistant", content: reply });
349
- if (sessionId) await appendMessage(sessionId, { role: "assistant", content: reply }, cwd);
350
- if (tui) tui.finish(reply);
1481
+ sessionUsage.turns++;
351
1482
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
352
1483
  return { done: result.done, steps: result.steps, reply, rendered: !!tui, usage };
353
1484
  };
354
1485
 
1486
+
355
1487
  const joinedArgs = flags.message;
356
- const isOneShot = joinedArgs.length > 0 || !process.stdin.isTTY;
1488
+ const isOneShot = flags.print || joinedArgs.length > 0 || !process.stdin.isTTY;
357
1489
 
358
1490
  if (isOneShot) {
359
1491
  let messageContent = joinedArgs;
@@ -364,73 +1496,1408 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
364
1496
  console.log("No input provided.");
365
1497
  return;
366
1498
  }
367
- try {
368
- const { reply, usage } = await runTurn(messageContent, false);
369
- console.log(reply + usage);
370
- } catch (err) {
371
- console.log(`! ${(err as Error).message}`);
372
- }
373
- return;
374
- }
375
-
376
- // INTERACTIVE mode
377
- const welcomeStage = EVOLUTION_STAGES[0];
378
- await animateAsciiArt(welcomeStage);
379
- console.log(`\n=== joc launch interactive coding agent (Evolution Stage: ${welcomeStage.name}) ===`);
380
- const { provider: startProvider } = await describeModel(defaultModel);
381
- console.log(`Model: ${defaultModel} (${startProvider}) · thinking: ${sessionThinking ?? "medium"}`);
382
- if (sessionId) console.log(`Session: ${sessionId}`);
383
- if (contextFiles.length > 0) console.log(`Project context: ${contextFiles.map(f => f.path).join(", ")}`);
384
- console.log("Type your request. Slash: /help /model /models /provider /agents /config /thinking /view /diff /find /search /sessions /exit" + (LaunchTui.usable(flags.noTui) ? "" : " (plain output)"));
1499
+ // One-shot piped/`-p` input that is a control slash command must be handled
1500
+ // HERE never forwarded to the model. `echo "/clear" | jeo` (and Ctrl-D after
1501
+ // a piped command) previously sent the literal "/clear" to the LLM as a prompt:
1502
+ // a slow, flaky, and semantically wrong round-trip. These no-arg session/system
1503
+ // commands have a meaningful one-shot effect (or are simple no-ops) and exit.
1504
+ {
1505
+ const cmd = messageContent.trim();
1506
+ if (cmd === "/exit" || cmd === "/quit") {
1507
+ return;
1508
+ }
1509
+ if (cmd === "/clear" || cmd === "/new" || cmd === "/drop") {
1510
+ // Reset history to just the system prompt and overwrite the session file so
1511
+ // the persisted transcript matches (a fresh session for /new and /drop).
1512
+ history.length = 1;
1513
+ if (sessionId && !flags.noSession) {
1514
+ try {
1515
+ sessionId = (await createSession(cwd)).id;
1516
+ } catch { /* best-effort: in-memory clear already done */ }
1517
+ }
1518
+ console.log("(history cleared)");
1519
+ return;
1520
+ }
1521
+ if (cmd === "/" || cmd === "/?" || cmd === "/help") {
1522
+ for (const line of formatSlashCommandList("/", skillSlashDetails)) console.log(line);
1523
+ console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
1524
+ return;
1525
+ }
1526
+ }
1527
+ const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
1528
+ if (skillInvocation) {
1529
+ const isBundleWorkflow = ["deep-interview", "ralplan", "team", "ultragoal"].includes(skillInvocation.skill.name);
1530
+ if (isBundleWorkflow) {
1531
+ const startMsg: Message = {
1532
+ role: "system",
1533
+ content: `[workflow:${skillInvocation.skill.name}:start]${skillInvocation.intent ? ` intent: ${skillInvocation.intent}` : ""}`
1534
+ };
1535
+ history.push(startMsg);
1536
+ if (sessionId) {
1537
+ await appendMessage(sessionId, startMsg, cwd);
1538
+ }
1539
+
1540
+ const harness = createInFlightAbortHarness({
1541
+ captureEsc: false,
1542
+ onAbortNotice: msg => console.log(msg),
1543
+ onHardExit: () => process.exit(130),
1544
+ });
1545
+ const ac = harness.controller;
1546
+
1547
+ const opts = {
1548
+ cwd,
1549
+ signal: ac.signal,
1550
+ onProgress: (e: { skill: string; phase: string; detail?: string }) => console.log(`[workflow:${e.skill}] ${e.phase}${e.detail ? ` — ${e.detail}` : ""}`),
1551
+ io: {
1552
+ output: (line: string) => {
1553
+ console.log(line);
1554
+ }
1555
+ },
1556
+ args: skillInvocation.skill.name === "deep-interview" ? (skillInvocation.intent ? skillInvocation.intent.split(/\s+/) : []) : undefined
1557
+ };
1558
+
1559
+ let ok = false;
1560
+ let reason: string | undefined;
1561
+ try {
1562
+ let res: { ok: boolean; reason?: string };
1563
+ if (skillInvocation.skill.name === "deep-interview") {
1564
+ res = await runDeepInterviewEngine(opts);
1565
+ } else if (skillInvocation.skill.name === "ralplan") {
1566
+ res = await runRalplanEngine(opts);
1567
+ } else if (skillInvocation.skill.name === "team") {
1568
+ res = await runTeamEngine(opts);
1569
+ } else {
1570
+ res = await runUltragoalEngine(opts);
1571
+ }
1572
+ ok = res.ok;
1573
+ reason = res.reason;
1574
+ } catch (err: any) {
1575
+ ok = false;
1576
+ reason = err.message;
1577
+ } finally {
1578
+ harness.dispose();
1579
+ }
1580
+
1581
+ const endMsg: Message = {
1582
+ role: "system",
1583
+ content: ok
1584
+ ? `[workflow:${skillInvocation.skill.name}:finish]`
1585
+ : `[workflow:${skillInvocation.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
1586
+ };
1587
+ if (sessionId) {
1588
+ await appendMessage(sessionId, endMsg, cwd);
1589
+ }
1590
+ return;
1591
+ }
1592
+
1593
+ const useOneShotTui = shouldUseOneShotTui(flags.noTui);
1594
+ if (!useOneShotTui) {
1595
+ console.log(`▶ Running skill: ${skillInvocation.skill.name}${skillInvocation.intent ? ` — ${skillInvocation.intent}` : ""}`);
1596
+ }
1597
+ const task = buildSkillTask(skillInvocation.skill, skillInvocation.intent, skillInvocation.invokedAs);
1598
+ const { reply, rendered, usage } = await runTurn(task, useOneShotTui);
1599
+ if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
1600
+ else if (usage) console.log(usage.trim());
1601
+ return;
1602
+ }
1603
+ try {
1604
+ const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
1605
+ if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
1606
+ else if (usage) console.log(usage.trim());
1607
+ } catch (err) {
1608
+ console.log(`! ${friendlyProviderError(err)}`);
1609
+ }
1610
+ return;
1611
+ }
1612
+
1613
+ // INTERACTIVE mode
1614
+ const updatePromise = checkForUpdate({ timeoutMs: 2500 });
1615
+ // Terminal hygiene BEFORE anything renders: a previous program (or stale tmux
1616
+ // pane) can leave xterm mouse-tracking ON, so the terminal reports clicks and
1617
+ // motion as escape sequences from the very first prompt — the "starts out
1618
+ // mouse-clicked" corruption. jeo never enables these modes; resetting them is
1619
+ // a no-op on a clean terminal.
1620
+ if (process.stdout.isTTY) process.stdout.write(resetMouseTracking());
1621
+ const activeStartModel = sessionModel || defaultModel;
1622
+ const { provider: startProvider } = await describeModel(activeStartModel);
1623
+ const welcomeTheme = resolveTheme(process.env);
1624
+ const welcomeData = {
1625
+ version: pkg.version,
1626
+ model: activeStartModel,
1627
+ provider: startProvider,
1628
+ cwd: cwd || process.cwd(),
1629
+ thinking: sessionThinking ?? "medium",
1630
+ sessionId,
1631
+ contextFiles: contextFiles.map(f => f.path),
1632
+ cols: terminalSize().cols,
1633
+ unicode: supportsUnicode(),
1634
+ color: welcomeTheme.color,
1635
+ accent: accentPaint(welcomeTheme),
1636
+ accentShadow: accentShadowPaint(welcomeTheme),
1637
+ };
1638
+ // Launch sweep: the DNA Claw's gradient loops seamlessly (default 2 full
1639
+ // cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
1640
+ // Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
1641
+ const sweepable =
1642
+ !!process.stdout.isTTY &&
1643
+ welcomeTheme.color &&
1644
+ detectColorLevel(process.env, true) === ColorLevel.TrueColor &&
1645
+ jeoEnv("NO_WELCOME_ANIM") !== "1";
1646
+ const sweepCycles = Math.min(10, Math.max(1, Number(jeoEnv("WELCOME_ANIM_CYCLES")) || 2));
1647
+ if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
1648
+ else console.log(renderWelcome(welcomeData).join("\n"));
1649
+
1650
+ const upd = await Promise.race([updatePromise, new Promise<null>(r => setTimeout(() => r(null), 1200))]);
1651
+ if (upd?.updateAvailable) console.log(renderUpdateBox(upd.current, upd.latest).join("\n"));
1652
+ if (!LaunchTui.usable(flags.noTui)) console.log("(plain output)");
385
1653
 
386
1654
  const useTui = LaunchTui.usable(flags.noTui);
1655
+ const runSkillInvocation = async (skill: SkillDoc, intent: string, invokedAs?: string): Promise<void> => {
1656
+ // gjc-style invocation card: surface WHAT is being injected before the work
1657
+ // starts — skill name, resolved SKILL.md path, and the prompt size.
1658
+ {
1659
+ const card = formatForgeBox(
1660
+ { title: "[skill]", lines: skillInvocationCard(skill) },
1661
+ { width: Math.min(100, Math.max(40, (process.stdout.columns ?? 80) - 2)), unicode: supportsUnicode(), paint: accentPaint(uiTheme), paintShadow: accentShadowPaint(uiTheme), color: uiTheme.color },
1662
+ );
1663
+ logLines(card);
1664
+ }
1665
+ const isBundleWorkflow = ["deep-interview", "ralplan", "team", "ultragoal"].includes(skill.name);
1666
+ if (isBundleWorkflow) {
1667
+ const startMsg: Message = {
1668
+ role: "system",
1669
+ content: `[workflow:${skill.name}:start]${intent ? ` intent: ${intent}` : ""}`
1670
+ };
1671
+ history.push(startMsg);
1672
+ if (sessionId) {
1673
+ await appendMessage(sessionId, startMsg, cwd);
1674
+ }
1675
+
1676
+ const harness = createInFlightAbortHarness({
1677
+ captureEsc: false,
1678
+ onAbortNotice: msg => console.log(msg),
1679
+ onHardExit: () => process.exit(130),
1680
+ });
1681
+ const ac = harness.controller;
1682
+
1683
+ const opts = {
1684
+ cwd,
1685
+ signal: ac.signal,
1686
+ onProgress: (e: { skill: string; phase: string; detail?: string }) => console.log(`[workflow:${e.skill}] ${e.phase}${e.detail ? ` — ${e.detail}` : ""}`),
1687
+ io: {
1688
+ output: (line: string) => {
1689
+ console.log(line);
1690
+ },
1691
+ input: async () => {
1692
+ const wasPreviewArmed = previewArmed;
1693
+ if (wasPreviewArmed) {
1694
+ disarmPreview();
1695
+ previewArmed = false;
1696
+ }
1697
+ try {
1698
+ // EOF-safe prompt: a closed stdin yields "/exit" instead of a
1699
+ // never-settling question that would hang the workflow forever.
1700
+ return await promptInput("");
1701
+ } finally {
1702
+ if (wasPreviewArmed) {
1703
+ armPreview();
1704
+ drawFooter(previewLines(typedLine, navIdx));
1705
+ }
1706
+ }
1707
+ }
1708
+ },
1709
+ args: skill.name === "deep-interview" ? (intent ? intent.split(/\s+/) : []) : undefined
1710
+ };
1711
+
1712
+ let ok = false;
1713
+ let reason: string | undefined;
1714
+ try {
1715
+ let res: { ok: boolean; reason?: string };
1716
+ if (skill.name === "deep-interview") {
1717
+ res = await runDeepInterviewEngine(opts);
1718
+ } else if (skill.name === "ralplan") {
1719
+ res = await runRalplanEngine(opts);
1720
+ } else if (skill.name === "team") {
1721
+ res = await runTeamEngine(opts);
1722
+ } else {
1723
+ res = await runUltragoalEngine(opts);
1724
+ }
1725
+ ok = res.ok;
1726
+ reason = res.reason;
1727
+ } catch (err: any) {
1728
+ ok = false;
1729
+ reason = err.message;
1730
+ } finally {
1731
+ harness.dispose();
1732
+ }
1733
+
1734
+ const endMsg: Message = {
1735
+ role: "system",
1736
+ content: ok
1737
+ ? `[workflow:${skill.name}:finish]`
1738
+ : `[workflow:${skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
1739
+ };
1740
+ history.push(endMsg);
1741
+ if (sessionId) {
1742
+ await appendMessage(sessionId, endMsg, cwd);
1743
+ }
1744
+ } else {
1745
+ // Drive the agent loop to EXECUTE the skill (don't just dump the doc). A concise
1746
+ // banner replaces the old full-doc print; the live TUI shows progress, and the
1747
+ // final reply is the skill's result.
1748
+ if (!useTui) console.log(`▶ Running skill: ${skill.name}${intent ? ` — ${intent}` : ""}`);
1749
+ const task = buildSkillTask(skill, intent, invokedAs);
1750
+ const { reply, rendered, usage } = await runTurn(task, useTui);
1751
+ if (!rendered) console.log(`jeo> ${stripMarkdown(renderMarkdownTables(reply))}${usage}`);
1752
+ else if (usage) console.log(usage.trim());
1753
+ }
1754
+ };
1755
+
387
1756
  // Tab autocomplete: alias names snapshotted once; live models come from the
388
1757
  // background-warmed cache (logged-in/OAuth accounts). The completer is sync, so
389
1758
  // it never blocks on the network — it reads whatever the cache currently holds.
390
1759
  const aliasNames = Object.keys(await listAliases());
391
- void discoverModels({ timeoutMs: 4000 })
1760
+ void getLiveModels()
392
1761
  .then(r => {
393
1762
  liveModelsCache ??= r;
394
1763
  })
395
1764
  .catch(() => {});
396
- const completionContext = (): CompletionContext => ({
397
- ...staticCompletionContext(),
398
- liveModels: liveModelsCache ? flattenModels(liveModelsCache).map(e => e.model) : [],
399
- aliases: aliasNames,
400
- modelsForProvider: p => liveModelsCache?.find(r => r.provider === p)?.models ?? [],
401
- });
1765
+ const mentionPaths = (prefix: string): string[] => {
1766
+ const norm = prefix.replace(/\\/g, "/");
1767
+ const wantsDirChildren = norm.endsWith("/");
1768
+ const dirPart = wantsDirChildren ? norm.slice(0, -1) : path.posix.dirname(norm) === "." ? "" : path.posix.dirname(norm);
1769
+ const namePart = wantsDirChildren ? "" : path.posix.basename(norm);
1770
+ const absDir = path.resolve(cwd, dirPart || ".");
1771
+ let entries: fs.Dirent[] = [];
1772
+ try {
1773
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
1774
+ } catch {
1775
+ return [];
1776
+ }
1777
+ return entries
1778
+ .filter(entry => !entry.name.startsWith("."))
1779
+ .filter(entry => !namePart || entry.name.toLowerCase().startsWith(namePart.toLowerCase()))
1780
+ .sort((a, b) => {
1781
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
1782
+ return a.name.localeCompare(b.name);
1783
+ })
1784
+ .slice(0, 50)
1785
+ .map(entry => {
1786
+ const rel = dirPart ? `${dirPart}/${entry.name}` : entry.name;
1787
+ return entry.isDirectory() ? `${rel}/` : rel;
1788
+ });
1789
+ };
1790
+ const completionContext = (): CompletionContext => {
1791
+ const base = staticCompletionContext();
1792
+ return {
1793
+ ...base,
1794
+ slashCommands: [...base.slashCommands, ...skillSlashDetails.map(d => d.command)],
1795
+ liveModels: liveModelsCache ? flattenModels(liveModelsCache).map(e => e.model) : [],
1796
+ aliases: aliasNames,
1797
+ skillNames: resolvedSkillNames,
1798
+ modelsForProvider: p => liveModelsCache?.find(r => r.provider === p)?.models ?? [],
1799
+ mentionPaths,
1800
+ };
1801
+ };
1802
+ let previewArmed = false;
1803
+ let pickerActive = false;
402
1804
  const rl = createInterface({
403
1805
  input: process.stdin,
404
- output: process.stdout,
1806
+ // Single-box input: gate readline's output while the boxed footer is armed so its own
1807
+ // `jeo>` prompt/echo is suppressed and ONLY our box shows. (Bun exposes no
1808
+ // `_writeToOutput` to patch, so gating the shared output stream is the portable fix.)
1809
+ // The gate also covers active select pickers: they disarm the preview, which
1810
+ // previously OPENED the gate and let readline echo typed filter characters
1811
+ // (CJK wide chars especially) straight onto the picker frame — the
1812
+ // "stacked input-box borders" corruption.
1813
+ output: gatedStdout(process.stdout, () => previewArmed || pickerActive || interactiveTurnActive),
405
1814
  completer: (line: string) => readlineCompleter(line, completionContext()),
406
1815
  });
1816
+ const promptStdin = process.stdin as typeof process.stdin & { isRaw?: boolean; setRawMode?(raw: boolean): void };
1817
+ const promptWasRaw = !!promptStdin.isRaw;
1818
+ let promptRawChanged = false;
1819
+ const restorePromptRawMode = () => {
1820
+ if (!promptRawChanged) return;
1821
+ try { promptStdin.setRawMode?.(false); } catch { /* terminal gone */ }
1822
+ promptRawChanged = false;
1823
+ };
1824
+ if (promptStdin.isTTY && promptStdin.setRawMode && !promptWasRaw) {
1825
+ promptStdin.setRawMode(true);
1826
+ promptRawChanged = true;
1827
+ }
1828
+ process.once("exit", restorePromptRawMode);
1829
+ // Stdin EOF must END the REPL, not hang it: under Bun a pending `rl.question`
1830
+ // NEVER settles once the input stream closes (Ctrl-D, exhausted pipe) — the
1831
+ // while(true) prompt loop then waits forever (the "jeo never exits" hang).
1832
+ // Bun's readline also DROPS piped lines that arrive between prompts (question()
1833
+ // only captures the line submitted while it is registered; orphan lines emit
1834
+ // 'line' instead), so queue those and serve them before prompting again.
1835
+ const pendingStdinLines: string[] = [];
1836
+ const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: "", pastedLines: [], inPaste: false };
1837
+ queueBusyInput = (chunk: string) => captureLivePromptInputChunk(queuedPromptInput, chunk);
1838
+ queueBusyPasteActive = () => queuedPromptInput.inPaste;
1839
+ queueBusySnapshot = () => ({
1840
+ text: queuedPromptInput.partial,
1841
+ });
1842
+ // Bracketed-paste line routing at the PROMPT: readline strips the 2004 markers
1843
+ // and replays pasted lines as synthetic keypresses, emitting paste-start /
1844
+ // paste-end around them. Lines submitted INSIDE that window are intentional
1845
+ // batch commands → pastedLines (one command per prompt, in order). Lines
1846
+ // outside it keep the existing contracts (typed-line prefill fold on a TTY,
1847
+ // in-order auto-serve for piped stdin).
1848
+ let promptPasteActive = false;
1849
+ if (process.stdin.isTTY) {
1850
+ process.stdin.on("keypress", (_ch: string, key: { name?: string } | undefined) => {
1851
+ if (key?.name === "paste-start") promptPasteActive = true;
1852
+ else if (key?.name === "paste-end") promptPasteActive = false;
1853
+ });
1854
+ // Enable bracketed paste for the REPL lifetime (restored on exit below):
1855
+ // terminals only wrap pastes in the 200~/201~ markers once the app opts in.
1856
+ process.stdout.write("\x1b[?2004h");
1857
+ process.once("exit", () => { try { process.stdout.write("\x1b[?2004l"); } catch { /* terminal gone */ } });
1858
+ }
1859
+ rl.on("line", l => {
1860
+ if (promptPasteActive) {
1861
+ queuedPromptInput.pastedLines.push(l);
1862
+ return;
1863
+ }
1864
+ if (!interactiveTurnActive) pendingStdinLines.push(l);
1865
+ });
1866
+ let stdinClosed = false;
1867
+ let notifyStdinClosed: (() => void) | undefined;
1868
+ let gracefulReadlineClose = false;
1869
+ let hardExitOnLoopEnd = false;
1870
+ // `on` + one-shot guard (not `once`): test harnesses stub readline with `on`/`question` only.
1871
+ rl.on("close", () => {
1872
+ if (stdinClosed) return;
1873
+ // Bun/readline can turn Ctrl+C into a bare close event without SIGINT/key
1874
+ // delivery. In an interactive terminal, an unexpected close is therefore a
1875
+ // hard break, not a graceful `/exit`. Some Bun/tmux paths leave stdin.isTTY
1876
+ // false while stdout is still a TTY, so accept either side as interactive.
1877
+ if ((process.stdin.isTTY || process.stdout.isTTY || previewEnabled) && !gracefulReadlineClose) {
1878
+ hardExitOnLoopEnd = true;
1879
+ forceExitFromCtrlC();
1880
+ }
1881
+ stdinClosed = true;
1882
+ notifyStdinClosed?.();
1883
+ });
1884
+ /** `rl.question` that resolves "/exit" on stdin EOF instead of hanging forever. */
1885
+ let promptServedFromPaste = false;
1886
+ const promptInput = async (prompt: string): Promise<string> => {
1887
+ promptServedFromPaste = false;
1888
+ // Pasted batch commands first — they execute one per prompt, in order.
1889
+ const pasted = queuedPromptInput.pastedLines.shift();
1890
+ if (pasted !== undefined) {
1891
+ promptServedFromPaste = true;
1892
+ return pasted;
1893
+ }
1894
+ const queued = pendingStdinLines.shift();
1895
+ if (queued !== undefined) return queued;
1896
+ if (stdinClosed) {
1897
+ if (hardExitOnLoopEnd || process.stdin.isTTY || process.stdout.isTTY) forceExitFromCtrlC();
1898
+ return "/exit";
1899
+ }
1900
+ try {
1901
+ return await Promise.race([
1902
+ rl.question(prompt),
1903
+ new Promise<string>(resolve => {
1904
+ notifyStdinClosed = () => {
1905
+ if (hardExitOnLoopEnd || process.stdin.isTTY || process.stdout.isTTY) forceExitFromCtrlC();
1906
+ resolve(pendingStdinLines.shift() ?? "/exit");
1907
+ };
1908
+ }),
1909
+ ]);
1910
+ } finally {
1911
+ notifyStdinClosed = undefined;
1912
+ }
1913
+ };
407
1914
 
408
- try {
409
- while (true) {
410
- const input = (await rl.question("\njoc> ")).trim();
1915
+ // Mouse-wheel scroll during a live turn (tmux or plain terminal) can inject
1916
+ // arrow/scroll escape sequences into stdin; readline buffers them into its
1917
+ // pending line and the NEXT prompt then shows/executes garbage. Drain tty input
1918
+ // before each prompt, but preserve printable text typed while a live turn was
1919
+ // still running so fast follow-up prompts (including Korean/CJK text) are not
1920
+ // silently eaten.
1921
+ const drainPendingTtyInput = (): void => {
1922
+ if (!process.stdin.isTTY) return;
1923
+ try {
1924
+ let chunk: unknown;
1925
+ while ((chunk = process.stdin.read()) !== null) {
1926
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString("utf8");
1927
+ queuePromptInputChunk(queuedPromptInput, text);
1928
+ }
1929
+ } catch { /* stream not readable in this state — nothing buffered */ }
1930
+ const r = rl as unknown as { line?: string; cursor?: number };
1931
+ if (typeof r.line === "string" && r.line.length > 0 && /\x1b|\[[ABCD]/.test(r.line)) {
1932
+ r.line = "";
1933
+ r.cursor = 0;
1934
+ }
1935
+ };
1936
+
1937
+ // Live slash preview pinned to a reserved bottom footer via a DEC scroll region
1938
+ // (DECSTBM). The region is armed ONLY while waiting for input, and disarmed for
1939
+ // turns/command output so the full-screen turn TUI renders normally. The footer
1940
+ // is drawn at absolute rows (per-row clear → no scroll, no duplication).
1941
+ // Opt out with JEO_NO_SLASH_PREVIEW=1; auto-off on short terminals.
1942
+ const currentAtLabel = (line: string): string | undefined => {
1943
+ const { tokens } = tokenize(line);
1944
+ const token = [...tokens].reverse().find(t => t.startsWith("@"));
1945
+ if (!token) return undefined;
1946
+ const norm = token.slice(1).replace(/\\/g, "/");
1947
+ if (!norm) return "@ .";
1948
+ if (norm.endsWith("/")) return `@ ${norm.slice(0, -1) || "."}`;
1949
+ const dir = path.posix.dirname(norm);
1950
+ return `@ ${dir === "." ? norm : dir}`;
1951
+ };
1952
+ // Boxed-input footer height — ADAPTIVE so short terminals/panes still get the single
1953
+ // boxed input instead of silently falling back to the raw `jeo>` prompt (previously
1954
+ // any terminal under 17 rows lost the box entirely and showed bare CLI input).
1955
+ const MAX_PREVIEW_ROWS = 12;
1956
+ const MIN_PREVIEW_ROWS = 7; // status bar (1) + spacer (1) + input box (3 rows) + 2 preview rows
1957
+ const previewRowsFor = (rows: number): number => Math.max(MIN_PREVIEW_ROWS, Math.min(MAX_PREVIEW_ROWS, rows - 6));
1958
+ const previewEnabled =
1959
+ process.stdin.isTTY &&
1960
+ jeoEnv("NO_SLASH_PREVIEW") !== "1" &&
1961
+ (process.stdout.rows ?? 24) >= MIN_PREVIEW_ROWS + 6; // box + ≥6 scrollable content rows
1962
+ // Footer height reserved by the CURRENTLY armed region; disarm/draw must use the
1963
+ // same value the arm computed, even if the terminal was resized in between.
1964
+ let footerRows = MAX_PREVIEW_ROWS;
1965
+ const out = process.stdout;
1966
+ // Arrow-key selection over the slash preview list.
1967
+ let navMatches: string[] = []; // command names matching the typed keyword (display order)
1968
+ let navIdx = -1; // highlighted row, -1 = none
1969
+ let typedLine = ""; // the user-typed line (restored after readline's history nav)
1970
+ let pendingSelection: string | undefined; // command chosen via arrows, applied on Enter
1971
+ let pendingImages: ImageAttachment[] = []; // clipboard images attached to the next message (ctrl+v)
1972
+ let pasteInFlight = false; // guard concurrent ctrl+v clipboard reads
1973
+ let idleDirtyCount: number | undefined; // git dirty count refreshed once per prompt
1974
+ let lastFooterKey = "";
1975
+ const logLines = (lines: string | string[]) => {
1976
+ const arr = Array.isArray(lines) ? lines : [lines];
1977
+ const cols = Math.max(20, (process.stdout.columns ?? 80) - 1);
1978
+ for (const line of arr) {
1979
+ console.log(truncateAnsi(line, cols));
1980
+ }
1981
+ };
1982
+ let previewPending = false;
1983
+ let promptHistoryLines: string[] | null = null;
1984
+
1985
+ // Inline boxed-footer rendering with a FIXED reservation (the "@-mention typing
1986
+ // pushes the box down" fix). The footer reserves its full `footerRows` height
1987
+ // eagerly on arm (one-time scroll cost), and every redraw paints inside that
1988
+ // reservation with CUD (cursor-down) only — never `\n`. The old grow path emitted
1989
+ // `\n` whenever lines.length > footerRendered, and `\n` at the bottom margin
1990
+ // SCROLLS the terminal: every keystroke that wrapped the input box body or grew
1991
+ // the `Paths:` preview ate a row of prior output and misaligned the next repaint.
1992
+ // With a fixed reservation, footer height is constant for the lifetime of the
1993
+ // prompt, so the box can never grow, scroll, or break alignment.
1994
+ let footerRendered = 0; // rows of the reserved region (= footerRows once armed)
1995
+ // Caret cell of the boxed input (row relative to the reservation top, 1-based col),
1996
+ // recomputed by previewLines from readline's live rl.cursor. drawFooter parks the
1997
+ // REAL terminal cursor there, so the blinking caret sits right after the `>` prompt
1998
+ // and visibly follows arrow-key movement.
1999
+ let footerCursor = { row: 0, col: 1 };
2000
+ // Row (within the reservation) where the real cursor was last parked; the next
2001
+ // drawFooter/disarmPreview must hop back to the top from here before painting.
2002
+ let footerParkedRow = 0;
2003
+ const padToFooter = (lines: string[]): string[] => {
2004
+ if (lines.length >= footerRows) return lines.slice(0, footerRows);
2005
+ return [...lines, ...new Array(footerRows - lines.length).fill("")];
2006
+ };
2007
+ const armPreview = () => {
2008
+ if (!previewEnabled || previewArmed) return;
2009
+ footerRows = previewRowsFor(process.stdout.rows ?? 24);
2010
+ // Reserve `footerRows` bottom rows: write blank newlines (the terminal scrolls
2011
+ // ONCE here, not on every keystroke), then park the cursor at the top of the
2012
+ // reservation. Every subsequent drawFooter call stays inside this region.
2013
+ if (footerRows > 1) {
2014
+ out.write("\n".repeat(footerRows - 1) + cursorUp(footerRows - 1));
2015
+ }
2016
+ out.write(toColumn(1));
2017
+ footerRendered = footerRows;
2018
+ footerParkedRow = 0;
2019
+ previewArmed = true;
2020
+ lastFooterKey = "";
2021
+ };
2022
+ // Clear the reserved region and park the cursor at its top row so subsequent
2023
+ // command output starts where the box was (and inherits the existing scrollback).
2024
+ const disarmPreview = () => {
2025
+ if (!previewArmed) return;
2026
+ previewArmed = false;
2027
+ lastFooterKey = "";
2028
+ if (footerRendered > 0) {
2029
+ // Hop back to the reservation top from wherever the caret was parked.
2030
+ let s = footerParkedRow > 0 ? cursorUp(footerParkedRow) : "";
2031
+ footerParkedRow = 0;
2032
+ for (let i = 0; i < footerRendered; i++) {
2033
+ s += toColumn(1) + clearLine();
2034
+ if (i < footerRendered - 1) s += "\x1b[1B"; // CUD: no scroll at bottom margin
2035
+ }
2036
+ if (footerRendered > 1) s += cursorUp(footerRendered - 1);
2037
+ s += toColumn(1) + "\x1b[?25h";
2038
+ out.write(s);
2039
+ footerRendered = 0;
2040
+ } else {
2041
+ out.write("\x1b[?25h");
2042
+ }
2043
+ };
2044
+ // KEYSTROKE-HOT theme handle: `previewLines`/`statusBarLine` run on EVERY
2045
+ // keypress, and an uncached `resolveTheme` walks config-file I/O and (on
2046
+ // macOS without a configured theme) an execSync `defaults read` appearance
2047
+ // probe ≈ 12ms/call — ×3 calls/key was the visible typing delay. Resolve
2048
+ // once, refresh ONLY when `/theme` changes the env.
2049
+ let uiTheme = resolveTheme(process.env);
2050
+ let uiAccent = accentPaint(uiTheme);
2051
+ let uiAccentShadow = accentShadowPaint(uiTheme);
2052
+ const refreshUiTheme = (): void => {
2053
+ uiTheme = resolveTheme(process.env);
2054
+ uiAccent = accentPaint(uiTheme);
2055
+ uiAccentShadow = accentShadowPaint(uiTheme);
2056
+ };
2057
+ // The gjc-layout status bar pinned directly ABOVE the input box: bg-gradient
2058
+ // identity block (model · thinking / branch / cwd) left, live ctx% right.
2059
+ const statusBarLine = (cols: number): string => {
2060
+ const activeModel = sessionModel || defaultModel;
2061
+ const meta = catalogMetadata(activeModel);
2062
+ const used = historyTokens(history);
2063
+ const theme = uiTheme;
2064
+ return renderStatusBar({
2065
+ model: activeModel,
2066
+ thinking: sessionThinking,
2067
+ branch,
2068
+ dirtyCount: idleDirtyCount,
2069
+ cwd,
2070
+ ctxPct: meta?.contextTokens ? (used / meta.contextTokens) * 100 : undefined,
2071
+ ctxMaxTokens: meta?.contextTokens,
2072
+ cols,
2073
+ unicode: true,
2074
+ color: theme.color,
2075
+ colorLevel: detectColorLevel(process.env, true),
2076
+ gradient: themeGradient(theme, 2),
2077
+ });
2078
+ };
2079
+ const previewLines = (line: string, selected = -1): string[] => {
2080
+ const cols = Math.max(24, (process.stdout.columns ?? 80) - 1);
2081
+ // Caret offset comes from readline's live cursor when it matches the rendered
2082
+ // line (arrow keys/Home/End move it); otherwise (history nav mismatch) caret
2083
+ // sits at the end of the text.
2084
+ const rli = rl as unknown as { line?: string; cursor?: number };
2085
+ const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
2086
+ const frame = renderInputFrame(line, {
2087
+ cols,
2088
+ color: true,
2089
+ unicode: true,
2090
+ accent: uiAccent,
2091
+ accentShadow: uiAccentShadow,
2092
+ cwdLabel: currentAtLabel(line),
2093
+ attachmentLabel: pendingImages.length
2094
+ ? `⧉ ${pendingImages.length} image${pendingImages.length > 1 ? "s" : ""} attached — sent with the next message`
2095
+ : undefined,
2096
+ maxBodyRows: Math.max(1, footerRows - 7),
2097
+ cursor: caret,
2098
+ });
2099
+ const input = frame.lines.map(l => truncateAnsi(l, cols));
2100
+ // jeo-ref layout: a blank spacer row between the status bar (row 0) and the
2101
+ // input box, so the box breathes instead of gluing to the bar — the caret
2102
+ // (and everything below) therefore shifts down TWO rows.
2103
+ footerCursor = {
2104
+ row: Math.max(0, Math.min(frame.cursorRow + 2, footerRows - 1)),
2105
+ col: Math.max(1, Math.min(frame.cursorCol, cols)),
2106
+ };
2107
+ const budget = Math.max(0, footerRows - 2 - input.length);
2108
+ const slash = budget > 0 ? formatSlashPreview(line, budget, selected, skillSlashDetails, resolvedSkills) : [];
2109
+ const args = !slash.length && budget > 0 ? formatCompletionPreview(line, completionContext(), budget) : [];
2110
+ const preview = (slash.length ? slash : args).map(l => chalk.gray(truncateAnsi(l, cols)));
2111
+ return [statusBarLine(cols), "", ...input, ...preview].slice(0, footerRows);
2112
+ };
2113
+ const historyPreviewLines = (detail: string[]): string[] => {
2114
+ const cols = Math.max(24, (process.stdout.columns ?? 80) - 1);
2115
+ const title = `${chalk.cyan.bold("history")} ${chalk.gray("· Ctrl+O closes")}`;
2116
+ const budget = Math.max(0, footerRows - 2);
2117
+ const physical = detail.flatMap(line => line.split("\n")).map(line => truncateAnsi(line, cols));
2118
+ let body = physical;
2119
+ if (physical.length > budget) {
2120
+ const keep = Math.max(0, budget - 1);
2121
+ body = physical.slice(0, keep);
2122
+ body.push(chalk.gray(`… ${physical.length - keep} more line(s)`));
2123
+ } else {
2124
+ body = physical.slice(0, budget);
2125
+ }
2126
+ footerCursor = { row: Math.min(1, footerRows - 1), col: 1 };
2127
+ return [statusBarLine(cols), title, ...body].slice(0, footerRows);
2128
+ };
2129
+ const drawFooter = (lines: string[]) => {
2130
+ if (!previewArmed || footerRendered === 0) return;
2131
+ // ALWAYS paint exactly footerRendered rows so the reservation is fully covered
2132
+ // and no row can spill past it — the bug fix that kept `@folder<more text>`
2133
+ // typing from scrolling the input box (and prior output) off the top.
2134
+ const padded = padToFooter(lines);
2135
+ // Pure caret moves (arrow keys) change no content — include the caret cell in
2136
+ // the repaint key so they still reposition the terminal cursor.
2137
+ const tRow = lines.length ? Math.min(footerCursor.row, footerRendered - 1) : 0;
2138
+ const tCol = lines.length ? footerCursor.col : 1;
2139
+ const key = `${padded.join("\n")}\u0000${tRow}:${tCol}`;
2140
+ if (key === lastFooterKey) return;
2141
+ lastFooterKey = key;
2142
+ // Hop back to the reservation top from the previously parked caret row, then
2143
+ // paint top→bottom using CUD only.
2144
+ let s = footerParkedRow > 0 ? cursorUp(footerParkedRow) : "";
2145
+ s += toColumn(1);
2146
+ for (let i = 0; i < footerRendered; i++) {
2147
+ s += toColumn(1) + clearLine();
2148
+ if (padded[i]) s += padded[i];
2149
+ if (i < footerRendered - 1) s += "\x1b[1B"; // CUD: never scroll
2150
+ }
2151
+ if (footerRendered > 1) s += cursorUp(footerRendered - 1);
2152
+ // Park the REAL cursor at the caret cell (right after the `>` prompt) so the
2153
+ // blinking terminal cursor marks the insertion point and follows arrow keys.
2154
+ s += toColumn(1);
2155
+ if (tRow > 0) s += `\x1b[${tRow}B`;
2156
+ s += toColumn(tCol) + "\x1b[?25h";
2157
+ footerParkedRow = tRow;
2158
+ out.write(s);
2159
+ };
2160
+
2161
+ // ESC at the prompt: wipe the typed text (and detach any pending clipboard
2162
+ // images — their `[image #N]` tags live in that text) instead of leaving stale
2163
+ // input. Ctrl+C is no longer a line editor shortcut; it hard-exits below.
2164
+ const clearTypedInput = (): boolean => {
2165
+ const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
2166
+ const hadPastedQueue = queuedPromptInput.pastedLines.length > 0;
2167
+ if ((rli.line?.length ?? 0) === 0 && pendingImages.length === 0 && !hadPastedQueue) return false;
2168
+ // ESC is the escape hatch for an accidental giant paste: drop the queued batch.
2169
+ if (hadPastedQueue) {
2170
+ const dropped = queuedPromptInput.pastedLines.splice(0).length;
2171
+ console.log(chalk.dim(`(discarded ${dropped} queued pasted command${dropped > 1 ? "s" : ""})`));
2172
+ }
2173
+ rli.line = "";
2174
+ rli.cursor = 0;
2175
+ rli._refreshLine?.();
2176
+ pendingImages = [];
2177
+ typedLine = "";
2178
+ navMatches = [];
2179
+ navIdx = -1;
2180
+ pendingSelection = undefined;
2181
+ if (previewArmed) drawFooter(previewLines(""));
2182
+ return true;
2183
+ };
2184
+ // Ctrl+C at the prompt is a hard terminal break (exit code 130), not a line
2185
+ // editor shortcut. `/exit` remains the graceful session-save path; ^C is the
2186
+ // emergency "get me back to my shell now" path and must work on the first press.
2187
+ const forceExitFromCtrlC = () => {
2188
+ try {
2189
+ disarmPreview();
2190
+ out.write("\x1b[?25h\n");
2191
+ restorePromptRawMode();
2192
+ } catch {
2193
+ // Best-effort terminal restore; process exit is the contract.
2194
+ }
2195
+ process.exit(130);
2196
+ };
2197
+ const forceExitOnCtrlCByte = (chunk: string | Uint8Array) => {
2198
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
2199
+ if (text.includes("\u0003")) forceExitFromCtrlC();
2200
+ };
2201
+ // Bun/readline can deliver Ctrl+C as readline SIGINT, process SIGINT, or (tmux)
2202
+ // a raw \u0003 byte before readline resolves the question; wire all three.
2203
+ process.on("SIGINT", forceExitFromCtrlC);
2204
+ process.stdin.on("data", forceExitOnCtrlCByte);
2205
+ rl.on("SIGINT", forceExitFromCtrlC);
2206
+
2207
+ const runSelectPicker = async <T>(
2208
+ render: (cols: number, rows: number) => string[],
2209
+ onKey: (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => boolean | undefined,
2210
+ ): Promise<void> => {
2211
+ pickerActive = true; // closes the readline output gate for the picker's lifetime
2212
+ disarmPreview();
2213
+ // NOTE: deliberately NOT rl.pause() — pausing stops the underlying stdin stream,
2214
+ // which would also starve the picker's own "keypress" listener (picker hang).
2215
+ // Echo suppression is handled by the output gate above; raw mode (kept below)
2216
+ // prevents terminal-driver local echo.
2217
+ const wasRaw = process.stdin.isRaw;
2218
+ if (process.stdin.setRawMode && !wasRaw) {
2219
+ process.stdin.setRawMode(true);
2220
+ }
2221
+ process.stdin.resume();
2222
+ const cols = Math.max(40, terminalSize().cols - 2);
2223
+ const rows = Math.max(6, terminalSize().rows - 6);
2224
+ let rendered = 0;
2225
+ const repaint = () => {
2226
+ const lines = render(cols, rows).map(line => truncateAnsi(line, cols));
2227
+ const total = Math.max(rendered, lines.length);
2228
+ // First paint: drop down ONCE to start on a fresh row; subsequent paints:
2229
+ // cursor sits on the LAST rendered row, so cursorUp(rendered - 1) returns
2230
+ // to row 0 of the prior block (off-by-one fix — the old `cursorUp(rendered)`
2231
+ // landed ABOVE the block, so each repaint duplicated the trailing hint
2232
+ // line below the picker instead of overwriting it).
2233
+ let s = rendered > 0 ? (rendered > 1 ? cursorUp(rendered - 1) : "") + toColumn(1) : "\n";
2234
+ for (let i = 0; i < total; i++) {
2235
+ s += toColumn(1) + clearLine();
2236
+ if (i < lines.length) s += lines[i]!;
2237
+ // Rows the block already occupies move with CUD (no scroll at the bottom
2238
+ // margin → no anchor drift); only genuinely NEW rows use a real newline.
2239
+ if (i < total - 1) s += i < rendered - 1 ? "\x1b[1B" + toColumn(1) : "\n";
2240
+ }
2241
+ out.write(s + "\x1b[?25h");
2242
+ rendered = total;
2243
+ };
2244
+ const clear = () => {
2245
+ if (rendered <= 0) return;
2246
+ // Same off-by-one: from last row, cursorUp(rendered - 1) reaches row 0.
2247
+ let s = (rendered > 1 ? cursorUp(rendered - 1) : "") + toColumn(1);
2248
+ for (let i = 0; i < rendered; i++) {
2249
+ s += toColumn(1) + clearLine();
2250
+ // Every row here already exists — CUD only, never a scrolling newline.
2251
+ if (i < rendered - 1) s += "\x1b[1B" + toColumn(1);
2252
+ }
2253
+ // Park back at the first cleared row so post-picker output starts there.
2254
+ if (rendered > 1) s += cursorUp(rendered - 1);
2255
+ s += toColumn(1);
2256
+ out.write(s + "\x1b[?25h");
2257
+ rendered = 0;
2258
+ };
2259
+ repaint();
2260
+ try {
2261
+ await new Promise<void>(resolve => {
2262
+ const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
2263
+ const done = onKey(ch, key);
2264
+ if (done) {
2265
+ process.stdin.off("keypress", handler);
2266
+ clear();
2267
+ resolve();
2268
+ return;
2269
+ }
2270
+ repaint();
2271
+ };
2272
+ process.stdin.on("keypress", handler);
2273
+ });
2274
+ } finally {
2275
+ if (process.stdin.setRawMode && !wasRaw) {
2276
+ process.stdin.setRawMode(false);
2277
+ }
2278
+ // Keys typed while the picker was open also landed in readline's hidden
2279
+ // line buffer; without this the NEXT prompt starts pre-filled with the
2280
+ // picker's filter text (invisible until submitted as garbage input).
2281
+ const rli = rl as unknown as { line?: string; cursor?: number };
2282
+ if (typeof rli.line === "string" && rli.line.length > 0) {
2283
+ rli.line = "";
2284
+ rli.cursor = 0;
2285
+ }
2286
+ pickerActive = false;
2287
+ }
2288
+ };
2289
+
2290
+ // Antigravity with ANY Google OAuth (own login or the gemini-cli fallback) stays
2291
+ // SELECTABLE in pickers even when not call-ready: picking the model is how users
2292
+ // reach the flow, and the auth layer gives actionable login guidance on the first
2293
+ // call if the fallback token is rejected (403). Refusing selection was a dead end.
2294
+ const selectableThoughNotReady = (st?: { name: string; kind: string }): boolean =>
2295
+ !!st && st.name === "antigravity" && st.kind === "oauth";
2296
+ const notReadyWarning = (st: { name: string; label: string }): string =>
2297
+ ` ! ${st.name} is not call-ready yet (${st.label}) — run /provider login antigravity before the first turn.`;
2298
+
2299
+ const CORE_MODEL_ACTION_ROLE_ORDER = ["executor", "architect", "planner", "critic"] as const;
2300
+ const MODEL_BADGE_ROLE_ORDER = ["planner", "architect", "executor", "critic"] as const;
2301
+
2302
+ const roleBadgeColor = (roleId: string): ModelAssignmentBadge["color"] =>
2303
+ roleId === "executor" || roleId === "architect" || roleId === "planner" || roleId === "critic" ? roleId : "critic";
2304
+
2305
+ const orderedModelRoles = (config: Awaited<ReturnType<typeof readGlobalConfig>>) => {
2306
+ const roles = allSubagentRoles(config);
2307
+ const emitted = new Set<string>();
2308
+ const out: ReturnType<typeof allSubagentRoles> = [];
2309
+ for (const id of CORE_MODEL_ACTION_ROLE_ORDER) {
2310
+ const role = roles.find(r => r.id === id);
2311
+ if (role) {
2312
+ emitted.add(role.id);
2313
+ out.push(role);
2314
+ }
2315
+ }
2316
+ for (const role of roles) {
2317
+ if (!emitted.has(role.id)) out.push(role);
2318
+ }
2319
+ return out;
2320
+ };
2321
+
2322
+ const modelPickerAssignments = async (): Promise<ModelAssignmentBadge[]> => {
2323
+ const cfg = await readGlobalConfig();
2324
+ const defaultModelForBadges = sessionModel || cfg.defaultModel;
2325
+ const cfgForBadges = { ...cfg, defaultModel: defaultModelForBadges };
2326
+ const roles = allSubagentRoles(cfgForBadges);
2327
+ const byId = new Map(roles.map(role => [role.id, role]));
2328
+ const orderedRoleIds = [
2329
+ ...MODEL_BADGE_ROLE_ORDER,
2330
+ ...roles.map(role => role.id).filter(id => !MODEL_BADGE_ROLE_ORDER.includes(id as (typeof MODEL_BADGE_ROLE_ORDER)[number])),
2331
+ ];
2332
+ const assignments: ModelAssignmentBadge[] = [
2333
+ {
2334
+ role: "default",
2335
+ label: "DEFAULT",
2336
+ model: defaultModelForBadges,
2337
+ thinking: sessionThinking ?? cfg.thinkingLevel ?? "medium",
2338
+ color: "default",
2339
+ },
2340
+ ];
2341
+ for (const roleId of orderedRoleIds) {
2342
+ const role = byId.get(roleId);
2343
+ if (!role) continue;
2344
+ assignments.push({
2345
+ role: role.id,
2346
+ label: role.title.toUpperCase(),
2347
+ model: resolveSubagentModel(role.id, cfgForBadges),
2348
+ thinking: resolveSubagentThinking(role.id, cfgForBadges) ?? "inherit",
2349
+ color: roleBadgeColor(role.id),
2350
+ });
2351
+ }
2352
+ return assignments;
2353
+ };
2354
+
2355
+ const pickLiveProviderModel = async (
2356
+ providerName: string,
2357
+ entries: PickEntry[],
2358
+ current?: string,
2359
+ disabledProviders: readonly ProviderName[] = [],
2360
+ ): Promise<PickEntry | undefined> => {
2361
+ if (!process.stdin.isTTY || entries.length === 0) return undefined;
2362
+ const list = liveModelPicker(entries, { current, assignments: await modelPickerAssignments(), disabledProviders, disabledHint: "needs API key/base URL" });
2363
+ let chosen: PickEntry | undefined;
2364
+ await runSelectPicker(
2365
+ (cols, rows) =>
2366
+ renderLiveModelPicker(list, {
2367
+ title: `Select ${providerName} model`,
2368
+ cols,
2369
+ rows: Math.max(4, Math.min(rows, 12)),
2370
+ unicode: true,
2371
+ color: true,
2372
+ }),
2373
+ (ch, key) => {
2374
+ if (key?.name === "up") {
2375
+ list.up();
2376
+ return false;
2377
+ }
2378
+ if (key?.name === "down") {
2379
+ list.down();
2380
+ return false;
2381
+ }
2382
+ if (key?.name === "pageup") {
2383
+ list.page(-1, 6);
2384
+ return false;
2385
+ }
2386
+ if (key?.name === "pagedown") {
2387
+ list.page(1, 6);
2388
+ return false;
2389
+ }
2390
+ if (key?.name === "backspace") {
2391
+ list.backspace();
2392
+ return false;
2393
+ }
2394
+ if (key?.name === "escape" || (key?.ctrl && key.name === "c")) {
2395
+ return true;
2396
+ }
2397
+ if (key?.name === "return" || key?.name === "enter") {
2398
+ chosen = list.selected()?.value;
2399
+ return true;
2400
+ }
2401
+ if (ch && ch >= " " && !key?.ctrl && !key?.meta) {
2402
+ list.typeChar(ch);
2403
+ }
2404
+ return false;
2405
+ },
2406
+ );
2407
+ return chosen;
2408
+ };
2409
+
2410
+ /** Generic arrows+Enter option picker (apply-target / role / action menus).
2411
+ * Returns the chosen value, or undefined on ESC / non-TTY. */
2412
+ const pickFromOptions = async (
2413
+ title: string,
2414
+ options: SelectItem<string>[],
2415
+ ): Promise<string | undefined> => {
2416
+ if (!process.stdin.isTTY || !process.stdout.isTTY || options.length === 0) return undefined;
2417
+ const list = new SelectList(options);
2418
+ let chosen: string | undefined;
2419
+ await runSelectPicker(
2420
+ (cols, rows) => renderSelectList(list, { title, cols, rows: Math.max(4, Math.min(rows, 14)), unicode: true, color: true }),
2421
+ (ch, key) => {
2422
+ if (key?.name === "up") { list.up(); return false; }
2423
+ if (key?.name === "down") { list.down(); return false; }
2424
+ if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
2425
+ if (key?.name === "return" || key?.name === "enter") {
2426
+ chosen = list.selected()?.value;
2427
+ return true;
2428
+ }
2429
+ if (key?.name === "backspace") { list.backspace(); return false; }
2430
+ if (ch && ch >= " " && !key?.ctrl && !key?.meta) list.typeChar(ch);
2431
+ return false;
2432
+ },
2433
+ );
2434
+ return chosen;
2435
+ };
2436
+
2437
+ const pickThinkingLevel = async (
2438
+ title: string,
2439
+ current: ThinkLevel | undefined,
2440
+ inheritLabel?: string,
2441
+ ): Promise<ThinkLevel | "inherit" | undefined> => {
2442
+ const mark = (lvl: string): string => (current === lvl ? "current" : "");
2443
+ const levels: { value: string; label: string; hint?: string }[] = [
2444
+ ...(inheritLabel ? [{ value: "inherit", label: inheritLabel, hint: current === undefined ? "current" : "" }] : []),
2445
+ { value: "minimal", label: "minimal — lightest reasoning", hint: mark("minimal") },
2446
+ { value: "low", label: `low — light reasoning (~${Math.round(thinkingMaxTokens("low") / 1000)}k tokens)`, hint: mark("low") },
2447
+ { value: "medium", label: `medium — moderate reasoning (~${Math.round(thinkingMaxTokens("medium") / 1000)}k tokens)`, hint: mark("medium") },
2448
+ { value: "high", label: `high — deep reasoning (~${Math.round(thinkingMaxTokens("high") / 1000)}k tokens)`, hint: mark("high") },
2449
+ { value: "xhigh", label: `xhigh — maximum reasoning (~${Math.round(thinkingMaxTokens("xhigh") / 1000)}k tokens)`, hint: mark("xhigh") },
2450
+ ];
2451
+ const picked = await pickFromOptions(title, levels);
2452
+ return picked === "inherit" || isThinkingLevel(picked) ? picked : undefined;
2453
+ };
2454
+
2455
+ const setRoleThinking = async (roleId: string, rawLevel: string | undefined): Promise<boolean> => {
2456
+ const cfgForRole = await readGlobalConfig();
2457
+ const role = getSubagentRole(roleId, cfgForRole);
2458
+ if (!role) return false;
2459
+ const level = (rawLevel ?? "").toLowerCase();
2460
+ if (level === "inherit") {
2461
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { thinking: undefined }) }));
2462
+ console.log(`${role.title} thinking now inherits the default → ~/.jeo/config.json`);
2463
+ return true;
2464
+ }
2465
+ if (!isThinkingLevel(level)) {
2466
+ console.log(`Usage: /model subagent ${role.id} thinking <inherit|minimal|low|medium|high|xhigh>`);
2467
+ return true;
2468
+ }
2469
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { thinking: level }) }));
2470
+ console.log(`${role.title} thinking set to ${level} (~${thinkingMaxTokens(level)} max tokens/step) → ~/.jeo/config.json`);
2471
+ return true;
2472
+ };
2473
+ const displayModelName = (model: string): string => {
2474
+ const leaf = (model.split("/").pop() || model).trim();
2475
+ return leaf.replace(/^gpt/i, "GPT");
2476
+ };
2477
+
2478
+ const modelActionChoices = (config: Awaited<ReturnType<typeof readGlobalConfig>>): SelectItem<string>[] => {
2479
+ const levels: ThinkLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
2480
+ const choices: SelectItem<string>[] = [];
2481
+ const currentDefaultThinking = sessionThinking ?? config.thinkingLevel ?? "medium";
2482
+ const appendChildren = (children: Array<Omit<SelectItem<string>, "depth" | "branch">>) => {
2483
+ children.forEach((child, index) => {
2484
+ choices.push({ ...child, depth: 1, branch: index === children.length - 1 ? "last" : "mid" });
2485
+ });
2486
+ };
2487
+
2488
+ choices.push({
2489
+ value: "heading:default",
2490
+ label: "Set as DEFAULT (Default)",
2491
+ hint: `${config.defaultModel} (${currentDefaultThinking})`,
2492
+ disabled: true,
2493
+ });
2494
+ appendChildren([
2495
+ { value: "default:keep", label: "Set model only", hint: `keep thinking ${currentDefaultThinking}` },
2496
+ ...levels.map(level => ({
2497
+ value: `default:${level}`,
2498
+ label: `thinking ${level}`,
2499
+ hint: level === currentDefaultThinking ? "current" : `~${Math.round(thinkingMaxTokens(level) / 1000)}k tokens`,
2500
+ })),
2501
+ ]);
2502
+
2503
+ for (const role of orderedModelRoles(config)) {
2504
+ const roleThinking = resolveSubagentThinking(role.id, config) ?? "inherit";
2505
+ choices.push({
2506
+ value: `heading:${role.id}`,
2507
+ label: `Set as ${role.title.toUpperCase()} (${role.title})`,
2508
+ hint: `${resolveSubagentModel(role.id, config)} (${roleThinking})`,
2509
+ disabled: true,
2510
+ });
2511
+ appendChildren([
2512
+ { value: `${role.id}:keep`, label: "Set model only", hint: `keep thinking ${roleThinking}` },
2513
+ {
2514
+ value: `${role.id}:inherit`,
2515
+ label: "thinking inherit",
2516
+ hint: `follow default (${config.thinkingLevel ?? "medium"})`,
2517
+ },
2518
+ ...levels.map(level => ({
2519
+ value: `${role.id}:${level}`,
2520
+ label: `thinking ${level}`,
2521
+ hint: roleThinking === level ? "current" : `~${Math.round(thinkingMaxTokens(level) / 1000)}k tokens`,
2522
+ })),
2523
+ ]);
2524
+ }
2525
+
2526
+ choices.push({
2527
+ value: "preset:openai-codex",
2528
+ label: "Apply OpenAI Codex role preset",
2529
+ hint: "Default medium · Executor low · Architect xhigh · Planner medium · Critic high",
2530
+ });
2531
+ return choices;
2532
+ };
2533
+
2534
+ const applyOpenAiCodexRolePreset = async (target: string, cfgForPick: Awaited<ReturnType<typeof readGlobalConfig>>): Promise<void> => {
2535
+ const roleThinking: Record<(typeof CORE_MODEL_ACTION_ROLE_ORDER)[number], ThinkLevel> = {
2536
+ executor: "low",
2537
+ architect: "xhigh",
2538
+ planner: "medium",
2539
+ critic: "high",
2540
+ };
2541
+ await saveConfigPatch(raw => {
2542
+ let subagents = raw.subagents ?? {};
2543
+ for (const roleId of CORE_MODEL_ACTION_ROLE_ORDER) {
2544
+ subagents = withSubagentSetting({ subagents }, roleId, { model: target, thinking: roleThinking[roleId] });
2545
+ }
2546
+ return {
2547
+ ...rememberModelPatch(raw, target),
2548
+ thinkingLevel: "medium",
2549
+ subagents,
2550
+ };
2551
+ });
2552
+ sessionModel = target;
2553
+ sessionThinking = "medium";
2554
+ const { resolved, provider } = await describeModel(target);
2555
+ const st = (await describeAllProviders(cfgForPick)).find(s => s.name === provider);
2556
+ console.log(`OpenAI Codex role preset applied to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })} — Default medium, Executor low, Architect xhigh, Planner medium, Critic high`);
2557
+ };
2558
+
2559
+
2560
+ const applyPickedModelWithTarget = async (target: string): Promise<boolean> => {
2561
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
2562
+ const cfgForPick = await readGlobalConfig();
2563
+ const choice = await pickFromOptions(`Model Name: ${displayModelName(target)}\n\nAction for: ${target}`, modelActionChoices(cfgForPick)) ?? "default:keep";
2564
+ if (choice === "preset:openai-codex") {
2565
+ await applyOpenAiCodexRolePreset(target, cfgForPick);
2566
+ return true;
2567
+ }
2568
+ const [applyTo, action = "keep"] = choice.split(":", 2);
2569
+ if (applyTo === "heading") return false;
2570
+ const roleTarget = applyTo !== "default" ? getSubagentRole(applyTo, cfgForPick) : undefined;
2571
+ const { resolved, provider } = await describeModel(target);
2572
+ const st = (await describeAllProviders(cfgForPick)).find(s => s.name === provider);
2573
+ if (roleTarget) {
2574
+ const thinkPatch = action === "inherit" ? { thinking: undefined } : isThinkingLevel(action) ? { thinking: action } : {};
2575
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, roleTarget.id, { model: target, ...thinkPatch }) }));
2576
+ const thinkNote = action !== "keep" ? ` · thinking ${action}` : "";
2577
+ console.log(`Subagent '${roleTarget.id}' model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })}${thinkNote} — saved (change anytime via /model or /agents)`);
2578
+ return true;
2579
+ }
2580
+ sessionModel = target;
2581
+ const defaultThinking = isThinkingLevel(action) ? action : undefined;
2582
+ if (defaultThinking) {
2583
+ sessionThinking = defaultThinking;
2584
+ }
2585
+ await saveConfigPatch(raw => ({
2586
+ ...rememberModelPatch(raw, target),
2587
+ ...(defaultThinking ? { thinkingLevel: defaultThinking } : {}),
2588
+ }));
2589
+ console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })}${defaultThinking ? ` · thinking ${defaultThinking}` : ""} — saved as default`);
2590
+ return true;
2591
+ };
2592
+
2593
+ const pickSkillFromList = async (skills: SkillDoc[]): Promise<SkillDoc | undefined> => {
2594
+ if (!process.stdin.isTTY || skills.length === 0) return undefined;
2595
+ const list = skillPicker(skills);
2596
+ let chosen: SkillDoc | undefined;
2597
+ await runSelectPicker(
2598
+ (cols, rows) =>
2599
+ renderSkillPicker(list, {
2600
+ cols,
2601
+ rows: Math.max(4, Math.min(rows, 12)),
2602
+ unicode: true,
2603
+ color: true,
2604
+ }),
2605
+ (ch, key) => {
2606
+ if (key?.name === "up") {
2607
+ list.up();
2608
+ return false;
2609
+ }
2610
+ if (key?.name === "down") {
2611
+ list.down();
2612
+ return false;
2613
+ }
2614
+ if (key?.name === "pageup") {
2615
+ list.page(-1, 6);
2616
+ return false;
2617
+ }
2618
+ if (key?.name === "pagedown") {
2619
+ list.page(1, 6);
2620
+ return false;
2621
+ }
2622
+ if (key?.name === "backspace") {
2623
+ list.backspace();
2624
+ return false;
2625
+ }
2626
+ if (key?.name === "escape" || (key?.ctrl && key.name === "c")) {
2627
+ return true;
2628
+ }
2629
+ if (key?.name === "return" || key?.name === "enter") {
2630
+ chosen = list.selected()?.value;
2631
+ return true;
2632
+ }
2633
+ if (ch && ch >= " " && !key?.ctrl && !key?.meta) {
2634
+ list.typeChar(ch);
2635
+ }
2636
+ return false;
2637
+ },
2638
+ );
2639
+ return chosen;
2640
+ };
2641
+
2642
+ const pickCloudProvider = async (statuses: Awaited<ReturnType<typeof describeAllProviders>>): Promise<AuthProvider | undefined> => {
2643
+ const cloud = new Set(["anthropic", "openai", "gemini", "antigravity"]);
2644
+ const list = providerPicker(statuses.filter(s => cloud.has(s.name)), true);
2645
+ let chosen: ProviderName | undefined;
2646
+ await runSelectPicker(
2647
+ (cols, rows) =>
2648
+ renderProviderPicker(list, {
2649
+ title: "Select OAuth provider",
2650
+ cols,
2651
+ rows: Math.max(4, Math.min(rows, 8)),
2652
+ unicode: true,
2653
+ color: true,
2654
+ }),
2655
+ (ch, key) => {
2656
+ if (key?.name === "up") {
2657
+ list.up();
2658
+ return false;
2659
+ }
2660
+ if (key?.name === "down") {
2661
+ list.down();
2662
+ return false;
2663
+ }
2664
+ if (key?.name === "pageup") {
2665
+ list.page(-1, 4);
2666
+ return false;
2667
+ }
2668
+ if (key?.name === "pagedown") {
2669
+ list.page(1, 4);
2670
+ return false;
2671
+ }
2672
+ if (key?.name === "backspace") {
2673
+ list.backspace();
2674
+ return false;
2675
+ }
2676
+ if (key?.name === "escape" || (key?.ctrl && key.name === "c")) {
2677
+ return true;
2678
+ }
2679
+ if (key?.name === "return" || key?.name === "enter") {
2680
+ chosen = list.selected()?.value;
2681
+ return true;
2682
+ }
2683
+ if (ch && ch >= " " && !key?.ctrl && !key?.meta) {
2684
+ list.typeChar(ch);
2685
+ }
2686
+ return false;
2687
+ },
2688
+ );
2689
+ return chosen && cloud.has(chosen) ? chosen as AuthProvider : undefined;
2690
+ };
2691
+
2692
+ if (previewEnabled) {
2693
+ process.once("exit", () => out.write("\x1b[?25h")); // safety net: never leave the cursor hidden
2694
+ process.stdin.on("keypress", (_ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
2695
+ if (key?.ctrl && key.name === "c") {
2696
+ forceExitFromCtrlC();
2697
+ return;
2698
+ }
2699
+ if (!previewArmed || pickerActive) return;
2700
+ // Ctrl+O: toggle a reversible history/detail panel. The live-turn TUI path
2701
+ // uses LaunchTui.showDetail(); this idle-prompt path paints the same content
2702
+ // into the fixed footer reservation, so the second Ctrl+O can close it.
2703
+ // (Cmd+O is intercepted by macOS/terminal and never reaches the app.)
2704
+ if (key?.ctrl && key.name === "o") {
2705
+ if (promptHistoryLines) {
2706
+ promptHistoryLines = null;
2707
+ drawFooter(previewLines(typedLine, navIdx));
2708
+ return;
2709
+ }
2710
+ const detail = composeDetailLines();
2711
+ if (detail.length === 0) return;
2712
+ promptHistoryLines = detail;
2713
+ drawFooter(historyPreviewLines(detail));
2714
+ return;
2715
+ }
2716
+ if (promptHistoryLines) promptHistoryLines = null;
2717
+ // Ctrl+V: attach a clipboard IMAGE to the next message. Terminal text paste
2718
+ // never arrives as a ctrl+v keypress (it streams as plain stdin data), so this
2719
+ // binding is image-only; when the clipboard holds no image it's a silent no-op.
2720
+ if (key?.ctrl && key.name === "v") {
2721
+ if (pasteInFlight) return;
2722
+ pasteInFlight = true;
2723
+ void (async () => {
2724
+ try {
2725
+ const img = await readClipboardImage();
2726
+ if (!img) return;
2727
+ pendingImages.push(img);
2728
+ const tag = `[image #${pendingImages.length}]`;
2729
+ const rli = rl as unknown as { line: string; cursor: number };
2730
+ const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
2731
+ const sep = rli.line.length > 0 && at > 0 && rli.line[at - 1] !== " " ? " " : "";
2732
+ rli.line = rli.line.slice(0, at) + sep + tag + " " + rli.line.slice(at);
2733
+ rli.cursor = at + sep.length + tag.length + 1;
2734
+ typedLine = rli.line;
2735
+ if (previewArmed) drawFooter(previewLines(typedLine, navIdx));
2736
+ } finally {
2737
+ pasteInFlight = false;
2738
+ }
2739
+ })();
2740
+ return;
2741
+ }
2742
+ if (previewPending) return;
2743
+ // ESC (or a meta-mapped Cmd+C) at the prompt: wipe the typed text. A bare
2744
+ // ESC decodes as `escape` (meta is set for a lone ESC byte — accept both)
2745
+ // only after readline's escape-sequence timeout, so arrow/wheel sequences
2746
+ // never trigger this.
2747
+ if (key && ((key.name === "escape" && !key.ctrl) || (key.meta && key.name === "c"))) {
2748
+ clearTypedInput();
2749
+ return;
2750
+ }
2751
+ // Ctrl+C hard-exits above; keep this guard for defensive ordering only.
2752
+ if (key?.ctrl && key.name === "c") return;
2753
+ previewPending = true;
2754
+ setImmediate(() => {
2755
+ previewPending = false;
2756
+ if (!previewArmed) return;
2757
+ try {
2758
+ if (key && (key.name === "return" || key.name === "enter")) {
2759
+ promptHistoryLines = null;
2760
+ drawFooter([]);
2761
+ return;
2762
+ }
2763
+ // Arrow up/down: move the highlight over the slash keyword preview list.
2764
+ // Once the user types a real argument (`/subagent `, `/provider login `, ...),
2765
+ // we stop intercepting arrows and just show the live completion preview.
2766
+ if (key && (key.name === "up" || key.name === "down") && navMatches.length > 0) {
2767
+ const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
2768
+ if (rli.line !== typedLine) {
2769
+ rli.line = typedLine;
2770
+ rli.cursor = typedLine.length;
2771
+ rli._refreshLine?.();
2772
+ }
2773
+ if (navIdx === -1) navIdx = key.name === "down" ? 0 : navMatches.length - 1;
2774
+ else navIdx = (navIdx + (key.name === "down" ? 1 : -1) + navMatches.length) % navMatches.length;
2775
+ pendingSelection = navMatches[navIdx];
2776
+ drawFooter(previewLines(typedLine, navIdx));
2777
+ return;
2778
+ }
2779
+ // Tab: complete the line to the highlighted popup row (or the TOP match
2780
+ // when nothing is highlighted) — `/mod`+Tab → `/model `, `$sp`+Tab →
2781
+ // `$spec-kit `. Runs AFTER readline's own completer fired; overwriting
2782
+ // rl.line here makes the popup's choice the deterministic final state
2783
+ // (the completer's candidate dump is gated while the preview is armed).
2784
+ if (key && key.name === "tab" && navMatches.length > 0) {
2785
+ const completed = tabCompleteSelection(typedLine, navMatches, navIdx);
2786
+
2787
+ if (completed) {
2788
+ const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
2789
+ rli.line = completed;
2790
+ rli.cursor = completed.length;
2791
+ rli._refreshLine?.();
2792
+ typedLine = completed;
2793
+ navMatches = slashPreviewMatches(typedLine, skillSlashDetails, resolvedSkills);
2794
+ navIdx = -1;
2795
+ pendingSelection = undefined;
2796
+ drawFooter(previewLines(typedLine));
2797
+ return;
2798
+ }
2799
+ }
2800
+ // Any other key edits the line: refresh the slash-keyword matches (if any),
2801
+ // reset the highlight, and show either the command preview or argument preview.
2802
+ typedLine = rl.line;
2803
+ navMatches = slashPreviewMatches(typedLine, skillSlashDetails, resolvedSkills);
2804
+ navIdx = -1;
2805
+ pendingSelection = undefined;
2806
+ drawFooter(previewLines(typedLine));
2807
+ } catch { /* ignore render races */ }
2808
+ });
2809
+ });
2810
+ // Idle-prompt resize: re-reserve the footer at the new terminal height so the
2811
+ // fixed reservation stays accurate (otherwise the next paint would target the
2812
+ // old row count and either over-shoot or under-paint the reserved region).
2813
+ process.stdout.on("resize", () => {
2814
+ if (!previewArmed) return;
2815
+ try {
2816
+ disarmPreview();
2817
+ armPreview();
2818
+ drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
2819
+ } catch { /* ignore resize render races */ }
2820
+ });
2821
+ }
2822
+
2823
+ while (true) {
2824
+ drainPendingTtyInput();
2825
+ // "New input first": queued FULL lines from the previous turn are folded
2826
+ // into the editable prefill (visible in the input box, Enter to run,
2827
+ // Esc/Ctrl+U to discard) instead of auto-executing ahead of fresh input —
2828
+ // the "continues the previous work first" bug. Piped stdin keeps the
2829
+ // legacy in-order auto-serve (scripted runs depend on it).
2830
+ if (process.stdin.isTTY) {
2831
+ const folded = restoreQueuedLinesToPrefill(queuedPromptInput);
2832
+ if (folded > 0) {
2833
+ console.log(chalk.dim(`(restored ${folded} queued input line${folded > 1 ? "s" : ""} into the prompt — Enter to run, Esc to discard)`));
2834
+ }
2835
+ }
2836
+ // Refresh the status bar's dirty flag once per prompt (one git spawn, not per frame).
2837
+ idleDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
2838
+ const prefilledLine = queuedPromptInput.partial;
2839
+ queuedPromptInput.partial = "";
2840
+ armPreview();
2841
+ // Render the boxed input immediately so the prompt is visible even though
2842
+ // readline's own echo is suppressed. If the user typed while the previous
2843
+ // live turn/subagent was still running, seed that text into readline and the
2844
+ // box instead of dropping it as "noise". A pasted batch's TRAILING partial
2845
+ // (no final newline) survives in readline's own buffer — adopt it as the
2846
+ // visible typed line so the box never hides editable input.
2847
+ const rli = rl as unknown as { line?: string; cursor?: number; _refreshLine?: () => void };
2848
+ const residualPartial = !prefilledLine && typeof rli.line === "string" && rli.line.length > 0 && !/\x1b/.test(rli.line)
2849
+ ? rli.line
2850
+ : "";
2851
+ typedLine = prefilledLine || residualPartial;
2852
+ if (prefilledLine) {
2853
+ rli.line = prefilledLine;
2854
+ rli.cursor = prefilledLine.length;
2855
+ rli._refreshLine?.();
2856
+ }
2857
+ navMatches = [];
2858
+ navIdx = -1;
2859
+ drawFooter(previewLines(typedLine));
2860
+ // Box mode: NO raw `jeo>` prompt at all — the boxed footer IS the input UI
2861
+ // (gating already suppresses readline echo, the empty prompt guarantees no
2862
+ // raw CLI input line can ever flash). Legacy prompt only without the box.
2863
+ const rawText = await promptInput(previewEnabled ? "" : "\njeo> ");
2864
+ if (rawText.includes("\u0003")) forceExitFromCtrlC();
2865
+ const raw = rawText.trim();
2866
+ disarmPreview();
2867
+ // Pasted batch command: echo what is about to run (with the remaining queue
2868
+ // depth) so a multi-line paste reads as a visible, ordered script.
2869
+ if (promptServedFromPaste && raw) {
2870
+ const remaining = queuedPromptInput.pastedLines.length;
2871
+ console.log(`${categoryBadge("progress")} ▶ pasted command: ${raw}${remaining > 0 ? chalk.dim(` (+${remaining} queued)`) : ""}`);
2872
+ }
2873
+ // If an arrow-key selection was made over the slash/skill preview, apply it
2874
+ // to the ACTIVE trigger token (which may sit anywhere in the line —
2875
+ // mention-style): only the token is replaced, surrounding text survives.
2876
+ // A leading-token selection therefore still replaces the whole line and
2877
+ // runs as a command, exactly as before.
2878
+ const trigger = activeTriggerToken(raw);
2879
+ let input = pendingSelection && trigger && pendingSelection.startsWith(trigger.token)
2880
+ ? raw.slice(0, trigger.start) + pendingSelection
2881
+ : raw;
2882
+ // gjc-parity command aliases (full behavior reuse, no duplicated handlers).
2883
+ if (input === "/login" || input.startsWith("/login ")) input = `/provider login${input.slice("/login".length)}`;
2884
+ else if (input === "/settings") input = "/config";
2885
+ // `/subagent`(s) → the /agents roster/editor (view + change the current
2886
+ // subagent composition: per-role model · thinking · steps).
2887
+ else if (input === "/subagent" || input.startsWith("/subagent ")) input = `/agents${input.slice("/subagent".length)}`;
2888
+ else if (input === "/subagents" || input.startsWith("/subagents ")) input = `/agents${input.slice("/subagents".length)}`;
2889
+ pendingSelection = undefined;
2890
+ navMatches = [];
2891
+ navIdx = -1;
411
2892
  if (input === "/exit" || input === "/quit") break;
412
- if (input === "") continue;
413
- if (input === "/help") {
414
- console.log("Slash Commands:");
415
- console.log(" /help - Show this help message");
416
- console.log(" /clear - Clear conversation history (keeps system prompt)");
417
- console.log(" /model [id|#N|save] - Set the session model by id, by #N from /models, or save as default");
418
- console.log(" /models [refresh|caps] - Live model list (caps = + context/out/thinking/img)");
419
- console.log(" /provider [name] [model] - Provider credentials, or switch + list that provider's live models");
420
- console.log(" /agents [role] [model] - List subagent roles, show one, or pin a role's model (saved)");
421
- console.log(" /config - Show the effective runtime configuration");
422
- console.log(" /roles [tier model] - Show or set model role tiers (smol/slow/plan)");
423
- console.log(" /thinking [level] - Show or set the thinking budget (minimal/low/medium/high/xhigh)");
424
- console.log(" /view <file> [a-b] - Code view: render a file with line numbers + light highlight");
425
- console.log(" /diff [file] - Render `git diff` with +/- coloring");
426
- console.log(" /find <glob> - List files matching a glob");
427
- console.log(" /search <pat> [glob]- Grep the repo for a pattern");
428
- console.log(" /sessions - List saved sessions");
429
- console.log(" /evolve - Simulate and view the agent's evolutionary gallery");
430
- console.log(" /compact - Summarize older turns to free context");
431
- console.log(" /exit, /quit - Exit the agent");
432
- console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .joc/sessions/.");
433
- const tip = getEvolutionTip(history.length, flags.maxSteps);
2893
+ if (input === "") {
2894
+ if (pendingImages.length === 0) continue;
2895
+ input = "Please look at the attached image(s)."; // image-only submit
2896
+ }
2897
+ if (input === "/" || input === "/?" || input === "/help") {
2898
+ logLines(formatSlashCommandList(input === "/help" ? "/" : input, skillSlashDetails));
2899
+ console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
2900
+ const tip = getEvolutionTip(history.length, flags.maxSteps > 0 ? flags.maxSteps : initialStepLimit);
434
2901
  console.log(`\n${chalk.cyan("Evolutionary Tip:")} ${tip}`);
435
2902
  continue;
436
2903
  }
@@ -440,14 +2907,305 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
440
2907
  continue;
441
2908
  }
442
2909
  if (input === "/compact") {
443
- const res = await maybeCompact(history, { model: sessionModel, force: true });
444
- console.log(res.compacted ? `(compacted ${res.removed} older messages)` : "(nothing to compact)");
2910
+ const turnConfig = await readGlobalConfig();
2911
+ const activeModel = sessionModel || turnConfig.defaultModel;
2912
+ const contextTokens = catalogMetadata(activeModel)?.contextTokens;
2913
+ const res = await maybeCompact(history, { model: sessionModel, force: true, contextTokens });
2914
+ if (res.error) {
2915
+ console.error(chalk.red(res.error));
2916
+ } else if (res.compacted && sessionId && res.replacesThrough !== undefined) {
2917
+ const touchedNote = res.touchedFiles?.length ? ` Files touched: ${res.touchedFiles.join(", ")}.` : "";
2918
+ const summaryText = res.summary ?? `[Earlier conversation omitted: ${res.removed} messages — summary unavailable.${touchedNote}]`;
2919
+ await appendCompaction(sessionId, ++compactionSeq, summaryText, res.replacesThrough, cwd);
2920
+ console.log(`(compacted ${res.removed} older messages${res.touchedFiles?.length ? `; kept ${res.touchedFiles.length} file refs` : ""})`);
2921
+ } else {
2922
+ console.log("(nothing to compact)");
2923
+ }
445
2924
  continue;
446
2925
  }
447
2926
  if (input === "/sessions") {
448
2927
  const sessions = await listSessions(cwd);
449
2928
  if (sessions.length === 0) console.log("(no saved sessions)");
450
- for (const s of sessions) console.log(` ${s.id} (${s.messageCount} msgs) ${s.preview}`);
2929
+ for (const s of sessions) {
2930
+ const marker = s.id === sessionId ? "*" : " ";
2931
+ const title = s.title ? `[${s.title}] ` : "";
2932
+ console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${title}${s.preview}`);
2933
+ }
2934
+ continue;
2935
+ }
2936
+ // ---- gjc-parity session management ------------------------------------
2937
+ const startFreshSession = async (verb: string): Promise<void> => {
2938
+ history.length = 1;
2939
+ if (!flags.noSession) {
2940
+ sessionId = (await createSession(cwd)).id;
2941
+ console.log(`(${verb} — new session ${sessionId})`);
2942
+ } else {
2943
+ sessionId = undefined;
2944
+ console.log(`(${verb} — sessions disabled)`);
2945
+ }
2946
+ };
2947
+ if (input === "/new") {
2948
+ await startFreshSession("started fresh");
2949
+ continue;
2950
+ }
2951
+ if (input === "/drop") {
2952
+ if (sessionId) {
2953
+ const removed = await deleteSession(sessionId, cwd);
2954
+ console.log(removed ? `(deleted session ${sessionId})` : `(session ${sessionId} already gone)`);
2955
+ }
2956
+ await startFreshSession("dropped");
2957
+ continue;
2958
+ }
2959
+ if (input === "/session" || input.startsWith("/session ")) {
2960
+ const sub = input.substring(8).trim().toLowerCase();
2961
+ if (sub === "delete") {
2962
+ if (!sessionId) {
2963
+ console.log("(sessions are disabled — nothing to delete)");
2964
+ continue;
2965
+ }
2966
+ const removed = await deleteSession(sessionId, cwd);
2967
+ console.log(removed ? `(deleted session ${sessionId})` : `(session ${sessionId} already gone)`);
2968
+ await startFreshSession("dropped");
2969
+ continue;
2970
+ }
2971
+ if (sub && sub !== "info") {
2972
+ console.log("Usage: /session [info|delete]");
2973
+ continue;
2974
+ }
2975
+ if (!sessionId) {
2976
+ console.log("Session: disabled (--no-session)");
2977
+ continue;
2978
+ }
2979
+ const all = await listSessions(cwd);
2980
+ const current = all.find(s => s.id === sessionId);
2981
+ console.log("Session info:");
2982
+ console.log(` id ${sessionId}`);
2983
+ if (current?.title) console.log(` title ${current.title}`);
2984
+ console.log(` file ${sessionPath(sessionId, cwd)}`);
2985
+ console.log(` started ${current?.timestamp ?? "(this run)"}`);
2986
+ console.log(` messages ${current?.messageCount ?? Math.max(0, history.length - 1)} persisted · ${history.length - 1} in context`);
2987
+ console.log(` workspace ${cwd}`);
2988
+ continue;
2989
+ }
2990
+ if (input === "/rename" || input.startsWith("/rename ")) {
2991
+ const title = input.substring(7).trim();
2992
+ if (!title) {
2993
+ console.log("Usage: /rename <title>");
2994
+ continue;
2995
+ }
2996
+ if (!sessionId) {
2997
+ console.log("(sessions are disabled — nothing to rename)");
2998
+ continue;
2999
+ }
3000
+ try {
3001
+ await renameSession(sessionId, title, cwd);
3002
+ console.log(`(session renamed to '${title}')`);
3003
+ } catch (err) {
3004
+ console.log(`! rename failed: ${(err as Error).message}`);
3005
+ }
3006
+ continue;
3007
+ }
3008
+ if (input === "/resume" || input.startsWith("/resume ")) {
3009
+ const id = input.substring(7).trim();
3010
+ if (!id) {
3011
+ const sessions = await listSessions(cwd);
3012
+ if (sessions.length === 0) {
3013
+ console.log("(no saved sessions)");
3014
+ continue;
3015
+ }
3016
+ console.log("Saved sessions — resume with /resume <id>:");
3017
+ for (const s of sessions.slice(0, 15)) {
3018
+ const marker = s.id === sessionId ? "*" : " ";
3019
+ console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${s.title ? `[${s.title}] ` : ""}${s.preview}`);
3020
+ }
3021
+ continue;
3022
+ }
3023
+ try {
3024
+ const { messages } = await loadSession(id, cwd);
3025
+ history.length = 1;
3026
+ for (const m of messages) history.push(m);
3027
+ sessionId = id;
3028
+ console.log(`Resumed session ${id} (${messages.length} messages).`);
3029
+ } catch (err) {
3030
+ console.log(`! ${(err as Error).message}`);
3031
+ }
3032
+ continue;
3033
+ }
3034
+ if (input === "/retry") {
3035
+ if (!lastUserInput) {
3036
+ console.log("(nothing to retry yet — send a request first)");
3037
+ continue;
3038
+ }
3039
+ console.log(`(retrying: ${lastUserInput.slice(0, 80)}${lastUserInput.length > 80 ? "…" : ""})`);
3040
+ try {
3041
+ const { done, steps, reply, rendered, usage } = await runTurn(lastUserInput, useTui);
3042
+ lastReply = reply;
3043
+ if (!rendered) {
3044
+ console.log(`jeo> ${renderMarkdownTables(reply)}${usage}`);
3045
+ if (!done) console.log(`(agent did not converge in ${steps} steps)`);
3046
+ } else if (usage) {
3047
+ console.log(usage.trim());
3048
+ }
3049
+ } catch (err) {
3050
+ console.log(`! ${friendlyProviderError(err)}`);
3051
+ }
3052
+ continue;
3053
+ }
3054
+ if (input === "/history" || input.startsWith("/history ")) {
3055
+ // Re-print the worked history into scrollback (tmux-friendly review of
3056
+ // past prompts / tool steps / replies without terminal scrollback access).
3057
+ const arg = input.slice("/history".length).trim().toLowerCase();
3058
+ const maxTurns = arg === "all" ? undefined : Math.max(1, Number.parseInt(arg, 10) || 5);
3059
+ const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
3060
+ logLines([sep, `history · last ${maxTurns ?? "all"} turn(s) (/history all for everything)`, sep,
3061
+ ...formatTranscript(history, { maxTurns, color: true, unicode: true }), sep]);
3062
+ continue;
3063
+ }
3064
+ if (input === "/export" || input.startsWith("/export ")) {
3065
+ if (!sessionId) {
3066
+ console.log("(sessions are disabled — nothing to export)");
3067
+ continue;
3068
+ }
3069
+ const tokens = input.substring(7).trim().split(/\s+/).filter(Boolean);
3070
+ const fmtToken = tokens.find(t => t.toLowerCase() === "json" || t.toLowerCase() === "markdown");
3071
+ const format = fmtToken?.toLowerCase() === "json" ? "json" as const : "markdown" as const;
3072
+ const pathToken = tokens.find(t => t !== fmtToken);
3073
+ const outPath = path.resolve(cwd, pathToken ?? `jeo-session-${sessionId.slice(0, 8)}.${format === "json" ? "json" : "md"}`);
3074
+ try {
3075
+ const text = await exportSession(sessionId, format, cwd);
3076
+ await fs.promises.writeFile(outPath, text, "utf-8");
3077
+ console.log(`${categoryBadge("file")} exported ${format} transcript → ${outPath}`);
3078
+ } catch (err) {
3079
+ console.log(`! export failed: ${(err as Error).message}`);
3080
+ }
3081
+ continue;
3082
+ }
3083
+ if (input === "/dump") {
3084
+ if (!sessionId) {
3085
+ console.log("(sessions are disabled — nothing to dump)");
3086
+ continue;
3087
+ }
3088
+ try {
3089
+ const text = await exportSession(sessionId, "markdown", cwd);
3090
+ const clip = process.platform === "darwin" ? "pbcopy" : Bun.which("wl-copy") ? "wl-copy" : Bun.which("xclip") ? "xclip" : "";
3091
+ if (clip && Bun.which(clip)) {
3092
+ const proc = Bun.spawn(clip === "xclip" ? [clip, "-selection", "clipboard"] : [clip], { stdin: "pipe" });
3093
+ proc.stdin.write(text);
3094
+ await proc.stdin.end();
3095
+ await proc.exited;
3096
+ console.log(`(transcript copied to clipboard — ${text.length} chars)`);
3097
+ } else {
3098
+ console.log(text);
3099
+ console.log("(no clipboard tool found — transcript printed above)");
3100
+ }
3101
+ } catch (err) {
3102
+ console.log(`! dump failed: ${(err as Error).message}`);
3103
+ }
3104
+ continue;
3105
+ }
3106
+ if (input === "/btw" || input.startsWith("/btw ")) {
3107
+ const q = input.substring(4).trim();
3108
+ if (!q) {
3109
+ console.log("Usage: /btw <question> (ephemeral side question — history stays untouched)");
3110
+ continue;
3111
+ }
3112
+ try {
3113
+ const side: Message[] = [
3114
+ { role: "system", content: "You are jeo. Answer the user's side question concisely in plain text using the conversation context. Do not call tools; reply directly." },
3115
+ ...history.slice(1).filter(m => m.role === "user" || m.role === "assistant").slice(-20),
3116
+ { role: "user", content: q },
3117
+ ];
3118
+ // Side questions are quick lookups: STREAM the reply (immediate feedback
3119
+ // instead of a silent 10-30s wait that reads as "broken") and cap the
3120
+ // reasoning budget at LOW — an xhigh session level made slow models burn
3121
+ // a huge thinking budget before the first byte.
3122
+ process.stdout.write("btw> ");
3123
+ let streamed = "";
3124
+ const answer = await callLlm(side, {
3125
+ model: sessionModel,
3126
+ maxTokens: thinkingMaxTokens("low"),
3127
+ onToken: delta => {
3128
+ streamed += delta;
3129
+ process.stdout.write(delta);
3130
+ },
3131
+ });
3132
+ if (!streamed.trim() && answer.trim()) process.stdout.write(answer.trim());
3133
+ process.stdout.write("\n");
3134
+ } catch (err) {
3135
+ console.log(`\n! ${friendlyProviderError(err)}`);
3136
+ }
3137
+ continue;
3138
+ }
3139
+ // ---- gjc-parity inspection commands ------------------------------------
3140
+ if (input === "/usage") {
3141
+ const total = sessionUsage.inputTokens + sessionUsage.outputTokens;
3142
+ console.log("Provider token usage (this REPL):");
3143
+ console.log(` turns ${sessionUsage.turns}`);
3144
+ console.log(` input ${sessionUsage.inputTokens}`);
3145
+ console.log(` output ${sessionUsage.outputTokens}`);
3146
+ console.log(` total ${total}${total === 0 ? " (providers report usage per turn; run a request first)" : ""}`);
3147
+ continue;
3148
+ }
3149
+ if (input === "/context") {
3150
+ // Token estimate (~4 chars/token) over the in-memory history, by role.
3151
+ const est = (s: string) => Math.ceil(s.length / 4);
3152
+ const byRole: Record<string, { msgs: number; tokens: number }> = {};
3153
+ for (const m of history) {
3154
+ const slot = (byRole[m.role] ??= { msgs: 0, tokens: 0 });
3155
+ slot.msgs++;
3156
+ slot.tokens += est(m.content);
3157
+ }
3158
+ const total = Object.values(byRole).reduce((sum, r) => sum + r.tokens, 0);
3159
+ const { resolved } = await describeModel(sessionModel || (await readGlobalConfig()).defaultModel);
3160
+ const window = catalogMetadata(resolved)?.contextTokens;
3161
+ console.log("Context usage (estimated, ~4 chars/token):");
3162
+ for (const [role, r] of Object.entries(byRole)) {
3163
+ console.log(` ${role.padEnd(9)} ${String(r.msgs).padStart(3)} msg${r.msgs === 1 ? " " : "s"} ~${r.tokens} tokens`);
3164
+ }
3165
+ console.log(` ${"total".padEnd(9)} ${String(history.length).padStart(3)} msgs ~${total} tokens${window ? ` (${Math.round((total / window) * 100)}% of ${resolved}'s ${window}-token window)` : ""}`);
3166
+ console.log(" Free context with /compact or /clear.");
3167
+ continue;
3168
+ }
3169
+ if (input === "/tools") {
3170
+ console.log("Tools visible to the agent:");
3171
+ for (const line of TOOL_PROTOCOL.split("\n")) console.log(` ${line}`);
3172
+ console.log(` ${taskToolProtocolLine(await readGlobalConfig())}`);
3173
+ console.log(` ${TODO_TOOL_PROTOCOL_LINE}`);
3174
+ continue;
3175
+ }
3176
+ if (input === "/hotkeys") {
3177
+ console.log("Keyboard shortcuts:");
3178
+ console.log(" Tab complete slash commands, models, roles, @paths");
3179
+ console.log(" ↑ / ↓ navigate the slash-command preview (Enter runs the highlighted one)");
3180
+ console.log(" Enter submit input / confirm picker selection");
3181
+ console.log(" Esc cancel an open picker");
3182
+ console.log(" Ctrl-C cancel the in-flight turn (press again at the prompt to exit)");
3183
+ console.log(" Ctrl-D exit the REPL");
3184
+ console.log(" Ctrl-O dump the full last response (untruncated, tables rendered) into scrollback");
3185
+ console.log(" Ctrl-K / Ctrl-U / Ctrl-W kill to end / start of line / previous word (emacs kill-ring)");
3186
+ console.log(" Ctrl-Y / Alt-Y yank / yank-pop the killed text");
3187
+ console.log(" Ctrl-A / Ctrl-E move to start / end of line");
3188
+ console.log(" / open the slash-command palette");
3189
+ console.log(" @path mention a file (Tab completes relative paths)");
3190
+ continue;
3191
+ }
3192
+ if (input === "/theme" || input.startsWith("/theme ")) {
3193
+ const want = input.substring(6).trim().toLowerCase();
3194
+ const themes = listThemes();
3195
+ if (!want) {
3196
+ const active = resolveTheme().name;
3197
+ console.log("TUI themes (set with /theme <name>, persists via ~/.jeo/config.json):");
3198
+ for (const t of themes) console.log(` ${t.name === active ? "*" : " "} ${t.name.padEnd(10)} ${t.description}`);
3199
+ continue;
3200
+ }
3201
+ if (!themes.some(t => t.name === want)) {
3202
+ console.log(`Unknown theme '${want}'. Known: ${themes.map(t => t.name).join(", ")}.`);
3203
+ continue;
3204
+ }
3205
+ process.env.JEO_TUI_THEME = want;
3206
+ await saveConfigPatch(raw => ({ theme: want }));
3207
+ refreshUiTheme(); // re-resolve the keystroke-hot theme handle immediately
3208
+ console.log(`Theme set to ${want} — saved to ~/.jeo/config.json`);
451
3209
  continue;
452
3210
  }
453
3211
  if (input === "/evolve") {
@@ -459,104 +3217,340 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
459
3217
  console.log("\n=== Evolved to Singularity! ===");
460
3218
  continue;
461
3219
  }
462
- if (input.startsWith("/models") && (input === "/models" || input[7] === " ")) {
463
- const sub = input.substring(7).trim().toLowerCase();
464
- const refresh = sub === "refresh";
465
- if (sub === "caps") {
466
- const live = await getLiveModels();
467
- const def = sessionModel || (await readGlobalConfig()).defaultModel;
468
- const { resolved } = await describeModel(def);
469
- console.log("Live models with capabilities (ctx/out/thinking/img):");
470
- for (const line of formatEnrichedModels(sortByCapability(enrichAll(live)), { current: resolved })) console.log(line);
471
- continue;
472
- }
473
- const cfgNow = await readGlobalConfig();
474
- const def = sessionModel || cfgNow.defaultModel;
475
- const { resolved, provider } = await describeModel(def);
476
- console.log(`Default model: ${formatModelLine({ label: def, resolved, provider })}`);
477
- console.log("Aliases:");
478
- for (const line of formatAliasLines(await listAliases())) console.log(line);
479
- const live = await getLiveModels(refresh);
480
- lastPickIndex = flattenModels(live);
481
- console.log("Live models (logged-in providers) — select with /model #N:");
482
- for (const line of formatPickList(lastPickIndex, { current: resolved })) console.log(line);
483
- console.log("Refresh: /models refresh · one provider: /provider <name>");
484
- continue;
485
- }
486
3220
  if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
487
3221
  const tokens = input.substring(9).trim().split(/\s+/).filter(Boolean);
488
3222
  const name = (tokens[0] ?? "").toLowerCase();
489
3223
  const explicitModel = tokens[1];
3224
+ // `/provider login|auth [name]` → run OAuth login from the REPL.
3225
+ if (name === "login" || name === "auth") {
3226
+ const cloud = ["anthropic", "openai", "gemini", "antigravity"] as const;
3227
+ let target = tokens.slice(1).map(t => t.toLowerCase()).find(t => (cloud as readonly string[]).includes(t));
3228
+ if (!target) {
3229
+ const statuses = await describeAllProviders();
3230
+ if (process.stdin.isTTY && process.stdout.isTTY) {
3231
+ target = await pickCloudProvider(statuses);
3232
+ } else {
3233
+ // No provider given → show current status and let the user pick.
3234
+ console.log("Log in to which provider?");
3235
+ cloud.forEach((p, i) => {
3236
+ const st = statuses.find(s => s.name === p);
3237
+ console.log(` ${i + 1}) ${p.padEnd(10)} ${st?.ready ? `✓ ${st.label}` : "· not ready"}`);
3238
+ });
3239
+ const ans = (await promptInput("Choose [1-3] or name (blank to cancel): ")).trim().toLowerCase();
3240
+ const byNum: Record<string, string> = { "1": "anthropic", "2": "openai", "3": "gemini" };
3241
+ target = byNum[ans] ?? ((cloud as readonly string[]).includes(ans) ? ans : undefined);
3242
+ }
3243
+ if (!target) {
3244
+ console.log("(cancelled)");
3245
+ continue;
3246
+ }
3247
+ }
3248
+ console.log(`Starting OAuth login for ${target}…`);
3249
+ try {
3250
+ const { email } = await interactiveOAuthLogin(target as AuthProvider, rl);
3251
+ console.log(`[SUCCESS] OAuth login complete for ${target}${email ? ` (${email})` : ""}. Tokens saved to ~/.jeo/config.json.`);
3252
+ const live = await refreshLiveModelsCache();
3253
+ const after = (await describeAllProviders()).find(s => s.name === target);
3254
+ if (after) console.log(` status → ${after.name}: ${after.ready ? `✓ ${after.label}` : after.label}`);
3255
+ const forProvider = live.filter(r => r.provider === target);
3256
+ if (forProvider.some(r => r.ok && r.models.length > 0)) {
3257
+ lastPickIndex = flattenModels(forProvider);
3258
+ const viaCatalog = forProvider.some(r => r.fallback);
3259
+ console.log(` ${viaCatalog ? "catalog" : "live"} ${target} models → /model #N or /provider ${target} #N${viaCatalog ? " (live list endpoint rejected this token; showing known models)" : ""}`);
3260
+ logLines(formatPickListWithCapabilities(lastPickIndex, { cap: 12 }));
3261
+ } else {
3262
+ const failed = forProvider.find(r => !r.ok);
3263
+ if (failed?.error) console.log(` live ${target} models unavailable: ${failed.error}`);
3264
+ }
3265
+ } catch (err) {
3266
+ console.log(`[FAILED] ${(err as Error).message} — or set ${target.toUpperCase()}_API_KEY.`);
3267
+ }
3268
+ continue;
3269
+ }
490
3270
  const cfgNow = await readGlobalConfig();
491
3271
  const statuses = await describeAllProviders(cfgNow);
492
3272
  if (!name) {
493
3273
  console.log("Providers (credential · base URL):");
494
- for (const line of formatProviderPanel(statuses)) console.log(line);
495
- console.log("Switch with: /provider <name> [model] · list live models: /models");
3274
+ logLines(formatProviderPanel(statuses));
3275
+ console.log("Switch with: /provider <name> [model] · arrows+Enter picker: /provider <name> · choose models: /model");
496
3276
  continue;
497
3277
  }
498
- const PROVIDER_DEFAULT: Record<string, string> = { anthropic: "sonnet", openai: "gpt", gemini: "flash", ollama: "fast" };
499
- if (!(name in PROVIDER_DEFAULT)) {
3278
+ if (!isProviderName(name)) {
500
3279
  console.log(`Unknown provider '${name}'. Known: ${statuses.map(s => s.name).join(", ")}.`);
501
3280
  continue;
502
3281
  }
503
3282
  const st = statuses.find(s => s.name === name);
504
3283
  if (st && !st.ready) {
505
- console.log(`! ${name} is not logged inrun 'joc auth login' or set ${st.envVar ?? "the provider key"}. Switching anyway.`);
3284
+ console.log(`! ${name} is not ready (${st.label}) — set ${st.envVar ?? "the provider key"} or configure a compatible base URL. Switching anyway.`);
506
3285
  }
507
- const target = explicitModel ?? PROVIDER_DEFAULT[name];
508
- sessionModel = target;
509
- const { resolved, provider } = await describeModel(target);
510
- console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })}`);
511
- // Show the provider's live, credentialed catalog so the user can pick a concrete id.
512
3286
  const live = await getLiveModels();
513
3287
  const forProvider = live.filter(r => r.provider === name);
514
- if (forProvider.length) {
515
- lastPickIndex = flattenModels(forProvider);
516
- console.log(`Live ${name} models — select with /model #N:`);
517
- for (const line of formatPickList(lastPickIndex, { current: resolved })) console.log(line);
3288
+ const providerPick = flattenModels(forProvider);
3289
+ const currentResolved = (await describeModel(sessionModel || cfgNow.defaultModel)).resolved;
3290
+ let pickedFromPicker = false;
3291
+ let target = explicitModel ?? PROVIDER_DEFAULT[name];
3292
+ if (!explicitModel && providerPick.length && process.stdin.isTTY && process.stdout.isTTY) {
3293
+ const picked = await pickLiveProviderModel(name, providerPick, currentResolved, st && !st.ready && !selectableThoughNotReady(st) ? [name] : []);
3294
+ if (!picked) {
3295
+ console.log("(cancelled)");
3296
+ continue;
3297
+ }
3298
+ pickedFromPicker = true;
3299
+ target = qualifyModelId(picked.model, picked.provider);
3300
+ } else if (explicitModel && providerPick.length) {
3301
+ const sel = resolveSelection(providerPick, explicitModel);
3302
+ if (sel.kind === "index" || sel.kind === "match") {
3303
+ target = qualifyModelId(sel.entry.model, sel.entry.provider);
3304
+ if (st && !st.ready) {
3305
+ if (selectableThoughNotReady(st)) {
3306
+ console.log(notReadyWarning(st));
3307
+ } else {
3308
+ console.log(`Cannot select ${sel.entry.model}: ${name} is not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
3309
+ continue;
3310
+ }
3311
+ }
3312
+ } else if (sel.kind === "ambiguous") {
3313
+ console.log(`'${explicitModel}' matches ${sel.matches.length} ${name} models — be more specific:`);
3314
+ for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
3315
+ continue;
3316
+ } else if (sel.kind === "out-of-range") {
3317
+ console.log(`#${explicitModel.slice(1)} is out of range for ${name} (1-${sel.max}).`);
3318
+ continue;
3319
+ }
3320
+ } else if (explicitModel?.startsWith("#")) {
3321
+ console.log(`No numbered ${name} model list is available yet.`);
3322
+ continue;
518
3323
  }
519
- if (explicitModel && !liveModelKnown(live, target)) {
520
- console.log(` (note: '${target}' is not in ${name}'s live list — it may still work, or pick one above)`);
3324
+ const { resolved, provider } = await describeModel(target);
3325
+ if (explicitModel && provider !== name) {
3326
+ console.log(`! '${target}' resolves to ${provider}, not ${name}. Pick a ${name} model from the live list below.`);
3327
+ if (providerPick.length) logLines(formatPickListWithCapabilities(providerPick, { cap: 20 }));
3328
+ continue;
3329
+ }
3330
+ if (pickedFromPicker && await applyPickedModelWithTarget(target)) {
3331
+ if (providerPick.length) lastPickIndex = providerPick;
3332
+ continue;
3333
+ }
3334
+ sessionModel = target;
3335
+ // MRU persistence: a provider/model pick becomes the default for EVERY
3336
+ // future session and the head of the recents rotation.
3337
+ await saveConfigPatch(raw => rememberModelPatch(raw, target));
3338
+ console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })} — saved as default`);
3339
+ // Show the provider's live, credentialed catalog so the user can pick a concrete id.
3340
+ if (providerPick.length) {
3341
+ lastPickIndex = providerPick;
3342
+ if (!pickedFromPicker) {
3343
+ console.log(`Live ${name} models — select with /model #N, /provider ${name} #N, or rerun /provider ${name} and use arrows+Enter:`);
3344
+ logLines(formatPickListWithCapabilities(lastPickIndex, { current: resolved }));
3345
+ }
521
3346
  }
522
3347
  continue;
523
3348
  }
524
- if (input.startsWith("/agents") && (input === "/agents" || input[7] === " ")) {
525
- const tokens = input.substring(7).trim().split(/\s+/).filter(Boolean);
3349
+ if (input.startsWith("/logout") && (input === "/logout" || input[7] === " ")) {
3350
+ const cloud = ["anthropic", "openai", "gemini", "antigravity"];
3351
+ const target = input.substring(7).trim().split(/\s+/).map(t => t.toLowerCase()).find(t => cloud.includes(t));
3352
+ if (!target) {
3353
+ console.log("Usage: /logout <anthropic|openai|gemini|antigravity>");
3354
+ continue;
3355
+ }
3356
+ const removed = await logoutOAuth(target as AuthProvider);
3357
+ console.log(removed ? `[SUCCESS] Removed OAuth token for ${target}.` : `No OAuth token stored for ${target}.`);
3358
+ await refreshLiveModelsCache();
3359
+ continue;
3360
+ }
3361
+ const agentsCommand =
3362
+ input === "/agents" || input.startsWith("/agents ") ? "/agents" :
3363
+ undefined;
3364
+ if (agentsCommand) {
3365
+ const tokens = input.substring(agentsCommand.length).trim().split(/\s+/).filter(Boolean);
526
3366
  const roleArg = tokens[0];
527
3367
  const modelArg = tokens[1];
528
3368
  const cfgNow = await readGlobalConfig();
529
- if (!roleArg) {
530
- console.log("Subagent roles (used by 'joc team'):");
531
- for (const line of formatAgentsPanel(SUBAGENT_ROLES, r => ({
3369
+ const subcommand = roleArg?.toLowerCase();
3370
+ const printRoster = () => {
3371
+ console.log("Subagent roles (used by 'jeo team'):");
3372
+ for (const line of formatAgentsPanel(allSubagentRoles(cfgNow), r => ({
532
3373
  model: resolveSubagentModel(r.id, cfgNow),
533
3374
  maxSteps: resolveSubagentMaxSteps(r.id, cfgNow),
3375
+ thinking: resolveSubagentThinking(r.id, cfgNow),
534
3376
  }))) console.log(line);
535
- console.log("Detail: /agents <role> · set model: /agents <role> <model>");
3377
+ 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");
3378
+ console.log("Tip: primary model flow: /model → pick model → choose default or subagent role → choose thinking level");
3379
+ console.log(`Available: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")} (declare custom roles in config.subagents)`);
3380
+ console.log("Subcommands: edit, <role> <model|#N>, <role> thinking <level|inherit>, <role> provider <name> [model], <role> maxSteps <N>, <role> reset");
3381
+ };
3382
+ if (!roleArg || roleArg === "/" || roleArg === "?" || subcommand === "help") {
3383
+ printRoster();
536
3384
  continue;
537
3385
  }
538
- const role = getSubagentRole(roleArg);
3386
+ if (subcommand === "edit" || subcommand === "picker") {
3387
+ printRoster();
3388
+ // Interactive editor (TTY): role picker → action picker → live model /
3389
+ // thinking / reset — the arrows+Enter way to CHANGE an existing setting.
3390
+ const rolePick = await pickFromOptions(
3391
+ "Edit a subagent role (ESC to skip)",
3392
+ allSubagentRoles(cfgNow).map(r => ({
3393
+ value: r.id,
3394
+ label: `${r.id} — ${r.title}`,
3395
+ hint: `${resolveSubagentModel(r.id, cfgNow)} · ${resolveSubagentMaxSteps(r.id, cfgNow)} steps${cfgNow.subagents?.[r.id]?.model ? "" : " (default)"}`,
3396
+ })),
3397
+ );
3398
+ const editRole = rolePick ? getSubagentRole(rolePick, cfgNow) : undefined;
3399
+ if (!editRole) continue;
3400
+ const action = await pickFromOptions(`${editRole.title} — choose action`, [
3401
+ { value: "model", label: "change model", hint: resolveSubagentModel(editRole.id, cfgNow) },
3402
+ { value: "thinking", label: "change thinking", hint: resolveSubagentThinking(editRole.id, cfgNow) ?? `inherit (${cfgNow.thinkingLevel ?? "medium"})` },
3403
+ { value: "reset", label: "reset to defaults", hint: "clears model + maxSteps + thinking override" },
3404
+ ]);
3405
+ if (action === "reset") {
3406
+ await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, editRole.id) }));
3407
+ console.log(`${editRole.title} settings reset to defaults → ~/.jeo/config.json`);
3408
+ } else if (action === "model") {
3409
+ const live = await getLiveModels();
3410
+ const entries = flattenModels(live);
3411
+ const picked = await pickLiveProviderModel(`${editRole.id}`, entries, resolveSubagentModel(editRole.id, cfgNow));
3412
+ if (picked) {
3413
+ const pinned = qualifyModelId(picked.model, picked.provider);
3414
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, editRole.id, { model: pinned }) }));
3415
+ console.log(`Subagent '${editRole.id}' model set to ${pinned} → ~/.jeo/config.json`);
3416
+ }
3417
+ } else if (action === "thinking") {
3418
+ const lvl = await pickThinkingLevel(
3419
+ `Reasoning for ${editRole.title}`,
3420
+ cfgNow.subagents?.[editRole.id]?.thinking,
3421
+ `inherit — follow default (${cfgNow.thinkingLevel ?? "medium"})`,
3422
+ );
3423
+ if (lvl) await setRoleThinking(editRole.id, lvl);
3424
+ }
3425
+ continue;
3426
+ }
3427
+ const role = getSubagentRole(roleArg, cfgNow);
539
3428
  if (!role) {
540
- console.log(`Unknown role '${roleArg}'. Known: ${SUBAGENT_ROLES.map(r => r.id).join(", ")}.`);
3429
+ console.log(`Unknown role '${roleArg}'. Known: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")}.`);
3430
+ continue;
3431
+ }
3432
+ if (modelArg?.toLowerCase() === "reset") {
3433
+ await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, role.id) }));
3434
+ console.log(`${role.title} settings reset to defaults → ~/.jeo/config.json`);
3435
+ continue;
3436
+ }
3437
+ if (modelArg?.toLowerCase() === "maxsteps" || modelArg?.toLowerCase() === "steps") {
3438
+ const maxSteps = parseMaxSteps(tokens[2]);
3439
+ if (!maxSteps) {
3440
+ console.log(`Usage: /agents ${role.id} maxSteps <positive-number>`);
3441
+ continue;
3442
+ }
3443
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { maxSteps }) }));
3444
+ console.log(`${role.title} maxSteps set to ${maxSteps} → ~/.jeo/config.json`);
3445
+ continue;
3446
+ }
3447
+ if (modelArg?.toLowerCase() === "thinking" || modelArg?.toLowerCase() === "think") {
3448
+ await setRoleThinking(role.id, tokens[2]);
3449
+ continue;
3450
+ }
3451
+ if (modelArg?.toLowerCase() === "provider") {
3452
+ const want = (tokens[2] ?? "").toLowerCase();
3453
+ if (!isProviderName(want)) {
3454
+ console.log(`Usage: /agents ${role.id} provider <anthropic|openai|gemini|antigravity|ollama> [model|#N]`);
3455
+ continue;
3456
+ }
3457
+ const st = (await describeAllProviders()).find(s => s.name === want);
3458
+ if (st && !st.ready) {
3459
+ if (selectableThoughNotReady(st)) {
3460
+ console.log(notReadyWarning(st));
3461
+ } else {
3462
+ console.log(`Cannot pin ${role.title} to ${want}: not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
3463
+ continue;
3464
+ }
3465
+ }
3466
+ const live = await getLiveModels();
3467
+ const forProvider = flattenModels(live.filter(r => r.provider === want));
3468
+ const explicit = tokens[3];
3469
+ let chosenModel: string;
3470
+ if (explicit && forProvider.length) {
3471
+ const sel = resolveSelection(forProvider, explicit);
3472
+ if (sel.kind === "index" || sel.kind === "match") chosenModel = qualifyModelId(sel.entry.model, want);
3473
+ else if (sel.kind === "ambiguous") {
3474
+ console.log(`'${explicit}' matches ${sel.matches.length} ${want} models — be more specific:`);
3475
+ for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model}`);
3476
+ continue;
3477
+ } else if (sel.kind === "out-of-range") {
3478
+ console.log(`#${explicit.slice(1)} is out of range for ${want} (1-${sel.max}).`);
3479
+ continue;
3480
+ } else {
3481
+ chosenModel = qualifyModelId(explicit, want);
3482
+ }
3483
+ } else if (explicit) {
3484
+ chosenModel = qualifyModelId(explicit, want);
3485
+ } else if (forProvider.length) {
3486
+ // No model given → the provider's first live model, provider-qualified.
3487
+ chosenModel = qualifyModelId(forProvider[0]!.model, want);
3488
+ } else {
3489
+ chosenModel = PROVIDER_DEFAULT[want];
3490
+ }
3491
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
3492
+ console.log(`${role.title} pinned to ${want} via model ${chosenModel} — saved to ~/.jeo/config.json`);
3493
+ if (forProvider.length) {
3494
+ lastPickIndex = forProvider;
3495
+ console.log(`Live ${want} models — refine with /agents ${role.id} #N:`);
3496
+ for (const line of formatPickListWithCapabilities(lastPickIndex, { current: chosenModel, cap: 12 })) console.log(line);
3497
+ }
541
3498
  continue;
542
3499
  }
543
3500
  if (modelArg) {
544
- // Persist a per-role model override to ~/.joc/config.json (consumed by 'joc team').
545
- const next = { ...cfgNow, subagents: { ...(cfgNow.subagents ?? {}) } };
546
- next.subagents[role.id] = { ...next.subagents[role.id], model: modelArg };
547
- await saveGlobalConfig(next);
548
- const { provider } = await describeModel(modelArg);
549
- console.log(`${role.title} model set to ${modelArg} (${provider}) — saved to ~/.joc/config.json`);
3501
+ let chosenModel = modelArg;
3502
+ let entries = lastPickIndex;
3503
+ if (modelArg.startsWith("#") && entries.length === 0) {
3504
+ const live = await getLiveModels();
3505
+ entries = flattenModels(live);
3506
+ }
3507
+ if (entries.length) {
3508
+ const sel = resolveSelection(entries, modelArg);
3509
+ if (sel.kind === "index" || sel.kind === "match") {
3510
+ chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
3511
+ const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
3512
+ if (bad) {
3513
+ if (selectableThoughNotReady(bad)) {
3514
+ console.log(notReadyWarning(bad));
3515
+ } else {
3516
+ console.log(`Cannot pin ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad.label}). Set ${bad.envVar ?? "the provider key"} first.`);
3517
+ continue;
3518
+ }
3519
+ }
3520
+ } else if (sel.kind === "ambiguous") {
3521
+ console.log(`'${modelArg}' matches ${sel.matches.length} live models — be more specific:`);
3522
+ for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
3523
+ continue;
3524
+ } else if (sel.kind === "out-of-range") {
3525
+ console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
3526
+ continue;
3527
+ }
3528
+ } else if (modelArg.startsWith("#")) {
3529
+ console.log("Use /model or /provider <name> first to build the numbered live model list.");
3530
+ continue;
3531
+ }
3532
+ // Persist a per-role model override to ~/.jeo/config.json (consumed by 'jeo team').
3533
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
3534
+ const { provider } = await describeModel(chosenModel);
3535
+ console.log(`${role.title} model set to ${chosenModel} (${provider}) — saved to ~/.jeo/config.json`);
550
3536
  const live = await getLiveModels();
551
- if (!liveModelKnown(live, modelArg)) {
552
- console.log(` (note: '${modelArg}' is not in any live model list — verify it is valid for ${provider})`);
3537
+ if (!liveModelKnown(live, chosenModel)) {
3538
+ console.log(` (note: '${chosenModel}' is not in any live model list — verify it is valid for ${provider})`);
553
3539
  }
554
3540
  continue;
555
3541
  }
556
3542
  for (const line of formatAgentDetail(role, {
557
3543
  model: resolveSubagentModel(role.id, cfgNow),
558
3544
  maxSteps: resolveSubagentMaxSteps(role.id, cfgNow),
3545
+ thinking: resolveSubagentThinking(role.id, cfgNow),
559
3546
  })) console.log(line);
3547
+ const live = await getLiveModels();
3548
+ const agentPick = flattenModels(live);
3549
+ if (agentPick.length) {
3550
+ lastPickIndex = agentPick;
3551
+ console.log(`Live models for ${role.title} — pin with /agents ${role.id} #N:`);
3552
+ for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolveSubagentModel(role.id, cfgNow), cap: 20 })) console.log(line);
3553
+ }
560
3554
  continue;
561
3555
  }
562
3556
  if (input === "/config") {
@@ -582,9 +3576,35 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
582
3576
  const TIERS = ["smol", "slow", "plan"] as const;
583
3577
  if (tokens.length >= 2 && (TIERS as readonly string[]).includes(tokens[0])) {
584
3578
  const tier = tokens[0] as (typeof TIERS)[number];
585
- const next = { ...cfgNow, roles: { ...(cfgNow.roles ?? {}), [tier]: tokens[1] } };
586
- await saveGlobalConfig(next);
587
- console.log(`Role '${tier}' model set to ${tokens[1]} → ~/.joc/config.json`);
3579
+ let chosenModel = tokens[1]!;
3580
+ let entries = lastPickIndex;
3581
+ if (chosenModel.startsWith("#") && entries.length === 0) {
3582
+ const live = await getLiveModels();
3583
+ entries = flattenModels(live);
3584
+ }
3585
+ if (entries.length) {
3586
+ const sel = resolveSelection(entries, chosenModel);
3587
+ if (sel.kind === "index" || sel.kind === "match") {
3588
+ chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
3589
+ const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
3590
+ if (bad) {
3591
+ 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.`);
3592
+ continue;
3593
+ }
3594
+ } else if (sel.kind === "ambiguous") {
3595
+ console.log(`'${chosenModel}' matches ${sel.matches.length} live models — be more specific:`);
3596
+ for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
3597
+ continue;
3598
+ } else if (sel.kind === "out-of-range") {
3599
+ console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
3600
+ continue;
3601
+ }
3602
+ } else if (chosenModel.startsWith("#")) {
3603
+ console.log("Use /model or /provider <name> first to build the numbered live model list.");
3604
+ continue;
3605
+ }
3606
+ await saveConfigPatch(raw => ({ roles: { ...(raw.roles ?? {}), [tier]: chosenModel } }));
3607
+ console.log(`Role '${tier}' model set to ${chosenModel} → ~/.jeo/config.json`);
588
3608
  continue;
589
3609
  }
590
3610
  console.log("Model role tiers (fall back to the default model):");
@@ -593,6 +3613,43 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
593
3613
  console.log(` ${tier.padEnd(5)} ${resolveRoleModel(tier, cfgNow)} (${provider})`);
594
3614
  }
595
3615
  console.log("Set a tier: /roles <smol|slow|plan> <model>");
3616
+ const live = await getLiveModels();
3617
+ const rolePick = flattenModels(live);
3618
+ if (rolePick.length) {
3619
+ lastPickIndex = rolePick;
3620
+ console.log("Live models for role tiers — set with /roles <tier> #N:");
3621
+ for (const line of formatPickListWithCapabilities(lastPickIndex, { cap: 15 })) console.log(line);
3622
+ }
3623
+ continue;
3624
+ }
3625
+ if (input.startsWith("/fast") && (input === "/fast" || input[5] === " ")) {
3626
+ const arg = input.substring(5).trim().toLowerCase() || "status";
3627
+ const cfgNow = await readGlobalConfig();
3628
+ const currentModel = sessionModel || cfgNow.defaultModel;
3629
+ const { resolved, provider } = await describeModel(currentModel);
3630
+ const fastLevel = fastThinkingLevelForModel(resolved);
3631
+ const currentThinking = sessionThinking ?? cfgNow.thinkingLevel ?? "medium";
3632
+ const status = fastLevel && currentThinking === fastLevel ? "on" : "off";
3633
+ if (arg === "status") {
3634
+ const support = fastLevel ? `supported (thinking ${fastLevel})` : "unsupported";
3635
+ console.log(`Fast mode: ${status} · ${support} · ${formatModelLine({ label: currentModel, resolved, provider })} · current thinking ${currentThinking}`);
3636
+ continue;
3637
+ }
3638
+ if (arg === "on") {
3639
+ if (!fastLevel) {
3640
+ console.log(`Fast mode is not advertised for ${formatModelLine({ label: currentModel, resolved, provider })}; pick a thinking-capable model with /model.`);
3641
+ continue;
3642
+ }
3643
+ sessionThinking = fastLevel;
3644
+ console.log(`Fast mode on: ${formatModelLine({ label: currentModel, resolved, provider })} · thinking ${fastLevel} (~${thinkingMaxTokens(fastLevel)} max tokens/step)`);
3645
+ continue;
3646
+ }
3647
+ if (arg === "off") {
3648
+ sessionThinking = cfgNow.thinkingLevel ?? "medium";
3649
+ console.log(`Fast mode off: restored thinking ${sessionThinking} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
3650
+ continue;
3651
+ }
3652
+ console.log("Usage: /fast [on|off|status]");
596
3653
  continue;
597
3654
  }
598
3655
  if (input.startsWith("/thinking") && (input === "/thinking" || input[9] === " ")) {
@@ -613,44 +3670,203 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
613
3670
  let arg = input.substring(6).trim();
614
3671
  // `/model save [id]` → persist the (session or given) model as the config default.
615
3672
  if (arg === "save" || arg.startsWith("save ")) {
616
- const toSave = arg.slice(4).trim() || sessionModel || defaultModel;
617
- const cfgNow = await readGlobalConfig();
618
- await saveGlobalConfig({ ...cfgNow, defaultModel: toSave });
619
- const { resolved, provider } = await describeModel(toSave);
620
- console.log(`Default model saved: ${formatModelLine({ label: toSave, resolved, provider })} → ~/.joc/config.json`);
3673
+ let toSave = arg.slice(4).trim();
3674
+ // Resolve `#N`/fuzzy through the same pick-list logic as `/model #N`, so we never
3675
+ // persist a literal token like "#2" as defaultModel (which then fails to route).
3676
+ if (toSave && lastPickIndex.length) {
3677
+ const sel = resolveSelection(lastPickIndex, toSave);
3678
+ if (sel.kind === "index" || sel.kind === "match") toSave = qualifyModelId(sel.entry.model, sel.entry.provider);
3679
+ else if (sel.kind === "ambiguous") {
3680
+ console.log(`'${toSave}' matches ${sel.matches.length} models — be more specific:`);
3681
+ for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
3682
+ continue;
3683
+ } else if (sel.kind === "out-of-range") {
3684
+ console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
3685
+ continue;
3686
+ }
3687
+ // kind "none" → treat `toSave` as a literal model id/alias.
3688
+ } else if (toSave.startsWith("#")) {
3689
+ console.log("Use /model or /provider <name> first to build the numbered list.");
3690
+ continue;
3691
+ }
3692
+ // Fall back to the FRESH on-disk default (not the stale session-start snapshot) so a
3693
+ // bare `/model save` after a prior `/model save <id>` never reverts the saved default.
3694
+ const finalSave = toSave || sessionModel || (await readGlobalConfig()).defaultModel;
3695
+ await saveConfigPatch(raw => rememberModelPatch(raw, finalSave));
3696
+ const { resolved, provider } = await describeModel(finalSave);
3697
+ console.log(`Default model saved: ${formatModelLine({ label: finalSave, resolved, provider })} → ~/.jeo/config.json`);
3698
+ continue;
3699
+ }
3700
+ const modelThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(arg);
3701
+ if (modelThinking) {
3702
+ const level = (modelThinking[1] ?? "").toLowerCase();
3703
+ if (!isThinkingLevel(level)) {
3704
+ console.log("Usage: /model thinking <minimal|low|medium|high|xhigh>");
3705
+ continue;
3706
+ }
3707
+ sessionThinking = level;
3708
+ await saveConfigPatch(() => ({ thinkingLevel: level }));
3709
+ console.log(`Default thinking set to ${level} (~${thinkingMaxTokens(level)} max tokens/step) → ~/.jeo/config.json`);
3710
+ continue;
3711
+ }
3712
+ const statuses = await describeAllProviders();
3713
+ const disabledModelProviders = statuses.filter(s => !s.ready && !selectableThoughNotReady(s)).map(s => s.name);
3714
+ const roleMatch = /^(subagent|role)\s+(\S+)(?:\s+(.+))?$/i.exec(arg);
3715
+ if (roleMatch) {
3716
+ const role = getSubagentRole(roleMatch[2] ?? "", await readGlobalConfig());
3717
+ if (!role) {
3718
+ console.log("Usage: /model subagent <executor|planner|architect|critic> [model|#N]");
3719
+ continue;
3720
+ }
3721
+ let roleModelArg = (roleMatch[3] ?? "").trim();
3722
+ const roleThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(roleModelArg);
3723
+ if (roleThinking) {
3724
+ await setRoleThinking(role.id, roleThinking[1]);
3725
+ continue;
3726
+ }
3727
+ let roleModelPickedFromSelector = false;
3728
+ if (!roleModelArg && process.stdin.isTTY && process.stdout.isTTY) {
3729
+ const live = await getLiveModels();
3730
+ lastPickIndex = flattenModels(live);
3731
+ if (lastPickIndex.length) {
3732
+ const currentResolved = (await describeModel(resolveSubagentModel(role.id, await readGlobalConfig()))).resolved;
3733
+ const picked = await pickLiveProviderModel(role.id, lastPickIndex, currentResolved, disabledModelProviders);
3734
+ if (!picked) {
3735
+ console.log("(cancelled)");
3736
+ continue;
3737
+ }
3738
+ roleModelArg = qualifyModelId(picked.model, picked.provider);
3739
+ roleModelPickedFromSelector = true;
3740
+ }
3741
+ }
3742
+ if (roleModelArg && lastPickIndex.length) {
3743
+ const sel = resolveSelection(lastPickIndex, roleModelArg);
3744
+ if (sel.kind === "index" || sel.kind === "match") {
3745
+ if (disabledModelProviders.includes(sel.entry.provider)) {
3746
+ const bad = statuses.find(s => s.name === sel.entry.provider);
3747
+ console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
3748
+ continue;
3749
+ }
3750
+ roleModelArg = qualifyModelId(sel.entry.model, sel.entry.provider);
3751
+ roleModelPickedFromSelector = true;
3752
+ } else if (sel.kind === "ambiguous") {
3753
+ console.log(`'${roleModelArg}' matches ${sel.matches.length} models — be more specific:`);
3754
+ for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
3755
+ continue;
3756
+ } else if (sel.kind === "out-of-range") {
3757
+ console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
3758
+ continue;
3759
+ }
3760
+ } else if (roleModelArg.startsWith("#")) {
3761
+ console.log("Use /model or /provider <name> first to build the numbered list.");
3762
+ continue;
3763
+ }
3764
+ if (roleModelArg) {
3765
+ let thinkPatch: { thinking?: ThinkLevel } = {};
3766
+ if (roleModelPickedFromSelector && process.stdin.isTTY && process.stdout.isTTY) {
3767
+ const cfgForRole = await readGlobalConfig();
3768
+ const lvl = await pickThinkingLevel(
3769
+ `Reasoning for ${role.title}: ${roleModelArg}`,
3770
+ cfgForRole.subagents?.[role.id]?.thinking,
3771
+ `inherit — follow default (${cfgForRole.thinkingLevel ?? "medium"})`,
3772
+ );
3773
+ thinkPatch = lvl === "inherit" ? { thinking: undefined } : lvl ? { thinking: lvl } : {};
3774
+ }
3775
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: roleModelArg, ...thinkPatch }) }));
3776
+ const { provider } = await describeModel(roleModelArg);
3777
+ const thinkNote = thinkPatch.thinking ? ` · thinking ${thinkPatch.thinking}` : "";
3778
+ console.log(`${role.title} model set to ${roleModelArg} (${provider})${thinkNote} — saved to ~/.jeo/config.json`);
3779
+ } else {
3780
+ const current = resolveSubagentModel(role.id, await readGlobalConfig());
3781
+ const { resolved, provider } = await describeModel(current);
3782
+ console.log(`${role.title} model: ${formatModelLine({ label: current, resolved, provider })}`);
3783
+ const live = await getLiveModels();
3784
+ lastPickIndex = flattenModels(live);
3785
+ if (lastPickIndex.length) {
3786
+ console.log(`Live models for ${role.title} — set with /model subagent ${role.id} #N:`);
3787
+ for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
3788
+ }
3789
+ }
621
3790
  continue;
622
3791
  }
3792
+ let modelPickedFromSelector = false;
3793
+ if (!arg && process.stdin.isTTY && process.stdout.isTTY) {
3794
+ const live = await getLiveModels();
3795
+ lastPickIndex = flattenModels(live);
3796
+ if (lastPickIndex.length) {
3797
+ const currentResolved = (await describeModel(sessionModel || defaultModel)).resolved;
3798
+ const picked = await pickLiveProviderModel("live", lastPickIndex, currentResolved, disabledModelProviders);
3799
+ if (!picked) {
3800
+ console.log("(cancelled)");
3801
+ continue;
3802
+ }
3803
+ arg = qualifyModelId(picked.model, picked.provider);
3804
+ modelPickedFromSelector = true;
3805
+ }
3806
+ }
623
3807
  // Selection from the last numbered pick list (`#N`) or a fuzzy substring.
624
3808
  if (arg && lastPickIndex.length) {
625
3809
  const sel = resolveSelection(lastPickIndex, arg);
626
3810
  if (sel.kind === "index" || sel.kind === "match") {
627
- arg = sel.entry.model;
3811
+ if (disabledModelProviders.includes(sel.entry.provider)) {
3812
+ const bad = statuses.find(s => s.name === sel.entry.provider);
3813
+ console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
3814
+ continue;
3815
+ }
3816
+ arg = qualifyModelId(sel.entry.model, sel.entry.provider);
3817
+ modelPickedFromSelector = true;
628
3818
  } else if (sel.kind === "ambiguous") {
629
3819
  console.log(`'${arg}' matches ${sel.matches.length} models — be more specific:`);
630
3820
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
631
3821
  continue;
632
3822
  } else if (sel.kind === "out-of-range") {
633
- console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Run /models first.`);
3823
+ console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
634
3824
  continue;
635
3825
  }
636
3826
  // kind "none" → fall through and treat `arg` as a literal model id/alias.
637
3827
  } else if (arg.startsWith("#")) {
638
- console.log("Run /models (or /provider <name>) first to build the numbered list.");
3828
+ console.log("Use /model or /provider <name> first to build the numbered list.");
639
3829
  continue;
640
3830
  }
641
3831
  const label = arg || (sessionModel || defaultModel);
642
- if (arg) sessionModel = arg;
3832
+ if (arg && modelPickedFromSelector && await applyPickedModelWithTarget(arg)) {
3833
+ continue;
3834
+ }
3835
+ if (arg) {
3836
+ sessionModel = arg;
3837
+ // MRU persistence: picking a model IS saving it — the newest pick wins
3838
+ // as the global default; recents keep the rotation for every session.
3839
+ await saveConfigPatch(raw => rememberModelPatch(raw, arg));
3840
+ }
643
3841
  const { resolved, provider } = await describeModel(label);
644
- const statuses = await describeAllProviders();
645
3842
  const st = statuses.find(s => s.name === provider);
646
- console.log(`${arg ? "Model set to" : "Current model"}: ${formatModelLine({ label, resolved, provider, ready: st?.ready })}`);
647
- if (st && !st.ready) console.log(` ! ${provider} has no credentialrun 'joc setup' or set ${st.envVar ?? "the provider key"}.`);
3843
+ console.log(`${arg ? "Model set to" : "Current model"}: ${formatModelLine({ label, resolved, provider, ready: st?.ready })}${arg ? " — saved as default" : ""}`);
3844
+ if (st && !st.ready) console.log(` ! ${provider} is not ready (${st.label}) — set ${st.envVar ?? "the provider key"} or run 'jeo setup'.`);
3845
+ // ChatGPT OAuth only serves the Codex models; warn before the turn fails if the user
3846
+ // pins a non-Codex id with no local base URL to fall back to (gjc-parity readiness guard).
3847
+ if (arg && provider === "openai" && st?.kind === "oauth" && !CODEX_MODELS.includes(resolved)) {
3848
+ const hasLocalBase = !!((await readGlobalConfig()).openaiBaseUrl || process.env.OPENAI_BASE_URL);
3849
+ if (!hasLocalBase) {
3850
+ 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.`);
3851
+ }
3852
+ }
648
3853
  if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
649
- console.log(` (note: '${resolved}' is not in the live ${provider} catalog — run /models to see valid ids)`);
3854
+ console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model or /provider <name> to pick a valid id)`);
650
3855
  }
651
3856
  const meta = catalogMetadata(resolved);
652
3857
  if (meta) console.log(` ${formatCapabilityLine(meta)}`);
653
- console.log(" (persist as default: /model save)");
3858
+ if (!arg) {
3859
+ const recents = recentModelsForDisplay(await readGlobalConfig());
3860
+ if (recents.length > 1) {
3861
+ console.log("Recent models (newest first):");
3862
+ recents.slice(0, 5).forEach((m, i) => console.log(` ${i + 1}. ${m}${i === 0 ? " ◀ default" : ""}`));
3863
+ }
3864
+ const live = await getLiveModels();
3865
+ lastPickIndex = flattenModels(live);
3866
+ console.log("Live models (logged-in providers) — set with /model #N:");
3867
+ for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
3868
+ }
3869
+ console.log(" (model picks persist automatically — newest selection is the default everywhere)");
654
3870
  continue;
655
3871
  }
656
3872
  if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {
@@ -675,7 +3891,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
675
3891
  const lang = detectLanguage(file);
676
3892
  const { lines, startLine } = sliceLines(content, range ?? undefined);
677
3893
  const { cols } = await import("../tui/terminal").then(m => m.size());
678
- console.log(chalk.bold(`${file}`) + chalk.gray(` (${languageLabel(lang)}, lines ${startLine}-${startLine + lines.length - 1})`));
3894
+ console.log(`${categoryBadge("file")} ${chalk.bold(`${file}`)}${chalk.gray(` (${languageLabel(lang)}, lines ${startLine}-${startLine + lines.length - 1})`)}`);
679
3895
  for (const line of formatCodeBlock(lines.join("\n"), { startLine, lang, cols: Math.max(40, cols - 1), maxLines: 200 })) {
680
3896
  console.log(line);
681
3897
  }
@@ -694,7 +3910,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
694
3910
  continue;
695
3911
  }
696
3912
  const { cols } = await import("../tui/terminal").then(m => m.size());
697
- for (const line of formatDiff(text, { cols: Math.max(40, cols - 1), maxLines: 400 })) console.log(line);
3913
+ console.log(`${categoryBadge("diff")} git diff${target ? ` -- ${target}` : ""}`);
3914
+ for (const line of formatDiff(text, { cols: Math.max(40, cols - 1), maxLines: 400, theme: uiTheme })) console.log(line);
698
3915
  continue;
699
3916
  }
700
3917
  if (input.startsWith("/find") && (input === "/find" || input[5] === " ")) {
@@ -703,6 +3920,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
703
3920
  console.log("Usage: /find <glob> (e.g. /find src/**/*.ts)");
704
3921
  continue;
705
3922
  }
3923
+ console.log(`${categoryBadge("search")} find files matching '${glob}':`);
706
3924
  const res = await findTool(glob, cwd);
707
3925
  console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
708
3926
  continue;
@@ -715,31 +3933,122 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
715
3933
  console.log("Usage: /search <pattern> [glob] (e.g. /search resolveProvider src/**/*.ts)");
716
3934
  continue;
717
3935
  }
3936
+ console.log(`${categoryBadge("search")} search pattern '${pattern}' in '${glob}':`);
718
3937
  const res = await searchTool(pattern, glob, cwd);
719
3938
  console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
720
3939
  continue;
721
3940
  }
722
-
3941
+ const skillEntrypoint = input.startsWith("/skill:") ? "/skill:" : input.startsWith("/skill") && (input === "/skill" || input[6] === " ") ? "/skill" : "";
3942
+ if (skillEntrypoint) {
3943
+ if (flags.noSkills) {
3944
+ console.log("Skills are disabled.");
3945
+ continue;
3946
+ }
3947
+ const rest = skillEntrypoint === "/skill:" ? input.substring(7).trim() : input.substring(6).trim();
3948
+ let skills = await loadSkills(cwd);
3949
+ if (flags.skills) {
3950
+ const patterns = flags.skills.split(",").map(p => p.trim()).filter(Boolean);
3951
+ skills = skills.filter(s => patterns.some(p => matchSkillGlob(p, s.name)));
3952
+ }
3953
+ if (!rest) {
3954
+ if (process.stdin.isTTY && process.stdout.isTTY) {
3955
+ const picked = await pickSkillFromList(skills);
3956
+ if (!picked) {
3957
+ console.log("(cancelled)");
3958
+ continue;
3959
+ }
3960
+ await runSkillInvocation(picked, "");
3961
+ continue;
3962
+ }
3963
+ console.log("Skills (bundled + configured docs) — run with /skill <name> [intent] or a skill slash alias:");
3964
+ for (const s of skills) {
3965
+ const aliases = skillSlashAliases(s);
3966
+ console.log(` ${s.name.padEnd(16)} ${s.summary}${aliases.length ? ` (${aliases.join(", ")})` : ""}`);
3967
+ }
3968
+ continue;
3969
+ }
3970
+ const [nm, ...intentParts] = rest.split(/\s+/);
3971
+ const skill = getSkillFrom(skills, nm);
3972
+ if (!skill) {
3973
+ console.log(`Unknown skill: ${nm}. Available: ${skills.map(s => s.name).join(", ")}`);
3974
+ continue;
3975
+ }
3976
+ const intent = intentParts.join(" ").trim();
3977
+ try {
3978
+ await runSkillInvocation(skill, intent);
3979
+ } catch (err) {
3980
+ console.log(`! ${(err as Error).message}`);
3981
+ }
3982
+ continue;
3983
+ }
3984
+ const aliasInvocation = parseSkillInvocation(input, resolvedSkills);
3985
+ if (aliasInvocation?.invokedAs) {
3986
+ try {
3987
+ await runSkillInvocation(aliasInvocation.skill, aliasInvocation.intent, aliasInvocation.invokedAs);
3988
+ } catch (err) {
3989
+ console.log(`! ${(err as Error).message}`);
3990
+ }
3991
+ continue;
3992
+ }
723
3993
  // Unhandled slash attempt → suggest, don't send the typo to the model.
724
3994
  if (isSlashAttempt(input)) {
725
- const m = matchSlash(input);
726
- console.log(m.length ? `Did you mean: ${m.join(" ")} ?` : `Unknown command '${input}'. Try /help.`);
3995
+ const m = matchSlash(input, [...completionContext().slashCommands]);
3996
+ if (m.length) {
3997
+ for (const line of formatSlashCommandList(input, skillSlashDetails)) console.log(line);
3998
+ } else {
3999
+ console.log(`Unknown command '${input}'. Try /help.`);
4000
+ }
727
4001
  continue;
728
4002
  }
729
4003
 
4004
+ lastUserInput = input;
4005
+ // Hand pending clipboard images to this turn and clear them — a failed turn
4006
+ // does not resurrect attachments (the [image #N] tags stay in the text).
4007
+ const turnImages = pendingImages.length ? [...pendingImages] : undefined;
4008
+ pendingImages = [];
730
4009
  try {
731
- const { done, steps, reply, rendered, usage } = await runTurn(input, useTui);
4010
+ const { done, steps, reply, rendered, usage } = await runTurn(input, useTui, turnImages);
4011
+ lastReply = reply;
4012
+ // A cancelled turn (ESC / Ctrl-C) must also cancel the pasted batch —
4013
+ // continuing to auto-run the rest of a paste after an abort is hostile.
4014
+ if (reply === "Cancelled." && queuedPromptInput.pastedLines.length > 0) {
4015
+ const dropped = queuedPromptInput.pastedLines.splice(0).length;
4016
+ console.log(chalk.dim(`(cancelled — dropped ${dropped} queued pasted command${dropped > 1 ? "s" : ""})`));
4017
+ }
732
4018
  if (!rendered) {
733
- console.log(`joc> ${reply}${usage}`);
4019
+ console.log(`jeo> ${renderMarkdownTables(reply)}${usage}`);
734
4020
  if (!done) console.log(`(agent did not converge in ${steps} steps)`);
735
4021
  } else if (usage) {
736
4022
  console.log(usage.trim());
737
4023
  }
738
4024
  } catch (err) {
739
- console.log(`! ${(err as Error).message}`);
4025
+ console.log(`! ${friendlyProviderError(err)}`);
740
4026
  }
741
4027
  }
742
- } finally {
743
- rl.close();
4028
+ disarmPreview(); // clear footer + restore full-screen scrolling before leaving the REPL
4029
+ if (hardExitOnLoopEnd) {
4030
+ try {
4031
+ disarmPreview();
4032
+ out.write("\x1b[?25h\n");
4033
+ } catch { /* best effort */ }
4034
+ process.removeListener("SIGINT", forceExitFromCtrlC);
4035
+ process.stdin.off("data", forceExitOnCtrlCByte);
4036
+ restorePromptRawMode();
4037
+ process.exit(130);
4038
+ }
4039
+ // hermes-style experience distill (plan/gjc-inheritance.md B6) — now handed to a
4040
+ // DETACHED child (round-16): /exit and ^C^C return the shell IMMEDIATELY instead
4041
+ // of blocking up to 20s on a final LLM call. The child writes MEMORY.md atomically.
4042
+ if (sessionUsage.turns > 0) {
4043
+ const spawned = await spawnDetachedDistill(history, cwd, sessionModel || defaultModel);
4044
+ if (spawned) console.log(chalk.gray("(session memory distilling in background)"));
744
4045
  }
4046
+ // gjc-parity resume pointer (logs/gjc-tui-study analysis Gap C): leave the exact
4047
+ // resume command in scrollback on exit, mirroring the --list handler's convention.
4048
+ if (sessionId && !flags.noSession) console.log(formatResumeHint(sessionId));
4049
+ process.removeListener("SIGINT", forceExitFromCtrlC);
4050
+ process.stdin.off("data", forceExitOnCtrlCByte);
4051
+ restorePromptRawMode();
4052
+ gracefulReadlineClose = true;
4053
+ rl.close();
745
4054
  }