jeo-code 0.6.4 → 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.
@@ -62,7 +62,7 @@ import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBl
62
62
  import { categoryBadge } from "../tui/components/category-index";
63
63
  import { renderInputFrame } from "../tui/components/input-box";
64
64
  import { renderStatusBar } from "../tui/components/status";
65
- import { detectColorLevel, ColorLevel } from "../tui/components/color";
65
+ import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
66
66
  import { readClipboardImage } from "../util/clipboard-image";
67
67
  import { formatTranscript } from "../tui/components/transcript";
68
68
  import { loadInputHistory, appendInputHistory } from "../agent/input-history";
@@ -92,438 +92,100 @@ import {
92
92
  sessionPath,
93
93
  appendCompaction,
94
94
  } from "../agent/session";
95
- 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";
96
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
- }
212
-
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. */
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
+ };
527
189
  export function normalizeSlashAlias(input: string): string {
528
190
  if (input === "/login" || input.startsWith("/login ")) return `/provider login${input.slice("/login".length)}`;
529
191
  if (input === "/settings") return "/config";
@@ -533,539 +195,12 @@ export function normalizeSlashAlias(input: string): string {
533
195
  return input;
534
196
  }
535
197
 
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
- };
757
-
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
- }
198
+ const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
767
199
 
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
200
 
782
- /** The exact resume command printed on REPL exit (and testable in isolation) —
783
- * same convention as the `--list` handler's hint. */
784
201
  export function formatResumeHint(sessionId: string): string {
785
202
  return `Resume with: jeo launch --resume ${sessionId}`;
786
203
  }
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
204
 
1070
205
  export async function runLaunchCommand(args: string[]): Promise<void> {
1071
206
  let cwd = process.cwd();
@@ -1871,6 +1006,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1871
1006
  accent: accentPaint(welcomeTheme),
1872
1007
  accentShadow: accentShadowPaint(welcomeTheme),
1873
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());
1874
1012
  // Launch sweep: the DNA Claw's gradient loops seamlessly (default 2 full
1875
1013
  // cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
1876
1014
  // Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
@@ -2125,6 +1263,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2125
1263
  if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }
2126
1264
  }
2127
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; }
2128
1270
  if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
2129
1271
  out += data[i]; i += 1;
2130
1272
  }
@@ -2225,7 +1367,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2225
1367
  pasteLineFired = true;
2226
1368
  return;
2227
1369
  }
2228
- 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);
2229
1376
  });
2230
1377
  let stdinClosed = false;
2231
1378
  let notifyStdinClosed: (() => void) | undefined;
@@ -2402,6 +1549,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2402
1549
  // Row (within the reservation) where the real cursor was last parked; the next
2403
1550
  // drawFooter/disarmPreview must hop back to the top from here before painting.
2404
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[] = [];
2405
1556
  const padToFooter = (lines: string[]): string[] => {
2406
1557
  if (lines.length >= footerRows) return lines.slice(0, footerRows);
2407
1558
  return [...lines, ...new Array(footerRows - lines.length).fill("")];
@@ -2439,6 +1590,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2439
1590
  s += toColumn(1) + "\x1b[?25h";
2440
1591
  out.write(s);
2441
1592
  footerRendered = 0;
1593
+ lastDrawnLines = [];
2442
1594
  } else {
2443
1595
  out.write("\x1b[?25h");
2444
1596
  }
@@ -2581,6 +1733,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2581
1733
  // and no row can spill past it — the bug fix that kept `@folder<more text>`
2582
1734
  // typing from scrolling the input box (and prior output) off the top.
2583
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;
2584
1739
  // Pure caret moves (arrow keys) change no content — include the caret cell in
2585
1740
  // the repaint key so they still reposition the terminal cursor.
2586
1741
  const tRow = lines.length ? Math.min(footerCursor.row, footerRendered - 1) : 0;
@@ -3176,8 +2331,43 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3176
2331
  lastIdleCols = cols;
3177
2332
  lastIdleRows = rows;
3178
2333
  try {
3179
- disarmPreview();
3180
- 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 = "";
3181
2371
  drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
3182
2372
  } catch { /* ignore resize render races */ }
3183
2373
  };
@@ -3314,7 +2504,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3314
2504
  // scrollback, and re-render the welcome banner so /clear looks like a fresh launch.
3315
2505
  if (process.stdout.isTTY) {
3316
2506
  disarmPreview();
3317
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H"); // clear screen + scrollback + cursor home
2507
+ process.stdout.write(clearScreen()); // clear screen + scrollback + cursor home
3318
2508
  console.log(renderWelcome(welcomeData).join("\n"));
3319
2509
  }
3320
2510
  console.log("(history cleared — back to the start screen)");
@@ -3419,7 +2609,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3419
2609
  // so the resumed view reads like a fresh, intact screen.
3420
2610
  if (process.stdout.isTTY) {
3421
2611
  disarmPreview();
3422
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
2612
+ process.stdout.write(clearScreen());
3423
2613
  console.log(renderWelcome(welcomeData).join("\n"));
3424
2614
  }
3425
2615
  const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));