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.
@@ -0,0 +1,330 @@
1
+ export interface InFlightAbortHarness {
2
+ controller: AbortController;
3
+ handleSigint(): void;
4
+ handleData(chunk: string | Uint8Array): void;
5
+ dispose(): void;
6
+ }
7
+
8
+ interface AbortHarnessOptions {
9
+ controller?: AbortController;
10
+ captureEsc?: boolean;
11
+ stdin?: {
12
+ isTTY?: boolean;
13
+ isRaw?: boolean;
14
+ setRawMode?(raw: boolean): void;
15
+ resume?(): void;
16
+ on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
17
+ off(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
18
+ };
19
+ onAbortNotice?: (message: string) => void;
20
+ onHardExit?: () => void;
21
+ /** Invoked when stray escape-sequence noise (wheel scroll etc.) arrives mid-turn. */
22
+ onNoise?: () => void;
23
+ /** Invoked when Ctrl+O (\u000f) is pressed mid-turn — the detail-view binding.
24
+ * Without this hook the byte would be swallowed into the buffered input queue,
25
+ * which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
26
+ onDetailKey?: () => void;
27
+ /** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
28
+ * open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
29
+ onScrollKey?: (dir: -1 | 1, page: boolean) => void;
30
+ /** Invoked with printable keyboard input received while the live turn owns stdin. */
31
+ onBufferedInput?: (chunk: string) => void;
32
+ /** True while the input queue is inside a bracketed paste (mid-paste chunks
33
+ * carry no marker and must keep routing to the queue, not the noise path). */
34
+ pasteActive?: () => boolean;
35
+ }
36
+
37
+ /** Bracketed-paste markers (DECSET 2004): terminals wrap pasted text in these so
38
+ * an app can treat the paste as DATA instead of keystrokes — the prompt_toolkit
39
+ * paste contract. jeo enables the mode for the REPL TTY so a multi-line paste
40
+ * arrives atomically and executes one command per line, in order. */
41
+ export const PASTE_START = "\u001b[200~";
42
+ export const PASTE_END = "\u001b[201~";
43
+
44
+ /** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
45
+ * standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
46
+ * line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
47
+ * event, which the REPL would treat as a hard exit ("Backspace quits jeo"). The
48
+ * input filter swallows these when the line buffer is already empty so the byte never
49
+ * reaches readline and the close can't fire. */
50
+ export function isStandaloneBackspace(chunk: string): boolean {
51
+ return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
52
+ }
53
+
54
+ /**
55
+ * macOS / fixterms combo-key normalization for the boxed prompt's line editor.
56
+ *
57
+ * Bun's readline acts on Ctrl+arrow (CSI `1;5D`/`1;5C` → word jump), Home/End, and the
58
+ * Emacs control bytes (Ctrl+A/E/W/U/K, Meta+b/f/d, Meta+DEL) — but it does NOT act on
59
+ * the modifier-flagged cursor keys macOS users reach for most: Option+Left/Right (word
60
+ * jump, CSI `1;3D`/`1;3C`) and Cmd+Left/Right (line start/end, CSI `1;9D`/`1;9C`) are
61
+ * inert. Rather than racing readline for cursor state in a keypress handler, we rewrite
62
+ * each inert combo to the canonical control byte readline DOES act on, BEFORE it reaches
63
+ * readline. readline stays the single owner of `rl.line`/`rl.cursor`; the box just reads
64
+ * and repaints. Replacement targets are empirically verified against Bun's readline.
65
+ *
66
+ * Modifier digit in `CSI 1;<m><dir>`: 3=Alt/Option, 5=Ctrl (already handled), 9=Cmd/Super.
67
+ * Both the CSI form and the ESC-prefixed alt-as-meta form (`ESC ESC [ D`) are covered. */
68
+ export const CURSOR_COMBO_REWRITES: ReadonlyArray<readonly [string, string]> = [
69
+ ["\u001b[1;3D", "\u001bb"], // Option+Left → word left
70
+ ["\u001b[1;3C", "\u001bf"], // Option+Right → word right
71
+ ["\u001b\u001b[D", "\u001bb"], // Option+Left (ESC-prefixed alt-as-meta)
72
+ ["\u001b\u001b[C", "\u001bf"], // Option+Right (ESC-prefixed alt-as-meta)
73
+ ["\u001b[1;9D", "\u0001"], // Cmd+Left → line start (Ctrl+A)
74
+ ["\u001b[1;9C", "\u0005"], // Cmd+Right → line end (Ctrl+E)
75
+ ["\u001b[127;3u", "\u0017"], // Option+Backspace (kitty CSI-u) → delete word left (Ctrl+W)
76
+ ["\u001b[127;9u", "\u0015"], // Cmd+Backspace (kitty CSI-u) → delete to line start (Ctrl+U)
77
+ ["\u001b[3;3~", "\u001bd"], // Option+Delete (forward) → delete word right (Meta+d)
78
+ ];
79
+
80
+ /** First combo-key rewrite whose source sequence begins at `data[i]`, else undefined. */
81
+ export function matchCursorCombo(data: string, i: number): readonly [string, string] | undefined {
82
+ for (const pair of CURSOR_COMBO_REWRITES) if (data.startsWith(pair[0], i)) return pair;
83
+ return undefined;
84
+ }
85
+
86
+ /** Apply combo-key rewrites across a plain (non-paste) input segment. Shares
87
+ * `matchCursorCombo` with the live input filter, so the filter and this exported
88
+ * helper can never diverge. */
89
+ export function rewriteCursorCombos(plain: string): string {
90
+ let out = "";
91
+ let i = 0;
92
+ while (i < plain.length) {
93
+ const combo = matchCursorCombo(plain, i);
94
+ if (combo) { out += combo[1]; i += combo[0].length; continue; }
95
+ out += plain[i];
96
+ i += 1;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ export interface PromptInputQueue {
102
+ pendingLines: string[];
103
+ partial: string;
104
+ /** Complete lines that arrived inside a bracketed PASTE: intentional batch
105
+ * commands, served one per prompt in order. Never folded into the typed-line
106
+ * prefill (that contract is for keystrokes typed during a live turn). */
107
+ pastedLines: string[];
108
+ /** True while a bracketed paste spans chunks (between \x1b[200~ and \x1b[201~). */
109
+ inPaste: boolean;
110
+ }
111
+
112
+ /** Typed (non-paste) keystrokes: printable chars build the partial, Enter promotes
113
+ * it to pendingLines, backspace edits — ESC/ctrl noise segments are rejected. */
114
+ function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
115
+ if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
116
+ let accepted = false;
117
+ const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
118
+ for (const ch of Array.from(normalized)) {
119
+ if (ch === "\n") {
120
+ if (state.partial.length > 0) accepted = true;
121
+ state.pendingLines.push(state.partial);
122
+ state.partial = "";
123
+ } else if (ch === "\u007f" || ch === "\b") {
124
+ const chars = Array.from(state.partial);
125
+ chars.pop();
126
+ state.partial = chars.join("");
127
+ accepted = true;
128
+ } else if (ch === "\t" || ch >= " ") {
129
+ state.partial += ch;
130
+ accepted = true;
131
+ }
132
+ }
133
+ return accepted;
134
+ }
135
+
136
+ /** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
137
+ * partial stays editable, and control bytes (incl. any stray ESC from copied ANSI
138
+ * text) are dropped instead of being interpreted as keystrokes. */
139
+ function feedPasteBody(state: PromptInputQueue, body: string): boolean {
140
+ if (!body) return false;
141
+ let accepted = false;
142
+ const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
143
+ for (const ch of Array.from(normalized)) {
144
+ if (ch === "\n") {
145
+ state.pastedLines.push(state.partial);
146
+ state.partial = "";
147
+ accepted = true;
148
+ } else if (ch === "\t" || ch >= " ") {
149
+ state.partial += ch;
150
+ accepted = true;
151
+ }
152
+ }
153
+ return accepted;
154
+ }
155
+
156
+ export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
157
+ if (!chunk) return false;
158
+ let accepted = false;
159
+ let rest = chunk;
160
+ while (rest.length > 0) {
161
+ if (state.inPaste) {
162
+ const end = rest.indexOf(PASTE_END);
163
+ const body = end === -1 ? rest : rest.slice(0, end);
164
+ if (end !== -1) state.inPaste = false;
165
+ rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
166
+ if (feedPasteBody(state, body)) accepted = true;
167
+ } else {
168
+ const start = rest.indexOf(PASTE_START);
169
+ const plain = start === -1 ? rest : rest.slice(0, start);
170
+ if (start !== -1) state.inPaste = true;
171
+ rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
172
+ if (feedTypedSegment(state, plain)) accepted = true;
173
+ }
174
+ }
175
+ return accepted;
176
+ }
177
+
178
+ /** Live-turn prompt capture: printable input edits the SAME next-prompt line the
179
+ * idle footer will show after the turn finishes. Enter does NOT promote a hidden
180
+ * queue entry; it merely marks the current line as ready, so the existing input
181
+ * box stays the single source of truth and the user presses Enter once more at
182
+ * the real prompt to run it. */
183
+ function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolean {
184
+ if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
185
+ let accepted = false;
186
+ const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
187
+ for (const ch of Array.from(normalized)) {
188
+ if (ch === "\n") {
189
+ accepted = true;
190
+ } else if (ch === "\u007f" || ch === "\b") {
191
+ const chars = Array.from(state.partial);
192
+ chars.pop();
193
+ state.partial = chars.join("");
194
+ accepted = true;
195
+ } else if (ch === "\t" || ch >= " ") {
196
+ state.partial += ch;
197
+ accepted = true;
198
+ }
199
+ }
200
+ return accepted;
201
+ }
202
+
203
+ function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
204
+ if (!body) return false;
205
+ const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
206
+ const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
207
+ if (!flattened) return false;
208
+ state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
209
+ return true;
210
+ }
211
+
212
+ export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
213
+ if (!chunk) return false;
214
+ let accepted = false;
215
+ let rest = chunk;
216
+ while (rest.length > 0) {
217
+ if (state.inPaste) {
218
+ const end = rest.indexOf(PASTE_END);
219
+ const body = end === -1 ? rest : rest.slice(0, end);
220
+ if (end !== -1) state.inPaste = false;
221
+ rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
222
+ if (feedLivePromptPasteBody(state, body)) accepted = true;
223
+ } else {
224
+ const start = rest.indexOf(PASTE_START);
225
+ const plain = start === -1 ? rest : rest.slice(0, start);
226
+ if (start !== -1) state.inPaste = true;
227
+ rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
228
+ if (feedLivePromptSegment(state, plain)) accepted = true;
229
+ }
230
+ }
231
+ return accepted;
232
+ }
233
+
234
+ /**
235
+ * TTY "new input first" contract: fold any queued FULL lines (stray
236
+ * Enter-terminated buffer noise, or older persisted queues) into the editable
237
+ * prompt prefill instead of leaving them to auto-execute as the next prompt.
238
+ * Without this, stale queued lines ran BEFORE the user's fresh input — jeo
239
+ * appeared to "continue the previous work first". Returns the number of lines
240
+ * folded. Pure over the queue object — piped/non-TTY callers must NOT use this
241
+ * (scripted stdin relies on in-order line execution).
242
+ */
243
+ export function restoreQueuedLinesToPrefill(state: PromptInputQueue): number {
244
+ const lines = state.pendingLines.splice(0, state.pendingLines.length).map(l => l.trim()).filter(Boolean);
245
+ if (lines.length === 0) return 0;
246
+ const restored = lines.join(" ");
247
+ state.partial = state.partial ? `${restored} ${state.partial}`.trim() : restored;
248
+ return lines.length;
249
+ }
250
+
251
+ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFlightAbortHarness {
252
+ const controller = opts.controller ?? new AbortController();
253
+ const stdin = opts.stdin ?? process.stdin;
254
+ const captureEsc = opts.captureEsc === true && !!stdin.isTTY;
255
+ const wasRaw = !!stdin.isRaw;
256
+ let rawChanged = false;
257
+
258
+ const abortNow = (message: string) => {
259
+ if (controller.signal.aborted) return false;
260
+ opts.onAbortNotice?.(message);
261
+ controller.abort();
262
+ return true;
263
+ };
264
+
265
+ const handleSigint = () => {
266
+ if (!controller.signal.aborted) controller.abort();
267
+ opts.onHardExit?.();
268
+ };
269
+
270
+ const handleData = (chunk: string | Uint8Array) => {
271
+ if (!captureEsc || controller.signal.aborted) return;
272
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
273
+ if (text.includes(PASTE_START) || opts.pasteActive?.()) {
274
+ opts.onBufferedInput?.(text);
275
+ return;
276
+ }
277
+ if (text === "\u000f") {
278
+ opts.onDetailKey?.();
279
+ return;
280
+ }
281
+ if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
282
+ if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
283
+ if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
284
+ if (text === "\u001b[6~") { opts.onScrollKey?.(1, true); return; }
285
+ const escAt = text.indexOf("\u001b");
286
+ const sigintAt = text.indexOf("\u0003");
287
+ const controlAt =
288
+ escAt === -1 ? sigintAt :
289
+ sigintAt === -1 ? escAt :
290
+ Math.min(escAt, sigintAt);
291
+ if (controlAt >= 0) {
292
+ const printablePrefix = text.slice(0, controlAt);
293
+ if (printablePrefix) opts.onBufferedInput?.(printablePrefix);
294
+ if (text[controlAt] === "\u0003") {
295
+ handleSigint();
296
+ return;
297
+ }
298
+ if (text === "\u001b") {
299
+ abortNow("ESC pressed — cancelling current run…");
300
+ return;
301
+ }
302
+ opts.onNoise?.();
303
+ return;
304
+ }
305
+ opts.onBufferedInput?.(text);
306
+ };
307
+
308
+ process.on("SIGINT", handleSigint);
309
+ if (captureEsc) {
310
+ stdin.on("data", handleData);
311
+ if (stdin.setRawMode && !wasRaw) {
312
+ stdin.setRawMode(true);
313
+ rawChanged = true;
314
+ }
315
+ stdin.resume?.();
316
+ }
317
+
318
+ return {
319
+ controller,
320
+ handleSigint,
321
+ handleData,
322
+ dispose() {
323
+ process.removeListener("SIGINT", handleSigint);
324
+ if (captureEsc) {
325
+ stdin.off("data", handleData);
326
+ if (rawChanged) stdin.setRawMode?.(false);
327
+ }
328
+ },
329
+ };
330
+ }
@@ -0,0 +1,102 @@
1
+ import chalk from "chalk";
2
+ import { type AgentLoopEvents } from "../../agent/engine";
3
+ import { type TaskSubEvent } from "../../agent/task-tool";
4
+ import { categoryBadge } from "../../tui/components/category-index";
5
+ import { summarizeForgeInvocation } from "../../tui/components/forge";
6
+ import { formatDuration, formatUsage } from "../../tui/components/duration";
7
+ import { LaunchTui } from "../../tui/app";
8
+
9
+ const GATED_OUTPUT_METHODS = new Set(["write", "cursorTo", "moveCursor", "clearLine", "clearScreenDown", "_write", "_writev"]);
10
+ export function gatedStdout(real: NodeJS.WriteStream, gated: () => boolean): NodeJS.WriteStream {
11
+ return new Proxy(real, {
12
+ get(target, prop, _receiver) {
13
+ if (typeof prop === "string" && GATED_OUTPUT_METHODS.has(prop)) {
14
+ return (...args: any[]) => {
15
+ if (gated()) {
16
+ const cb = args[args.length - 1]; // honor readline's write callback so it never stalls
17
+ if (typeof cb === "function") cb();
18
+ return true;
19
+ }
20
+ return (target as any)[prop](...args);
21
+ };
22
+ }
23
+ const value = Reflect.get(target, prop, target);
24
+ return typeof value === "function" ? value.bind(target) : value;
25
+ },
26
+ }) as unknown as NodeJS.WriteStream;
27
+ }
28
+
29
+ function firstOutputLine(output: string | undefined): string {
30
+ if (!output) return "";
31
+ const line = String(output)
32
+ .split("\n")
33
+ .map(l => l.trim())
34
+ .find(l => l.length > 0);
35
+ return line ? line.replace(/\s+/g, " ").slice(0, 140) : "";
36
+ }
37
+
38
+ function streamResultSuffix(tool: string, ok: boolean, output: string | undefined): string {
39
+ const summary = firstOutputLine(output);
40
+ if (!summary) return "";
41
+ if (!ok || tool === "task") return ` — ${summary}`;
42
+ return "";
43
+ }
44
+
45
+ export function formatTaskSubEvent(e: TaskSubEvent): string {
46
+ const role = e.role || "subagent";
47
+ const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
48
+ const tokTag = e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : "";
49
+ const detail = firstOutputLine(e.detail);
50
+ const summary = e.summary ? ` — ${e.summary}` : "";
51
+ const badge = categoryBadge("subagent");
52
+ if (e.kind === "start") return `${badge} ${chalk.magenta(`▸ ${roleLabel}`)} · ${detail}`.slice(0, 240);
53
+ if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
54
+ if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
55
+ if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
56
+ return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${tokTag}${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
57
+ }
58
+
59
+ export function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
60
+ log(formatTaskSubEvent(e));
61
+ }
62
+
63
+ export function createStreamEvents(
64
+ _maxSteps: number,
65
+ log: (line: string) => void = (s: string) => console.log(s),
66
+ now: () => number = Date.now,
67
+ ): AgentLoopEvents {
68
+ let pending = "";
69
+ let latestUsage: { inputTokens: number; outputTokens: number } | undefined;
70
+ const startTime = now();
71
+
72
+ return {
73
+ onStep: () => {},
74
+ onAssistant: (_raw: string, invocation: { tool?: string; arguments?: unknown } | null) => {
75
+ const tool = typeof invocation?.tool === "string" ? invocation.tool.trim() : "";
76
+ if (!tool || tool === "done") return;
77
+ pending = summarizeForgeInvocation(tool, invocation?.arguments).title;
78
+ const elapsedMs = now() - startTime;
79
+ let suffix = "";
80
+ if (elapsedMs >= 1000) suffix += ` · ${formatDuration(elapsedMs)}`;
81
+ if (latestUsage) suffix += ` · ${formatUsage(latestUsage)}`;
82
+ log(`${categoryBadge("progress")} ${pending}${suffix ? chalk.dim(suffix) : ""}`);
83
+ },
84
+ onToolResult: (tool: string, ok: boolean, output?: string) => {
85
+ const label = pending || tool;
86
+ const mark = ok ? chalk.green("✓") : chalk.red("✗");
87
+ log(` ${categoryBadge(ok ? "done" : "error")} ${mark} ${label}${streamResultSuffix(tool, ok, output)}`);
88
+ pending = "";
89
+ },
90
+ onNotice: (msg: string) => log(` ${categoryBadge("progress")} ${chalk.yellow(msg)}`),
91
+ onBudget: (_limit: number, reason: string) => {
92
+ log(` ${categoryBadge("progress")} ${chalk.yellow(reason)}`);
93
+ },
94
+ onUsage: (usage: { inputTokens: number; outputTokens: number }) => {
95
+ latestUsage = usage;
96
+ },
97
+ };
98
+ }
99
+
100
+ export function shouldUseOneShotTui(noTui: boolean): boolean {
101
+ return LaunchTui.usable(noTui);
102
+ }
@@ -0,0 +1,227 @@
1
+ import * as path from "node:path";
2
+ import * as fs from "node:fs";
3
+ import { type LaunchFlags } from "./flags";
4
+
5
+ function hashString(input: string): string {
6
+ let hash = 2166136261;
7
+ for (let i = 0; i < input.length; i++) {
8
+ hash ^= input.charCodeAt(i);
9
+ hash = Math.imul(hash, 16777619);
10
+ }
11
+ return (hash >>> 0).toString(36).padStart(6, "0").slice(0, 6);
12
+ }
13
+
14
+ function tmuxSafeNamePart(input: string, max = 32): string {
15
+ const safe = input.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "value";
16
+ if (safe.length <= max) return safe;
17
+ return `${safe.slice(0, Math.max(1, max - 7))}-${hashString(input)}`;
18
+ }
19
+
20
+ function tmuxRuntimeSuffix(flags: LaunchFlags): string {
21
+ const parts: string[] = [];
22
+ if (flags.provider) parts.push(`provider-${flags.provider}`);
23
+ if (flags.model) parts.push(`model-${tmuxSafeNamePart(flags.model)}`);
24
+ else if (flags.modelRole) parts.push(flags.modelRole);
25
+ if (flags.thinking) parts.push(`think-${flags.thinking}`);
26
+ // Only an EXPLICIT --max-steps cap names the session; the dynamic default (0) adds nothing.
27
+ if (flags.maxSteps > 0) parts.push(`steps-${flags.maxSteps}`);
28
+ if (parts.length === 0) return "";
29
+ const joined = parts.join("-");
30
+ const suffix = joined.length <= 72 ? joined : `${joined.slice(0, 65)}-${hashString(joined)}`;
31
+ return `-${suffix}`;
32
+ }
33
+
34
+ /**
35
+ * Base tmux session name for `jeo --tmux`. Keyed on the working DIRECTORY (not just the
36
+ * git branch) so two different projects/worktrees on the same branch (e.g. `main`)
37
+ * never share a base. {@link uniqueTmuxSessionName} then makes each concurrent invocation
38
+ * fully independent, so a second `jeo --tmux` never attaches to (and mirrors) the first.
39
+ */
40
+ export function tmuxSessionName(cwd: string, branch: string, flags: LaunchFlags): string {
41
+ const dirTag = `${tmuxSafeNamePart(path.basename(cwd) || "root", 16)}-${hashString(cwd)}`;
42
+ const base = branch ? `jeo-${branch}-${dirTag}` : `jeo-${dirTag}`;
43
+ return base + tmuxRuntimeSuffix(flags);
44
+ }
45
+
46
+ /**
47
+ * Count uncommitted git entries for the `⑂ <branch> ?N` footer dirty flag (gjc parity).
48
+ * One `git status --porcelain` spawn per CALL; callers invoke it once per turn start, not
49
+ * per render. Returns undefined when not a repo / git absent / clean.
50
+ */
51
+ export function gitDirtyCount(cwd: string): number | undefined {
52
+ try {
53
+ const res = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
54
+ if (res.exitCode !== 0) return undefined;
55
+ const n = res.stdout.toString().split("\n").filter(l => l.trim().length > 0).length;
56
+ return n || undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Allocate + create an INDEPENDENT tmux session from a base name. Each separate,
64
+ * concurrent `jeo --tmux` invocation gets its OWN session instead of attaching to (and
65
+ * mirroring) one another process already created: try `base`, then `base-2`, `base-3`, …
66
+ * The create itself is the guard, so this is race-safe — two processes starting at the
67
+ * same instant can't both win `base`. `tryCreate` must attempt to create the named session
68
+ * and return `"ok"` (created — it's ours), `"taken"` (name already live / lost the race →
69
+ * try the next suffix), or `"error:<msg>"` (a real failure → abort). Sessions die with
70
+ * their jeo process, so a sequential re-run reuses the clean base; only live overlap is
71
+ * suffixed.
72
+ */
73
+ export type TmuxCreateResult = "ok" | "taken" | `error:${string}`;
74
+ export function allocateTmuxSession(
75
+ base: string,
76
+ tryCreate: (name: string) => TmuxCreateResult,
77
+ ): { name: string } | { error: string } {
78
+ for (let n = 1; n <= 1000; n++) {
79
+ const candidate = n === 1 ? base : `${base}-${n}`;
80
+ const result = tryCreate(candidate);
81
+ if (result === "ok") return { name: candidate };
82
+ if (result === "taken") continue;
83
+ return { error: result.slice("error:".length) };
84
+ }
85
+ return { error: `could not allocate a free tmux session name for ${base} (1000 already live?)` };
86
+ }
87
+ export function shellQuote(arg: string): string {
88
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
89
+ }
90
+
91
+
92
+
93
+
94
+ /**
95
+ * True when `jeo --tmux` runs INSIDE an existing tmux session and should enable
96
+ * session-scoped mouse mode for the CURRENT session: no jeo-owned session is created
97
+ * on this path, so without `mouse on` tmux ignores the wheel entirely and the
98
+ * mid-turn scrollback (ledger lines flushed above the live frame) is unreachable.
99
+ * Skipped for jeo-spawned sessions (JEO_TMUX_LAUNCHED=1 — the creator already set
100
+ * it) and when JEO_TMUX_MOUSE=0 opts out.
101
+ */
102
+ export function shouldEnableCurrentTmuxMouse(env: Record<string, string | undefined>): boolean {
103
+ return !!env.TMUX
104
+ && (env.JEO_TMUX_LAUNCHED ?? env.JEO_TMUX_LAUNCHED) !== "1"
105
+ && (env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0";
106
+ }
107
+
108
+ /**
109
+ * The runnable command for the INNER `jeo launch` a `--tmux` session executes.
110
+ * Pure — testable. Three runtime shapes:
111
+ * - compiled standalone binary: `argv[1]` is a Bun VIRTUAL path (`/$bunfs/…`)
112
+ * that does not exist on disk; the binary itself (`execPath`) is the
113
+ * entrypoint. Passing the virtual path made the inner command crash on
114
+ * spawn, so `tmux new-session` died instantly and the follow-up attach
115
+ * failed with "can't find session".
116
+ * - source run (`bun src/cli.ts`): re-run the script through the runtime.
117
+ * - anything else (a shim/binary path on disk): run it directly.
118
+ */
119
+ export function tmuxLaunchCommand(argv1: string | undefined, execPath: string, cwd: string): string[] {
120
+ const entrypoint = argv1 ?? "";
121
+ if (entrypoint === "" || entrypoint.startsWith("/$bunfs/") || entrypoint.startsWith("B:\\~BUN\\")) {
122
+ return [execPath];
123
+ }
124
+ const resolved = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(cwd, entrypoint);
125
+ if (/\.(ts|js|mjs)$/.test(entrypoint)) return [execPath, resolved];
126
+ return [resolved];
127
+ }
128
+
129
+ /** One tmux configuration step applied to a jeo-owned session after creation. */
130
+ export interface TmuxProfileCommand {
131
+ description: string;
132
+ args: string[];
133
+ }
134
+
135
+ /**
136
+ * gjc-parity tmux profile for jeo-OWNED sessions (mirrors gjc's
137
+ * `buildGjcTmuxProfileCommands`). Applied right after `new-session`, before attach:
138
+ * - `mouse on` (session-scoped): wheel-up enters copy-mode over the REAL pane
139
+ * history — this is what makes the mid-turn scrollback (ledger lines flushed
140
+ * above the inline live frame) reachable with the mouse wheel. Wheel-down at
141
+ * the bottom drops back out. `JEO_TMUX_MOUSE=0` opts out.
142
+ * - ownership/identity markers (`@jeo-profile`, `@jeo-branch`, `@jeo-project`):
143
+ * lets tooling tell jeo-owned sessions apart from user sessions (gjc parity
144
+ * with `@gjc-*`). Never applied to foreign sessions.
145
+ * - `set-clipboard on` + a readable copy-mode `mode-style`: text selected while
146
+ * wheel-scrolled back is visibly highlighted and lands on the system clipboard
147
+ * (OSC52). `JEO_TMUX_PROFILE=0` opts out of these cosmetic extras while keeping
148
+ * mouse + markers.
149
+ */
150
+ export function tmuxProfileCommands(
151
+ target: string,
152
+ env: Record<string, string | undefined>,
153
+ meta: { branch?: string; project?: string } = {},
154
+ ): TmuxProfileCommand[] {
155
+ const t = `=${target}:`;
156
+ const commands: TmuxProfileCommand[] = [];
157
+ if ((env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0") {
158
+ commands.push({
159
+ description: "enable tmux mouse scrolling (wheel-up → copy-mode over real history)",
160
+ args: ["set-option", "-t", t, "mouse", "on"],
161
+ });
162
+ }
163
+ commands.push({
164
+ description: "mark jeo tmux ownership",
165
+ args: ["set-option", "-t", t, "@jeo-profile", "1"],
166
+ });
167
+ if (meta.branch) {
168
+ commands.push({
169
+ description: "record jeo branch identity",
170
+ args: ["set-option", "-t", t, "@jeo-branch", meta.branch],
171
+ });
172
+ }
173
+ if (meta.project) {
174
+ commands.push({
175
+ description: "record jeo project identity",
176
+ args: ["set-option", "-t", t, "@jeo-project", meta.project],
177
+ });
178
+ }
179
+ if ((env.JEO_TMUX_PROFILE ?? env.JEO_TMUX_PROFILE) !== "0") {
180
+ commands.push(
181
+ {
182
+ description: "enable tmux clipboard integration",
183
+ args: ["set-option", "-t", t, "set-clipboard", "on"],
184
+ },
185
+ {
186
+ description: "make copy-mode selection readable",
187
+ args: ["set-window-option", "-t", t, "mode-style", "fg=colour231,bg=colour60"],
188
+ },
189
+ );
190
+ }
191
+ return commands;
192
+ }
193
+
194
+ /**
195
+ * Resolve a git worktree path (gjc `--worktree <path>` parity). If the path
196
+ * already exists it is reused as-is; otherwise a new worktree is created on a
197
+ * branch derived from the path basename. Returns the absolute worktree path.
198
+ */
199
+ export function resolveWorktree(cwd: string, wt: string): string {
200
+ const abs = path.isAbsolute(wt) ? wt : path.resolve(cwd, wt);
201
+ if (fs.existsSync(abs)) return abs;
202
+ if (!Bun.which("git")) {
203
+ console.error("error: --worktree requires git on PATH");
204
+ process.exit(1);
205
+ }
206
+ const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "jeo-wt");
207
+ const withBranch = Bun.spawnSync(["git", "worktree", "add", "-b", branch, abs], {
208
+ cwd,
209
+ stdout: "pipe",
210
+ stderr: "pipe",
211
+ });
212
+ if (withBranch.exitCode !== 0) {
213
+ // Branch may already exist; retry attaching the existing branch.
214
+ const plain = Bun.spawnSync(["git", "worktree", "add", abs], {
215
+ cwd,
216
+ stdout: "pipe",
217
+ stderr: "pipe",
218
+ });
219
+ if (plain.exitCode !== 0) {
220
+ console.error(
221
+ `error: failed to create git worktree at ${abs}: ${withBranch.stderr.toString().trim()}`,
222
+ );
223
+ process.exit(1);
224
+ }
225
+ }
226
+ return abs;
227
+ }
@@ -0,0 +1,26 @@
1
+ import { runDeepInterviewEngine, type DeepInterviewEngineOptions } from "../deep-interview";
2
+ import { runRalplanEngine, type RalplanEngineOptions } from "../ralplan";
3
+ import { runTeamEngine, type TeamEngineOptions } from "../team";
4
+ import { runUltragoalEngine, type UltragoalEngineOptions } from "../ultragoal";
5
+
6
+ /** The bundled workflow skills that run through a dedicated engine (deep-interview /
7
+ * ralplan / team / ultragoal), not the ordinary agent loop. Single source of truth —
8
+ * the menu listing, the dispatch guards, and the engine switch all read from here. */
9
+ export const WORKFLOW_NAMES = ["deep-interview", "ralplan", "team", "ultragoal"] as const;
10
+
11
+ /** True when a skill name is one of the bundled workflow engines. */
12
+ export function isWorkflowSkill(name: string): boolean {
13
+ return (WORKFLOW_NAMES as readonly string[]).includes(name);
14
+ }
15
+
16
+ /** Dispatch a bundled workflow by name to its engine. Keeps the name→engine mapping in
17
+ * ONE place so the one-shot and interactive skill runners can't drift apart. */
18
+ export function runWorkflowEngine(
19
+ name: string,
20
+ opts: DeepInterviewEngineOptions & RalplanEngineOptions & TeamEngineOptions & UltragoalEngineOptions,
21
+ ): Promise<{ ok: boolean; reason?: string }> {
22
+ if (name === "deep-interview") return runDeepInterviewEngine(opts);
23
+ if (name === "ralplan") return runRalplanEngine(opts);
24
+ if (name === "team") return runTeamEngine(opts);
25
+ return runUltragoalEngine(opts);
26
+ }