jeo-code 0.6.8 → 0.6.10

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,22 @@ 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.10] - 2026-06-16
10
+ _OKF memory-format foundation and a hardened bashTool subprocess drain._
11
+
12
+ ### Added
13
+ - **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/`.
14
+
15
+ ### Fixed
16
+ - **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).
17
+
18
+ ## [0.6.9] - 2026-06-16
19
+ _Live streaming blocks size to their content and the viewport instead of a fixed rectangle._
20
+
21
+ ### Changed
22
+ - **`Thinking` / tool `Output` live blocks size to their content.** The dimmed streaming trace and tool-output tail are now rendered by a single `renderLiveBlock` helper that shows only the most-recent lines, capped at ~30% of the terminal height — instead of a fixed blank-padded rectangle. A short stream no longer leaves dead "hole" rows, and a short terminal keeps the rows the heartbeat needs.
23
+ - Dropped the rounded-icon header image from the READMEs (the hero image and title stay).
24
+
9
25
  ## [0.6.8] - 2026-06-16
10
26
  _OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs._
11
27
 
package/README.ja.md CHANGED
@@ -1,7 +1,3 @@
1
- <p align="center">
2
- <img src="assets/icon-rounded-256.png" alt="jeo-code icon" width="128" />
3
- </p>
4
-
5
1
  <p align="center">
6
2
  <img src="assets/hero.png" alt="jeo-code 自律コーディングエージェントのヒーローイラスト" width="100%" />
7
3
  </p>
@@ -162,11 +158,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
162
158
  ## 変更履歴 (Changelog)
163
159
 
164
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
162
+ - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
165
163
  - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
166
164
  - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
167
165
  - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
168
- - **[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.
169
- - **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
170
166
 
171
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
172
168
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -1,7 +1,3 @@
1
- <p align="center">
2
- <img src="assets/icon-rounded-256.png" alt="jeo-code icon" width="128" />
3
- </p>
4
-
5
1
  <p align="center">
6
2
  <img src="assets/hero.png" alt="jeo-code 자율 코딩 에이전트 히어로 일러스트" width="100%" />
7
3
  </p>
@@ -162,11 +158,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
162
158
  ## 변경 이력 (Changelog)
163
159
 
164
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
162
+ - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
165
163
  - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
166
164
  - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
167
165
  - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
168
- - **[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.
169
- - **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
170
166
 
171
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
172
168
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -1,7 +1,3 @@
1
- <p align="center">
2
- <img src="assets/icon-rounded-256.png" alt="jeo-code icon" width="128" />
3
- </p>
4
-
5
1
  <p align="center">
6
2
  <img src="assets/hero.png" alt="jeo-code autonomous coding-agent hero illustration" width="100%" />
7
3
  </p>
@@ -162,11 +158,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
162
158
  ## Changelog
163
159
 
164
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
162
+ - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
165
163
  - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
166
164
  - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
167
165
  - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
168
- - **[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.
169
- - **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
170
166
 
171
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
172
168
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -1,7 +1,3 @@
1
- <p align="center">
2
- <img src="assets/icon-rounded-256.png" alt="jeo-code icon" width="128" />
3
- </p>
4
-
5
1
  <p align="center">
6
2
  <img src="assets/hero.png" alt="jeo-code 自主编码代理主视觉插图" width="100%" />
7
3
  </p>
@@ -162,11 +158,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
162
158
  ## 更新日志 (Changelog)
163
159
 
164
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
162
+ - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
165
163
  - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
166
164
  - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
167
165
  - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
168
- - **[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.
169
- - **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
170
166
 
171
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
172
168
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
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
 
package/src/tui/app.ts CHANGED
@@ -1178,6 +1178,30 @@ export class LaunchTui {
1178
1178
  }
1179
1179
 
1180
1180
 
1181
+ /** Build a live, DIMMED streaming block (the `Thinking` reasoning trace or a tool's
1182
+ * `Output` tail). Sized to its ACTUAL content and the terminal height — no fixed
1183
+ * blank-row padding. The old fixed-height region (`ROWS` constant, blank-padded at
1184
+ * the top) reserved a constant rectangle: a short stream left dead rows that read as
1185
+ * a torn "hole", and on a short terminal it stole rows the heartbeat needed. Now the
1186
+ * block shows only the most-recent lines, capped at ~30% of the screen height (a
1187
+ * ceiling guards a tall terminal), so it grows with the stream and shrinks with the
1188
+ * viewport. Returns [] when there is nothing to show. */
1189
+ private renderLiveBlock(label: string, text: string, cols: number, rows: number, ceiling: number): string[] {
1190
+ const dim = this.theme.color ? chalk.dim : (s: string) => s;
1191
+ if (!text.trim()) return [];
1192
+ const wrapW = Math.max(8, cols - 2);
1193
+ const wrapped = tailForWrap(text)
1194
+ .split("\n")
1195
+ .flatMap(l => wrapTextWithAnsi(l, wrapW))
1196
+ .filter(l => l.length > 0);
1197
+ if (wrapped.length === 0) return [];
1198
+ const cap = Math.max(3, Math.min(ceiling, Math.floor(rows * 0.3)));
1199
+ const out: string[] = [sectionLabel(label, Math.max(8, cols), { color: this.theme.color, unicode: this.unicode })];
1200
+ for (const l of wrapped.slice(-cap)) out.push(dim(` ${l}`));
1201
+ out.push("");
1202
+ return out;
1203
+ }
1204
+
1181
1205
  /** Render the Ctrl+O panel inside the live frame. `maxRows` includes borders. */
1182
1206
  private renderHistoryPanel(width: number, maxRows: number): string[] {
1183
1207
  if (!this.historyLines || maxRows < 4) return [];
@@ -1294,42 +1318,18 @@ export class LaunchTui {
1294
1318
  // as a DIMMED, bounded block above the status line. It is transient — flushed
1295
1319
  // UN-dimmed into scrollback once the model commits to a tool/reply (onAssistant),
1296
1320
  // so the in-progress trace stays shaded while the final record reads in normal text.
1321
+ // Height is sized to the content and the viewport (renderLiveBlock), not a fixed
1322
+ // rectangle, so a short trace leaves no padded "hole" and a short terminal is spared.
1297
1323
  const liveThink = this.streamingThought.trim() || this.streamingReasoning.trim();
1298
1324
  if (isThinking && liveThink) {
1299
- const wrapW = Math.max(8, cols - 2);
1300
- const wrapped = tailForWrap(liveThink)
1301
- .split("\n")
1302
- .flatMap(l => wrapTextWithAnsi(l, wrapW))
1303
- .filter(l => l.length > 0);
1304
- // FIXED reserved height (bottom-anchored, blank-padded at top): once present the
1305
- // block's row count is CONSTANT, so streaming content never changes the frame
1306
- // height. The per-100ms height thrash that desynced the differential renderer
1307
- // (duplicate model bar) is gone; height now toggles only at lifecycle boundaries.
1308
- const ROWS = 6;
1309
- const shown = wrapped.slice(-ROWS);
1310
- tail.push(sectionLabel("Thinking", Math.max(8, cols), { color: this.theme.color, unicode: this.unicode }));
1311
- for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
1312
- for (const l of shown) tail.push(dim(` ${l}`));
1313
- tail.push("");
1325
+ tail.push(...this.renderLiveBlock("Thinking", liveThink, cols, rows, 6));
1314
1326
  }
1315
1327
 
1316
1328
  // Live tool output (gjc-style streaming bash stdout): while a tool runs, its
1317
1329
  // output arrives via onToolProgress and is shown as a DIMMED, bounded tail block.
1318
1330
  // It is transient — cleared on result, when the formatted forge card takes over.
1319
1331
  if (this.runningTool && this.liveToolOutput.trim()) {
1320
- const wrapW = Math.max(8, cols - 2);
1321
- const wrapped = tailForWrap(this.liveToolOutput)
1322
- .split("\n")
1323
- .flatMap(l => wrapTextWithAnsi(l, wrapW))
1324
- .filter(l => l.length > 0);
1325
- // FIXED reserved height (see thinking block): constant rows while a tool streams,
1326
- // so cumulative stdout growth does not thrash the frame height.
1327
- const ROWS = 8;
1328
- const shown = wrapped.slice(-ROWS);
1329
- tail.push(sectionLabel("Output", Math.max(8, cols), { color: this.theme.color, unicode: this.unicode }));
1330
- for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
1331
- for (const l of shown) tail.push(dim(` ${l}`));
1332
- tail.push("");
1332
+ tail.push(...this.renderLiveBlock("Output", this.liveToolOutput, cols, rows, 8));
1333
1333
  }
1334
1334
 
1335
1335
  // Live status field: unboxed thinking line + compact metrics row. The model's