jeo-code 0.6.4 → 0.6.6

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.
@@ -60,9 +60,10 @@ import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } fro
60
60
  import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
61
61
  import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
62
62
  import { categoryBadge } from "../tui/components/category-index";
63
- import { renderInputFrame } from "../tui/components/input-box";
63
+ import { renderInputFrame, verticalCursorOffset } from "../tui/components/input-box";
64
+
64
65
  import { renderStatusBar } from "../tui/components/status";
65
- import { detectColorLevel, ColorLevel } from "../tui/components/color";
66
+ import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
66
67
  import { readClipboardImage } from "../util/clipboard-image";
67
68
  import { formatTranscript } from "../tui/components/transcript";
68
69
  import { loadInputHistory, appendInputHistory } from "../agent/input-history";
@@ -92,438 +93,100 @@ import {
92
93
  sessionPath,
93
94
  appendCompaction,
94
95
  } from "../agent/session";
95
- import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking } from "../tui/terminal";
96
-
97
- export interface LaunchFlags {
98
- list: boolean;
99
- resume: boolean;
100
- resumeId?: string;
101
- noSession: boolean;
102
- noTui: boolean;
103
- /** Explicit step cap from --max-steps; 0 = dynamic (process-driven budget that
104
- * keeps extending while the turn shows progress — no hardcoded step ceiling). */
105
- maxSteps: number;
106
- message: string;
107
- tmux: boolean;
108
- worktree?: string;
109
- model?: string;
110
- provider?: ProviderName;
111
- modelRole?: ModelRole;
112
- thinking?: ThinkLevel;
113
- errors: string[];
114
- print?: boolean;
115
- appendSystemPromptRaw?: string;
116
- appendSystemPrompt?: string;
117
- noSkills: boolean;
118
- skills?: string;
119
- noTools: boolean;
120
- tools?: string;
121
- systemPromptRaw?: string;
122
- systemPrompt?: string;
123
- }
124
-
125
- const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
126
-
127
- function takeValue(args: string[], index: number, inlinePrefix: string): { value?: string; nextIndex: number } {
128
- const current = args[index]!;
129
- if (current.startsWith(inlinePrefix)) return { value: current.slice(inlinePrefix.length), nextIndex: index };
130
- const next = args[index + 1];
131
- if (next && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
132
- return { nextIndex: index };
133
- }
134
-
135
- function isProviderName(input: string | undefined): input is ProviderName {
136
- return input === "anthropic" || input === "openai" || input === "gemini" || input === "antigravity" || input === "ollama";
137
- }
138
-
139
- function isThinkingLevel(input: string | undefined): input is ThinkLevel {
140
- return input === "minimal" || input === "low" || input === "medium" || input === "high" || input === "xhigh";
141
- }
142
-
143
- function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
144
- const supported = catalogMetadata(modelId)?.thinking ?? [];
145
- if (supported.includes("minimal")) return "minimal";
146
- if (supported.includes("low")) return "low";
147
- // Fallback for the one thinking-capable family that misses a catalog entry in practice:
148
- // the dynamic antigravity `gemini-3.x` variants (gemini-3.5-flash-low/-extra-low, …) the
149
- // CCA backend serves but the static catalog can't enumerate. They apply a thinking budget,
150
- // so /fast offers `minimal` as the fast level instead of reporting "unsupported". (openai
151
- // reasoning models and *-thinking/-high/-low antigravity ids are already catalogued.)
152
- if (/gemini-(2\.5|[3-9])/.test(modelId.toLowerCase())) return "minimal";
153
- return undefined;
154
- }
155
-
156
- function hashString(input: string): string {
157
- let hash = 2166136261;
158
- for (let i = 0; i < input.length; i++) {
159
- hash ^= input.charCodeAt(i);
160
- hash = Math.imul(hash, 16777619);
161
- }
162
- return (hash >>> 0).toString(36).padStart(6, "0").slice(0, 6);
163
- }
164
-
165
- function tmuxSafeNamePart(input: string, max = 32): string {
166
- const safe = input.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "value";
167
- if (safe.length <= max) return safe;
168
- return `${safe.slice(0, Math.max(1, max - 7))}-${hashString(input)}`;
169
- }
170
-
171
- function tmuxRuntimeSuffix(flags: LaunchFlags): string {
172
- const parts: string[] = [];
173
- if (flags.provider) parts.push(`provider-${flags.provider}`);
174
- if (flags.model) parts.push(`model-${tmuxSafeNamePart(flags.model)}`);
175
- else if (flags.modelRole) parts.push(flags.modelRole);
176
- if (flags.thinking) parts.push(`think-${flags.thinking}`);
177
- // Only an EXPLICIT --max-steps cap names the session; the dynamic default (0) adds nothing.
178
- if (flags.maxSteps > 0) parts.push(`steps-${flags.maxSteps}`);
179
- if (parts.length === 0) return "";
180
- const joined = parts.join("-");
181
- const suffix = joined.length <= 72 ? joined : `${joined.slice(0, 65)}-${hashString(joined)}`;
182
- return `-${suffix}`;
183
- }
184
-
185
- /**
186
- * Base tmux session name for `jeo --tmux`. Keyed on the working DIRECTORY (not just the
187
- * git branch) so two different projects/worktrees on the same branch (e.g. `main`)
188
- * never share a base. {@link uniqueTmuxSessionName} then makes each concurrent invocation
189
- * fully independent, so a second `jeo --tmux` never attaches to (and mirrors) the first.
190
- */
191
- export function tmuxSessionName(cwd: string, branch: string, flags: LaunchFlags): string {
192
- const dirTag = `${tmuxSafeNamePart(path.basename(cwd) || "root", 16)}-${hashString(cwd)}`;
193
- const base = branch ? `jeo-${branch}-${dirTag}` : `jeo-${dirTag}`;
194
- return base + tmuxRuntimeSuffix(flags);
195
- }
196
-
197
- /**
198
- * Count uncommitted git entries for the `⑂ <branch> ?N` footer dirty flag (gjc parity).
199
- * One `git status --porcelain` spawn per CALL; callers invoke it once per turn start, not
200
- * per render. Returns undefined when not a repo / git absent / clean.
201
- */
202
- export function gitDirtyCount(cwd: string): number | undefined {
203
- try {
204
- const res = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
205
- if (res.exitCode !== 0) return undefined;
206
- const n = res.stdout.toString().split("\n").filter(l => l.trim().length > 0).length;
207
- return n || undefined;
208
- } catch {
209
- return undefined;
210
- }
211
- }
96
+ import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearToEnd } from "../tui/terminal";
212
97
 
213
- /**
214
- * Allocate + create an INDEPENDENT tmux session from a base name. Each separate,
215
- * concurrent `jeo --tmux` invocation gets its OWN session instead of attaching to (and
216
- * mirroring) one another process already created: try `base`, then `base-2`, `base-3`, …
217
- * The create itself is the guard, so this is race-safe — two processes starting at the
218
- * same instant can't both win `base`. `tryCreate` must attempt to create the named session
219
- * and return `"ok"` (created — it's ours), `"taken"` (name already live / lost the race →
220
- * try the next suffix), or `"error:<msg>"` (a real failure → abort). Sessions die with
221
- * their jeo process, so a sequential re-run reuses the clean base; only live overlap is
222
- * suffixed.
223
- */
224
- export type TmuxCreateResult = "ok" | "taken" | `error:${string}`;
225
- export function allocateTmuxSession(
226
- base: string,
227
- tryCreate: (name: string) => TmuxCreateResult,
228
- ): { name: string } | { error: string } {
229
- for (let n = 1; n <= 1000; n++) {
230
- const candidate = n === 1 ? base : `${base}-${n}`;
231
- const result = tryCreate(candidate);
232
- if (result === "ok") return { name: candidate };
233
- if (result === "taken") continue;
234
- return { error: result.slice("error:".length) };
235
- }
236
- return { error: `could not allocate a free tmux session name for ${base} (1000 already live?)` };
237
- }
238
-
239
- function shellQuote(arg: string): string {
240
- return `'${arg.replace(/'/g, `'\\''`)}'`;
241
- }
242
-
243
- /**
244
- * True when `jeo --tmux` runs INSIDE an existing tmux session and should enable
245
- * session-scoped mouse mode for the CURRENT session: no jeo-owned session is created
246
- * on this path, so without `mouse on` tmux ignores the wheel entirely and the
247
- * mid-turn scrollback (ledger lines flushed above the live frame) is unreachable.
248
- * Skipped for jeo-spawned sessions (JEO_TMUX_LAUNCHED=1 — the creator already set
249
- * it) and when JEO_TMUX_MOUSE=0 opts out.
250
- */
251
- export function shouldEnableCurrentTmuxMouse(env: Record<string, string | undefined>): boolean {
252
- return !!env.TMUX
253
- && (env.JEO_TMUX_LAUNCHED ?? env.JEO_TMUX_LAUNCHED) !== "1"
254
- && (env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0";
255
- }
256
-
257
- /**
258
- * The runnable command for the INNER `jeo launch` a `--tmux` session executes.
259
- * Pure — testable. Three runtime shapes:
260
- * - compiled standalone binary: `argv[1]` is a Bun VIRTUAL path (`/$bunfs/…`)
261
- * that does not exist on disk; the binary itself (`execPath`) is the
262
- * entrypoint. Passing the virtual path made the inner command crash on
263
- * spawn, so `tmux new-session` died instantly and the follow-up attach
264
- * failed with "can't find session".
265
- * - source run (`bun src/cli.ts`): re-run the script through the runtime.
266
- * - anything else (a shim/binary path on disk): run it directly.
267
- */
268
- export function tmuxLaunchCommand(argv1: string | undefined, execPath: string, cwd: string): string[] {
269
- const entrypoint = argv1 ?? "";
270
- if (entrypoint === "" || entrypoint.startsWith("/$bunfs/") || entrypoint.startsWith("B:\\~BUN\\")) {
271
- return [execPath];
272
- }
273
- const resolved = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(cwd, entrypoint);
274
- if (/\.(ts|js|mjs)$/.test(entrypoint)) return [execPath, resolved];
275
- return [resolved];
276
- }
277
-
278
- /** One tmux configuration step applied to a jeo-owned session after creation. */
279
- export interface TmuxProfileCommand {
280
- description: string;
281
- args: string[];
282
- }
283
-
284
- /**
285
- * gjc-parity tmux profile for jeo-OWNED sessions (mirrors gjc's
286
- * `buildGjcTmuxProfileCommands`). Applied right after `new-session`, before attach:
287
- * - `mouse on` (session-scoped): wheel-up enters copy-mode over the REAL pane
288
- * history — this is what makes the mid-turn scrollback (ledger lines flushed
289
- * above the inline live frame) reachable with the mouse wheel. Wheel-down at
290
- * the bottom drops back out. `JEO_TMUX_MOUSE=0` opts out.
291
- * - ownership/identity markers (`@jeo-profile`, `@jeo-branch`, `@jeo-project`):
292
- * lets tooling tell jeo-owned sessions apart from user sessions (gjc parity
293
- * with `@gjc-*`). Never applied to foreign sessions.
294
- * - `set-clipboard on` + a readable copy-mode `mode-style`: text selected while
295
- * wheel-scrolled back is visibly highlighted and lands on the system clipboard
296
- * (OSC52). `JEO_TMUX_PROFILE=0` opts out of these cosmetic extras while keeping
297
- * mouse + markers.
298
- */
299
- export function tmuxProfileCommands(
300
- target: string,
301
- env: Record<string, string | undefined>,
302
- meta: { branch?: string; project?: string } = {},
303
- ): TmuxProfileCommand[] {
304
- // Exact-name session target. `=name:` (explicit session:window form), NOT bare
305
- // `=name`: tmux 3.6 rejects bare `=name` for set-option/show-options with
306
- // "no such session" even while the session is live (has-session/attach accept
307
- // it). The trailing colon makes cmd-find parse `=name` as the session part —
308
- // exact-matched, never prefix-matched — and resolves the session's current
309
- // window for set-window-option. This was the silent failure that left
310
- // `mouse on` unset, killing wheel-up scrollback in jeo-owned sessions.
311
- const t = `=${target}:`;
312
- const commands: TmuxProfileCommand[] = [];
313
- if ((env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0") {
314
- commands.push({
315
- description: "enable tmux mouse scrolling (wheel-up → copy-mode over real history)",
316
- args: ["set-option", "-t", t, "mouse", "on"],
317
- });
318
- }
319
- commands.push({
320
- description: "mark jeo tmux ownership",
321
- args: ["set-option", "-t", t, "@jeo-profile", "1"],
322
- });
323
- if (meta.branch) {
324
- commands.push({
325
- description: "record jeo branch identity",
326
- args: ["set-option", "-t", t, "@jeo-branch", meta.branch],
327
- });
328
- }
329
- if (meta.project) {
330
- commands.push({
331
- description: "record jeo project identity",
332
- args: ["set-option", "-t", t, "@jeo-project", meta.project],
333
- });
334
- }
335
- if ((env.JEO_TMUX_PROFILE ?? env.JEO_TMUX_PROFILE) !== "0") {
336
- commands.push(
337
- {
338
- description: "enable tmux clipboard integration",
339
- args: ["set-option", "-t", t, "set-clipboard", "on"],
340
- },
341
- {
342
- description: "make copy-mode selection readable",
343
- args: ["set-window-option", "-t", t, "mode-style", "fg=colour231,bg=colour60"],
344
- },
345
- );
346
- }
347
- return commands;
348
- }
349
-
350
- /**
351
- * A `process.stdout` view whose visible-output methods become no-ops while `gated()` is
352
- * true. Used as readline's `output` so that, while the boxed slash-preview footer is armed,
353
- * readline's OWN prompt/echo is suppressed and only our box is visible — no duplicated raw
354
- * `jeo>` line. The previous approach monkeypatched `rl._writeToOutput`, a Node internal Bun
355
- * does not expose (so on Bun both inputs showed at once). Gating the shared `output` stream
356
- * works on both runtimes. Our footer is written straight to `process.stdout`, never through
357
- * this proxy, so it always renders. Geometry/everything else is forwarded unchanged.
358
- */
359
- const GATED_OUTPUT_METHODS = new Set(["write", "cursorTo", "moveCursor", "clearLine", "clearScreenDown", "_write", "_writev"]);
360
- export function gatedStdout(real: NodeJS.WriteStream, gated: () => boolean): NodeJS.WriteStream {
361
- return new Proxy(real, {
362
- get(target, prop, _receiver) {
363
- if (typeof prop === "string" && GATED_OUTPUT_METHODS.has(prop)) {
364
- return (...args: any[]) => {
365
- if (gated()) {
366
- const cb = args[args.length - 1]; // honor readline's write callback so it never stalls
367
- if (typeof cb === "function") cb();
368
- return true;
369
- }
370
- return (target as any)[prop](...args);
371
- };
372
- }
373
- const value = Reflect.get(target, prop, target);
374
- return typeof value === "function" ? value.bind(target) : value;
375
- },
376
- }) as unknown as NodeJS.WriteStream;
377
- }
378
-
379
- function firstOutputLine(output: string | undefined): string {
380
- if (!output) return "";
381
- const line = String(output)
382
- .split("\n")
383
- .map(l => l.trim())
384
- .find(l => l.length > 0);
385
- return line ? line.replace(/\s+/g, " ").slice(0, 140) : "";
386
- }
387
-
388
- function streamResultSuffix(tool: string, ok: boolean, output: string | undefined): string {
389
- const summary = firstOutputLine(output);
390
- if (!summary) return "";
391
- if (!ok || tool === "task") return ` — ${summary}`;
392
- return "";
393
- }
394
-
395
- export function formatTaskSubEvent(e: TaskSubEvent): string {
396
- const role = e.role || "subagent";
397
- const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
398
- const tokTag = e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : "";
399
- const detail = firstOutputLine(e.detail);
400
- const summary = e.summary ? ` — ${e.summary}` : "";
401
- // No ` step N/M` marker — step counters carry no meaning under the dynamic
402
- // budget (user feedback); a tree prefix makes nested subagent activity scan as
403
- // one readable branch in plain logs and TUI scrollback.
404
- const badge = categoryBadge("subagent");
405
- if (e.kind === "start") return `${badge} ${chalk.magenta(`▸ ${roleLabel}`)} · ${detail}`.slice(0, 240);
406
- if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
407
- if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
408
- if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
409
- return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${tokTag}${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
410
- }
411
-
412
- function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
413
- log(formatTaskSubEvent(e));
414
- }
415
-
416
-
417
- /**
418
- * Plain (non-TTY / `--no-tui`) progress sink — the cmd-mode equivalent of the live TUI, and
419
- * the gjc-parity fix for "I typed a request but saw no steps/results". The old sink only
420
- * logged tool RESULTS, so a turn that finished without a tool call (or before the first
421
- * result) printed nothing but the final reply. This surfaces every STEP, the tool it is about
422
- * to run (with the real file/command target via `summarizeForgeInvocation`), and each result —
423
- * tracking the current step + pending invocation across the engine's
424
- * onStep → onAssistant → onToolResult sequence.
425
- */
426
- export function createStreamEvents(
427
- _maxSteps: number,
428
- log: (line: string) => void = (s: string) => console.log(s),
429
- now: () => number = Date.now,
430
- ): AgentLoopEvents {
431
- let pending = "";
432
- let latestUsage: { inputTokens: number; outputTokens: number } | undefined;
433
- const startTime = now();
434
-
435
- return {
436
- // Lazy header: nothing is printed until the tool call is known (onAssistant).
437
- // A `done` / invalid reply therefore emits no progress line at all. The step
438
- // NUMBER itself is never shown — meaningless under the dynamic budget.
439
- onStep: () => {},
440
- onAssistant: (_raw: string, invocation: { tool?: string; arguments?: unknown } | null) => {
441
- const tool = typeof invocation?.tool === "string" ? invocation.tool.trim() : "";
442
- if (!tool || tool === "done") return;
443
- pending = summarizeForgeInvocation(tool, invocation?.arguments).title;
444
- // gjc-style live status unit: step header + tool target + elapsed + token usage.
445
- const elapsedMs = now() - startTime;
446
- let suffix = "";
447
- if (elapsedMs >= 1000) suffix += ` · ${formatDuration(elapsedMs)}`;
448
- if (latestUsage) suffix += ` · ${formatUsage(latestUsage)}`;
449
- log(`${categoryBadge("progress")} ${pending}${suffix ? chalk.dim(suffix) : ""}`);
450
- },
451
- onToolResult: (tool: string, ok: boolean, output?: string) => {
452
- const label = pending || tool;
453
- const mark = ok ? chalk.green("✓") : chalk.red("✗");
454
- log(` ${categoryBadge(ok ? "done" : "error")} ${mark} ${label}${streamResultSuffix(tool, ok, output)}`);
455
- pending = "";
456
- },
457
- onNotice: (msg: string) => log(` ${categoryBadge("progress")} ${chalk.yellow(msg)}`),
458
- onBudget: (_limit: number, reason: string) => {
459
- log(` ${categoryBadge("progress")} ${chalk.yellow(reason)}`);
460
- },
461
- onUsage: (usage: { inputTokens: number; outputTokens: number }) => {
462
- latestUsage = usage;
463
- },
464
- };
465
- }
466
-
467
- export function shouldUseOneShotTui(noTui: boolean): boolean {
468
- return LaunchTui.usable(noTui);
469
- }
470
-
471
- export interface InFlightAbortHarness {
472
- controller: AbortController;
473
- handleSigint(): void;
474
- handleData(chunk: string | Uint8Array): void;
475
- dispose(): void;
476
- }
477
-
478
- interface AbortHarnessOptions {
479
- controller?: AbortController;
480
- captureEsc?: boolean;
481
- stdin?: {
482
- isTTY?: boolean;
483
- isRaw?: boolean;
484
- setRawMode?(raw: boolean): void;
485
- resume?(): void;
486
- on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
487
- off(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
488
- };
489
- onAbortNotice?: (message: string) => void;
490
- onHardExit?: () => void;
491
- /** Invoked when stray escape-sequence noise (wheel scroll etc.) arrives mid-turn. */
492
- onNoise?: () => void;
493
- /** Invoked when Ctrl+O (\u000f) is pressed mid-turn — the detail-view binding.
494
- * Without this hook the byte would be swallowed into the buffered input queue,
495
- * which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
496
- onDetailKey?: () => void;
497
- /** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
498
- * open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
499
- onScrollKey?: (dir: -1 | 1, page: boolean) => void;
500
- /** Invoked with printable keyboard input received while the live turn owns stdin. */
501
- onBufferedInput?: (chunk: string) => void;
502
- /** True while the input queue is inside a bracketed paste (mid-paste chunks
503
- * carry no marker and must keep routing to the queue, not the noise path). */
504
- pasteActive?: () => boolean;
505
- }
506
-
507
- /** Bracketed-paste markers (DECSET 2004): terminals wrap pasted text in these so
508
- * an app can treat the paste as DATA instead of keystrokes — the prompt_toolkit
509
- * paste contract. jeo enables the mode for the REPL TTY so a multi-line paste
510
- * arrives atomically and executes one command per line, in order. */
511
- export const PASTE_START = "\u001b[200~";
512
- export const PASTE_END = "\u001b[201~";
513
-
514
- /** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
515
- * standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
516
- * line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
517
- * event, which the REPL would treat as a hard exit ("Backspace quits jeo"). The
518
- * input filter swallows these when the line buffer is already empty so the byte never
519
- * reaches readline and the close can't fire. */
520
- export function isStandaloneBackspace(chunk: string): boolean {
521
- return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
522
- }
523
-
524
- /** gjc-parity slash-command aliases, applied once before dispatch so each real command
525
- * keeps a SINGLE handler: `/login`→`/provider login`, `/settings`→`/config`,
526
- * `/subagent(s)`→`/agents`. Pure rewrite that preserves any trailing args. */
98
+ import {
99
+ type LaunchFlags,
100
+ parseFlags,
101
+ matchSkillGlob,
102
+ filterToolMap,
103
+ buildToolProtocol,
104
+ TOOL_DESCRIPTIONS,
105
+ fastThinkingLevelForModel,
106
+ isProviderName,
107
+ isThinkingLevel,
108
+ } from "./launch/flags";
109
+ import {
110
+ tmuxSessionName,
111
+ gitDirtyCount,
112
+ allocateTmuxSession,
113
+ shouldEnableCurrentTmuxMouse,
114
+ tmuxLaunchCommand,
115
+ tmuxProfileCommands,
116
+ resolveWorktree,
117
+ shellQuote,
118
+ type TmuxCreateResult,
119
+ type TmuxProfileCommand,
120
+ } from "./launch/tmux";
121
+ import {
122
+ type InFlightAbortHarness,
123
+ type PromptInputQueue,
124
+ PASTE_START,
125
+ PASTE_END,
126
+ isStandaloneBackspace,
127
+ CURSOR_COMBO_REWRITES,
128
+ matchCursorCombo,
129
+ rewriteCursorCombos,
130
+ queuePromptInputChunk,
131
+ captureLivePromptInputChunk,
132
+ restoreQueuedLinesToPrefill,
133
+ createInFlightAbortHarness,
134
+ } from "./launch/input";
135
+ import {
136
+ gatedStdout,
137
+ formatTaskSubEvent,
138
+ logTaskSubEvent,
139
+ createStreamEvents,
140
+ shouldUseOneShotTui,
141
+ } from "./launch/stream";
142
+ import {
143
+ WORKFLOW_NAMES,
144
+ isWorkflowSkill,
145
+ runWorkflowEngine,
146
+ } from "./launch/workflow";
147
+
148
+ export {
149
+ type LaunchFlags,
150
+ parseFlags,
151
+ matchSkillGlob,
152
+ filterToolMap,
153
+ buildToolProtocol,
154
+ TOOL_DESCRIPTIONS,
155
+ fastThinkingLevelForModel,
156
+ isProviderName,
157
+ isThinkingLevel,
158
+ tmuxSessionName,
159
+ gitDirtyCount,
160
+ allocateTmuxSession,
161
+ shouldEnableCurrentTmuxMouse,
162
+ tmuxLaunchCommand,
163
+ tmuxProfileCommands,
164
+ resolveWorktree,
165
+ shellQuote,
166
+ type TmuxCreateResult,
167
+ type TmuxProfileCommand,
168
+ type InFlightAbortHarness,
169
+ type PromptInputQueue,
170
+ PASTE_START,
171
+ PASTE_END,
172
+ isStandaloneBackspace,
173
+ CURSOR_COMBO_REWRITES,
174
+ matchCursorCombo,
175
+ rewriteCursorCombos,
176
+ queuePromptInputChunk,
177
+ captureLivePromptInputChunk,
178
+ restoreQueuedLinesToPrefill,
179
+ createInFlightAbortHarness,
180
+
181
+ gatedStdout,
182
+ formatTaskSubEvent,
183
+ logTaskSubEvent,
184
+ createStreamEvents,
185
+ shouldUseOneShotTui,
186
+ WORKFLOW_NAMES,
187
+ isWorkflowSkill,
188
+ runWorkflowEngine,
189
+ };
527
190
  export function normalizeSlashAlias(input: string): string {
528
191
  if (input === "/login" || input.startsWith("/login ")) return `/provider login${input.slice("/login".length)}`;
529
192
  if (input === "/settings") return "/config";
@@ -533,539 +196,12 @@ export function normalizeSlashAlias(input: string): string {
533
196
  return input;
534
197
  }
535
198
 
536
- export interface PromptInputQueue {
537
- pendingLines: string[];
538
- partial: string;
539
- /** Complete lines that arrived inside a bracketed PASTE: intentional batch
540
- * commands, served one per prompt in order. Never folded into the typed-line
541
- * prefill (that contract is for keystrokes typed during a live turn). */
542
- pastedLines: string[];
543
- /** True while a bracketed paste spans chunks (between \x1b[200~ and \x1b[201~). */
544
- inPaste: boolean;
545
- }
546
-
547
- /** Typed (non-paste) keystrokes: printable chars build the partial, Enter promotes
548
- * it to pendingLines, backspace edits — ESC/ctrl noise segments are rejected. */
549
- function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
550
- if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
551
- let accepted = false;
552
- const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
553
- for (const ch of Array.from(normalized)) {
554
- if (ch === "\n") {
555
- if (state.partial.length > 0) accepted = true;
556
- state.pendingLines.push(state.partial);
557
- state.partial = "";
558
- } else if (ch === "\u007f" || ch === "\b") {
559
- const chars = Array.from(state.partial);
560
- chars.pop();
561
- state.partial = chars.join("");
562
- accepted = true;
563
- } else if (ch === "\t" || ch >= " ") {
564
- state.partial += ch;
565
- accepted = true;
566
- }
567
- }
568
- return accepted;
569
- }
570
-
571
- /** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
572
- * partial stays editable, and control bytes (incl. any stray ESC from copied ANSI
573
- * text) are dropped instead of being interpreted as keystrokes. */
574
- function feedPasteBody(state: PromptInputQueue, body: string): boolean {
575
- if (!body) return false;
576
- let accepted = false;
577
- const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
578
- for (const ch of Array.from(normalized)) {
579
- if (ch === "\n") {
580
- state.pastedLines.push(state.partial);
581
- state.partial = "";
582
- accepted = true;
583
- } else if (ch === "\t" || ch >= " ") {
584
- state.partial += ch;
585
- accepted = true;
586
- }
587
- }
588
- return accepted;
589
- }
590
-
591
- export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
592
- if (!chunk) return false;
593
- let accepted = false;
594
- let rest = chunk;
595
- while (rest.length > 0) {
596
- if (state.inPaste) {
597
- const end = rest.indexOf(PASTE_END);
598
- const body = end === -1 ? rest : rest.slice(0, end);
599
- if (end !== -1) state.inPaste = false;
600
- rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
601
- if (feedPasteBody(state, body)) accepted = true;
602
- } else {
603
- const start = rest.indexOf(PASTE_START);
604
- const plain = start === -1 ? rest : rest.slice(0, start);
605
- if (start !== -1) state.inPaste = true;
606
- rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
607
- if (feedTypedSegment(state, plain)) accepted = true;
608
- }
609
- }
610
- return accepted;
611
- }
612
-
613
- /** Live-turn prompt capture: printable input edits the SAME next-prompt line the
614
- * idle footer will show after the turn finishes. Enter does NOT promote a hidden
615
- * queue entry; it merely marks the current line as ready, so the existing input
616
- * box stays the single source of truth and the user presses Enter once more at
617
- * the real prompt to run it. */
618
- function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolean {
619
- if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
620
- let accepted = false;
621
- const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
622
- for (const ch of Array.from(normalized)) {
623
- if (ch === "\n") {
624
- accepted = true;
625
- } else if (ch === "\u007f" || ch === "\b") {
626
- const chars = Array.from(state.partial);
627
- chars.pop();
628
- state.partial = chars.join("");
629
- accepted = true;
630
- } else if (ch === "\t" || ch >= " ") {
631
- state.partial += ch;
632
- accepted = true;
633
- }
634
- }
635
- return accepted;
636
- }
637
-
638
- function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
639
- if (!body) return false;
640
- const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
641
- const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
642
- if (!flattened) return false;
643
- state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
644
- return true;
645
- }
646
-
647
- export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
648
- if (!chunk) return false;
649
- let accepted = false;
650
- let rest = chunk;
651
- while (rest.length > 0) {
652
- if (state.inPaste) {
653
- const end = rest.indexOf(PASTE_END);
654
- const body = end === -1 ? rest : rest.slice(0, end);
655
- if (end !== -1) state.inPaste = false;
656
- rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
657
- if (feedLivePromptPasteBody(state, body)) accepted = true;
658
- } else {
659
- const start = rest.indexOf(PASTE_START);
660
- const plain = start === -1 ? rest : rest.slice(0, start);
661
- if (start !== -1) state.inPaste = true;
662
- rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
663
- if (feedLivePromptSegment(state, plain)) accepted = true;
664
- }
665
- }
666
- return accepted;
667
- }
668
-
669
- /**
670
- * TTY "new input first" contract: fold any queued FULL lines (stray
671
- * Enter-terminated buffer noise, or older persisted queues) into the editable
672
- * prompt prefill instead of leaving them to auto-execute as the next prompt.
673
- * Without this, stale queued lines ran BEFORE the user's fresh input — jeo
674
- * appeared to "continue the previous work first". Returns the number of lines
675
- * folded. Pure over the queue object — piped/non-TTY callers must NOT use this
676
- * (scripted stdin relies on in-order line execution).
677
- */
678
- export function restoreQueuedLinesToPrefill(state: PromptInputQueue): number {
679
- const lines = state.pendingLines.splice(0, state.pendingLines.length).map(l => l.trim()).filter(Boolean);
680
- if (lines.length === 0) return 0;
681
- const restored = lines.join(" ");
682
- state.partial = state.partial ? `${restored} ${state.partial}`.trim() : restored;
683
- return lines.length;
684
- }
685
-
686
- export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFlightAbortHarness {
687
- const controller = opts.controller ?? new AbortController();
688
- const stdin = opts.stdin ?? process.stdin;
689
- const captureEsc = opts.captureEsc === true && !!stdin.isTTY;
690
- const wasRaw = !!stdin.isRaw;
691
- let rawChanged = false;
692
-
693
- const abortNow = (message: string) => {
694
- if (controller.signal.aborted) return false;
695
- opts.onAbortNotice?.(message);
696
- controller.abort();
697
- return true;
698
- };
699
-
700
- const handleSigint = () => {
701
- // Ctrl+C is a hard terminal break. Older jeo softened the first press into
702
- // "abort current run; press again to exit", which left users trapped in raw
703
- // TTY/TUI states when they expected the terminal to stop. Abort the controller
704
- // for cleanup observers, then invoke the hard-exit hook immediately.
705
- if (!controller.signal.aborted) controller.abort();
706
- opts.onHardExit?.();
707
- };
708
-
709
- const handleData = (chunk: string | Uint8Array) => {
710
- if (!captureEsc || controller.signal.aborted) return;
711
- const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
712
- // Bracketed paste is DATA, not keystrokes (the prompt_toolkit contract):
713
- // marker-carrying and mid-paste chunks go straight to the input queue BEFORE
714
- // any ESC interpretation — the markers themselves contain ESC, and a paste
715
- // must never be classified as cancel/noise.
716
- if (text.includes(PASTE_START) || opts.pasteActive?.()) {
717
- opts.onBufferedInput?.(text);
718
- return;
719
- }
720
- // Ctrl+O — detail view. Exact-match like the lone-ESC cancel: a raw \u000f
721
- // keystroke arrives as its own chunk; embedded \u000f inside pasted/streamed
722
- // data must NOT trigger the view.
723
- if (text === "\u000f") {
724
- opts.onDetailKey?.();
725
- return;
726
- }
727
- // Arrow / PageUp / PageDown — scroll the open Ctrl+O detail panel. Exact-match
728
- // (whole chunk) like Ctrl+O so embedded sequences in pasted/streamed data don't
729
- // trigger; bracketed paste already returned above. When the panel is closed the
730
- // hook is a no-op, so these keys stay inert mid-turn as before.
731
- if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
732
- if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
733
- if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
734
- if (text === "\u001b[6~") { opts.onScrollKey?.(1, true); return; }
735
- const escAt = text.indexOf("\u001b");
736
- const sigintAt = text.indexOf("\u0003");
737
- const controlAt =
738
- escAt === -1 ? sigintAt :
739
- sigintAt === -1 ? escAt :
740
- Math.min(escAt, sigintAt);
741
- if (controlAt >= 0) {
742
- const printablePrefix = text.slice(0, controlAt);
743
- if (printablePrefix) opts.onBufferedInput?.(printablePrefix);
744
- if (text[controlAt] === "\u0003") {
745
- handleSigint();
746
- return;
747
- }
748
- if (text === "\u001b") {
749
- abortNow("ESC pressed — cancelling current run…");
750
- return;
751
- }
752
- opts.onNoise?.();
753
- return;
754
- }
755
- opts.onBufferedInput?.(text);
756
- };
199
+ const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
757
200
 
758
- process.on("SIGINT", handleSigint);
759
- if (captureEsc) {
760
- stdin.on("data", handleData);
761
- if (stdin.setRawMode && !wasRaw) {
762
- stdin.setRawMode(true);
763
- rawChanged = true;
764
- }
765
- stdin.resume?.();
766
- }
767
201
 
768
- return {
769
- controller,
770
- handleSigint,
771
- handleData,
772
- dispose() {
773
- process.removeListener("SIGINT", handleSigint);
774
- if (captureEsc) {
775
- stdin.off("data", handleData);
776
- if (rawChanged) stdin.setRawMode?.(false);
777
- }
778
- },
779
- };
780
- }
781
-
782
- /** The exact resume command printed on REPL exit (and testable in isolation) —
783
- * same convention as the `--list` handler's hint. */
784
202
  export function formatResumeHint(sessionId: string): string {
785
203
  return `Resume with: jeo launch --resume ${sessionId}`;
786
204
  }
787
- export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
788
- // maxSteps 0 = dynamic: the engine's process-driven budget extends itself while the
789
- // turn shows progress instead of stopping at a hardcoded count (old default: 100).
790
- const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
791
- const rest: string[] = [];
792
- const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
793
- for (let i = 0; i < args.length; i++) {
794
- const a = args[i];
795
- if (a === "--") {
796
- rest.push(...args.slice(i + 1));
797
- break;
798
- }
799
- if (a === "--list") {
800
- flags.list = true;
801
- } else if (a === "-p" || a === "--print") {
802
- flags.print = true;
803
- flags.noTui = true;
804
- } else if (a === "--tmux") {
805
- flags.tmux = true;
806
- } else if (a === "--worktree") {
807
- const next = args[i + 1];
808
- if (next && !next.startsWith("-")) {
809
- flags.worktree = next;
810
- i++;
811
- }
812
- } else if (a.startsWith("--worktree=")) {
813
- flags.worktree = a.slice("--worktree=".length);
814
- } else if (a === "--no-session") {
815
- flags.noSession = true;
816
- } else if (a === "--no-tui") {
817
- flags.noTui = true;
818
- } else if (a === "--max-steps") {
819
- const n = parseInt(args[i + 1] ?? "", 10);
820
- if (Number.isFinite(n) && n > 0) {
821
- flags.maxSteps = n;
822
- i++;
823
- }
824
- } else if (a.startsWith("--max-steps=")) {
825
- const n = parseInt(a.slice(12), 10);
826
- if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
827
- } else if (a === "--model") {
828
- const { value, nextIndex } = takeValue(args, i, "--model=");
829
- if (value) flags.model = value;
830
- else flags.errors.push("--model requires a value");
831
- i = nextIndex;
832
- } else if (a.startsWith("--model=")) {
833
- const { value } = takeValue(args, i, "--model=");
834
- if (value) flags.model = value;
835
- else flags.errors.push("--model requires a value");
836
- } else if (a === "--provider") {
837
- const { value, nextIndex } = takeValue(args, i, "--provider=");
838
- const normalized = value?.toLowerCase();
839
- if (isProviderName(normalized)) flags.provider = normalized;
840
- else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
841
- i = nextIndex;
842
- } else if (a.startsWith("--provider=")) {
843
- const { value } = takeValue(args, i, "--provider=");
844
- const normalized = value?.toLowerCase();
845
- if (isProviderName(normalized)) flags.provider = normalized;
846
- else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
847
- } else if (a === "--thinking") {
848
- const { value, nextIndex } = takeValue(args, i, "--thinking=");
849
- const normalized = value?.toLowerCase();
850
- if (isThinkingLevel(normalized)) flags.thinking = normalized;
851
- else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
852
- i = nextIndex;
853
- } else if (a.startsWith("--thinking=")) {
854
- const { value } = takeValue(args, i, "--thinking=");
855
- const normalized = value?.toLowerCase();
856
- if (isThinkingLevel(normalized)) flags.thinking = normalized;
857
- else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
858
- } else if (a === "--smol" || a === "--slow" || a === "--plan") {
859
- flags.modelRole = a.slice(2) as ModelRole;
860
- } else if (a === "--resume" || a === "--continue" || a === "-c") {
861
- flags.resume = true;
862
- const next = args[i + 1];
863
- if (next && UUID_REGEX.test(next)) {
864
- flags.resumeId = next;
865
- i++;
866
- }
867
- } else if (a.startsWith("--resume=") || a.startsWith("--continue=") || a.startsWith("-c=")) {
868
- flags.resume = true;
869
- const eqIdx = a.indexOf("=");
870
- const val = a.slice(eqIdx + 1);
871
- if (UUID_REGEX.test(val)) {
872
- flags.resumeId = val;
873
- } else {
874
- rest.push(val);
875
- }
876
- } else if (a === "--append-system-prompt") {
877
- const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
878
- if (value) {
879
- flags.appendSystemPromptRaw = value;
880
- } else {
881
- flags.errors.push("--append-system-prompt requires a value");
882
- }
883
- i = nextIndex;
884
- } else if (a.startsWith("--append-system-prompt=")) {
885
- const { value } = takeValue(args, i, "--append-system-prompt=");
886
- if (value) {
887
- flags.appendSystemPromptRaw = value;
888
- } else {
889
- flags.errors.push("--append-system-prompt requires a value");
890
- }
891
- } else if (a === "--no-skills") {
892
- flags.noSkills = true;
893
- } else if (a === "--skills") {
894
- const { value, nextIndex } = takeValue(args, i, "--skills=");
895
- if (value) flags.skills = value;
896
- else flags.errors.push("--skills requires a value");
897
- i = nextIndex;
898
- } else if (a.startsWith("--skills=")) {
899
- const { value } = takeValue(args, i, "--skills=");
900
- if (value) flags.skills = value;
901
- else flags.errors.push("--skills requires a value");
902
- } else if (a === "--no-tools") {
903
- flags.noTools = true;
904
- } else if (a === "--tools") {
905
- const { value, nextIndex } = takeValue(args, i, "--tools=");
906
- if (value) flags.tools = value;
907
- else flags.errors.push("--tools requires a value");
908
- i = nextIndex;
909
- } else if (a.startsWith("--tools=")) {
910
- const { value } = takeValue(args, i, "--tools=");
911
- if (value) flags.tools = value;
912
- else flags.errors.push("--tools requires a value");
913
- } else if (a === "--system-prompt") {
914
- const { value, nextIndex } = takeValue(args, i, "--system-prompt=");
915
- if (value) flags.systemPromptRaw = value;
916
- else flags.errors.push("--system-prompt requires a value");
917
- i = nextIndex;
918
- } else if (a.startsWith("--system-prompt=")) {
919
- const { value } = takeValue(args, i, "--system-prompt=");
920
- if (value) flags.systemPromptRaw = value;
921
- else flags.errors.push("--system-prompt requires a value");
922
- } else {
923
- rest.push(a);
924
- }
925
- }
926
- flags.message = rest.join(" ").trim();
927
-
928
- if (flags.print && !flags.message) {
929
- flags.errors.push("-p/--print requires a message argument");
930
- }
931
-
932
- if (flags.appendSystemPromptRaw) {
933
- if (flags.appendSystemPromptRaw.startsWith("@")) {
934
- const filePath = flags.appendSystemPromptRaw.slice(1);
935
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
936
- try {
937
- flags.appendSystemPrompt = fs.readFileSync(absPath, "utf8");
938
- } catch (err) {
939
- flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
940
- }
941
- } else {
942
- flags.appendSystemPrompt = flags.appendSystemPromptRaw;
943
- }
944
- }
945
- if (flags.systemPromptRaw) {
946
- if (flags.systemPromptRaw.startsWith("@")) {
947
- const filePath = flags.systemPromptRaw.slice(1);
948
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
949
- try {
950
- flags.systemPrompt = fs.readFileSync(absPath, "utf8");
951
- } catch (err) {
952
- flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
953
- }
954
- } else {
955
- flags.systemPrompt = flags.systemPromptRaw;
956
- }
957
- }
958
-
959
- return flags;
960
- }
961
- export function matchSkillGlob(pattern: string, name: string): boolean {
962
- const p = pattern.toLowerCase();
963
- const n = name.toLowerCase();
964
- if (!p.includes("*")) {
965
- return p === n;
966
- }
967
- const escaped = p.replace(/[.+^${}()|[\]\\]/g, "\\$&");
968
- const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
969
- const regex = new RegExp(regexStr);
970
- return regex.test(n);
971
- }
972
-
973
- export function filterToolMap(
974
- tools: Record<string, any>,
975
- allowlist: string[]
976
- ): Record<string, any> {
977
- const result: Record<string, any> = {};
978
- for (const name of allowlist) {
979
- if (name in tools) {
980
- result[name] = tools[name];
981
- }
982
- }
983
- return result;
984
- }
985
- export const TOOL_DESCRIPTIONS: Record<string, string> = {
986
- read: "read {filePath, lineRange?, raw?} — read a file; lines are prefixed `LINEhh|` (hh = 2-char content anchor; the | is a separator, not file bytes)",
987
- write: "write {filePath, content} — create/overwrite a file",
988
- 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",
989
- bash: "bash {command, timeoutMs?, cwd?, env?} — run a shell command (cwd: subdir; env: extra vars)",
990
- find: "find {globPattern} — find files by name",
991
- search: "search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
992
- ls: "ls {dirPath} — list a directory's entries (dirs first)",
993
- };
994
-
995
- export function buildToolProtocol(allowedTools: Set<string>): string {
996
- const lines: string[] = ["You have these tools (call exactly ONE per step):"];
997
- let num = 1;
998
- for (const name of ["read", "write", "edit", "bash", "find", "search", "ls"]) {
999
- if (allowedTools.has(name)) {
1000
- lines.push(`${num}. ${TOOL_DESCRIPTIONS[name]}`);
1001
- num++;
1002
- }
1003
- }
1004
- lines.push(`${num}. done {reason?} — call when the task is fully implemented AND verified`);
1005
- lines.push("");
1006
- lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
1007
- lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
1008
- lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
1009
- 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.");
1010
- return lines.join("\n");
1011
- }
1012
-
1013
- /**
1014
- * Resolve a git worktree path (gjc `--worktree <path>` parity). If the path
1015
- * already exists it is reused as-is; otherwise a new worktree is created on a
1016
- * branch derived from the path basename. Returns the absolute worktree path.
1017
- */
1018
- function resolveWorktree(cwd: string, wt: string): string {
1019
- const abs = path.isAbsolute(wt) ? wt : path.resolve(cwd, wt);
1020
- if (fs.existsSync(abs)) return abs;
1021
- if (!Bun.which("git")) {
1022
- console.error("error: --worktree requires git on PATH");
1023
- process.exit(1);
1024
- }
1025
- const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "jeo-wt");
1026
- const withBranch = Bun.spawnSync(["git", "worktree", "add", "-b", branch, abs], {
1027
- cwd,
1028
- stdout: "pipe",
1029
- stderr: "pipe",
1030
- });
1031
- if (withBranch.exitCode !== 0) {
1032
- // Branch may already exist; retry attaching the existing branch.
1033
- const plain = Bun.spawnSync(["git", "worktree", "add", abs], {
1034
- cwd,
1035
- stdout: "pipe",
1036
- stderr: "pipe",
1037
- });
1038
- if (plain.exitCode !== 0) {
1039
- console.error(
1040
- `error: failed to create git worktree at ${abs}: ${withBranch.stderr.toString().trim()}`,
1041
- );
1042
- process.exit(1);
1043
- }
1044
- }
1045
- return abs;
1046
- }
1047
-
1048
- /** The bundled workflow skills that run through a dedicated engine (deep-interview /
1049
- * ralplan / team / ultragoal), not the ordinary agent loop. Single source of truth —
1050
- * the menu listing, the dispatch guards, and the engine switch all read from here. */
1051
- export const WORKFLOW_NAMES = ["deep-interview", "ralplan", "team", "ultragoal"] as const;
1052
-
1053
- /** True when a skill name is one of the bundled workflow engines. */
1054
- export function isWorkflowSkill(name: string): boolean {
1055
- return (WORKFLOW_NAMES as readonly string[]).includes(name);
1056
- }
1057
-
1058
- /** Dispatch a bundled workflow by name to its engine. Keeps the name→engine mapping in
1059
- * ONE place so the one-shot and interactive skill runners can't drift apart. */
1060
- export function runWorkflowEngine(
1061
- name: string,
1062
- opts: DeepInterviewEngineOptions & RalplanEngineOptions & TeamEngineOptions & UltragoalEngineOptions,
1063
- ): Promise<{ ok: boolean; reason?: string }> {
1064
- if (name === "deep-interview") return runDeepInterviewEngine(opts);
1065
- if (name === "ralplan") return runRalplanEngine(opts);
1066
- if (name === "team") return runTeamEngine(opts);
1067
- return runUltragoalEngine(opts);
1068
- }
1069
205
 
1070
206
  export async function runLaunchCommand(args: string[]): Promise<void> {
1071
207
  let cwd = process.cwd();
@@ -1868,9 +1004,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1868
1004
  cols: terminalSize().cols,
1869
1005
  unicode: supportsUnicode(),
1870
1006
  color: welcomeTheme.color,
1007
+ center: true,
1871
1008
  accent: accentPaint(welcomeTheme),
1872
1009
  accentShadow: accentShadowPaint(welcomeTheme),
1873
1010
  };
1011
+ // gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
1012
+ // never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
1013
+ if (process.stdout.isTTY) process.stdout.write(clearScreen());
1874
1014
  // Launch sweep: the DNA Claw's gradient loops seamlessly (default 2 full
1875
1015
  // cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
1876
1016
  // Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
@@ -2083,7 +1223,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2083
1223
  let keyFilter: PassThrough | undefined;
2084
1224
  // Holder for the active readline so the input filter can see the current line
2085
1225
  // buffer (used by the empty-line backspace guard below). Set after rl is created.
2086
- let activeRl: { line?: string } | undefined;
1226
+ let activeRl: { line?: string; cursor?: number } | undefined;
1227
+
2087
1228
  if (multilineInput) {
2088
1229
  const kf = new PassThrough();
2089
1230
  (kf as unknown as { isTTY: boolean }).isTTY = true;
@@ -2125,6 +1266,26 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2125
1266
  if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }
2126
1267
  }
2127
1268
  if (matched) continue;
1269
+ // Normalize macOS/fixterms combo cursor keys readline ignores (Option/Cmd+arrow,
1270
+ // Option/Cmd+Backspace) into the canonical control bytes it DOES act on.
1271
+ const combo = matchCursorCombo(data, i);
1272
+ if (combo) { out += combo[1]; i += combo[0].length; continue; }
1273
+ // Up/Down inside a multi-line / wrapped draft move the caret between the box's
1274
+ // visual rows (textarea feel). Only when no slash list or history panel owns ↑/↓,
1275
+ // and only away from the top/bottom edge — at the edge the keys fall through to
1276
+ // readline so ↑/↓ still recalls input history.
1277
+ if ((data.startsWith("\u001b[", i) || data.startsWith("\u001bO", i)) && (data[i + 2] === "A" || data[i + 2] === "B")) {
1278
+ const dir = data[i + 2] === "A" ? "up" : "down";
1279
+ const line = activeRl?.line ?? "";
1280
+ if (line.length > 0 && navMatches.length === 0 && promptHistoryLines == null && activeRl) {
1281
+ const winCols = Math.max(24, (process.stdout.columns ?? 80) - 1);
1282
+ const textWidth = Math.max(1, Math.max(24, Math.min(120, winCols)) - 6);
1283
+ const cur = typeof activeRl.cursor === "number" ? activeRl.cursor : line.length;
1284
+ const next = verticalCursorOffset(expandSentinel(line), cur, textWidth, dir);
1285
+ if (next != null) { activeRl.cursor = next; i += 3; continue; }
1286
+ }
1287
+ out += data.slice(i, i + 3); i += 3; continue;
1288
+ }
2128
1289
  if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
2129
1290
  out += data[i]; i += 1;
2130
1291
  }
@@ -2225,7 +1386,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2225
1386
  pasteLineFired = true;
2226
1387
  return;
2227
1388
  }
2228
- if (!interactiveTurnActive) pendingStdinLines.push(l);
1389
+ // A select picker (/model, /agents, …) reads keypresses directly while NO
1390
+ // rl.question is active, so its filter-text + selecting Enter fire an ORPHAN
1391
+ // `line` event here. Queuing that would auto-serve the leftover filter as the
1392
+ // next prompt (the "/model · /agents leaves typed text behind" bug). Drop it:
1393
+ // the picker owns those keystrokes and clears the readline buffer on exit.
1394
+ if (!interactiveTurnActive && !pickerActive) pendingStdinLines.push(l);
2229
1395
  });
2230
1396
  let stdinClosed = false;
2231
1397
  let notifyStdinClosed: (() => void) | undefined;
@@ -2402,6 +1568,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2402
1568
  // Row (within the reservation) where the real cursor was last parked; the next
2403
1569
  // drawFooter/disarmPreview must hop back to the top from here before painting.
2404
1570
  let footerParkedRow = 0;
1571
+ // The footer's last painted lines (padded to the reservation height). The resize
1572
+ // relayout uses these to find the frame's physical top at the live width — a full-width
1573
+ // line painted at an older, wider geometry reflows onto extra rows after a width shrink.
1574
+ let lastDrawnLines: string[] = [];
2405
1575
  const padToFooter = (lines: string[]): string[] => {
2406
1576
  if (lines.length >= footerRows) return lines.slice(0, footerRows);
2407
1577
  return [...lines, ...new Array(footerRows - lines.length).fill("")];
@@ -2439,6 +1609,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2439
1609
  s += toColumn(1) + "\x1b[?25h";
2440
1610
  out.write(s);
2441
1611
  footerRendered = 0;
1612
+ lastDrawnLines = [];
2442
1613
  } else {
2443
1614
  out.write("\x1b[?25h");
2444
1615
  }
@@ -2581,6 +1752,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2581
1752
  // and no row can spill past it — the bug fix that kept `@folder<more text>`
2582
1753
  // typing from scrolling the input box (and prior output) off the top.
2583
1754
  const padded = padToFooter(lines);
1755
+ // Remember the exact painted frame so resize/disarm can clear its real physical
1756
+ // footprint at the (possibly changed) live width instead of trusting logical rows.
1757
+ lastDrawnLines = padded;
2584
1758
  // Pure caret moves (arrow keys) change no content — include the caret cell in
2585
1759
  // the repaint key so they still reposition the terminal cursor.
2586
1760
  const tRow = lines.length ? Math.min(footerCursor.row, footerRendered - 1) : 0;
@@ -3176,8 +2350,43 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3176
2350
  lastIdleCols = cols;
3177
2351
  lastIdleRows = rows;
3178
2352
  try {
3179
- disarmPreview();
3180
- armPreview();
2353
+ // Resolution-safe relayout, anchored to the CURSOR (not the screen bottom). The
2354
+ // previous frame was painted at the OLD width; on resize the terminal reflows its
2355
+ // full-width rows AND repositions the frame — a width shrink wraps lines and floats
2356
+ // the frame UP by its blank tail, while a height grow leaves the frame high while
2357
+ // adding rows at the bottom. In every case the terminal keeps the REAL cursor on
2358
+ // the input caret's cell, so the caret is the one stable anchor. The old logical
2359
+ // disarm→arm cycle ignored this, clearing the wrong rows and scrolling the stray
2360
+ // frame into scrollback (the stacked status-bar corruption).
2361
+ //
2362
+ // Hop UP from the caret to the frame's physical top — `footerParkedRow` logical
2363
+ // rows of content sit above the caret; recompute their PHYSICAL height at the new
2364
+ // width (wrapped lines count for more) — then clear-to-end-of-display in one op.
2365
+ // This wipes the whole stray frame regardless of which way it drifted, and is
2366
+ // banner-safe: the hop stops exactly at the frame top (just below the static
2367
+ // content). ED is safe here — this path emits no bottom-margin scroll, so (unlike
2368
+ // the mid-turn ledger flush) tmux never copies the erased rows into history.
2369
+ const c = Math.max(1, cols);
2370
+ let abovePhysical = 0;
2371
+ for (let i = 0; i < footerParkedRow && i < lastDrawnLines.length; i++) {
2372
+ abovePhysical += Math.max(1, Math.ceil(Math.max(1, visibleWidth(lastDrawnLines[i]!)) / c));
2373
+ }
2374
+ // The caret may sit on a LOWER physical sub-row of its own (now-wrapped) line: a
2375
+ // long input wraps and the terminal keeps the cursor on its cell, so add the
2376
+ // caret's sub-row offset (caret column / width) or the hop stops below the top.
2377
+ const caretSubRow = Math.floor(Math.max(0, footerCursor.col - 1) / c);
2378
+ const hopUp = abovePhysical + caretSubRow;
2379
+ let s = (hopUp > 0 ? cursorUp(hopUp) : "") + toColumn(1) + clearToEnd();
2380
+ // Re-pin a clean reservation to the screen bottom and repaint. ED already blanked
2381
+ // from the frame top down to the bottom, so just position at the bottom region.
2382
+ footerRows = previewRowsFor(rows);
2383
+ s += `\x1b[${Math.max(1, rows)};1H`;
2384
+ if (footerRows > 1) s += cursorUp(footerRows - 1);
2385
+ s += toColumn(1);
2386
+ out.write(s);
2387
+ footerRendered = footerRows;
2388
+ footerParkedRow = 0;
2389
+ lastFooterKey = "";
3181
2390
  drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
3182
2391
  } catch { /* ignore resize render races */ }
3183
2392
  };
@@ -3314,7 +2523,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3314
2523
  // scrollback, and re-render the welcome banner so /clear looks like a fresh launch.
3315
2524
  if (process.stdout.isTTY) {
3316
2525
  disarmPreview();
3317
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H"); // clear screen + scrollback + cursor home
2526
+ process.stdout.write(clearScreen()); // clear screen + scrollback + cursor home
3318
2527
  console.log(renderWelcome(welcomeData).join("\n"));
3319
2528
  }
3320
2529
  console.log("(history cleared — back to the start screen)");
@@ -3419,7 +2628,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3419
2628
  // so the resumed view reads like a fresh, intact screen.
3420
2629
  if (process.stdout.isTTY) {
3421
2630
  disarmPreview();
3422
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
2631
+ process.stdout.write(clearScreen());
3423
2632
  console.log(renderWelcome(welcomeData).join("\n"));
3424
2633
  }
3425
2634
  const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));