jeo-code 0.6.9 → 0.6.11

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.6.11] - 2026-06-16
10
+ _Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt._
11
+
12
+ ### Changed
13
+ - **Larger thinking-token budgets across every level.** `thinkingMaxTokens` is raised (minimal 1k→4k, low 2k→8k, default 4k→16k, high 8k→24k, xhigh 16k→32k), along with the Anthropic (medium 4k→10k, high 10k→24k) and Gemini (low→4k, medium→10k, high→24k) per-provider budgets — so each reasoning level actually gets room to think.
14
+
15
+ ### Fixed
16
+ - **Terminal capability-response sequences no longer corrupt the prompt.** The mouse-report filter (0.6.7) now also swallows Device-Attributes / mode replies that the outer terminal sends when tmux probes it on attach, on both the idle keypress path and the live-turn raw-stdin drain.
17
+
18
+ ## [0.6.10] - 2026-06-16
19
+ _OKF memory-format foundation and a hardened bashTool subprocess drain._
20
+
21
+ ### Added
22
+ - **OKF (Open Knowledge Format) v0.1 foundation** — a standalone schema/format layer for jeo memory: YAML-frontmatter parse/serialize with extension-key round-trip, concept-ID computation, and a tolerant v0.1 conformance validator (`src/agent/memory-okf.ts`). Sprint 01 only — it does not touch the existing distill/inject pipeline yet, and adds zero native dependencies. Design notes live under `docs/okf_mem/`.
23
+
24
+ ### Fixed
25
+ - **bashTool subprocess draining hardened.** A shared `drainPipe` reader plus reader-cancellation, a SIGTERM→SIGKILL kill timer, and a brief pipe-linger ensure stdout/stderr fully drain before teardown — no file-descriptor or child-process leak across the normal, timed-out, and abandoned lifecycles (the subprocess-leak probe stays at baseline).
26
+
9
27
  ## [0.6.9] - 2026-06-16
10
28
  _Live streaming blocks size to their content and the viewport instead of a fixed rectangle._
11
29
 
package/README.ja.md CHANGED
@@ -158,11 +158,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
158
158
  ## 変更履歴 (Changelog)
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
162
+ - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
161
163
  - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
162
164
  - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
163
165
  - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
164
- - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
165
- - **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -158,11 +158,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
158
158
  ## 변경 이력 (Changelog)
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
162
+ - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
161
163
  - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
162
164
  - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
163
165
  - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
164
- - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
165
- - **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -158,11 +158,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
158
158
  ## Changelog
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
162
+ - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
161
163
  - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
162
164
  - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
163
165
  - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
164
- - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
165
- - **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -158,11 +158,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
158
158
  ## 更新日志 (Changelog)
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
162
+ - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
161
163
  - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
162
164
  - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
163
165
  - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
164
- - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
165
- - **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -0,0 +1,275 @@
1
+ /**
2
+ * OKF (Open Knowledge Format) v0.1 foundation for jeo memory — Sprint 01.
3
+ *
4
+ * Pure schema/format layer: YAML-frontmatter parse/serialize (extension keys
5
+ * round-trip preserved), concept-ID computation, and a tolerant v0.1
6
+ * conformance validator. This module deliberately does NOT touch the existing
7
+ * `memory.ts` distill/inject pipeline (Sprints 02–05 build on this contract).
8
+ *
9
+ * Design contract: docs/okf_mem/sprint-01-format-foundation/index.md
10
+ * Spec digest: docs/okf_mem/concepts/okf-spec-digest.md
11
+ *
12
+ * Why a bespoke YAML subset (not a dep): OKF frontmatter is intentionally tiny
13
+ * — flat `key: value` pairs with scalars and inline `[a, b]` lists. A focused
14
+ * parser keeps jeo's zero-native-dependency promise and gives us exact
15
+ * round-trip control over extension keys (e.g. `confidence`, `last_verified`).
16
+ */
17
+
18
+ /** jeo's agreed `type` vocabulary. OKF requires no central registry, but our
19
+ * producers/consumers share these values so injection can filter/prioritize.
20
+ * Unknown types are TOLERATED by the validator (lenient consumption). */
21
+ export const JEO_TYPES = [
22
+ "RepoFact",
23
+ "Command",
24
+ "Gotcha",
25
+ "UserPreference",
26
+ "Reference",
27
+ ] as const;
28
+ export type JeoType = (typeof JEO_TYPES)[number];
29
+
30
+ /** Reserved OKF filenames — never concept documents. */
31
+ export const RESERVED_FILES = ["index.md", "log.md"] as const;
32
+
33
+ /** A parsed frontmatter value: scalar or inline list of strings. */
34
+ export type FrontmatterValue = string | number | boolean | string[];
35
+ /** Ordered frontmatter map (JS string-key insertion order is preserved). */
36
+ export type Frontmatter = Record<string, FrontmatterValue>;
37
+
38
+ export interface ParsedConcept {
39
+ /** Frontmatter map, key order preserved. Empty when no frontmatter block. */
40
+ frontmatter: Frontmatter;
41
+ /** Markdown body after the closing `---` (leading newline trimmed). */
42
+ body: string;
43
+ /** True when a `---`-delimited frontmatter block was found and parsed. */
44
+ hasFrontmatter: boolean;
45
+ }
46
+
47
+ // ── Concept identity ────────────────────────────────────────────────────────
48
+
49
+ /** Concept ID = bundle-relative path with `.md` stripped and separators
50
+ * normalized to `/` (OKF rule: `commands/bun-test.md` → `commands/bun-test`). */
51
+ export function conceptId(bundleRelativePath: string): string {
52
+ const norm = bundleRelativePath.replace(/\\/g, "/").replace(/^\.?\/+/, "");
53
+ return norm.replace(/\.md$/i, "");
54
+ }
55
+
56
+ /** Basename of a bundle-relative path (handles both separators). */
57
+ function basename(p: string): string {
58
+ const norm = p.replace(/\\/g, "/");
59
+ const i = norm.lastIndexOf("/");
60
+ return i === -1 ? norm : norm.slice(i + 1);
61
+ }
62
+
63
+ /** True when a path's filename is an OKF reserved file (`index.md`/`log.md`). */
64
+ export function isReservedFile(bundleRelativePath: string): boolean {
65
+ const base = basename(bundleRelativePath).toLowerCase();
66
+ return (RESERVED_FILES as readonly string[]).includes(base);
67
+ }
68
+
69
+ /** kebab-case slug for a concept filename: lowercased, non-alphanumerics → `-`,
70
+ * collapsed and trimmed. Empty input yields `untitled`. */
71
+ export function slugify(title: string): string {
72
+ const slug = title
73
+ .normalize("NFKD")
74
+ .toLowerCase()
75
+ .replace(/[^a-z0-9]+/g, "-")
76
+ .replace(/^-+|-+$/g, "")
77
+ .replace(/-{2,}/g, "-");
78
+ return slug || "untitled";
79
+ }
80
+
81
+ // ── Frontmatter parse / serialize (round-trip preserving) ────────────────────
82
+
83
+ function unquote(raw: string): string {
84
+ const s = raw.trim();
85
+ if (s.length >= 2 && ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'"))) {
86
+ return s.slice(1, -1);
87
+ }
88
+ return s;
89
+ }
90
+
91
+ /** Parse a single scalar token into the narrowest faithful JS value. */
92
+ function parseScalar(raw: string): FrontmatterValue {
93
+ const s = raw.trim();
94
+ if (s.length === 0) return "";
95
+ // Quoted strings are ALWAYS strings (preserve e.g. okf_version "0.1").
96
+ if ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'")) {
97
+ return unquote(s);
98
+ }
99
+ if (s === "true") return true;
100
+ if (s === "false") return false;
101
+ // Integer or float (no leading zeros like 0.1 issue — that's a valid float).
102
+ if (/^-?\d+(\.\d+)?$/.test(s)) {
103
+ const n = Number(s);
104
+ if (Number.isFinite(n)) return n;
105
+ }
106
+ return s;
107
+ }
108
+
109
+ /** Parse one frontmatter value, supporting inline `[a, b, c]` lists. */
110
+ function parseValue(raw: string): FrontmatterValue {
111
+ const s = raw.trim();
112
+ if (s.startsWith("[") && s.endsWith("]")) {
113
+ const inner = s.slice(1, -1).trim();
114
+ if (inner === "") return [];
115
+ return inner.split(",").map(item => unquote(item.trim()));
116
+ }
117
+ return parseScalar(s);
118
+ }
119
+
120
+ /**
121
+ * Parse an OKF concept document into `{ frontmatter, body, hasFrontmatter }`.
122
+ * Tolerant: a document without a `---` block yields an empty frontmatter and
123
+ * the whole text as body (conformance is judged separately by the validator).
124
+ */
125
+ export function parseConcept(text: string): ParsedConcept {
126
+ const lines = text.split("\n");
127
+ // A frontmatter block must START at line 0 with a bare `---`.
128
+ if (lines[0]?.trim() !== "---") {
129
+ return { frontmatter: {}, body: text, hasFrontmatter: false };
130
+ }
131
+ let end = -1;
132
+ for (let i = 1; i < lines.length; i++) {
133
+ if (lines[i]?.trim() === "---") {
134
+ end = i;
135
+ break;
136
+ }
137
+ }
138
+ if (end === -1) {
139
+ // Opening `---` with no close: not a valid block — treat as bodyless content.
140
+ return { frontmatter: {}, body: text, hasFrontmatter: false };
141
+ }
142
+ const frontmatter: Frontmatter = {};
143
+ for (let i = 1; i < end; i++) {
144
+ const line = lines[i] ?? "";
145
+ const trimmed = line.trim();
146
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
147
+ const colon = line.indexOf(":");
148
+ if (colon === -1) continue; // skip malformed lines tolerantly
149
+ const key = line.slice(0, colon).trim();
150
+ if (!key) continue;
151
+ frontmatter[key] = parseValue(line.slice(colon + 1));
152
+ }
153
+ const body = lines.slice(end + 1).join("\n").replace(/^\n+/, "");
154
+ return { frontmatter, body, hasFrontmatter: true };
155
+ }
156
+
157
+ /** True when a string scalar must be quoted to round-trip as a string
158
+ * (i.e. would otherwise parse back as a number/bool, or has fragile edges). */
159
+ function needsQuoting(s: string): boolean {
160
+ if (s === "") return true;
161
+ if (s === "true" || s === "false") return true;
162
+ if (/^-?\d+(\.\d+)?$/.test(s)) return true;
163
+ if (s !== s.trim()) return true; // leading/trailing whitespace
164
+ if (/[:#\[\]{}",']/.test(s)) return true;
165
+ return false;
166
+ }
167
+
168
+ function serializeScalar(v: string | number | boolean): string {
169
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
170
+ return needsQuoting(v) ? `"${v.replace(/"/g, '\\"')}"` : v;
171
+ }
172
+
173
+ function serializeValue(v: FrontmatterValue): string {
174
+ if (Array.isArray(v)) {
175
+ return `[${v.map(item => (needsQuoting(item) ? `"${item.replace(/"/g, '\\"')}"` : item)).join(", ")}]`;
176
+ }
177
+ return serializeScalar(v);
178
+ }
179
+
180
+ /**
181
+ * Serialize frontmatter + body back into an OKF concept document.
182
+ * Key order is preserved; `serialize(parse(x))` is idempotent for documents
183
+ * this module produced. Body gets exactly one blank line after the block.
184
+ */
185
+ export function serializeConcept(frontmatter: Frontmatter, body: string): string {
186
+ const fmLines = Object.entries(frontmatter).map(([k, v]) => `${k}: ${serializeValue(v)}`);
187
+ const block = ["---", ...fmLines, "---"].join("\n");
188
+ const trimmedBody = body.replace(/^\n+/, "");
189
+ return trimmedBody ? `${block}\n\n${trimmedBody}` : `${block}\n`;
190
+ }
191
+
192
+ // ── OKF v0.1 conformance ─────────────────────────────────────────────────────
193
+
194
+ export interface BundleFile {
195
+ /** Bundle-relative path, e.g. `commands/bun-test.md`. */
196
+ path: string;
197
+ /** Raw file contents. */
198
+ content: string;
199
+ }
200
+
201
+ export interface ConformanceIssue {
202
+ path: string;
203
+ /** `error` fails conformance; `warning` is a lenient-guide hint only. */
204
+ level: "error" | "warning";
205
+ message: string;
206
+ }
207
+
208
+ export interface ConformanceReport {
209
+ conformant: boolean;
210
+ issues: ConformanceIssue[];
211
+ }
212
+
213
+ const ISO_DATE_HEADING = /^#{1,6}\s+(\d{4}-\d{2}-\d{2})/;
214
+
215
+ /**
216
+ * Validate one file against OKF v0.1 + jeo conventions.
217
+ *
218
+ * Errors (reject conformance):
219
+ * - non-reserved `.md` missing a parseable frontmatter block.
220
+ * - frontmatter with missing or empty `type`.
221
+ * - `log.md` date heading that is not ISO 8601 `YYYY-MM-DD`.
222
+ *
223
+ * Warnings (lenient guides — never reject):
224
+ * - unknown `type` value.
225
+ * - missing recommended fields (`title`, `description`).
226
+ */
227
+ export function validateFile(file: BundleFile): ConformanceIssue[] {
228
+ const issues: ConformanceIssue[] = [];
229
+ const base = basename(file.path).toLowerCase();
230
+
231
+ if (base === "log.md") {
232
+ // Reserved: if present, date headings must be ISO 8601.
233
+ for (const line of file.content.split("\n")) {
234
+ const m = line.match(/^#{1,6}\s+\S+/);
235
+ if (!m) continue;
236
+ // Only enforce on headings that look like dates; tolerate prose headings.
237
+ if (/^#{1,6}\s+\d/.test(line) && !ISO_DATE_HEADING.test(line)) {
238
+ issues.push({ path: file.path, level: "error", message: `log.md date heading not ISO 8601 (YYYY-MM-DD): "${line.trim()}"` });
239
+ }
240
+ }
241
+ return issues;
242
+ }
243
+
244
+ if (base === "index.md") {
245
+ // Reserved: index.md needs no frontmatter; nothing to reject.
246
+ return issues;
247
+ }
248
+
249
+ // Concept document.
250
+ const parsed = parseConcept(file.content);
251
+ if (!parsed.hasFrontmatter) {
252
+ issues.push({ path: file.path, level: "error", message: "concept document missing YAML frontmatter block" });
253
+ return issues;
254
+ }
255
+ const type = parsed.frontmatter.type;
256
+ if (typeof type !== "string" || type.trim() === "") {
257
+ issues.push({ path: file.path, level: "error", message: "frontmatter `type` is required and must be non-empty" });
258
+ } else if (!(JEO_TYPES as readonly string[]).includes(type)) {
259
+ issues.push({ path: file.path, level: "warning", message: `unknown type "${type}" (tolerated; not in jeo vocabulary)` });
260
+ }
261
+ if (!parsed.frontmatter.title) {
262
+ issues.push({ path: file.path, level: "warning", message: "missing recommended field `title`" });
263
+ }
264
+ if (!parsed.frontmatter.description) {
265
+ issues.push({ path: file.path, level: "warning", message: "missing recommended field `description`" });
266
+ }
267
+ return issues;
268
+ }
269
+
270
+ /** Validate a whole bundle. Conformant iff there are zero `error`-level issues
271
+ * (warnings never reject — OKF's tolerant consumption model). */
272
+ export function validateBundle(files: BundleFile[]): ConformanceReport {
273
+ const issues = files.flatMap(validateFile);
274
+ return { conformant: !issues.some(i => i.level === "error"), issues };
275
+ }
@@ -587,6 +587,31 @@ export async function editTool(
587
587
  }
588
588
  }
589
589
 
590
+ /** Drain a readable byte stream to a string, cancel-safe. We read via an explicit
591
+ * reader (NOT `new Response(stream).text()`, whose read is uncancellable) so a caller
592
+ * can cancel() to force the in-flight read to settle — essential when a SIGKILLed
593
+ * process leaves a backgrounded grandchild holding the pipe's write end open, so it
594
+ * never hits EOF (proven by scripts/subproc-probe.ts ABANDON mode). An optional
595
+ * (caller-throttled) onChunk drives the live output view. */
596
+ async function drainPipe(
597
+ r: ReadableStreamDefaultReader<Uint8Array>,
598
+ onChunk?: (partial: string) => void,
599
+ ): Promise<string> {
600
+ const dec = new TextDecoder();
601
+ let out = "";
602
+ try {
603
+ for (;;) {
604
+ const { done, value } = await r.read();
605
+ if (done) break;
606
+ out += dec.decode(value, { stream: true });
607
+ onChunk?.(out);
608
+ }
609
+ out += dec.decode();
610
+ onChunk?.(out);
611
+ } catch { /* cancelled reader surfaces here; return what we have */ }
612
+ return out;
613
+ }
614
+
590
615
  export async function bashTool(
591
616
  command: string,
592
617
  cwd: string = process.cwd(),
@@ -612,6 +637,10 @@ export async function bashTool(
612
637
  // Run the command using Bun's native spawn
613
638
  const proc = Bun.spawn(["bash", "-c", command], {
614
639
  cwd: runCwd,
640
+ // Detach stdin (no inherited TTY): a command that reads stdin (`cat`, `read`,
641
+ // a REPL, an interactive prompt) then gets immediate EOF and exits instead of
642
+ // blocking the whole turn forever waiting for input that never comes.
643
+ stdin: "ignore",
615
644
  stdout: "pipe",
616
645
  stderr: "pipe",
617
646
  // Inherit the parent env; merge caller-supplied (sanitized) vars on top.
@@ -621,90 +650,93 @@ export async function bashTool(
621
650
  let timedOut = false;
622
651
  let aborted = false;
623
652
  const TIMEOUT_MS = timeoutMs;
653
+ // Grace after the SHELL exits before we force the pipe readers closed: a finished
654
+ // command's buffered tail flushes far inside this window; only a backgrounded
655
+ // grandchild that inherited the pipe (`cmd &`, daemons, `nohup`) keeps it open
656
+ // past it — and that one must not stall the turn.
657
+ const PIPE_LINGER_MS = 500;
624
658
  let killTimer: ReturnType<typeof setTimeout> | undefined;
659
+ let stdoutReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
660
+ let stderrReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
661
+ // Force EOF on both pipe readers. The drain loops `await r.read()`, which NEVER
662
+ // returns `done` while ANY process holds the write end open — a SIGKILLed shell
663
+ // can leave a backgrounded grandchild clutching the FD, so killing the shell is
664
+ // not enough (proven by scripts/subproc-probe.ts ABANDON mode). cancel() resolves
665
+ // the in-flight read immediately, unwinding the drains so the call returns instead
666
+ // of hanging forever. Cheap to call repeatedly; harmless before the readers exist.
667
+ const cancelReaders = () => {
668
+ try { stdoutReader?.cancel(); } catch {}
669
+ try { stderrReader?.cancel(); } catch {}
670
+ };
625
671
  const timer = setTimeout(() => {
626
672
  timedOut = true;
627
- // Graceful first (SIGTERM), then force-kill (SIGKILL) if it ignores it.
673
+ // Graceful first (SIGTERM), then force-kill (SIGKILL) if it ignores it. At the
674
+ // SIGKILL step ALSO cut the readers: the old code killed the shell but never
675
+ // cancelled the readers, so an orphan-held pipe outlived the deadline and the
676
+ // turn hung forever (the reported freeze).
628
677
  try { proc.kill(); } catch {}
629
- killTimer = setTimeout(() => { try { proc.kill(9); } catch {} }, 3_000);
678
+ killTimer = setTimeout(() => { try { proc.kill(9); } catch {} cancelReaders(); }, 3_000);
630
679
  }, TIMEOUT_MS);
631
680
  // Abort wiring: if the turn is cancelled, SIGKILL the child immediately AND cancel
632
- // both pipe readers so the drain loops below unwind at once. We own the readers
633
- // explicitly (rather than `for await` / `new Response`, whose hidden iterator locks
634
- // we cannot cancel): cancel() resolves the in-flight read({ done:true }) immediately,
635
- // unwinding each loop even when the killed child's pipe is slow to hit EOF. Cancelling
636
- // stderr also prevents a hang — after kill(9) its pipe never sees EOF, so awaiting an
637
- // uncancellable Response would block forever. Without all this the child is orphaned,
638
- // holding two pipe FDs (proven by scripts/subproc-probe.ts ABANDON mode: +1 fd & +1
639
- // child per call).
640
- let stdoutReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
641
- let stderrReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
681
+ // both pipe readers so the drain loops unwind at once.
642
682
  const onAbort = () => {
643
683
  aborted = true;
644
684
  try { proc.kill(9); } catch {}
645
- try { stdoutReader?.cancel(); } catch {}
646
- try { stderrReader?.cancel(); } catch {}
685
+ cancelReaders();
647
686
  };
648
687
  if (signal) {
649
688
  if (signal.aborted) onAbort();
650
689
  else signal.addEventListener("abort", onAbort, { once: true });
651
690
  }
652
691
 
653
- // Drain a pipe to a string, cancel-safe. An optional onChunk sink receives the
654
- // running output (throttled by the caller) to drive the live DIMMED bash view.
655
- const drainAll = async (
656
- r: ReadableStreamDefaultReader<Uint8Array>,
657
- onChunk?: (partial: string) => void,
658
- ): Promise<string> => {
659
- const dec = new TextDecoder();
660
- let out = "";
661
- try {
662
- for (;;) {
663
- if (aborted) break;
664
- const { done, value } = await r.read();
665
- if (done) break;
666
- out += dec.decode(value, { stream: true });
667
- onChunk?.(out);
668
- }
669
- out += dec.decode();
670
- onChunk?.(out);
671
- } catch { /* cancelled reader surfaces here; return what we have */ }
672
- return out;
673
- };
692
+ // Start BOTH drains concurrently so neither pipe can fill its 64KB buffer and
693
+ // deadlock the child (a child blocks on write() once an unread pipe fills up).
694
+ // cancelReaders() (abort/timeout) unwinds an in-flight read, so no aborted-flag
695
+ // poll is needed inside the shared drainPipe.
696
+ stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
674
697
  stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
675
- const stderrPromise = drainAll(stderrReader).catch(() => "");
676
- let stdout = "";
698
+ let lastEmit = 0;
699
+ const stdoutPromise = drainPipe(
700
+ stdoutReader,
701
+ onProgress
702
+ ? (partial) => {
703
+ // Throttle the live sink to ~80ms.
704
+ const now = Date.now();
705
+ if (now - lastEmit >= 80) { lastEmit = now; onProgress(partial); }
706
+ }
707
+ : undefined,
708
+ ).catch(() => "");
709
+ const stderrPromise = drainPipe(stderrReader).catch(() => "");
710
+
677
711
  try {
678
- if (onProgress) {
679
- // Throttle the live sink to ~80ms; drainAll owns the cancel-safe read loop.
680
- let lastEmit = 0;
681
- stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
682
- stdout = await drainAll(stdoutReader, (partial) => {
683
- const now = Date.now();
684
- if (now - lastEmit >= 80) { lastEmit = now; onProgress(partial); }
685
- });
686
- onProgress(stdout);
687
- } else if (!aborted) {
688
- stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
689
- stdout = await drainAll(stdoutReader);
690
- }
712
+ // Wait for the SHELL to exit. Bounded: the timeout path SIGTERMs then SIGKILLs,
713
+ // so even a foreground command that blocks (e.g. on /dev/tty) cannot exceed
714
+ // TIMEOUT_MS (+3s grace).
691
715
  if (!aborted) await proc.exited;
692
- } catch (streamErr) {
693
- // A cancelled stdout reader (from onAbort) surfaces here; swallow it so we can
694
- // return a clean aborted result rather than a stream-internal error.
695
- if (!aborted) throw streamErr;
716
+ // The shell exited, but a backgrounded grandchild can keep the pipes open
717
+ // indefinitely; without forcing them closed the drains hang the whole turn.
718
+ // Give a finished command's tail a brief grace to flush, then cut the readers
719
+ // in the finally below — so a daemon-spawning command returns ~PIPE_LINGER_MS
720
+ // after the shell exits instead of stalling until the timeout.
721
+ if (!aborted && !timedOut) {
722
+ await Promise.race([
723
+ Promise.all([stdoutPromise, stderrPromise]),
724
+ new Promise<void>(resolve => setTimeout(resolve, PIPE_LINGER_MS)),
725
+ ]);
726
+ }
696
727
  } finally {
697
728
  clearTimeout(timer);
698
729
  if (killTimer) clearTimeout(killTimer);
699
730
  if (signal) signal.removeEventListener("abort", onAbort);
700
- // Belt-and-suspenders: if we are leaving for ANY reason (normal exit, stdout-loop
701
- // throw, abort) and the child is somehow still alive, reap it so no orphaned
702
- // process or pipe FD survives the call.
731
+ // Reap a still-alive child (normal exit, abort, or timeout) so no orphaned
732
+ // process survives, then force any pipe still held open (backgrounded
733
+ // grandchild) closed so the awaits below cannot hang.
703
734
  if (proc.exitCode === null && proc.signalCode === null) { try { proc.kill(9); } catch {} }
704
- // Always settle the stderr reader to release its pipe FD.
705
- await stderrPromise;
735
+ cancelReaders();
706
736
  }
737
+ const stdout = await stdoutPromise;
707
738
  const stderr = await stderrPromise;
739
+ if (onProgress) onProgress(stdout);
708
740
 
709
741
  let output = [stdout, stderr].filter(Boolean).join("\n");
710
742
  const MAX_OUTPUT = 100_000;
@@ -740,22 +772,41 @@ async function spawnTextWithTimeout(
740
772
  cwd: string,
741
773
  timeoutMs = 60_000,
742
774
  ): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> {
743
- const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
775
+ // stdin detached so a command that reads stdin gets EOF instead of blocking.
776
+ const proc = Bun.spawn(cmd, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
744
777
  let timedOut = false;
745
778
  let killTimer: ReturnType<typeof setTimeout> | undefined;
779
+ const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
780
+ const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader() as ReadableStreamDefaultReader<Uint8Array>;
781
+ const cancelReaders = () => {
782
+ try { stdoutReader.cancel(); } catch {}
783
+ try { stderrReader.cancel(); } catch {}
784
+ };
785
+ const stdoutPromise = drainPipe(stdoutReader);
786
+ const stderrPromise = drainPipe(stderrReader);
746
787
  const timer = setTimeout(() => {
747
788
  timedOut = true;
748
789
  try { proc.kill(); } catch {}
749
- killTimer = setTimeout(() => { try { proc.kill(9); } catch {} }, 3_000);
790
+ killTimer = setTimeout(() => { try { proc.kill(9); } catch {} cancelReaders(); }, 3_000);
750
791
  }, timeoutMs);
751
792
  try {
752
793
  await proc.exited;
794
+ // Brief grace for a finished command's tail to flush, then cut any pipe still held
795
+ // open by a backgrounded grandchild so the awaits below cannot hang the turn.
796
+ if (!timedOut) {
797
+ await Promise.race([
798
+ Promise.all([stdoutPromise, stderrPromise]),
799
+ new Promise<void>(resolve => setTimeout(resolve, 500)),
800
+ ]);
801
+ }
753
802
  } finally {
754
803
  clearTimeout(timer);
755
804
  if (killTimer) clearTimeout(killTimer);
805
+ if (proc.exitCode === null && proc.signalCode === null) { try { proc.kill(9); } catch {} }
806
+ cancelReaders();
756
807
  }
757
- const stdout = await new Response(proc.stdout).text();
758
- const stderr = await new Response(proc.stderr).text();
808
+ const stdout = await stdoutPromise;
809
+ const stderr = await stderrPromise;
759
810
  return { stdout, stderr, exitCode: proc.exitCode, timedOut };
760
811
  }
761
812
 
@@ -68,11 +68,11 @@ export function providerModelFor(model: string): string {
68
68
 
69
69
  /** Map the configured thinking level to a default max-token budget. */
70
70
  export function thinkingMaxTokens(level?: "minimal" | "low" | "medium" | "high" | "xhigh"): number {
71
- if (level === "minimal") return 1000;
72
- if (level === "low") return 2000;
73
- if (level === "high") return 8000;
74
- if (level === "xhigh") return 16000;
75
- return 4000;
71
+ if (level === "minimal") return 4000;
72
+ if (level === "low") return 8000;
73
+ if (level === "high") return 24000;
74
+ if (level === "xhigh") return 31999;
75
+ return 16000;
76
76
  }
77
77
 
78
78
  /** Map the thinking level to an OpenAI reasoning-effort tier. `minimal` maps to `low`
@@ -77,8 +77,8 @@ function anthropicSystemBlocks(
77
77
  function anthropicThinkingBudget(effort: CallOptions["reasoningEffort"], maxTokens: number): number | undefined {
78
78
  let budget: number;
79
79
  switch (effort) {
80
- case "medium": budget = 4096; break;
81
- case "high": budget = 10000; break;
80
+ case "medium": budget = 10000; break;
81
+ case "high": budget = 24000; break;
82
82
  default: return undefined;
83
83
  }
84
84
  return Math.min(budget, Math.max(1024, maxTokens - 1024));
@@ -17,9 +17,9 @@ export function geminiThinkingBudget(model: string, effort?: CallOptions["reason
17
17
  const floor = m.includes("pro") ? 128 : 0; // pro-class cannot fully disable thinking
18
18
  let budget: number;
19
19
  switch (effort) {
20
- case "low": budget = 1024; break;
21
- case "medium": budget = 4096; break;
22
- case "high": budget = 8192; break;
20
+ case "low": budget = 4000; break;
21
+ case "medium": budget = 10000; break;
22
+ case "high": budget = 24000; break;
23
23
  case "minimal":
24
24
  default: budget = floor;
25
25
  }
@@ -104,16 +104,45 @@ export function matchMouseReport(data: string, i: number): number {
104
104
  return 0;
105
105
  }
106
106
 
107
- /** Remove every terminal MOUSE-REPORT sequence from a plain (non-paste) input segment.
108
- * The live-turn drain (`queuePromptInputChunk`) reads RAW stdin, so a wheel/click report
109
- * buffered during a running turn would otherwise have its printable remnant (`[M`, SGR
110
- * digits) fed into the next prompt the same "값 입력" corruption the keyFilter blocks
111
- * on the idle path. */
107
+ /** Byte length of a terminal CAPABILITY-RESPONSE sequence beginning at `data[i]`, else 0.
108
+ * These are REPLIES the terminal sends to capability queries — Primary/Secondary Device
109
+ * Attributes (`ESC[?…c` / `ESC[>…c` / `ESC[=…c`), XTVERSION and other DCS replies
110
+ * (`ESC P…ST`), and OSC replies like a color query (`ESC]11;rgb:…ST`). jeo never sends
111
+ * these queries, but tmux probes the OUTER terminal on attach, and the outer terminal's
112
+ * answers can land on stdin (the leaked `62;4;9;22c…>|xterm.js(…)` garbage in the prompt).
113
+ * They are never typed input, so the whole sequence is swallowed. A reply split across
114
+ * chunks (no terminator yet) consumes the tail rather than leaking it. */
115
+ export function matchTerminalReport(data: string, i: number): number {
116
+ // CSI device-attribute / mode replies: ESC [ (? | > | =) … <final letter>.
117
+ if (data.startsWith("\u001b[?", i) || data.startsWith("\u001b[>", i) || data.startsWith("\u001b[=", i)) {
118
+ let j = i + 3;
119
+ while (j < data.length && !/[A-Za-z]/.test(data[j]!)) j++; // params: digits ; : $ → final letter
120
+ return (j < data.length ? j + 1 : data.length) - i;
121
+ }
122
+ // DCS (ESC P … ) or OSC (ESC ] … ) reply — terminated by ST (ESC \) or BEL.
123
+ if (data.startsWith("\u001bP", i) || data.startsWith("\u001b]", i)) {
124
+ let j = i + 2;
125
+ while (j < data.length) {
126
+ if (data[j] === "\u0007") return j + 1 - i; // BEL
127
+ if (data[j] === "\u001b" && data[j + 1] === "\\") return j + 2 - i; // ST
128
+ j++;
129
+ }
130
+ return data.length - i; // unterminated tail (split chunk) — consume the rest
131
+ }
132
+ return 0;
133
+ }
134
+
135
+ /** Remove every terminal MOUSE-REPORT and CAPABILITY-RESPONSE sequence from a plain
136
+ * (non-paste) input segment. The live-turn drain (`queuePromptInputChunk`) reads RAW
137
+ * stdin, so a wheel/click report or a tmux-probed device-attribute reply buffered during
138
+ * a running turn would otherwise have its printable remnant (`[M`, SGR digits, or
139
+ * `62;4;9;22c…`) fed into the next prompt — the same "값 입력" corruption the keyFilter
140
+ * blocks on the idle path. */
112
141
  export function stripMouseReports(s: string): string {
113
142
  let out = "";
114
143
  let i = 0;
115
144
  while (i < s.length) {
116
- const m = matchMouseReport(s, i);
145
+ const m = matchMouseReport(s, i) || matchTerminalReport(s, i);
117
146
  if (m > 0) { i += m; continue; }
118
147
  out += s[i];
119
148
  i += 1;
@@ -127,6 +127,7 @@ import {
127
127
  CURSOR_COMBO_REWRITES,
128
128
  matchCursorCombo,
129
129
  matchMouseReport,
130
+ matchTerminalReport,
130
131
  stripMouseReports,
131
132
  rewriteCursorCombos,
132
133
  queuePromptInputChunk,
@@ -175,6 +176,7 @@ export {
175
176
  CURSOR_COMBO_REWRITES,
176
177
  matchCursorCombo,
177
178
  matchMouseReport,
179
+ matchTerminalReport,
178
180
  stripMouseReports,
179
181
  rewriteCursorCombos,
180
182
  queuePromptInputChunk,
@@ -1268,6 +1270,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1268
1270
  // their payload bytes would otherwise be typed into the prompt. Never in a paste.
1269
1271
  const mouse = matchMouseReport(data, i);
1270
1272
  if (mouse > 0) { i += mouse; continue; }
1273
+ // Swallow TERMINAL CAPABILITY-RESPONSE sequences (DA1/DA2/XTVERSION/OSC replies):
1274
+ // tmux probes the outer terminal on attach and its answers (`62;4;9;22c…>|xterm.js…`)
1275
+ // can land on stdin; without this they spray into the prompt as typed text.
1276
+ const report = matchTerminalReport(data, i);
1277
+ if (report > 0) { i += report; continue; }
1271
1278
  let matched = false;
1272
1279
  for (const seq of SHIFT_ENTER_SEQS) {
1273
1280
  if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }