jeo-code 0.6.3 → 0.6.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.
@@ -19,8 +19,9 @@ import { formatForgeBox } from "../tui/components/forge";
19
19
  import { interactiveOAuthLogin } from "./auth";
20
20
  import { logoutOAuth } from "../auth";
21
21
  import type { AuthProvider } from "../auth";
22
- import { matchSlash, isSlashAttempt, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
22
+ import { matchSlash, isSlashAttempt, suggestSlashCommands, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
23
23
  import { staticCompletionContext, readlineCompleter, formatCompletionPreview, tokenize, type CompletionContext } from "../tui/components/autocomplete";
24
+ import { normalizeBaseUrl } from "./setup-helpers";
24
25
  import { EVOLUTION_STAGES, animateAsciiArt } from "../tui/components/ascii-art";
25
26
  import { getEvolutionTip } from "../tui/components/evolution";
26
27
  import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
@@ -37,6 +38,7 @@ import { readGlobalConfig, saveConfigPatch } from "../agent/state";
37
38
  import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
38
39
  import { describeModel, describeAllProviders, thinkingMaxTokens, discoverModels, flattenModels, resolveSelection, catalogMetadata, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
39
40
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
41
+ import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
40
42
 
41
43
  import { listAliases } from "../ai/model-registry";
42
44
 
@@ -45,7 +47,6 @@ import { SelectList, renderSelectList, type SelectItem } from "../tui/components
45
47
  import {
46
48
  formatModelLine,
47
49
  formatProviderPanel,
48
- emitLoginCleanup,
49
50
  formatAgentsPanel,
50
51
  formatAgentDetail,
51
52
  formatConfigPanel,
@@ -56,12 +57,12 @@ import {
56
57
  } from "../tui/components/config-panel";
57
58
  import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
58
59
 
59
- import { providerPicker, renderProviderPicker, buildProviderChoices } from "../tui/components/provider-picker";
60
+ import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
60
61
  import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
61
62
  import { categoryBadge } from "../tui/components/category-index";
62
63
  import { renderInputFrame } from "../tui/components/input-box";
63
64
  import { renderStatusBar } from "../tui/components/status";
64
- import { detectColorLevel, ColorLevel } from "../tui/components/color";
65
+ import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
65
66
  import { readClipboardImage } from "../util/clipboard-image";
66
67
  import { formatTranscript } from "../tui/components/transcript";
67
68
  import { loadInputHistory, appendInputHistory } from "../agent/input-history";
@@ -91,438 +92,100 @@ import {
91
92
  sessionPath,
92
93
  appendCompaction,
93
94
  } from "../agent/session";
94
- import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking } from "../tui/terminal";
95
+ import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearToEnd } from "../tui/terminal";
95
96
 
96
- export interface LaunchFlags {
97
- list: boolean;
98
- resume: boolean;
99
- resumeId?: string;
100
- noSession: boolean;
101
- noTui: boolean;
102
- /** Explicit step cap from --max-steps; 0 = dynamic (process-driven budget that
103
- * keeps extending while the turn shows progress — no hardcoded step ceiling). */
104
- maxSteps: number;
105
- message: string;
106
- tmux: boolean;
107
- worktree?: string;
108
- model?: string;
109
- provider?: ProviderName;
110
- modelRole?: ModelRole;
111
- thinking?: ThinkLevel;
112
- errors: string[];
113
- print?: boolean;
114
- appendSystemPromptRaw?: string;
115
- appendSystemPrompt?: string;
116
- noSkills: boolean;
117
- skills?: string;
118
- noTools: boolean;
119
- tools?: string;
120
- systemPromptRaw?: string;
121
- systemPrompt?: string;
122
- }
123
-
124
- const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
125
-
126
- function takeValue(args: string[], index: number, inlinePrefix: string): { value?: string; nextIndex: number } {
127
- const current = args[index]!;
128
- if (current.startsWith(inlinePrefix)) return { value: current.slice(inlinePrefix.length), nextIndex: index };
129
- const next = args[index + 1];
130
- if (next && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
131
- return { nextIndex: index };
132
- }
133
-
134
- function isProviderName(input: string | undefined): input is ProviderName {
135
- return input === "anthropic" || input === "openai" || input === "gemini" || input === "antigravity" || input === "ollama";
136
- }
137
-
138
- function isThinkingLevel(input: string | undefined): input is ThinkLevel {
139
- return input === "minimal" || input === "low" || input === "medium" || input === "high" || input === "xhigh";
140
- }
141
-
142
- function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
143
- const supported = catalogMetadata(modelId)?.thinking ?? [];
144
- if (supported.includes("minimal")) return "minimal";
145
- if (supported.includes("low")) return "low";
146
- // Fallback for the one thinking-capable family that misses a catalog entry in practice:
147
- // the dynamic antigravity `gemini-3.x` variants (gemini-3.5-flash-low/-extra-low, …) the
148
- // CCA backend serves but the static catalog can't enumerate. They apply a thinking budget,
149
- // so /fast offers `minimal` as the fast level instead of reporting "unsupported". (openai
150
- // reasoning models and *-thinking/-high/-low antigravity ids are already catalogued.)
151
- if (/gemini-(2\.5|[3-9])/.test(modelId.toLowerCase())) return "minimal";
152
- return undefined;
153
- }
154
-
155
- function hashString(input: string): string {
156
- let hash = 2166136261;
157
- for (let i = 0; i < input.length; i++) {
158
- hash ^= input.charCodeAt(i);
159
- hash = Math.imul(hash, 16777619);
160
- }
161
- return (hash >>> 0).toString(36).padStart(6, "0").slice(0, 6);
162
- }
163
-
164
- function tmuxSafeNamePart(input: string, max = 32): string {
165
- const safe = input.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "value";
166
- if (safe.length <= max) return safe;
167
- return `${safe.slice(0, Math.max(1, max - 7))}-${hashString(input)}`;
168
- }
169
-
170
- function tmuxRuntimeSuffix(flags: LaunchFlags): string {
171
- const parts: string[] = [];
172
- if (flags.provider) parts.push(`provider-${flags.provider}`);
173
- if (flags.model) parts.push(`model-${tmuxSafeNamePart(flags.model)}`);
174
- else if (flags.modelRole) parts.push(flags.modelRole);
175
- if (flags.thinking) parts.push(`think-${flags.thinking}`);
176
- // Only an EXPLICIT --max-steps cap names the session; the dynamic default (0) adds nothing.
177
- if (flags.maxSteps > 0) parts.push(`steps-${flags.maxSteps}`);
178
- if (parts.length === 0) return "";
179
- const joined = parts.join("-");
180
- const suffix = joined.length <= 72 ? joined : `${joined.slice(0, 65)}-${hashString(joined)}`;
181
- return `-${suffix}`;
182
- }
183
-
184
- /**
185
- * Base tmux session name for `jeo --tmux`. Keyed on the working DIRECTORY (not just the
186
- * git branch) so two different projects/worktrees on the same branch (e.g. `main`)
187
- * never share a base. {@link uniqueTmuxSessionName} then makes each concurrent invocation
188
- * fully independent, so a second `jeo --tmux` never attaches to (and mirrors) the first.
189
- */
190
- export function tmuxSessionName(cwd: string, branch: string, flags: LaunchFlags): string {
191
- const dirTag = `${tmuxSafeNamePart(path.basename(cwd) || "root", 16)}-${hashString(cwd)}`;
192
- const base = branch ? `jeo-${branch}-${dirTag}` : `jeo-${dirTag}`;
193
- return base + tmuxRuntimeSuffix(flags);
194
- }
195
-
196
- /**
197
- * Count uncommitted git entries for the `⑂ <branch> ?N` footer dirty flag (gjc parity).
198
- * One `git status --porcelain` spawn per CALL; callers invoke it once per turn start, not
199
- * per render. Returns undefined when not a repo / git absent / clean.
200
- */
201
- export function gitDirtyCount(cwd: string): number | undefined {
202
- try {
203
- const res = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
204
- if (res.exitCode !== 0) return undefined;
205
- const n = res.stdout.toString().split("\n").filter(l => l.trim().length > 0).length;
206
- return n || undefined;
207
- } catch {
208
- return undefined;
209
- }
210
- }
211
-
212
- /**
213
- * Allocate + create an INDEPENDENT tmux session from a base name. Each separate,
214
- * concurrent `jeo --tmux` invocation gets its OWN session instead of attaching to (and
215
- * mirroring) one another process already created: try `base`, then `base-2`, `base-3`, …
216
- * The create itself is the guard, so this is race-safe — two processes starting at the
217
- * same instant can't both win `base`. `tryCreate` must attempt to create the named session
218
- * and return `"ok"` (created — it's ours), `"taken"` (name already live / lost the race →
219
- * try the next suffix), or `"error:<msg>"` (a real failure → abort). Sessions die with
220
- * their jeo process, so a sequential re-run reuses the clean base; only live overlap is
221
- * suffixed.
222
- */
223
- export type TmuxCreateResult = "ok" | "taken" | `error:${string}`;
224
- export function allocateTmuxSession(
225
- base: string,
226
- tryCreate: (name: string) => TmuxCreateResult,
227
- ): { name: string } | { error: string } {
228
- for (let n = 1; n <= 1000; n++) {
229
- const candidate = n === 1 ? base : `${base}-${n}`;
230
- const result = tryCreate(candidate);
231
- if (result === "ok") return { name: candidate };
232
- if (result === "taken") continue;
233
- return { error: result.slice("error:".length) };
234
- }
235
- return { error: `could not allocate a free tmux session name for ${base} (1000 already live?)` };
236
- }
237
-
238
- function shellQuote(arg: string): string {
239
- return `'${arg.replace(/'/g, `'\\''`)}'`;
240
- }
241
-
242
- /**
243
- * True when `jeo --tmux` runs INSIDE an existing tmux session and should enable
244
- * session-scoped mouse mode for the CURRENT session: no jeo-owned session is created
245
- * on this path, so without `mouse on` tmux ignores the wheel entirely and the
246
- * mid-turn scrollback (ledger lines flushed above the live frame) is unreachable.
247
- * Skipped for jeo-spawned sessions (JEO_TMUX_LAUNCHED=1 — the creator already set
248
- * it) and when JEO_TMUX_MOUSE=0 opts out.
249
- */
250
- export function shouldEnableCurrentTmuxMouse(env: Record<string, string | undefined>): boolean {
251
- return !!env.TMUX
252
- && (env.JEO_TMUX_LAUNCHED ?? env.JEO_TMUX_LAUNCHED) !== "1"
253
- && (env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0";
254
- }
255
-
256
- /**
257
- * The runnable command for the INNER `jeo launch` a `--tmux` session executes.
258
- * Pure — testable. Three runtime shapes:
259
- * - compiled standalone binary: `argv[1]` is a Bun VIRTUAL path (`/$bunfs/…`)
260
- * that does not exist on disk; the binary itself (`execPath`) is the
261
- * entrypoint. Passing the virtual path made the inner command crash on
262
- * spawn, so `tmux new-session` died instantly and the follow-up attach
263
- * failed with "can't find session".
264
- * - source run (`bun src/cli.ts`): re-run the script through the runtime.
265
- * - anything else (a shim/binary path on disk): run it directly.
266
- */
267
- export function tmuxLaunchCommand(argv1: string | undefined, execPath: string, cwd: string): string[] {
268
- const entrypoint = argv1 ?? "";
269
- if (entrypoint === "" || entrypoint.startsWith("/$bunfs/") || entrypoint.startsWith("B:\\~BUN\\")) {
270
- return [execPath];
271
- }
272
- const resolved = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(cwd, entrypoint);
273
- if (/\.(ts|js|mjs)$/.test(entrypoint)) return [execPath, resolved];
274
- return [resolved];
275
- }
276
-
277
- /** One tmux configuration step applied to a jeo-owned session after creation. */
278
- export interface TmuxProfileCommand {
279
- description: string;
280
- args: string[];
281
- }
282
-
283
- /**
284
- * gjc-parity tmux profile for jeo-OWNED sessions (mirrors gjc's
285
- * `buildGjcTmuxProfileCommands`). Applied right after `new-session`, before attach:
286
- * - `mouse on` (session-scoped): wheel-up enters copy-mode over the REAL pane
287
- * history — this is what makes the mid-turn scrollback (ledger lines flushed
288
- * above the inline live frame) reachable with the mouse wheel. Wheel-down at
289
- * the bottom drops back out. `JEO_TMUX_MOUSE=0` opts out.
290
- * - ownership/identity markers (`@jeo-profile`, `@jeo-branch`, `@jeo-project`):
291
- * lets tooling tell jeo-owned sessions apart from user sessions (gjc parity
292
- * with `@gjc-*`). Never applied to foreign sessions.
293
- * - `set-clipboard on` + a readable copy-mode `mode-style`: text selected while
294
- * wheel-scrolled back is visibly highlighted and lands on the system clipboard
295
- * (OSC52). `JEO_TMUX_PROFILE=0` opts out of these cosmetic extras while keeping
296
- * mouse + markers.
297
- */
298
- export function tmuxProfileCommands(
299
- target: string,
300
- env: Record<string, string | undefined>,
301
- meta: { branch?: string; project?: string } = {},
302
- ): TmuxProfileCommand[] {
303
- // Exact-name session target. `=name:` (explicit session:window form), NOT bare
304
- // `=name`: tmux 3.6 rejects bare `=name` for set-option/show-options with
305
- // "no such session" even while the session is live (has-session/attach accept
306
- // it). The trailing colon makes cmd-find parse `=name` as the session part —
307
- // exact-matched, never prefix-matched — and resolves the session's current
308
- // window for set-window-option. This was the silent failure that left
309
- // `mouse on` unset, killing wheel-up scrollback in jeo-owned sessions.
310
- const t = `=${target}:`;
311
- const commands: TmuxProfileCommand[] = [];
312
- if ((env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0") {
313
- commands.push({
314
- description: "enable tmux mouse scrolling (wheel-up → copy-mode over real history)",
315
- args: ["set-option", "-t", t, "mouse", "on"],
316
- });
317
- }
318
- commands.push({
319
- description: "mark jeo tmux ownership",
320
- args: ["set-option", "-t", t, "@jeo-profile", "1"],
321
- });
322
- if (meta.branch) {
323
- commands.push({
324
- description: "record jeo branch identity",
325
- args: ["set-option", "-t", t, "@jeo-branch", meta.branch],
326
- });
327
- }
328
- if (meta.project) {
329
- commands.push({
330
- description: "record jeo project identity",
331
- args: ["set-option", "-t", t, "@jeo-project", meta.project],
332
- });
333
- }
334
- if ((env.JEO_TMUX_PROFILE ?? env.JEO_TMUX_PROFILE) !== "0") {
335
- commands.push(
336
- {
337
- description: "enable tmux clipboard integration",
338
- args: ["set-option", "-t", t, "set-clipboard", "on"],
339
- },
340
- {
341
- description: "make copy-mode selection readable",
342
- args: ["set-window-option", "-t", t, "mode-style", "fg=colour231,bg=colour60"],
343
- },
344
- );
345
- }
346
- return commands;
347
- }
348
-
349
- /**
350
- * A `process.stdout` view whose visible-output methods become no-ops while `gated()` is
351
- * true. Used as readline's `output` so that, while the boxed slash-preview footer is armed,
352
- * readline's OWN prompt/echo is suppressed and only our box is visible — no duplicated raw
353
- * `jeo>` line. The previous approach monkeypatched `rl._writeToOutput`, a Node internal Bun
354
- * does not expose (so on Bun both inputs showed at once). Gating the shared `output` stream
355
- * works on both runtimes. Our footer is written straight to `process.stdout`, never through
356
- * this proxy, so it always renders. Geometry/everything else is forwarded unchanged.
357
- */
358
- const GATED_OUTPUT_METHODS = new Set(["write", "cursorTo", "moveCursor", "clearLine", "clearScreenDown", "_write", "_writev"]);
359
- export function gatedStdout(real: NodeJS.WriteStream, gated: () => boolean): NodeJS.WriteStream {
360
- return new Proxy(real, {
361
- get(target, prop, _receiver) {
362
- if (typeof prop === "string" && GATED_OUTPUT_METHODS.has(prop)) {
363
- return (...args: any[]) => {
364
- if (gated()) {
365
- const cb = args[args.length - 1]; // honor readline's write callback so it never stalls
366
- if (typeof cb === "function") cb();
367
- return true;
368
- }
369
- return (target as any)[prop](...args);
370
- };
371
- }
372
- const value = Reflect.get(target, prop, target);
373
- return typeof value === "function" ? value.bind(target) : value;
374
- },
375
- }) as unknown as NodeJS.WriteStream;
376
- }
377
-
378
- function firstOutputLine(output: string | undefined): string {
379
- if (!output) return "";
380
- const line = String(output)
381
- .split("\n")
382
- .map(l => l.trim())
383
- .find(l => l.length > 0);
384
- return line ? line.replace(/\s+/g, " ").slice(0, 140) : "";
385
- }
386
-
387
- function streamResultSuffix(tool: string, ok: boolean, output: string | undefined): string {
388
- const summary = firstOutputLine(output);
389
- if (!summary) return "";
390
- if (!ok || tool === "task") return ` — ${summary}`;
391
- return "";
392
- }
393
-
394
- export function formatTaskSubEvent(e: TaskSubEvent): string {
395
- const role = e.role || "subagent";
396
- const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
397
- const tokTag = e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : "";
398
- const detail = firstOutputLine(e.detail);
399
- const summary = e.summary ? ` — ${e.summary}` : "";
400
- // No ` step N/M` marker — step counters carry no meaning under the dynamic
401
- // budget (user feedback); a tree prefix makes nested subagent activity scan as
402
- // one readable branch in plain logs and TUI scrollback.
403
- const badge = categoryBadge("subagent");
404
- if (e.kind === "start") return `${badge} ${chalk.magenta(`▸ ${roleLabel}`)} · ${detail}`.slice(0, 240);
405
- if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
406
- if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
407
- if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
408
- return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${tokTag}${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
409
- }
410
-
411
- function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
412
- log(formatTaskSubEvent(e));
413
- }
414
-
415
-
416
- /**
417
- * Plain (non-TTY / `--no-tui`) progress sink — the cmd-mode equivalent of the live TUI, and
418
- * the gjc-parity fix for "I typed a request but saw no steps/results". The old sink only
419
- * logged tool RESULTS, so a turn that finished without a tool call (or before the first
420
- * result) printed nothing but the final reply. This surfaces every STEP, the tool it is about
421
- * to run (with the real file/command target via `summarizeForgeInvocation`), and each result —
422
- * tracking the current step + pending invocation across the engine's
423
- * onStep → onAssistant → onToolResult sequence.
424
- */
425
- export function createStreamEvents(
426
- _maxSteps: number,
427
- log: (line: string) => void = (s: string) => console.log(s),
428
- now: () => number = Date.now,
429
- ): AgentLoopEvents {
430
- let pending = "";
431
- let latestUsage: { inputTokens: number; outputTokens: number } | undefined;
432
- const startTime = now();
433
-
434
- return {
435
- // Lazy header: nothing is printed until the tool call is known (onAssistant).
436
- // A `done` / invalid reply therefore emits no progress line at all. The step
437
- // NUMBER itself is never shown — meaningless under the dynamic budget.
438
- onStep: () => {},
439
- onAssistant: (_raw: string, invocation: { tool?: string; arguments?: unknown } | null) => {
440
- const tool = typeof invocation?.tool === "string" ? invocation.tool.trim() : "";
441
- if (!tool || tool === "done") return;
442
- pending = summarizeForgeInvocation(tool, invocation?.arguments).title;
443
- // gjc-style live status unit: step header + tool target + elapsed + token usage.
444
- const elapsedMs = now() - startTime;
445
- let suffix = "";
446
- if (elapsedMs >= 1000) suffix += ` · ${formatDuration(elapsedMs)}`;
447
- if (latestUsage) suffix += ` · ${formatUsage(latestUsage)}`;
448
- log(`${categoryBadge("progress")} ${pending}${suffix ? chalk.dim(suffix) : ""}`);
449
- },
450
- onToolResult: (tool: string, ok: boolean, output?: string) => {
451
- const label = pending || tool;
452
- const mark = ok ? chalk.green("✓") : chalk.red("✗");
453
- log(` ${categoryBadge(ok ? "done" : "error")} ${mark} ${label}${streamResultSuffix(tool, ok, output)}`);
454
- pending = "";
455
- },
456
- onNotice: (msg: string) => log(` ${categoryBadge("progress")} ${chalk.yellow(msg)}`),
457
- onBudget: (_limit: number, reason: string) => {
458
- log(` ${categoryBadge("progress")} ${chalk.yellow(reason)}`);
459
- },
460
- onUsage: (usage: { inputTokens: number; outputTokens: number }) => {
461
- latestUsage = usage;
462
- },
463
- };
464
- }
465
-
466
- export function shouldUseOneShotTui(noTui: boolean): boolean {
467
- return LaunchTui.usable(noTui);
468
- }
469
-
470
- export interface InFlightAbortHarness {
471
- controller: AbortController;
472
- handleSigint(): void;
473
- handleData(chunk: string | Uint8Array): void;
474
- dispose(): void;
475
- }
476
-
477
- interface AbortHarnessOptions {
478
- controller?: AbortController;
479
- captureEsc?: boolean;
480
- stdin?: {
481
- isTTY?: boolean;
482
- isRaw?: boolean;
483
- setRawMode?(raw: boolean): void;
484
- resume?(): void;
485
- on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
486
- off(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
487
- };
488
- onAbortNotice?: (message: string) => void;
489
- onHardExit?: () => void;
490
- /** Invoked when stray escape-sequence noise (wheel scroll etc.) arrives mid-turn. */
491
- onNoise?: () => void;
492
- /** Invoked when Ctrl+O (\u000f) is pressed mid-turn — the detail-view binding.
493
- * Without this hook the byte would be swallowed into the buffered input queue,
494
- * which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
495
- onDetailKey?: () => void;
496
- /** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
497
- * open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
498
- onScrollKey?: (dir: -1 | 1, page: boolean) => void;
499
- /** Invoked with printable keyboard input received while the live turn owns stdin. */
500
- onBufferedInput?: (chunk: string) => void;
501
- /** True while the input queue is inside a bracketed paste (mid-paste chunks
502
- * carry no marker and must keep routing to the queue, not the noise path). */
503
- pasteActive?: () => boolean;
504
- }
505
-
506
- /** Bracketed-paste markers (DECSET 2004): terminals wrap pasted text in these so
507
- * an app can treat the paste as DATA instead of keystrokes — the prompt_toolkit
508
- * paste contract. jeo enables the mode for the REPL TTY so a multi-line paste
509
- * arrives atomically and executes one command per line, in order. */
510
- export const PASTE_START = "\u001b[200~";
511
- export const PASTE_END = "\u001b[201~";
512
-
513
- /** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
514
- * standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
515
- * line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
516
- * event, which the REPL would treat as a hard exit ("Backspace quits jeo"). The
517
- * input filter swallows these when the line buffer is already empty so the byte never
518
- * reaches readline and the close can't fire. */
519
- export function isStandaloneBackspace(chunk: string): boolean {
520
- return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
521
- }
522
-
523
- /** gjc-parity slash-command aliases, applied once before dispatch so each real command
524
- * keeps a SINGLE handler: `/login`→`/provider login`, `/settings`→`/config`,
525
- * `/subagent(s)`→`/agents`. Pure rewrite that preserves any trailing args. */
97
+ import {
98
+ type LaunchFlags,
99
+ parseFlags,
100
+ matchSkillGlob,
101
+ filterToolMap,
102
+ buildToolProtocol,
103
+ TOOL_DESCRIPTIONS,
104
+ fastThinkingLevelForModel,
105
+ isProviderName,
106
+ isThinkingLevel,
107
+ } from "./launch/flags";
108
+ import {
109
+ tmuxSessionName,
110
+ gitDirtyCount,
111
+ allocateTmuxSession,
112
+ shouldEnableCurrentTmuxMouse,
113
+ tmuxLaunchCommand,
114
+ tmuxProfileCommands,
115
+ resolveWorktree,
116
+ shellQuote,
117
+ type TmuxCreateResult,
118
+ type TmuxProfileCommand,
119
+ } from "./launch/tmux";
120
+ import {
121
+ type InFlightAbortHarness,
122
+ type PromptInputQueue,
123
+ PASTE_START,
124
+ PASTE_END,
125
+ isStandaloneBackspace,
126
+ CURSOR_COMBO_REWRITES,
127
+ matchCursorCombo,
128
+ rewriteCursorCombos,
129
+ queuePromptInputChunk,
130
+ captureLivePromptInputChunk,
131
+ restoreQueuedLinesToPrefill,
132
+ createInFlightAbortHarness,
133
+ } from "./launch/input";
134
+ import {
135
+ gatedStdout,
136
+ formatTaskSubEvent,
137
+ logTaskSubEvent,
138
+ createStreamEvents,
139
+ shouldUseOneShotTui,
140
+ } from "./launch/stream";
141
+ import {
142
+ WORKFLOW_NAMES,
143
+ isWorkflowSkill,
144
+ runWorkflowEngine,
145
+ } from "./launch/workflow";
146
+
147
+ export {
148
+ type LaunchFlags,
149
+ parseFlags,
150
+ matchSkillGlob,
151
+ filterToolMap,
152
+ buildToolProtocol,
153
+ TOOL_DESCRIPTIONS,
154
+ fastThinkingLevelForModel,
155
+ isProviderName,
156
+ isThinkingLevel,
157
+ tmuxSessionName,
158
+ gitDirtyCount,
159
+ allocateTmuxSession,
160
+ shouldEnableCurrentTmuxMouse,
161
+ tmuxLaunchCommand,
162
+ tmuxProfileCommands,
163
+ resolveWorktree,
164
+ shellQuote,
165
+ type TmuxCreateResult,
166
+ type TmuxProfileCommand,
167
+ type InFlightAbortHarness,
168
+ type PromptInputQueue,
169
+ PASTE_START,
170
+ PASTE_END,
171
+ isStandaloneBackspace,
172
+ CURSOR_COMBO_REWRITES,
173
+ matchCursorCombo,
174
+ rewriteCursorCombos,
175
+ queuePromptInputChunk,
176
+ captureLivePromptInputChunk,
177
+ restoreQueuedLinesToPrefill,
178
+ createInFlightAbortHarness,
179
+
180
+ gatedStdout,
181
+ formatTaskSubEvent,
182
+ logTaskSubEvent,
183
+ createStreamEvents,
184
+ shouldUseOneShotTui,
185
+ WORKFLOW_NAMES,
186
+ isWorkflowSkill,
187
+ runWorkflowEngine,
188
+ };
526
189
  export function normalizeSlashAlias(input: string): string {
527
190
  if (input === "/login" || input.startsWith("/login ")) return `/provider login${input.slice("/login".length)}`;
528
191
  if (input === "/settings") return "/config";
@@ -532,539 +195,12 @@ export function normalizeSlashAlias(input: string): string {
532
195
  return input;
533
196
  }
534
197
 
535
- export interface PromptInputQueue {
536
- pendingLines: string[];
537
- partial: string;
538
- /** Complete lines that arrived inside a bracketed PASTE: intentional batch
539
- * commands, served one per prompt in order. Never folded into the typed-line
540
- * prefill (that contract is for keystrokes typed during a live turn). */
541
- pastedLines: string[];
542
- /** True while a bracketed paste spans chunks (between \x1b[200~ and \x1b[201~). */
543
- inPaste: boolean;
544
- }
545
-
546
- /** Typed (non-paste) keystrokes: printable chars build the partial, Enter promotes
547
- * it to pendingLines, backspace edits — ESC/ctrl noise segments are rejected. */
548
- function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
549
- if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
550
- let accepted = false;
551
- const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
552
- for (const ch of Array.from(normalized)) {
553
- if (ch === "\n") {
554
- if (state.partial.length > 0) accepted = true;
555
- state.pendingLines.push(state.partial);
556
- state.partial = "";
557
- } else if (ch === "\u007f" || ch === "\b") {
558
- const chars = Array.from(state.partial);
559
- chars.pop();
560
- state.partial = chars.join("");
561
- accepted = true;
562
- } else if (ch === "\t" || ch >= " ") {
563
- state.partial += ch;
564
- accepted = true;
565
- }
566
- }
567
- return accepted;
568
- }
569
-
570
- /** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
571
- * partial stays editable, and control bytes (incl. any stray ESC from copied ANSI
572
- * text) are dropped instead of being interpreted as keystrokes. */
573
- function feedPasteBody(state: PromptInputQueue, body: string): boolean {
574
- if (!body) return false;
575
- let accepted = false;
576
- const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
577
- for (const ch of Array.from(normalized)) {
578
- if (ch === "\n") {
579
- state.pastedLines.push(state.partial);
580
- state.partial = "";
581
- accepted = true;
582
- } else if (ch === "\t" || ch >= " ") {
583
- state.partial += ch;
584
- accepted = true;
585
- }
586
- }
587
- return accepted;
588
- }
589
-
590
- export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
591
- if (!chunk) return false;
592
- let accepted = false;
593
- let rest = chunk;
594
- while (rest.length > 0) {
595
- if (state.inPaste) {
596
- const end = rest.indexOf(PASTE_END);
597
- const body = end === -1 ? rest : rest.slice(0, end);
598
- if (end !== -1) state.inPaste = false;
599
- rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
600
- if (feedPasteBody(state, body)) accepted = true;
601
- } else {
602
- const start = rest.indexOf(PASTE_START);
603
- const plain = start === -1 ? rest : rest.slice(0, start);
604
- if (start !== -1) state.inPaste = true;
605
- rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
606
- if (feedTypedSegment(state, plain)) accepted = true;
607
- }
608
- }
609
- return accepted;
610
- }
611
-
612
- /** Live-turn prompt capture: printable input edits the SAME next-prompt line the
613
- * idle footer will show after the turn finishes. Enter does NOT promote a hidden
614
- * queue entry; it merely marks the current line as ready, so the existing input
615
- * box stays the single source of truth and the user presses Enter once more at
616
- * the real prompt to run it. */
617
- function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolean {
618
- if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
619
- let accepted = false;
620
- const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
621
- for (const ch of Array.from(normalized)) {
622
- if (ch === "\n") {
623
- accepted = true;
624
- } else if (ch === "\u007f" || ch === "\b") {
625
- const chars = Array.from(state.partial);
626
- chars.pop();
627
- state.partial = chars.join("");
628
- accepted = true;
629
- } else if (ch === "\t" || ch >= " ") {
630
- state.partial += ch;
631
- accepted = true;
632
- }
633
- }
634
- return accepted;
635
- }
636
-
637
- function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
638
- if (!body) return false;
639
- const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
640
- const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
641
- if (!flattened) return false;
642
- state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
643
- return true;
644
- }
645
-
646
- export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
647
- if (!chunk) return false;
648
- let accepted = false;
649
- let rest = chunk;
650
- while (rest.length > 0) {
651
- if (state.inPaste) {
652
- const end = rest.indexOf(PASTE_END);
653
- const body = end === -1 ? rest : rest.slice(0, end);
654
- if (end !== -1) state.inPaste = false;
655
- rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
656
- if (feedLivePromptPasteBody(state, body)) accepted = true;
657
- } else {
658
- const start = rest.indexOf(PASTE_START);
659
- const plain = start === -1 ? rest : rest.slice(0, start);
660
- if (start !== -1) state.inPaste = true;
661
- rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
662
- if (feedLivePromptSegment(state, plain)) accepted = true;
663
- }
664
- }
665
- return accepted;
666
- }
667
-
668
- /**
669
- * TTY "new input first" contract: fold any queued FULL lines (stray
670
- * Enter-terminated buffer noise, or older persisted queues) into the editable
671
- * prompt prefill instead of leaving them to auto-execute as the next prompt.
672
- * Without this, stale queued lines ran BEFORE the user's fresh input — jeo
673
- * appeared to "continue the previous work first". Returns the number of lines
674
- * folded. Pure over the queue object — piped/non-TTY callers must NOT use this
675
- * (scripted stdin relies on in-order line execution).
676
- */
677
- export function restoreQueuedLinesToPrefill(state: PromptInputQueue): number {
678
- const lines = state.pendingLines.splice(0, state.pendingLines.length).map(l => l.trim()).filter(Boolean);
679
- if (lines.length === 0) return 0;
680
- const restored = lines.join(" ");
681
- state.partial = state.partial ? `${restored} ${state.partial}`.trim() : restored;
682
- return lines.length;
683
- }
684
-
685
- export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFlightAbortHarness {
686
- const controller = opts.controller ?? new AbortController();
687
- const stdin = opts.stdin ?? process.stdin;
688
- const captureEsc = opts.captureEsc === true && !!stdin.isTTY;
689
- const wasRaw = !!stdin.isRaw;
690
- let rawChanged = false;
691
-
692
- const abortNow = (message: string) => {
693
- if (controller.signal.aborted) return false;
694
- opts.onAbortNotice?.(message);
695
- controller.abort();
696
- return true;
697
- };
698
-
699
- const handleSigint = () => {
700
- // Ctrl+C is a hard terminal break. Older jeo softened the first press into
701
- // "abort current run; press again to exit", which left users trapped in raw
702
- // TTY/TUI states when they expected the terminal to stop. Abort the controller
703
- // for cleanup observers, then invoke the hard-exit hook immediately.
704
- if (!controller.signal.aborted) controller.abort();
705
- opts.onHardExit?.();
706
- };
707
-
708
- const handleData = (chunk: string | Uint8Array) => {
709
- if (!captureEsc || controller.signal.aborted) return;
710
- const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
711
- // Bracketed paste is DATA, not keystrokes (the prompt_toolkit contract):
712
- // marker-carrying and mid-paste chunks go straight to the input queue BEFORE
713
- // any ESC interpretation — the markers themselves contain ESC, and a paste
714
- // must never be classified as cancel/noise.
715
- if (text.includes(PASTE_START) || opts.pasteActive?.()) {
716
- opts.onBufferedInput?.(text);
717
- return;
718
- }
719
- // Ctrl+O — detail view. Exact-match like the lone-ESC cancel: a raw \u000f
720
- // keystroke arrives as its own chunk; embedded \u000f inside pasted/streamed
721
- // data must NOT trigger the view.
722
- if (text === "\u000f") {
723
- opts.onDetailKey?.();
724
- return;
725
- }
726
- // Arrow / PageUp / PageDown — scroll the open Ctrl+O detail panel. Exact-match
727
- // (whole chunk) like Ctrl+O so embedded sequences in pasted/streamed data don't
728
- // trigger; bracketed paste already returned above. When the panel is closed the
729
- // hook is a no-op, so these keys stay inert mid-turn as before.
730
- if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
731
- if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
732
- if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
733
- if (text === "\u001b[6~") { opts.onScrollKey?.(1, true); return; }
734
- const escAt = text.indexOf("\u001b");
735
- const sigintAt = text.indexOf("\u0003");
736
- const controlAt =
737
- escAt === -1 ? sigintAt :
738
- sigintAt === -1 ? escAt :
739
- Math.min(escAt, sigintAt);
740
- if (controlAt >= 0) {
741
- const printablePrefix = text.slice(0, controlAt);
742
- if (printablePrefix) opts.onBufferedInput?.(printablePrefix);
743
- if (text[controlAt] === "\u0003") {
744
- handleSigint();
745
- return;
746
- }
747
- if (text === "\u001b") {
748
- abortNow("ESC pressed — cancelling current run…");
749
- return;
750
- }
751
- opts.onNoise?.();
752
- return;
753
- }
754
- opts.onBufferedInput?.(text);
755
- };
756
-
757
- process.on("SIGINT", handleSigint);
758
- if (captureEsc) {
759
- stdin.on("data", handleData);
760
- if (stdin.setRawMode && !wasRaw) {
761
- stdin.setRawMode(true);
762
- rawChanged = true;
763
- }
764
- stdin.resume?.();
765
- }
198
+ const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
766
199
 
767
- return {
768
- controller,
769
- handleSigint,
770
- handleData,
771
- dispose() {
772
- process.removeListener("SIGINT", handleSigint);
773
- if (captureEsc) {
774
- stdin.off("data", handleData);
775
- if (rawChanged) stdin.setRawMode?.(false);
776
- }
777
- },
778
- };
779
- }
780
200
 
781
- /** The exact resume command printed on REPL exit (and testable in isolation) —
782
- * same convention as the `--list` handler's hint. */
783
201
  export function formatResumeHint(sessionId: string): string {
784
202
  return `Resume with: jeo launch --resume ${sessionId}`;
785
203
  }
786
- export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
787
- // maxSteps 0 = dynamic: the engine's process-driven budget extends itself while the
788
- // turn shows progress instead of stopping at a hardcoded count (old default: 100).
789
- const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
790
- const rest: string[] = [];
791
- const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
792
- for (let i = 0; i < args.length; i++) {
793
- const a = args[i];
794
- if (a === "--") {
795
- rest.push(...args.slice(i + 1));
796
- break;
797
- }
798
- if (a === "--list") {
799
- flags.list = true;
800
- } else if (a === "-p" || a === "--print") {
801
- flags.print = true;
802
- flags.noTui = true;
803
- } else if (a === "--tmux") {
804
- flags.tmux = true;
805
- } else if (a === "--worktree") {
806
- const next = args[i + 1];
807
- if (next && !next.startsWith("-")) {
808
- flags.worktree = next;
809
- i++;
810
- }
811
- } else if (a.startsWith("--worktree=")) {
812
- flags.worktree = a.slice("--worktree=".length);
813
- } else if (a === "--no-session") {
814
- flags.noSession = true;
815
- } else if (a === "--no-tui") {
816
- flags.noTui = true;
817
- } else if (a === "--max-steps") {
818
- const n = parseInt(args[i + 1] ?? "", 10);
819
- if (Number.isFinite(n) && n > 0) {
820
- flags.maxSteps = n;
821
- i++;
822
- }
823
- } else if (a.startsWith("--max-steps=")) {
824
- const n = parseInt(a.slice(12), 10);
825
- if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
826
- } else if (a === "--model") {
827
- const { value, nextIndex } = takeValue(args, i, "--model=");
828
- if (value) flags.model = value;
829
- else flags.errors.push("--model requires a value");
830
- i = nextIndex;
831
- } else if (a.startsWith("--model=")) {
832
- const { value } = takeValue(args, i, "--model=");
833
- if (value) flags.model = value;
834
- else flags.errors.push("--model requires a value");
835
- } else if (a === "--provider") {
836
- const { value, nextIndex } = takeValue(args, i, "--provider=");
837
- const normalized = value?.toLowerCase();
838
- if (isProviderName(normalized)) flags.provider = normalized;
839
- else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
840
- i = nextIndex;
841
- } else if (a.startsWith("--provider=")) {
842
- const { value } = takeValue(args, i, "--provider=");
843
- const normalized = value?.toLowerCase();
844
- if (isProviderName(normalized)) flags.provider = normalized;
845
- else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
846
- } else if (a === "--thinking") {
847
- const { value, nextIndex } = takeValue(args, i, "--thinking=");
848
- const normalized = value?.toLowerCase();
849
- if (isThinkingLevel(normalized)) flags.thinking = normalized;
850
- else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
851
- i = nextIndex;
852
- } else if (a.startsWith("--thinking=")) {
853
- const { value } = takeValue(args, i, "--thinking=");
854
- const normalized = value?.toLowerCase();
855
- if (isThinkingLevel(normalized)) flags.thinking = normalized;
856
- else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
857
- } else if (a === "--smol" || a === "--slow" || a === "--plan") {
858
- flags.modelRole = a.slice(2) as ModelRole;
859
- } else if (a === "--resume" || a === "--continue" || a === "-c") {
860
- flags.resume = true;
861
- const next = args[i + 1];
862
- if (next && UUID_REGEX.test(next)) {
863
- flags.resumeId = next;
864
- i++;
865
- }
866
- } else if (a.startsWith("--resume=") || a.startsWith("--continue=") || a.startsWith("-c=")) {
867
- flags.resume = true;
868
- const eqIdx = a.indexOf("=");
869
- const val = a.slice(eqIdx + 1);
870
- if (UUID_REGEX.test(val)) {
871
- flags.resumeId = val;
872
- } else {
873
- rest.push(val);
874
- }
875
- } else if (a === "--append-system-prompt") {
876
- const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
877
- if (value) {
878
- flags.appendSystemPromptRaw = value;
879
- } else {
880
- flags.errors.push("--append-system-prompt requires a value");
881
- }
882
- i = nextIndex;
883
- } else if (a.startsWith("--append-system-prompt=")) {
884
- const { value } = takeValue(args, i, "--append-system-prompt=");
885
- if (value) {
886
- flags.appendSystemPromptRaw = value;
887
- } else {
888
- flags.errors.push("--append-system-prompt requires a value");
889
- }
890
- } else if (a === "--no-skills") {
891
- flags.noSkills = true;
892
- } else if (a === "--skills") {
893
- const { value, nextIndex } = takeValue(args, i, "--skills=");
894
- if (value) flags.skills = value;
895
- else flags.errors.push("--skills requires a value");
896
- i = nextIndex;
897
- } else if (a.startsWith("--skills=")) {
898
- const { value } = takeValue(args, i, "--skills=");
899
- if (value) flags.skills = value;
900
- else flags.errors.push("--skills requires a value");
901
- } else if (a === "--no-tools") {
902
- flags.noTools = true;
903
- } else if (a === "--tools") {
904
- const { value, nextIndex } = takeValue(args, i, "--tools=");
905
- if (value) flags.tools = value;
906
- else flags.errors.push("--tools requires a value");
907
- i = nextIndex;
908
- } else if (a.startsWith("--tools=")) {
909
- const { value } = takeValue(args, i, "--tools=");
910
- if (value) flags.tools = value;
911
- else flags.errors.push("--tools requires a value");
912
- } else if (a === "--system-prompt") {
913
- const { value, nextIndex } = takeValue(args, i, "--system-prompt=");
914
- if (value) flags.systemPromptRaw = value;
915
- else flags.errors.push("--system-prompt requires a value");
916
- i = nextIndex;
917
- } else if (a.startsWith("--system-prompt=")) {
918
- const { value } = takeValue(args, i, "--system-prompt=");
919
- if (value) flags.systemPromptRaw = value;
920
- else flags.errors.push("--system-prompt requires a value");
921
- } else {
922
- rest.push(a);
923
- }
924
- }
925
- flags.message = rest.join(" ").trim();
926
-
927
- if (flags.print && !flags.message) {
928
- flags.errors.push("-p/--print requires a message argument");
929
- }
930
-
931
- if (flags.appendSystemPromptRaw) {
932
- if (flags.appendSystemPromptRaw.startsWith("@")) {
933
- const filePath = flags.appendSystemPromptRaw.slice(1);
934
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
935
- try {
936
- flags.appendSystemPrompt = fs.readFileSync(absPath, "utf8");
937
- } catch (err) {
938
- flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
939
- }
940
- } else {
941
- flags.appendSystemPrompt = flags.appendSystemPromptRaw;
942
- }
943
- }
944
- if (flags.systemPromptRaw) {
945
- if (flags.systemPromptRaw.startsWith("@")) {
946
- const filePath = flags.systemPromptRaw.slice(1);
947
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
948
- try {
949
- flags.systemPrompt = fs.readFileSync(absPath, "utf8");
950
- } catch (err) {
951
- flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
952
- }
953
- } else {
954
- flags.systemPrompt = flags.systemPromptRaw;
955
- }
956
- }
957
-
958
- return flags;
959
- }
960
- export function matchSkillGlob(pattern: string, name: string): boolean {
961
- const p = pattern.toLowerCase();
962
- const n = name.toLowerCase();
963
- if (!p.includes("*")) {
964
- return p === n;
965
- }
966
- const escaped = p.replace(/[.+^${}()|[\]\\]/g, "\\$&");
967
- const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
968
- const regex = new RegExp(regexStr);
969
- return regex.test(n);
970
- }
971
-
972
- export function filterToolMap(
973
- tools: Record<string, any>,
974
- allowlist: string[]
975
- ): Record<string, any> {
976
- const result: Record<string, any> = {};
977
- for (const name of allowlist) {
978
- if (name in tools) {
979
- result[name] = tools[name];
980
- }
981
- }
982
- return result;
983
- }
984
- export const TOOL_DESCRIPTIONS: Record<string, string> = {
985
- read: "read {filePath, lineRange?, raw?} — read a file; lines are prefixed `LINEhh|` (hh = 2-char content anchor; the | is a separator, not file bytes)",
986
- write: "write {filePath, content} — create/overwrite a file",
987
- 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",
988
- bash: "bash {command, timeoutMs?, cwd?, env?} — run a shell command (cwd: subdir; env: extra vars)",
989
- find: "find {globPattern} — find files by name",
990
- search: "search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
991
- ls: "ls {dirPath} — list a directory's entries (dirs first)",
992
- };
993
-
994
- export function buildToolProtocol(allowedTools: Set<string>): string {
995
- const lines: string[] = ["You have these tools (call exactly ONE per step):"];
996
- let num = 1;
997
- for (const name of ["read", "write", "edit", "bash", "find", "search", "ls"]) {
998
- if (allowedTools.has(name)) {
999
- lines.push(`${num}. ${TOOL_DESCRIPTIONS[name]}`);
1000
- num++;
1001
- }
1002
- }
1003
- lines.push(`${num}. done {reason?} — call when the task is fully implemented AND verified`);
1004
- lines.push("");
1005
- lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
1006
- lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
1007
- lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
1008
- lines.push("Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. Locate before you open: search/find first, then read the hit, instead of guessing paths.");
1009
- return lines.join("\n");
1010
- }
1011
-
1012
- /**
1013
- * Resolve a git worktree path (gjc `--worktree <path>` parity). If the path
1014
- * already exists it is reused as-is; otherwise a new worktree is created on a
1015
- * branch derived from the path basename. Returns the absolute worktree path.
1016
- */
1017
- function resolveWorktree(cwd: string, wt: string): string {
1018
- const abs = path.isAbsolute(wt) ? wt : path.resolve(cwd, wt);
1019
- if (fs.existsSync(abs)) return abs;
1020
- if (!Bun.which("git")) {
1021
- console.error("error: --worktree requires git on PATH");
1022
- process.exit(1);
1023
- }
1024
- const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "jeo-wt");
1025
- const withBranch = Bun.spawnSync(["git", "worktree", "add", "-b", branch, abs], {
1026
- cwd,
1027
- stdout: "pipe",
1028
- stderr: "pipe",
1029
- });
1030
- if (withBranch.exitCode !== 0) {
1031
- // Branch may already exist; retry attaching the existing branch.
1032
- const plain = Bun.spawnSync(["git", "worktree", "add", abs], {
1033
- cwd,
1034
- stdout: "pipe",
1035
- stderr: "pipe",
1036
- });
1037
- if (plain.exitCode !== 0) {
1038
- console.error(
1039
- `error: failed to create git worktree at ${abs}: ${withBranch.stderr.toString().trim()}`,
1040
- );
1041
- process.exit(1);
1042
- }
1043
- }
1044
- return abs;
1045
- }
1046
-
1047
- /** The bundled workflow skills that run through a dedicated engine (deep-interview /
1048
- * ralplan / team / ultragoal), not the ordinary agent loop. Single source of truth —
1049
- * the menu listing, the dispatch guards, and the engine switch all read from here. */
1050
- export const WORKFLOW_NAMES = ["deep-interview", "ralplan", "team", "ultragoal"] as const;
1051
-
1052
- /** True when a skill name is one of the bundled workflow engines. */
1053
- export function isWorkflowSkill(name: string): boolean {
1054
- return (WORKFLOW_NAMES as readonly string[]).includes(name);
1055
- }
1056
-
1057
- /** Dispatch a bundled workflow by name to its engine. Keeps the name→engine mapping in
1058
- * ONE place so the one-shot and interactive skill runners can't drift apart. */
1059
- export function runWorkflowEngine(
1060
- name: string,
1061
- opts: DeepInterviewEngineOptions & RalplanEngineOptions & TeamEngineOptions & UltragoalEngineOptions,
1062
- ): Promise<{ ok: boolean; reason?: string }> {
1063
- if (name === "deep-interview") return runDeepInterviewEngine(opts);
1064
- if (name === "ralplan") return runRalplanEngine(opts);
1065
- if (name === "team") return runTeamEngine(opts);
1066
- return runUltragoalEngine(opts);
1067
- }
1068
204
 
1069
205
  export async function runLaunchCommand(args: string[]): Promise<void> {
1070
206
  let cwd = process.cwd();
@@ -1549,14 +685,48 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1549
685
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
1550
686
  // because nothing ever forced the model to update item statuses).
1551
687
  let turnTodos: { title: string; status: string }[] = [];
1552
- const onBeforeDone = (): string | null => {
688
+ const onBeforeDone = async (reason: string): Promise<string | null> => {
1553
689
  const unfinished = turnTodos.filter(t => t.status !== "done");
1554
- if (turnTodos.length === 0 || unfinished.length === 0) return null;
1555
- return (
1556
- `Your todo list still shows ${unfinished.length} unfinished item(s): ${unfinished.map(t => `"${t.title}"`).join(", ")}. ` +
1557
- `Reconcile the plan first — call the todo tool resending the FULL list with every actually-completed item marked "done" ` +
1558
- `(drop items that no longer apply), then call done again.`
1559
- );
690
+ if (turnTodos.length > 0 && unfinished.length > 0) {
691
+ return (
692
+ `Your todo list still shows ${unfinished.length} unfinished item(s): ${unfinished.map(t => `"${t.title}"`).join(", ")}. ` +
693
+ `Reconcile the plan first — call the todo tool resending the FULL list with every actually-completed item marked "done" ` +
694
+ `(drop items that no longer apply), then call done again.`
695
+ );
696
+ }
697
+
698
+ const goalState = await readGoalState(cwd);
699
+ if (goalState && goalState.condition) {
700
+ const reBlockCount = goalState.verdicts.filter(v => v.verdict === "NOT_MET" || v.verdict === "IMPOSSIBLE").length;
701
+ const MAX_RE_BLOCKS = 2;
702
+
703
+ if (reBlockCount >= MAX_RE_BLOCKS) {
704
+ if (tui) tui.events().onNotice?.(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
705
+ else console.log(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
706
+ return null;
707
+ }
708
+
709
+ if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
710
+ const verdict = await verifyGoal(goalState.condition, history, sessionModel);
711
+
712
+ goalState.verdicts.push({
713
+ at: Date.now(),
714
+ verdict: verdict.verdict,
715
+ gap: verdict.reason,
716
+ });
717
+ await writeGoalState(goalState, cwd);
718
+
719
+ if (verdict.verdict === "NOT_MET" || verdict.verdict === "IMPOSSIBLE") {
720
+ const prefix = verdict.verdict === "IMPOSSIBLE" ? "[IMPOSSIBLE] " : "";
721
+ return (
722
+ `Goal verifier check failed: The goal "${goalState.condition}" is not yet met.\n` +
723
+ `Reason: ${prefix}${verdict.reason}\n` +
724
+ `Please address the remaining gaps and call done again.`
725
+ );
726
+ }
727
+ }
728
+
729
+ return null;
1560
730
  };
1561
731
  const fullTools = {
1562
732
  ...DEFAULT_TOOLS,
@@ -1836,6 +1006,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1836
1006
  accent: accentPaint(welcomeTheme),
1837
1007
  accentShadow: accentShadowPaint(welcomeTheme),
1838
1008
  };
1009
+ // gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
1010
+ // never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
1011
+ if (process.stdout.isTTY) process.stdout.write(clearScreen());
1839
1012
  // Launch sweep: the DNA Claw's gradient loops seamlessly (default 2 full
1840
1013
  // cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
1841
1014
  // Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
@@ -2090,6 +1263,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2090
1263
  if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }
2091
1264
  }
2092
1265
  if (matched) continue;
1266
+ // Normalize macOS/fixterms combo cursor keys readline ignores (Option/Cmd+arrow,
1267
+ // Option/Cmd+Backspace) into the canonical control bytes it DOES act on.
1268
+ const combo = matchCursorCombo(data, i);
1269
+ if (combo) { out += combo[1]; i += combo[0].length; continue; }
2093
1270
  if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
2094
1271
  out += data[i]; i += 1;
2095
1272
  }
@@ -2190,7 +1367,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2190
1367
  pasteLineFired = true;
2191
1368
  return;
2192
1369
  }
2193
- if (!interactiveTurnActive) pendingStdinLines.push(l);
1370
+ // A select picker (/model, /agents, …) reads keypresses directly while NO
1371
+ // rl.question is active, so its filter-text + selecting Enter fire an ORPHAN
1372
+ // `line` event here. Queuing that would auto-serve the leftover filter as the
1373
+ // next prompt (the "/model · /agents leaves typed text behind" bug). Drop it:
1374
+ // the picker owns those keystrokes and clears the readline buffer on exit.
1375
+ if (!interactiveTurnActive && !pickerActive) pendingStdinLines.push(l);
2194
1376
  });
2195
1377
  let stdinClosed = false;
2196
1378
  let notifyStdinClosed: (() => void) | undefined;
@@ -2367,6 +1549,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2367
1549
  // Row (within the reservation) where the real cursor was last parked; the next
2368
1550
  // drawFooter/disarmPreview must hop back to the top from here before painting.
2369
1551
  let footerParkedRow = 0;
1552
+ // The footer's last painted lines (padded to the reservation height). The resize
1553
+ // relayout uses these to find the frame's physical top at the live width — a full-width
1554
+ // line painted at an older, wider geometry reflows onto extra rows after a width shrink.
1555
+ let lastDrawnLines: string[] = [];
2370
1556
  const padToFooter = (lines: string[]): string[] => {
2371
1557
  if (lines.length >= footerRows) return lines.slice(0, footerRows);
2372
1558
  return [...lines, ...new Array(footerRows - lines.length).fill("")];
@@ -2404,6 +1590,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2404
1590
  s += toColumn(1) + "\x1b[?25h";
2405
1591
  out.write(s);
2406
1592
  footerRendered = 0;
1593
+ lastDrawnLines = [];
2407
1594
  } else {
2408
1595
  out.write("\x1b[?25h");
2409
1596
  }
@@ -2473,7 +1660,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2473
1660
  const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
2474
1661
  const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
2475
1662
  const frame = renderInputFrame(expandSentinel(line), {
2476
- cols,
1663
+ // Cap the input box at 120 cols to match the live-turn box (renderLiveInputBox)
1664
+ // and the user-card width, so the box doesn't visibly jump width on the
1665
+ // idle→live transition on wide terminals. The status bar below stays full-width.
1666
+ cols: Math.min(120, cols),
2477
1667
  color: true,
2478
1668
  unicode: true,
2479
1669
  accent: boxAccent,
@@ -2543,6 +1733,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2543
1733
  // and no row can spill past it — the bug fix that kept `@folder<more text>`
2544
1734
  // typing from scrolling the input box (and prior output) off the top.
2545
1735
  const padded = padToFooter(lines);
1736
+ // Remember the exact painted frame so resize/disarm can clear its real physical
1737
+ // footprint at the (possibly changed) live width instead of trusting logical rows.
1738
+ lastDrawnLines = padded;
2546
1739
  // Pure caret moves (arrow keys) change no content — include the caret cell in
2547
1740
  // the repaint key so they still reposition the terminal cursor.
2548
1741
  const tRow = lines.length ? Math.min(footerCursor.row, footerRendered - 1) : 0;
@@ -3121,16 +2314,83 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3121
2314
  // Idle-prompt resize: re-reserve the footer at the new terminal height so the
3122
2315
  // fixed reservation stays accurate (otherwise the next paint would target the
3123
2316
  // old row count and either over-shoot or under-paint the reserved region).
3124
- const idleResizeHandler = () => {
2317
+ // gjc-style responsiveness: re-reserve the footer IMMEDIATELY on the first resize
2318
+ // event (leading edge → no lag), then cap re-reservations to ~30fps during a
2319
+ // drag-resize, with a trailing settle so the FINAL height is always reserved exactly.
2320
+ // Skip same-geometry events; each re-reserve disarms→arms so it must not run per-event.
2321
+ const RESIZE_THROTTLE_MS = 33;
2322
+ let idleResizeTimer: ReturnType<typeof setTimeout> | undefined;
2323
+ let lastIdleAt = 0;
2324
+ let lastIdleCols = process.stdout.columns ?? 80;
2325
+ let lastIdleRows = process.stdout.rows ?? 24;
2326
+ const reReserveFooter = () => {
3125
2327
  if (!previewArmed) return;
2328
+ const cols = process.stdout.columns ?? 80;
2329
+ const rows = process.stdout.rows ?? 24;
2330
+ if (cols === lastIdleCols && rows === lastIdleRows) return;
2331
+ lastIdleCols = cols;
2332
+ lastIdleRows = rows;
3126
2333
  try {
3127
- disarmPreview();
3128
- armPreview();
2334
+ // Resolution-safe relayout, anchored to the CURSOR (not the screen bottom). The
2335
+ // previous frame was painted at the OLD width; on resize the terminal reflows its
2336
+ // full-width rows AND repositions the frame — a width shrink wraps lines and floats
2337
+ // the frame UP by its blank tail, while a height grow leaves the frame high while
2338
+ // adding rows at the bottom. In every case the terminal keeps the REAL cursor on
2339
+ // the input caret's cell, so the caret is the one stable anchor. The old logical
2340
+ // disarm→arm cycle ignored this, clearing the wrong rows and scrolling the stray
2341
+ // frame into scrollback (the stacked status-bar corruption).
2342
+ //
2343
+ // Hop UP from the caret to the frame's physical top — `footerParkedRow` logical
2344
+ // rows of content sit above the caret; recompute their PHYSICAL height at the new
2345
+ // width (wrapped lines count for more) — then clear-to-end-of-display in one op.
2346
+ // This wipes the whole stray frame regardless of which way it drifted, and is
2347
+ // banner-safe: the hop stops exactly at the frame top (just below the static
2348
+ // content). ED is safe here — this path emits no bottom-margin scroll, so (unlike
2349
+ // the mid-turn ledger flush) tmux never copies the erased rows into history.
2350
+ const c = Math.max(1, cols);
2351
+ let abovePhysical = 0;
2352
+ for (let i = 0; i < footerParkedRow && i < lastDrawnLines.length; i++) {
2353
+ abovePhysical += Math.max(1, Math.ceil(Math.max(1, visibleWidth(lastDrawnLines[i]!)) / c));
2354
+ }
2355
+ // The caret may sit on a LOWER physical sub-row of its own (now-wrapped) line: a
2356
+ // long input wraps and the terminal keeps the cursor on its cell, so add the
2357
+ // caret's sub-row offset (caret column / width) or the hop stops below the top.
2358
+ const caretSubRow = Math.floor(Math.max(0, footerCursor.col - 1) / c);
2359
+ const hopUp = abovePhysical + caretSubRow;
2360
+ let s = (hopUp > 0 ? cursorUp(hopUp) : "") + toColumn(1) + clearToEnd();
2361
+ // Re-pin a clean reservation to the screen bottom and repaint. ED already blanked
2362
+ // from the frame top down to the bottom, so just position at the bottom region.
2363
+ footerRows = previewRowsFor(rows);
2364
+ s += `\x1b[${Math.max(1, rows)};1H`;
2365
+ if (footerRows > 1) s += cursorUp(footerRows - 1);
2366
+ s += toColumn(1);
2367
+ out.write(s);
2368
+ footerRendered = footerRows;
2369
+ footerParkedRow = 0;
2370
+ lastFooterKey = "";
3129
2371
  drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
3130
2372
  } catch { /* ignore resize render races */ }
3131
2373
  };
2374
+ const idleResizeHandler = () => {
2375
+ if (!previewArmed) return;
2376
+ const now = Date.now();
2377
+ if (now - lastIdleAt >= RESIZE_THROTTLE_MS) {
2378
+ lastIdleAt = now;
2379
+ reReserveFooter();
2380
+ return;
2381
+ }
2382
+ if (idleResizeTimer) clearTimeout(idleResizeTimer);
2383
+ idleResizeTimer = setTimeout(() => {
2384
+ idleResizeTimer = undefined;
2385
+ lastIdleAt = Date.now();
2386
+ reReserveFooter();
2387
+ }, RESIZE_THROTTLE_MS);
2388
+ };
3132
2389
  process.stdout.on("resize", idleResizeHandler);
3133
- promptListenerCleanups.push(() => process.stdout.off("resize", idleResizeHandler));
2390
+ promptListenerCleanups.push(() => {
2391
+ process.stdout.off("resize", idleResizeHandler);
2392
+ if (idleResizeTimer) clearTimeout(idleResizeTimer);
2393
+ });
3134
2394
  }
3135
2395
 
3136
2396
  while (true) {
@@ -3244,7 +2504,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3244
2504
  // scrollback, and re-render the welcome banner so /clear looks like a fresh launch.
3245
2505
  if (process.stdout.isTTY) {
3246
2506
  disarmPreview();
3247
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H"); // clear screen + scrollback + cursor home
2507
+ process.stdout.write(clearScreen()); // clear screen + scrollback + cursor home
3248
2508
  console.log(renderWelcome(welcomeData).join("\n"));
3249
2509
  }
3250
2510
  console.log("(history cleared — back to the start screen)");
@@ -3349,7 +2609,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3349
2609
  // so the resumed view reads like a fresh, intact screen.
3350
2610
  if (process.stdout.isTTY) {
3351
2611
  disarmPreview();
3352
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
2612
+ process.stdout.write(clearScreen());
3353
2613
  console.log(renderWelcome(welcomeData).join("\n"));
3354
2614
  }
3355
2615
  const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
@@ -3548,6 +2808,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3548
2808
  }
3549
2809
  continue;
3550
2810
  }
2811
+ if (input === "/goal" || input.startsWith("/goal ")) {
2812
+ const arg = input.substring(5).trim();
2813
+ if (!arg) {
2814
+ const current = await readGoalState(cwd);
2815
+ if (current) {
2816
+ console.log(`Current goal: "${current.condition}" (set at ${new Date(current.setAt).toLocaleTimeString()})`);
2817
+ } else {
2818
+ console.log("Usage: /goal <condition> (set a natural language stop condition for the session)");
2819
+ console.log(" /goal clear (clear the current goal)");
2820
+ }
2821
+ continue;
2822
+ }
2823
+ if (arg === "clear" || arg === "none") {
2824
+ await clearGoalState(cwd);
2825
+ console.log("Goal cleared.");
2826
+ continue;
2827
+ }
2828
+ const state = {
2829
+ condition: arg,
2830
+ setAt: Date.now(),
2831
+ verdicts: [],
2832
+ };
2833
+ await writeGoalState(state, cwd);
2834
+ console.log(`Goal set to: "${arg}"`);
2835
+ continue;
2836
+ }
3551
2837
  // ---- gjc-parity inspection commands ------------------------------------
3552
2838
  if (input === "/usage") {
3553
2839
  const total = sessionUsage.inputTokens + sessionUsage.outputTokens;
@@ -3632,8 +2918,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3632
2918
  }
3633
2919
  if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
3634
2920
  const tokens = input.substring(9).trim().split(/\s+/).filter(Boolean);
3635
- let name = (tokens[0] ?? "").toLowerCase();
3636
- const explicitModel = tokens[1];
2921
+ const name = (tokens[0] ?? "").toLowerCase();
2922
+ // gjc-parity (semantic): /provider is ONBOARDING ONLY — set up OAuth credentials
2923
+ // or an API-compatible endpoint. Switching the active provider/model lives in /model.
2924
+ const providerOnboardingUsage = (): string[] => [
2925
+ "Provider onboarding — set up credentials or an API-compatible endpoint:",
2926
+ " OAuth / subscription : /provider login [anthropic|openai|gemini|antigravity] (alias: /login)",
2927
+ " API-compatible : /provider add --base-url <url> [--model <model>] [--compat openai] (reads OPENAI_API_KEY)",
2928
+ " show current / clear: /provider add · /provider add clear",
2929
+ " Logout : /logout <provider>",
2930
+ "Switch the active model or provider with /model.",
2931
+ ];
3637
2932
  // `/provider login|auth [name]` → run OAuth login from the REPL.
3638
2933
  if (name === "login" || name === "auth") {
3639
2934
  const cloud = ["anthropic", "openai", "gemini", "antigravity"] as const;
@@ -3643,14 +2938,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3643
2938
  if (process.stdin.isTTY && process.stdout.isTTY) {
3644
2939
  target = await pickCloudProvider(statuses);
3645
2940
  } else {
3646
- // No provider given → show current status and let the user pick.
3647
2941
  console.log("Log in to which provider?");
3648
2942
  cloud.forEach((p, i) => {
3649
2943
  const st = statuses.find(s => s.name === p);
3650
2944
  console.log(` ${i + 1}) ${p.padEnd(10)} ${st?.ready ? `✓ ${st.label}` : "· not ready"}`);
3651
2945
  });
3652
- const ans = (await promptInput("Choose [1-3] or name (blank to cancel): ")).trim().toLowerCase();
3653
- const byNum: Record<string, string> = { "1": "anthropic", "2": "openai", "3": "gemini" };
2946
+ const ans = (await promptInput(`Choose [1-${cloud.length}] or name (blank to cancel): `)).trim().toLowerCase();
2947
+ const byNum: Record<string, string> = Object.fromEntries(cloud.map((p, i) => [String(i + 1), p]));
3654
2948
  target = byNum[ans] ?? ((cloud as readonly string[]).includes(ans) ? ans : undefined);
3655
2949
  }
3656
2950
  if (!target) {
@@ -3661,131 +2955,107 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3661
2955
  console.log(`Starting OAuth login for ${target}…`);
3662
2956
  try {
3663
2957
  const { email } = await interactiveOAuthLogin(target as AuthProvider, rl);
3664
- // Tidy back to the initial query-input screen: the OAuth flow printed
3665
- // browser prompts / "waiting…" lines that clutter scrollback. Clear the
3666
- // screen + scrollback and re-render the welcome (same path as /clear),
3667
- // then a single concise confirmation. lastPickIndex is still seeded so
3668
- // `/model #N` works; the verbose live-model dump is dropped (it cluttered).
2958
+ console.log(`[SUCCESS] OAuth login complete for ${target}${email ? ` (${email})` : ""}. Tokens saved to ~/.jeo/config.json.`);
3669
2959
  const live = await refreshLiveModelsCache();
3670
2960
  const after = (await describeAllProviders()).find(s => s.name === target);
2961
+ if (after) console.log(` status → ${after.name}: ${after.ready ? `✓ ${after.label}` : after.label}`);
3671
2962
  const forProvider = live.filter(r => r.provider === target);
3672
- if (forProvider.some(r => r.ok && r.models.length > 0)) lastPickIndex = flattenModels(forProvider);
3673
- // Tidy back to the initial query-input screen: the OAuth flow printed
3674
- // browser prompts / "waiting…" lines that clutter scrollback. emitLoginCleanup
3675
- // clears + re-renders the welcome (same path as /clear) then prints one
3676
- // confirmation; the verbose live-model dump is dropped (lastPickIndex is
3677
- // still seeded so /model #N works). Orchestration is unit-tested.
3678
- emitLoginCleanup(
3679
- {
3680
- clear: () => { disarmPreview(); process.stdout.write("\x1b[2J\x1b[3J\x1b[H"); },
3681
- write: line => console.log(line),
3682
- },
3683
- {
3684
- isTty: process.stdout.isTTY === true,
3685
- provider: target,
3686
- email,
3687
- ready: after?.ready ?? false,
3688
- label: after?.label,
3689
- welcomeLines: renderWelcome(welcomeData),
3690
- },
3691
- );
2963
+ if (forProvider.some(r => r.ok && r.models.length > 0)) {
2964
+ lastPickIndex = flattenModels(forProvider);
2965
+ const viaCatalog = forProvider.some(r => r.fallback);
2966
+ console.log(` ${viaCatalog ? "catalog" : "live"} ${target} models → /model #N${viaCatalog ? " (live list endpoint rejected this token; showing known models)" : ""}`);
2967
+ logLines(formatPickListWithCapabilities(lastPickIndex, { cap: 12 }));
2968
+ } else {
2969
+ const failed = forProvider.find(r => !r.ok);
2970
+ if (failed?.error) console.log(` live ${target} models unavailable: ${failed.error}`);
2971
+ }
3692
2972
  } catch (err) {
3693
2973
  console.log(`[FAILED] ${(err as Error).message} — or set ${target.toUpperCase()}_API_KEY.`);
2974
+ } finally {
2975
+ // The OAuth manual-code prompt opens an rl.question that is aborted the
2976
+ // instant the browser callback (or a failure) settles the flow. Bun leaves
2977
+ // that abandoned question's buffer behind, so the next prompt would adopt
2978
+ // it as residual prefill and resurrect the submitted "/provider login" in
2979
+ // the input box. Reset the readline line buffer so the prompt starts empty.
2980
+ const rli = rl as unknown as { line?: string; cursor?: number };
2981
+ rli.line = "";
2982
+ rli.cursor = 0;
3694
2983
  }
3695
2984
  continue;
3696
2985
  }
3697
- const cfgNow = await readGlobalConfig();
3698
- const statuses = await describeAllProviders(cfgNow);
3699
- if (!name) {
3700
- if (process.stdin.isTTY && process.stdout.isTTY) {
3701
- // gjc-style interactive picker (grouped ready / needs-setup, current marked)
3702
- // instead of a static text dump — arrows + Enter switch, Esc cancels.
3703
- const current = (await describeModel(sessionModel || cfgNow.defaultModel, cfgNow)).provider;
3704
- const items = buildProviderChoices(statuses, true, current).map(c => ({
3705
- value: c.value as string,
3706
- label: c.label,
3707
- hint: c.hint,
3708
- group: c.group,
3709
- }));
3710
- const picked = await pickFromOptions("Select a provider ↑↓ move · Enter switch · Esc cancel", items);
3711
- if (!picked) { console.log("(switch cancelled)"); continue; }
3712
- name = picked.toLowerCase(); // fall through to the switch logic below
3713
- } else {
3714
- console.log("Providers (credential · base URL):");
3715
- logLines(formatProviderPanel(statuses));
3716
- console.log("Switch with: /provider <name> [model] · choose models: /model");
2986
+ // `/provider add --base-url <url> [--model <m>] [--compat openai]` → register an
2987
+ // OpenAI-compatible endpoint (sets openaiBaseUrl). gjc flag style; bare positional
2988
+ // URL/model still accepted for leniency. `/provider add clear` removes it.
2989
+ if (name === "add") {
2990
+ const rest = tokens.slice(1);
2991
+ if ((rest[0] ?? "").toLowerCase() === "clear") {
2992
+ await saveConfigPatch(() => ({ openaiBaseUrl: undefined }));
2993
+ await refreshLiveModelsCache();
2994
+ console.log("OpenAI-compatible base URL cleared — saved to ~/.jeo/config.json.");
3717
2995
  continue;
3718
2996
  }
3719
- }
3720
- if (!isProviderName(name)) {
3721
- console.log(`Unknown provider '${name}'. Known: ${statuses.map(s => s.name).join(", ")}.`);
3722
- continue;
3723
- }
3724
- const st = statuses.find(s => s.name === name);
3725
- if (st && !st.ready) {
3726
- console.log(`! ${name} is not ready (${st.label}) set ${st.envVar ?? "the provider key"} or configure a compatible base URL. Switching anyway.`);
3727
- }
3728
- const live = await getLiveModels();
3729
- const forProvider = live.filter(r => r.provider === name);
3730
- const providerPick = flattenModels(forProvider);
3731
- const currentResolved = (await describeModel(sessionModel || cfgNow.defaultModel)).resolved;
3732
- let pickedFromPicker = false;
3733
- let target = explicitModel ?? PROVIDER_DEFAULT[name];
3734
- if (!explicitModel && providerPick.length && process.stdin.isTTY && process.stdout.isTTY) {
3735
- const picked = await pickLiveProviderModel(name, providerPick, currentResolved, st && !st.ready && !selectableThoughNotReady(st) ? [name] : []);
3736
- if (!picked) {
3737
- console.log("(cancelled)");
2997
+ let baseUrl: string | undefined;
2998
+ let modelArg: string | undefined;
2999
+ let compat: string | undefined;
3000
+ for (let i = 0; i < rest.length; i++) {
3001
+ const t = rest[i]!;
3002
+ if (t === "--base-url" || t === "--url") baseUrl = rest[++i];
3003
+ else if (t === "--model") modelArg = rest[++i];
3004
+ else if (t === "--compat") compat = (rest[++i] ?? "").toLowerCase();
3005
+ else if (t === "--api-key-env" || t === "--provider") { i++; /* gjc-only: jeo has no named-provider registry — ignored */ }
3006
+ else if (!t.startsWith("--") && baseUrl === undefined) baseUrl = t; // positional url
3007
+ else if (!t.startsWith("--") && modelArg === undefined) modelArg = t; // positional model
3008
+ }
3009
+ if (compat && compat !== "openai") {
3010
+ console.log(`Only --compat openai is supported (got '${compat}') — jeo registers a single OpenAI-compatible endpoint.`);
3738
3011
  continue;
3739
3012
  }
3740
- pickedFromPicker = true;
3741
- target = qualifyModelId(picked.model, picked.provider);
3742
- } else if (explicitModel && providerPick.length) {
3743
- const sel = resolveSelection(providerPick, explicitModel);
3744
- if (sel.kind === "index" || sel.kind === "match") {
3745
- target = qualifyModelId(sel.entry.model, sel.entry.provider);
3746
- if (st && !st.ready) {
3747
- if (selectableThoughNotReady(st)) {
3748
- console.log(notReadyWarning(st));
3749
- } else {
3750
- console.log(`Cannot select ${sel.entry.model}: ${name} is not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
3751
- continue;
3752
- }
3753
- }
3754
- } else if (sel.kind === "ambiguous") {
3755
- console.log(`'${explicitModel}' matches ${sel.matches.length} ${name} models — be more specific:`);
3756
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
3013
+ if (!baseUrl) {
3014
+ const cur = (await readGlobalConfig()).openaiBaseUrl;
3015
+ console.log(cur ? `OpenAI-compatible base URL: ${cur}` : "No OpenAI-compatible base URL set.");
3016
+ console.log("Set one with: /provider add --base-url <url> [--model <model>] · clear with: /provider add clear");
3757
3017
  continue;
3758
- } else if (sel.kind === "out-of-range") {
3759
- console.log(`#${explicitModel.slice(1)} is out of range for ${name} (1-${sel.max}).`);
3018
+ }
3019
+ const url = normalizeBaseUrl(baseUrl, "");
3020
+ if (!url) {
3021
+ console.log("Provide a base URL, e.g. /provider add --base-url http://localhost:1234/v1");
3760
3022
  continue;
3761
3023
  }
3762
- } else if (explicitModel?.startsWith("#")) {
3763
- console.log(`No numbered ${name} model list is available yet.`);
3764
- continue;
3765
- }
3766
- const { resolved, provider } = await describeModel(target);
3767
- if (explicitModel && provider !== name) {
3768
- console.log(`! '${target}' resolves to ${provider}, not ${name}. Pick a ${name} model from the live list below.`);
3769
- if (providerPick.length) logLines(formatPickListWithCapabilities(providerPick, { cap: 20 }));
3024
+ await saveConfigPatch(() => ({ openaiBaseUrl: url }));
3025
+ if (modelArg) {
3026
+ const qualified = qualifyModelId(modelArg, "openai");
3027
+ sessionModel = qualified;
3028
+ await saveConfigPatch(raw => rememberModelPatch(raw, qualified));
3029
+ console.log(`OpenAI-compatible endpoint set: ${url} · default model ${qualified} — saved to ~/.jeo/config.json.`);
3030
+ } else {
3031
+ console.log(`OpenAI-compatible endpoint set: ${url} — saved to ~/.jeo/config.json.`);
3032
+ }
3033
+ const live = await refreshLiveModelsCache();
3034
+ const forProvider = live.filter(r => r.provider === "openai");
3035
+ const pick = flattenModels(forProvider);
3036
+ if (pick.length) {
3037
+ lastPickIndex = pick;
3038
+ console.log("Discovered models at this endpoint — select with /model #N:");
3039
+ logLines(formatPickListWithCapabilities(pick, { cap: 12 }));
3040
+ } else {
3041
+ const failed = forProvider.find(r => !r.ok);
3042
+ console.log(failed?.error ? ` could not list models: ${failed.error}` : " no models discovered yet — the endpoint may need a key (set OPENAI_API_KEY).");
3043
+ }
3770
3044
  continue;
3771
3045
  }
3772
- if (pickedFromPicker && await applyPickedModelWithTarget(target)) {
3773
- if (providerPick.length) lastPickIndex = providerPick;
3046
+ // Bare `/provider` (or `help`): show readiness + the onboarding usage. No switching.
3047
+ if (!name || name === "help") {
3048
+ const cfgNow = await readGlobalConfig();
3049
+ const statuses = await describeAllProviders(cfgNow);
3050
+ console.log("Providers (credential · base URL):");
3051
+ logLines(formatProviderPanel(statuses));
3052
+ for (const line of providerOnboardingUsage()) console.log(line);
3774
3053
  continue;
3775
3054
  }
3776
- sessionModel = target;
3777
- // MRU persistence: a provider/model pick becomes the default for EVERY
3778
- // future session and the head of the recents rotation.
3779
- await saveConfigPatch(raw => rememberModelPatch(raw, target));
3780
- console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })} — saved as default`);
3781
- // Show the provider's live, credentialed catalog so the user can pick a concrete id.
3782
- if (providerPick.length) {
3783
- lastPickIndex = providerPick;
3784
- if (!pickedFromPicker) {
3785
- console.log(`Live ${name} models — select with /model #N, /provider ${name} #N, or rerun /provider ${name} and use arrows+Enter:`);
3786
- logLines(formatPickListWithCapabilities(lastPickIndex, { current: resolved }));
3787
- }
3788
- }
3055
+ // Anything else (a provider name) used to switch the active provider — that moved
3056
+ // to /model (gjc semantics: /provider onboards, /model selects).
3057
+ console.log(`'/provider ${name}' no longer switches providers provider/model selection moved to /model.`);
3058
+ console.log("Run /model to pick any provider's model (or /model <id|#N>). /provider now only onboards — see /provider help.");
3789
3059
  continue;
3790
3060
  }
3791
3061
  if (input.startsWith("/logout") && (input === "/logout" || input[7] === " ")) {
@@ -3964,11 +3234,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3964
3234
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
3965
3235
  continue;
3966
3236
  } else if (sel.kind === "out-of-range") {
3967
- console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
3237
+ console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
3968
3238
  continue;
3969
3239
  }
3970
3240
  } else if (modelArg.startsWith("#")) {
3971
- console.log("Use /model or /provider <name> first to build the numbered live model list.");
3241
+ console.log("Use /model first to build the numbered live model list.");
3972
3242
  continue;
3973
3243
  }
3974
3244
  // Persist a per-role model override to ~/.jeo/config.json (consumed by 'jeo team').
@@ -4038,11 +3308,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4038
3308
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4039
3309
  continue;
4040
3310
  } else if (sel.kind === "out-of-range") {
4041
- console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
3311
+ console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4042
3312
  continue;
4043
3313
  }
4044
3314
  } else if (chosenModel.startsWith("#")) {
4045
- console.log("Use /model or /provider <name> first to build the numbered live model list.");
3315
+ console.log("Use /model first to build the numbered live model list.");
4046
3316
  continue;
4047
3317
  }
4048
3318
  await saveConfigPatch(raw => ({ roles: { ...(raw.roles ?? {}), [tier]: chosenModel } }));
@@ -4123,12 +3393,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4123
3393
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4124
3394
  continue;
4125
3395
  } else if (sel.kind === "out-of-range") {
4126
- console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
3396
+ console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4127
3397
  continue;
4128
3398
  }
4129
3399
  // kind "none" → treat `toSave` as a literal model id/alias.
4130
3400
  } else if (toSave.startsWith("#")) {
4131
- console.log("Use /model or /provider <name> first to build the numbered list.");
3401
+ console.log("Use /model first to build the numbered list.");
4132
3402
  continue;
4133
3403
  }
4134
3404
  // Fall back to the FRESH on-disk default (not the stale session-start snapshot) so a
@@ -4196,11 +3466,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4196
3466
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4197
3467
  continue;
4198
3468
  } else if (sel.kind === "out-of-range") {
4199
- console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
3469
+ console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4200
3470
  continue;
4201
3471
  }
4202
3472
  } else if (roleModelArg.startsWith("#")) {
4203
- console.log("Use /model or /provider <name> first to build the numbered list.");
3473
+ console.log("Use /model first to build the numbered list.");
4204
3474
  continue;
4205
3475
  }
4206
3476
  if (roleModelArg) {
@@ -4251,12 +3521,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4251
3521
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4252
3522
  continue;
4253
3523
  } else if (sel.kind === "out-of-range") {
4254
- console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
3524
+ console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4255
3525
  continue;
4256
3526
  }
4257
3527
  // kind "none" → fall through and treat `arg` as a literal model id/alias.
4258
3528
  } else if (arg.startsWith("#")) {
4259
- console.log("Use /model or /provider <name> first to build the numbered list.");
3529
+ console.log("Use /model first to build the numbered list.");
4260
3530
  continue;
4261
3531
  }
4262
3532
  const label = arg || (sessionModel || defaultModel);
@@ -4282,7 +3552,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4282
3552
  }
4283
3553
  }
4284
3554
  if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
4285
- console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model or /provider <name> to pick a valid id)`);
3555
+ console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model to pick a valid id)`);
4286
3556
  }
4287
3557
  const meta = catalogMetadata(resolved);
4288
3558
  if (meta) console.log(` ${formatCapabilityLine(meta)}`);
@@ -4450,7 +3720,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4450
3720
  if (m.length) {
4451
3721
  for (const line of formatSlashCommandList(input, skillSlashDetails)) console.log(line);
4452
3722
  } else {
4453
- console.log(`Unknown command '${input}'. Try /help.`);
3723
+ const cmds = [...completionContext().slashCommands];
3724
+ const near = suggestSlashCommands(input, cmds);
3725
+ console.log(near.length ? `Unknown command '${input}'. Did you mean ${near.join(", ")}?` : `Unknown command '${input}'. Try /help.`);
4454
3726
  }
4455
3727
  continue;
4456
3728
  }