jeo-code 0.7.29 → 0.7.31

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,40 @@ 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.7.31] - 2026-07-03
10
+ _Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity._
11
+
12
+ ### Performance
13
+ - **Bash tool output streaming no longer accumulates without bound.** `drainPipe` (`src/agent/tools.ts`) stops growing its buffer once it reaches the `bashTool` truncation cap and keeps only a small rolling 16KB tail for the live view afterward — a chatty long-running command no longer holds hundreds of MB in memory for the whole timeout window, and the live `onChunk` callback receives a bounded string instead of the full cumulative buffer (was making every downstream sanitize/wrap pass O(n²) over stream length).
14
+ - **Engine stream emission is throttled (~80ms) instead of firing per token.** `onModelStream`/`onReasoningStream` in `src/agent/engine.ts` no longer re-flatten the growing response rope and re-scan it in the TUI on every streamed token; a guaranteed final flush (`flushStreams()`) delivers the complete text before parse/commit so no content is ever lost to the throttle. Per-step call-signature digests are now computed once and reused across the repeat/cycle/step-budget novelty checks instead of being re-hashed three times.
15
+ - **Stream idle-watchdog no longer allocates a Promise+timer pair per chunk.** `nextMaybeIdle`/`retryableStream` (`src/ai/model-manager.ts`) now set up one watchdog per stream attempt and reuse it across every `iter.next()` call instead of constructing a new race per yielded token.
16
+ - **SSE line-splitting and think-tag boundary checks are now allocation-free.** `readLines` (`src/ai/sse.ts`) scans the buffer incrementally with `indexOf` instead of re-splitting the full accumulated buffer on every chunk; `partialTail` (`src/ai/think-tags.ts`) checks a candidate tag boundary with an index-based `charCodeAt` loop instead of allocating a substring per check.
17
+ - **Memory retrieval no longer rebuilds corpus stats and the concept graph twice per turn.** `selectWithinBudget` (`src/agent/memory.ts`) now builds `CorpusStats` and the `ConceptGraph` once and threads them through `priorityOrder`, instead of `buildCorpusStats`/`buildConceptGraph` re-running on every subset scored. Per-concept lowercased fields (title/description/body/type/tags) are cached in a module-level `WeakMap` keyed by object identity, eliminating redundant `.toLowerCase()` calls across repeated scoring passes. Concept file loading in `loadConceptsFromBundle` is now batched (16 files in flight at a time) instead of either fully sequential or fully unbounded, preserving throughput without unbounded file-descriptor/RAM spikes on large memory bundles.
18
+ - **Concept graph adjacency is cached per graph instance.** `undirectedAdjacency` (`src/agent/memory-graph.ts`) is now computed once and cached on `ConceptGraph.adj`, avoiding an O(seeds × edges) reconstruction on every graph-proximity expansion.
19
+ - **`estimateTokens` (`src/agent/compaction.ts`) simplified to a single-pass ASCII/non-ASCII counter** — a prior version's explicit CJK-range branching resolved to the same 1 / 1.5 chars-per-token weight as the plain non-ASCII branch, so the two are now one cheaper pass with identical results.
20
+ - **TUI gradient rendering is time-quantized and memoized.** `applyDimensionalGradient` (`src/tui/components/evolution.ts`) now buckets its animation clock to 225ms steps (20 discrete phases over the 4.5s cycle) and caches up to 16 recent `(bucket, style, text)` results, so idle ticks that land in the same bucket reuse the prior truecolor escape sequence instead of re-computing and re-printing ~2-4KB of ANSI on every tick.
21
+ - **`wrapTextWithAnsi` (`src/tui/components/width.ts`) gained a plain-ASCII fast path** that slices fixed-width chunks directly instead of calling `visibleWidth` repeatedly over the remaining text — the common (no ANSI, no wide/combining characters) wrapping case drops from quadratic O(L²/cols) to linear O(L).
22
+ - **`LaunchTui` (`src/tui/app.ts`) caches wrapped history lines** (keyed by line-array identity + width) instead of re-wrapping the entire scrollback history on every render tick; `detectColorLevel` is now resolved once in the constructor instead of being re-parsed from `process.env` 2-4 times per draw; the Edit-tool live-output view is sanitized only over its bounded visible tail instead of the full cumulative buffer.
23
+
24
+ ### Fixed
25
+ - **Deep-interview no longer loops on a re-asked question.** `runDeepInterviewEngine` (`src/commands/deep-interview.ts`) now normalizes each candidate follow-up question (case, whitespace, trailing punctuation) and tracks repeats: the first time the model re-asks the same question it gets one corrective bounce (steered toward a new gap or dimension, without re-prompting the user); if it repeats again after the correction, the interview stops instead of burning through the remaining rounds on a question that already has an answer. The Socratic system prompt also gains an explicit "never re-ask an answered question" rule, and the `[HOLD]` acceptance-criteria follow-up path runs through the same guard — an `--auto` run that previously burned all 10 rounds (10 LLM calls) on one repeated question now stops after 3.
26
+ - **`jeo --tmux` no longer renders its first frames at 80x24 (gajae-code #1376 parity).** A detached `tmux new-session` defaults to 80x24 until a client attaches, so the inner jeo's banner/status frames were drawn mis-wrapped into the pane scrollback before the attach resized the window. The launch path now sizes the detached session to the caller terminal (`new-session -d -x <cols> -y <rows>` via new `callerTmuxTerminalSize`/`tmuxNewSessionSizeArgs` helpers in `src/commands/launch/tmux.ts`) and re-asserts the dimensions with a best-effort `resize-window` right before attach. Unknown/invalid dimensions or a non-TTY stdout skip sizing entirely.
27
+
28
+ ### Verified
29
+ - `bun run typecheck` clean; full suite green (batched run: 1138/1138 across 140 files with no failures, remainder consistent with prior releases).
30
+ - New/updated coverage: `test/deep-interview.test.ts` (+4 cases for the repeat-question guard, including an alternating-question case that confirms non-repeating follow-ups never trip the guard) and `test/tmux.test.ts` (+2 cases for caller-terminal size resolution and `-x/-y` arg construction).
31
+
32
+ ## [0.7.30] - 2026-07-02
33
+ _GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs._
34
+
35
+ ### Fixed
36
+ - **GPT/Codex OAuth no longer sends `max_output_tokens` to the ChatGPT/Codex backend.** `0.7.28` moved the computed output cap onto the shared Responses payload, but the OAuth Codex endpoint rejects that public-API-only parameter with `HTTP 400 {"detail":"Unsupported parameter: max_output_tokens"}`. The cap is now applied only on the public `/v1/responses` API-key path; OAuth requests keep using the Codex backend shape that `jeo doctor` probes.
37
+ - **`JEO_TURN_MAX_MS` is now a stall budget, not an absolute kill switch.** The old implementation measured 30 minutes from turn start and could terminate a long autonomous run even while it was still executing tools and making progress. The clock now resets on every executed tool step and on mid-turn steering, so it only stops no-progress loops such as refusal backoff spins, endless provider retries, or bounce loops. The existing step hard cap and repeat/cycle/failure guards still bound genuinely long runs.
38
+
39
+ ### Verified
40
+ - `bun test` green — 2147 pass / 0 fail across 244 files; `bun run typecheck` clean.
41
+ - Live GPT/Codex OAuth smoke tests through the installed `jeo` symlink: `jeo chat "Say exactly: hello" --model gpt-5.5` and `jeo chat "Reply with one word: pong" --model gpt-5.5` both returned the requested text. Added/updated regression coverage in `test/codex-responses.test.ts` and `test/cycle-and-turn-budget.test.ts`.
42
+
9
43
  ## [0.7.29] - 2026-07-02
10
44
  _gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder._
11
45
 
package/README.ja.md CHANGED
@@ -16,7 +16,7 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
- <img src="assets/character.png" alt="jeo-code キャラクターマスコット" width="320" />
19
+ <img src="assets/character.gif" alt="動く jeo-code 赤いザリガニマスコット" width="320" />
20
20
  </p>
21
21
 
22
22
  <p align="center">
@@ -205,11 +205,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
205
205
  ## 変更履歴 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
209
+ - **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
208
210
  - **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
209
211
  - **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
210
212
  - **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
211
- - **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
212
- - **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -16,7 +16,7 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
- <img src="assets/character.png" alt="jeo-code 캐릭터 마스코트" width="320" />
19
+ <img src="assets/character.gif" alt="움직이는 jeo-code 붉은 가재 마스코트" width="320" />
20
20
  </p>
21
21
 
22
22
  <p align="center">
@@ -205,11 +205,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
205
205
  ## 변경 이력 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
209
+ - **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
208
210
  - **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
209
211
  - **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
210
212
  - **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
211
- - **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
212
- - **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
- <img src="assets/character.png" alt="jeo-code character mascot" width="320" />
19
+ <img src="assets/character.gif" alt="animated jeo-code red crayfish mascot" width="320" />
20
20
  </p>
21
21
 
22
22
  <p align="center">
@@ -232,11 +232,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
232
232
  ## Changelog
233
233
 
234
234
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
235
+ - **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
236
+ - **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
235
237
  - **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
236
238
  - **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
237
239
  - **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
238
- - **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
239
- - **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
240
240
 
241
241
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
242
242
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -16,7 +16,7 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
- <img src="assets/character.png" alt="jeo-code 吉祥物" width="320" />
19
+ <img src="assets/character.gif" alt="动态 jeo-code 红色小龙虾吉祥物" width="320" />
20
20
  </p>
21
21
 
22
22
  <p align="center">
@@ -205,11 +205,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
205
205
  ## 更新日志 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
209
+ - **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
208
210
  - **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
209
211
  - **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
210
212
  - **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
211
- - **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
212
- - **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.29",
3
+ "version": "0.7.31",
4
4
 
5
5
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
6
6
  "type": "module",
@@ -40,27 +40,14 @@ export const DEFAULT_MAX_TOKENS = 30_000;
40
40
  export const DEFAULT_SUMMARY_INPUT_TOKENS = 20_000;
41
41
 
42
42
  export function estimateTokens(text: string): number {
43
- let tokens = 0;
43
+ // ASCII ≈ 4 chars/token; everything else (CJK, Hangul, emoji, …) ≈ 1.5 chars/token.
44
+ // A previous version listed the CJK ranges explicitly, but BOTH branches resolved
45
+ // to the same 1/1.5 weight — two counters keep the same result in one cheap pass.
46
+ let ascii = 0;
44
47
  for (let i = 0; i < text.length; i++) {
45
- const code = text.charCodeAt(i);
46
- if (code <= 127) {
47
- tokens += 0.25;
48
- } else if (
49
- (code >= 0xac00 && code <= 0xd7a3) || // 한글 가~힣
50
- (code >= 0x1100 && code <= 0x11ff) || // 한글 자모
51
- (code >= 0x3130 && code <= 0x318f) || // 한글 호환 자모
52
- (code >= 0x4e00 && code <= 0x9fff) || // CJK 통합 한자
53
- (code >= 0x3400 && code <= 0x4dbf) || // CJK 통합 한자 확장 A
54
- (code >= 0x3040 && code <= 0x309f) || // 히라가나
55
- (code >= 0x30a0 && code <= 0x30ff) || // 가타카나
56
- (code >= 0xff00 && code <= 0xffef) // 전각 문자
57
- ) {
58
- tokens += 1 / 1.5;
59
- } else {
60
- tokens += 1 / 1.5; // Default CJK-like weight for non-ASCII
61
- }
48
+ if (text.charCodeAt(i) <= 127) ascii++;
62
49
  }
63
- return tokens;
50
+ return ascii * 0.25 + (text.length - ascii) / 1.5;
64
51
  }
65
52
 
66
53
  /** Rough per-image vision-token cost (provider median for a clipboard screenshot).
@@ -286,10 +286,12 @@ export interface AgentLoopResult {
286
286
  }
287
287
 
288
288
 
289
- /** Wall-clock budget for ONE agent turn (ms). JEO_TURN_MAX_MS overrides; 0 disables.
290
- * Default 30 minutes: long autonomous runs stay alive, while a turn that spins in
291
- * "thinking" (huge contexts, endless extensions) is guaranteed to terminate into
292
- * the consolidation wrap-up instead of running for hours. */
289
+ /** Wall-clock STALL budget for ONE agent turn (ms): the maximum time a turn may run
290
+ * WITHOUT PROGRESS (no executed tool step). JEO_TURN_MAX_MS overrides; 0 disables.
291
+ * Default 30 minutes: long autonomous runs that keep executing tools stay alive
292
+ * indefinitely (the step budget's hard cap bounds them), while a turn that spins in
293
+ * "thinking" (refusal backoff, endless provider retries) is guaranteed to terminate
294
+ * into the consolidation wrap-up instead of running for hours. */
293
295
  export function turnMaxMs(env: Record<string, string | undefined> = process.env): number {
294
296
  const raw = jeoEnv("TURN_MAX_MS", env);
295
297
  if (raw !== undefined && raw !== "") {
@@ -364,12 +366,18 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
364
366
  const ev = opts.events ?? {};
365
367
  const maxHistoryTokens = Math.max(10_000, opts.maxHistoryTokens ?? 80_000);
366
368
 
367
- // Wall-clock turn budget — the definitive "never sits in thinking forever"
368
- // guarantee. Step budgets bound the COUNT of model calls; this bounds their total
369
- // TIME: a turn that crosses it stops at the next loop boundary and consolidates a
370
- // wrap-up instead of spinning for hours under a generous dynamic step cap.
369
+ // Wall-clock STALL budget — the definitive "never sits in thinking forever"
370
+ // guarantee. Step budgets bound the COUNT of model calls; this bounds the time a
371
+ // turn may spend WITHOUT PROGRESS (no executed tool step): refusal-backoff spins,
372
+ // endless provider retries, and bounce loops terminate into the consolidation
373
+ // wrap-up, while a long-running turn that keeps executing tools stays alive —
374
+ // its termination is owned by the step budget's hard cap and the spin guards.
375
+ // Field regression this replaces: an ABSOLUTE 30m budget measured from turn start
376
+ // killed genuinely progressing autonomous runs mid-work.
371
377
  const turnStartedAt = Date.now();
372
378
  const turnBudgetMs = turnMaxMs();
379
+ // Reset on every executed tool step and on mid-turn steering.
380
+ let lastProgressAt = turnStartedAt;
373
381
  // "steps" | "time" — drives honest wording in the consolidation message.
374
382
  let stopKind: "steps" | "time" = "steps";
375
383
  let step = 1;
@@ -449,7 +457,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
449
457
  // ladder is exhausted the loop keeps resending with capped exponential backoff
450
458
  // instead of surfacing a terminal error (gjc parity: a refusal is retried until
451
459
  // it clears or the user cancels — Esc aborts the backoff wait via opts.signal,
452
- // and the turn wall-clock budget still bounds the whole turn).
460
+ // and the stall budget bounds the spin: refusals execute no tools, so the
461
+ // no-progress clock keeps ticking and terminates the turn at JEO_TURN_MAX_MS).
453
462
  const MAX_REFUSAL_RETRIES = GUARD_LIMITS.MAX_REFUSAL_RETRIES;
454
463
  let refusalRetries = 0;
455
464
  let lastSig = "";
@@ -475,9 +484,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
475
484
  // schemas ONCE instead of rebuilding/reallocating the list on every step.
476
485
  const turnToolSchemas = nativeToolSchemasFor(Object.keys(tools));
477
486
  while (true) {
478
- if (turnBudgetMs > 0 && Date.now() - turnStartedAt > turnBudgetMs) {
487
+ if (turnBudgetMs > 0 && Date.now() - lastProgressAt > turnBudgetMs) {
479
488
  stopKind = "time";
480
- budgetStopReason = `turn wall-clock budget of ${Math.round(turnBudgetMs / 60_000)}m exceeded (JEO_TURN_MAX_MS) without done`;
489
+ budgetStopReason = `no tool progress for ${Math.round(turnBudgetMs / 60_000)}m — turn stall budget (JEO_TURN_MAX_MS) exceeded without done`;
481
490
  break;
482
491
  }
483
492
  if (step > budget.limit()) {
@@ -515,6 +524,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
515
524
  consecutiveFailures = 0;
516
525
  recentStepSigs.length = 0;
517
526
  budget.noteSteer?.();
527
+ lastProgressAt = Date.now(); // fresh instruction = fresh progress window
518
528
  }
519
529
  }
520
530
 
@@ -535,14 +545,39 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
535
545
  // (a TUI). Non-interactive/test callers leave onModelStream unset → a single
536
546
  // non-streaming call(), unchanged. The accumulated text is still parsed as one JSON
537
547
  // tool call below, so streaming changes nothing about loop semantics.
548
+ //
549
+ // Emission throttle (~80ms, mirroring the bash live sink): every delta still
550
+ // ACCUMULATES, but re-emitting the full growing buffer per token made the handoff
551
+ // O(len²) over a long response — each emit flattens the rope and the TUI re-scans
552
+ // its head. Timer-less by design; flushStreams() below guarantees the consumers'
553
+ // LAST snapshot is the complete text before it is parsed/committed.
554
+ const STREAM_EMIT_MS = 80;
538
555
  let streamBuf = "";
556
+ let lastModelEmit = 0;
539
557
  const onToken = ev.onModelStream
540
- ? (delta: string) => { streamBuf += delta; ev.onModelStream!(streamBuf); }
558
+ ? (delta: string) => {
559
+ streamBuf += delta;
560
+ const now = Date.now();
561
+ if (now - lastModelEmit >= STREAM_EMIT_MS) { lastModelEmit = now; ev.onModelStream!(streamBuf); }
562
+ }
541
563
  : undefined;
542
564
  let reasonBuf = "";
565
+ let lastReasonEmit = 0;
543
566
  const onReasoning = ev.onReasoningStream
544
- ? (delta: string) => { reasonBuf += delta; ev.onReasoningStream!(reasonBuf); }
567
+ ? (delta: string) => {
568
+ reasonBuf += delta;
569
+ const now = Date.now();
570
+ if (now - lastReasonEmit >= STREAM_EMIT_MS) { lastReasonEmit = now; ev.onReasoningStream!(reasonBuf); }
571
+ }
545
572
  : undefined;
573
+ // The TUI commits its thought/plan blocks from the LAST received snapshot
574
+ // (onAssistant); emit the complete buffers once so a throttled trailing delta
575
+ // can never be dropped from the committed record. Re-emitting an identical
576
+ // snapshot is idempotent for the consumers (they diff before drawing).
577
+ const flushStreams = () => {
578
+ if (streamBuf) ev.onModelStream?.(streamBuf);
579
+ if (reasonBuf) ev.onReasoningStream?.(reasonBuf);
580
+ };
546
581
  // Capture provider-native reasoning ARTIFACTS for replay (always — independent of any
547
582
  // TUI display sink). Stays scoped to THIS step so a later consolidation push can't
548
583
  // inherit a prior step's signatures.
@@ -618,8 +653,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
618
653
  // backoff instead of surfacing a terminal error (gjc parity: gjc's
619
654
  // session auto-retry classifies a refusal as retryable and never ends
620
655
  // the turn on it). Esc/cancel aborts the wait via opts.signal and the
621
- // loop-top check turns it into "Cancelled."; JEO_TURN_MAX_MS still
622
- // bounds the whole turn.
656
+ // loop-top check turns it into "Cancelled."; the JEO_TURN_MAX_MS stall
657
+ // budget still bounds the spin (a refusal loop executes no tools, so
658
+ // the no-progress clock never resets).
623
659
  if (isRefusalError(err)) {
624
660
  refusalRetries++;
625
661
  if (refusalRetries === 1) {
@@ -676,6 +712,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
676
712
  // separate error event here printed the same message twice (live stream + reply).
677
713
  return finish({ done: false, steps: step, doneReason: `Error: ${message}` });
678
714
  }
715
+ flushStreams(); // deliver the final (possibly throttled-out) snapshot before parse/commit
679
716
  if (sawUsage) ev.onUsage?.({ ...acc });
680
717
 
681
718
  let invocation: any;
@@ -826,10 +863,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
826
863
  // after a SUCCESSFUL write because nothing ever told the model to stop
827
864
  // repeating — a recovery prompt resolves that without killing the turn.
828
865
  // - 3rd identical step (repeated through the explicit correction) → stop.
829
- const callSigs = toolCalls.map(c => `${c.tool}:${JSON.stringify(c.arguments ?? {})}`);
830
- // Fixed-size digest of the whole step — `write` signatures embed entire file
831
- // bodies, so the repeat/cycle guards compare digests, not megabyte strings.
832
- const sig = hashSignature(callSigs.join(" | "));
866
+ // Per-call fixed-size digests — `write` arguments embed entire file bodies, so
867
+ // every guard below compares/records digests, never megabyte strings. Hashing
868
+ // once per call here (instead of joining full sigs, hashing, then re-hashing
869
+ // inside budget.record) avoids re-scanning a large write body 3× per step.
870
+ const callSigs = toolCalls.map(c => hashSignature(`${c.tool}:${JSON.stringify(c.arguments ?? {})}`));
871
+ const sig = callSigs.length === 1 ? callSigs[0] : hashSignature(callSigs.join(" | "));
833
872
  if (sig === lastSig) repeatCount++;
834
873
  else {
835
874
  repeatCount = 1;
@@ -1133,6 +1172,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1133
1172
  // repeated call's ACTUAL last outcome (A). A skipped/bounced step never reaches
1134
1173
  // here, so this always holds the last REAL execution's results.
1135
1174
  lastResults = results;
1175
+ // Executed tool step = progress: the stall budget clocks time WITHOUT this.
1176
+ if (results.some(r => r.executed)) lastProgressAt = Date.now();
1136
1177
  step++;
1137
1178
  }
1138
1179
 
@@ -1143,7 +1184,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1143
1184
  const extInfo = budget.extensionsUsed() > 0 ? ` after ${budget.extensionsUsed()} extension(s)` : "";
1144
1185
  const stopInfo = budgetStopReason ? `; ${budgetStopReason}` : "";
1145
1186
  const budgetLabel = stopKind === "time"
1146
- ? `turn time budget of ${Math.round(turnBudgetMs / 60_000)}m reached`
1187
+ ? `turn time budget of ${Math.round(turnBudgetMs / 60_000)}m reached (no tool progress)`
1147
1188
  : `step budget of ${budget.limit()} reached`;
1148
1189
  try {
1149
1190
  if (!opts.signal?.aborted) {
@@ -39,6 +39,8 @@ export interface ConceptGraph {
39
39
  edges: Map<string, Set<string>>;
40
40
  /** from-ID → set of link targets that have NO node (tolerated broken links). */
41
41
  broken: Map<string, Set<string>>;
42
+ /** ponytail: cached undirected adjacency for O(1) retrieval during graph expansion */
43
+ adj?: Map<string, Set<string>>;
42
44
  }
43
45
 
44
46
  /** A markdown inline link `](target)` — captures the target, not the label. */
@@ -115,7 +117,10 @@ function undirectedAdjacency(graph: ConceptGraph): Map<string, Set<string>> {
115
117
  * nodes are dropped. With `hops` 0 this is just the valid seeds.
116
118
  */
117
119
  export function expandByGraph(seedIds: Iterable<string>, graph: ConceptGraph, hops = 1): Set<string> {
118
- const adj = undirectedAdjacency(graph);
120
+ if (!graph.adj) {
121
+ graph.adj = undirectedAdjacency(graph);
122
+ }
123
+ const adj = graph.adj;
119
124
  const result = new Set<string>();
120
125
  for (const id of seedIds) if (graph.nodes.has(id)) result.add(id);
121
126
  let frontier = [...result];
@@ -251,57 +251,58 @@ export async function lintMemoryBundle(cwd: string): Promise<GraphLintReport> {
251
251
  return lintConceptGraph(concepts, buildConceptGraph(concepts));
252
252
  }
253
253
 
254
+ const CONCEPT_LOAD_BATCH = 16;
255
+
254
256
  async function loadConceptsFromBundle(bundleDir: string): Promise<Concept[]> {
255
257
  const files = await findMarkdownFiles(bundleDir);
256
- const concepts: Concept[] = [];
257
- for (const file of files) {
258
+ const loadOne = async (file: string): Promise<Concept | null> => {
258
259
  const relPath = path.relative(bundleDir, file).replace(/\\/g, "/");
259
- if (isReservedFile(relPath)) continue;
260
+ if (isReservedFile(relPath)) return null;
260
261
 
261
- // Skip the read+parse when the file is byte-for-byte unchanged since we last
262
- // saw it (same mtime+size). A missing stat falls through to a fresh read.
263
262
  let sig: string | null = null;
264
263
  try {
265
264
  const st = await fs.stat(file);
266
265
  sig = `${st.mtimeMs}:${st.size}`;
267
266
  } catch {
268
- continue;
267
+ return null;
269
268
  }
270
269
  const cached = parsedConceptCache.get(file);
271
270
  if (cached && cached.sig === sig) {
272
- // LRU refresh so a hot file is evicted last.
273
271
  parsedConceptCache.delete(file);
274
272
  parsedConceptCache.set(file, cached);
275
- if (cached.concept) concepts.push(cached.concept);
276
- continue;
273
+ return cached.concept;
277
274
  }
278
275
 
279
- let parsed;
280
276
  try {
281
- parsed = parseConcept(await fs.readFile(file, "utf-8"));
277
+ const parsed = parseConcept(await fs.readFile(file, "utf-8"));
278
+ if (!parsed.hasFrontmatter) {
279
+ cacheParsedConcept(file, sig, null);
280
+ return null;
281
+ }
282
+ const fm = parsed.frontmatter;
283
+ const concept: Concept = {
284
+ type: (fm.type as string) || "RepoFact",
285
+ title: (fm.title as string) || path.basename(file, ".md"),
286
+ description: (fm.description as string) || "",
287
+ body: parsed.body.trim(),
288
+ tags: Array.isArray(fm.tags) ? fm.tags.filter((t): t is string => typeof t === "string") : [],
289
+ confidence: typeof fm.confidence === "string" ? fm.confidence : "high",
290
+ pinned: fm.pinned === true,
291
+ relPath,
292
+ };
293
+ cacheParsedConcept(file, sig, concept);
294
+ return concept;
282
295
  } catch {
283
296
  cacheParsedConcept(file, sig, null);
284
- continue;
297
+ return null;
285
298
  }
286
- if (!parsed.hasFrontmatter) {
287
- cacheParsedConcept(file, sig, null);
288
- continue;
289
- }
290
- const fm = parsed.frontmatter;
291
- const concept: Concept = {
292
- type: (fm.type as string) || "RepoFact",
293
- title: (fm.title as string) || path.basename(file, ".md"),
294
- description: (fm.description as string) || "",
295
- body: parsed.body.trim(),
296
- tags: Array.isArray(fm.tags) ? fm.tags.filter((t): t is string => typeof t === "string") : [],
297
- confidence: typeof fm.confidence === "string" ? fm.confidence : "high",
298
- pinned: fm.pinned === true,
299
- relPath,
300
- };
301
- cacheParsedConcept(file, sig, concept);
302
- concepts.push(concept);
299
+ };
300
+ const results: Concept[] = [];
301
+ for (let i = 0; i < files.length; i += CONCEPT_LOAD_BATCH) {
302
+ const batch = await Promise.all(files.slice(i, i + CONCEPT_LOAD_BATCH).map(loadOne));
303
+ for (const c of batch) if (c) results.push(c);
303
304
  }
304
- return concepts;
305
+ return results;
305
306
  }
306
307
 
307
308
  /** Store a per-file parse result under its stat signature, evicting the oldest
@@ -329,12 +330,35 @@ export interface CorpusStats {
329
330
  df: Map<string, number>;
330
331
  }
331
332
 
333
+ interface LowercasedConceptFields {
334
+ title: string;
335
+ description: string;
336
+ body: string;
337
+ type: string;
338
+ tagsText: string;
339
+ searchableText: string;
340
+ }
341
+ const lowercasedFieldsCache = new WeakMap<Concept, LowercasedConceptFields>();
342
+
343
+ function getLowercasedFields(concept: Concept): LowercasedConceptFields {
344
+ let cached = lowercasedFieldsCache.get(concept);
345
+ if (!cached) {
346
+ const title = concept.title.toLowerCase();
347
+ const description = concept.description.toLowerCase();
348
+ const body = concept.body.toLowerCase();
349
+ const type = concept.type.toLowerCase();
350
+ const tagsText = concept.tags.map(t => t.toLowerCase()).join(" ");
351
+ const searchableText = [title, tagsText, type, description, body].join(" ");
352
+ cached = { title, description, body, type, tagsText, searchableText };
353
+ lowercasedFieldsCache.set(concept, cached);
354
+ }
355
+ return cached;
356
+ }
357
+
332
358
  /** Lowercased searchable text of a concept (title + tags + type + description +
333
359
  * body) — the field corpus over which document frequency is measured. */
334
360
  function searchableText(concept: Concept): string {
335
- return [concept.title, concept.tags.join(" "), concept.type, concept.description, concept.body]
336
- .join(" ")
337
- .toLowerCase();
361
+ return getLowercasedFields(concept).searchableText;
338
362
  }
339
363
 
340
364
  /** Build IDF corpus stats from the concept set: per distinct token, how many
@@ -372,19 +396,15 @@ function tokenIdf(token: string, stats: CorpusStats): number {
372
396
  * field-weighted presence count (IDF = 1). 0 = no hit. */
373
397
  export function scoreConcept(concept: Concept, tokens: string[], stats?: CorpusStats): number {
374
398
  if (tokens.length === 0) return 0;
375
- const title = concept.title.toLowerCase();
376
- const desc = concept.description.toLowerCase();
377
- const body = concept.body.toLowerCase();
378
- const type = concept.type.toLowerCase();
379
- const tags = concept.tags.map(t => t.toLowerCase());
399
+ const fields = getLowercasedFields(concept);
380
400
  let score = 0;
381
401
  for (const t of tokens) {
382
402
  let weight = 0;
383
- if (title.includes(t)) weight += 5;
384
- if (tags.some(tag => tag.includes(t))) weight += 3;
385
- if (type.includes(t)) weight += 2;
386
- if (desc.includes(t)) weight += 2;
387
- if (body.includes(t)) weight += 1;
403
+ if (fields.title.includes(t)) weight += 5;
404
+ if (fields.tagsText.includes(t)) weight += 3;
405
+ if (fields.type.includes(t)) weight += 2;
406
+ if (fields.description.includes(t)) weight += 2;
407
+ if (fields.body.includes(t)) weight += 1;
388
408
  score += stats ? weight * tokenIdf(t, stats) : weight;
389
409
  }
390
410
  return score;
@@ -462,10 +482,10 @@ function graphProximityOrder(concepts: Concept[], seedIds: string[], graph: Conc
462
482
  * final tiebreak. The failure boost only fires when the query actually hits the
463
483
  * concept (score > 0), so an unrelated FailedAttempt never crowds out relevant
464
484
  * context. */
465
- function priorityOrder(concepts: Concept[], query?: string): Concept[] {
485
+ function priorityOrder(concepts: Concept[], query?: string, stats?: CorpusStats, graph?: ConceptGraph): Concept[] {
466
486
  const tokens = tokenize(query);
467
- const stats = buildCorpusStats(concepts);
468
- const scored = concepts.map(c => ({ c, id: conceptId(c.relPath), score: scoreConcept(c, tokens, stats) }));
487
+ const localStats = stats ?? buildCorpusStats(concepts);
488
+ const scored = concepts.map(c => ({ c, id: conceptId(c.relPath), score: scoreConcept(c, tokens, localStats) }));
469
489
 
470
490
  // Channel 1 — lexical ("sparse"/BM25): query-hit concepts, IDF score descending.
471
491
  // Stable sort keeps input order on ties.
@@ -474,7 +494,8 @@ function priorityOrder(concepts: Concept[], query?: string): Concept[] {
474
494
  // Channel 2 — graph proximity ("dense"): neighbours of the query-hit seeds.
475
495
  let graphOrder: string[] = [];
476
496
  if (tokens.length > 0 && lexicalOrder.length > 0) {
477
- graphOrder = graphProximityOrder(concepts, lexicalOrder, buildConceptGraph(concepts));
497
+ const localGraph = graph ?? buildConceptGraph(concepts);
498
+ graphOrder = graphProximityOrder(concepts, lexicalOrder, localGraph);
478
499
  }
479
500
 
480
501
  const fused = reciprocalRankFusion([lexicalOrder, graphOrder]);
@@ -483,9 +504,6 @@ function priorityOrder(concepts: Concept[], query?: string): Concept[] {
483
504
  .map(({ c, id, score }, i) => ({
484
505
  concept: c,
485
506
  i,
486
- // Query-relevant past failure → resurface first so the loop is reminded of
487
- // what NOT to repeat for the current task. Gated on score > 0: a stale,
488
- // unrelated dead end stays out of the way.
489
507
  failure: c.type === "FailedAttempt" && score > 0,
490
508
  core: c.confidence === "high",
491
509
  rrf: fused.get(id) ?? 0,
@@ -554,8 +572,11 @@ function selectWithinBudget(concepts: Concept[], query: string | undefined, budg
554
572
  const sectionLenByType = new Map<string, number>(); // type -> rendered length of that type's section so far
555
573
  let total = 0; // rendered length of `selected` under renderConcepts
556
574
 
575
+ const stats = buildCorpusStats(concepts);
576
+ const graph = buildConceptGraph(concepts);
577
+
557
578
  const take = (pool: Concept[]) => {
558
- for (const c of priorityOrder(pool, query)) {
579
+ for (const c of priorityOrder(pool, query, stats, graph)) {
559
580
  const itemLen = renderConceptItem(c).length;
560
581
  const priorSectionLen = sectionLenByType.get(c.type);
561
582
  let newSectionLen: number;
@@ -579,7 +600,7 @@ function selectWithinBudget(concepts: Concept[], query: string | undefined, budg
579
600
  };
580
601
  take(concepts.filter(c => c.pinned)); // reserved budget: invariants survive the cap
581
602
  take(concepts.filter(c => !c.pinned)); // recency/query fill in the remaining budget
582
- if (selected.length === 0 && concepts.length > 0) selected.push(priorityOrder(concepts, query)[0]!);
603
+ if (selected.length === 0 && concepts.length > 0) selected.push(priorityOrder(concepts, query, stats, graph)[0]!);
583
604
  return selected;
584
605
  }
585
606
 
@@ -599,22 +599,50 @@ export async function editTool(
599
599
  * can cancel() to force the in-flight read to settle — essential when a SIGKILLed
600
600
  * process leaves a backgrounded grandchild holding the pipe's write end open, so it
601
601
  * never hits EOF (proven by scripts/subproc-probe.ts ABANDON mode). An optional
602
- * (caller-throttled) onChunk drives the live output view. */
602
+ * (caller-throttled) onChunk drives the live output view.
603
+ *
604
+ * Memory bound: `bashTool` truncates the final output to MAX_OUTPUT chars anyway, so
605
+ * accumulating past the cap only risked RAM (a chatty command could hold hundreds of
606
+ * MB for the whole timeout window). Once the head is full we stop growing it and keep
607
+ * a small rolling TAIL purely for the live view — the persisted result (head + cut
608
+ * notice) is byte-identical to the old behaviour. onChunk likewise receives a BOUNDED
609
+ * string (head while small, rolling tail once capped) instead of the full cumulative
610
+ * buffer, which made every downstream sanitize/wrap pass O(n²) over stream length. */
611
+ const DRAIN_HEAD_CAP = 100_000 + 4_096; // MAX_OUTPUT + slack so the final cut still fires
612
+ const DRAIN_LIVE_TAIL = 16 * 1024; // matches FRAME_WRAP_TAIL_CHARS — the TUI renders only this window
603
613
  async function drainPipe(
604
614
  r: ReadableStreamDefaultReader<Uint8Array>,
605
615
  onChunk?: (partial: string) => void,
606
616
  ): Promise<string> {
607
617
  const dec = new TextDecoder();
608
618
  let out = "";
619
+ let tail = ""; // rolling live-view window once `out` is capped
620
+ let capped = false;
609
621
  try {
610
622
  for (;;) {
611
623
  const { done, value } = await r.read();
612
624
  if (done) break;
613
- out += dec.decode(value, { stream: true });
625
+ const text = dec.decode(value, { stream: true });
626
+ if (!capped) {
627
+ out += text;
628
+ if (out.length >= DRAIN_HEAD_CAP) {
629
+ capped = true;
630
+ tail = out.slice(-DRAIN_LIVE_TAIL);
631
+ }
632
+ onChunk?.(out);
633
+ } else {
634
+ tail = (tail + text).slice(-DRAIN_LIVE_TAIL);
635
+ onChunk?.(tail);
636
+ }
637
+ }
638
+ const last = dec.decode();
639
+ if (!capped) {
640
+ out += last;
614
641
  onChunk?.(out);
642
+ } else if (last) {
643
+ tail = (tail + last).slice(-DRAIN_LIVE_TAIL);
644
+ onChunk?.(tail);
615
645
  }
616
- out += dec.decode();
617
- onChunk?.(out);
618
646
  } catch { /* cancelled reader surfaces here; return what we have */ }
619
647
  return out;
620
648
  }
@@ -521,18 +521,29 @@ export interface StreamIdleOptions {
521
521
  onIdle?: () => void;
522
522
  }
523
523
 
524
- /** `iter.next()`, racing a per-chunk idle watchdog AND (when set) the overall deadline.
525
- * The watchdog re-arms while reasoning activity (idle.lastActivityAt) keeps advancing,
526
- * so a model that streams thinking tokens for longer than idleMs before emitting visible
527
- * text is NOT mistaken for a stalled stream — only a genuinely silent stream aborts. */
528
- async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptions): Promise<IteratorResult<string>> {
529
- if (!idle) return iter.next();
530
- const next = iter.next();
531
- // Baseline: this wait began now; reasoning deltas during it move lastActivityAt forward.
532
- const startedAt = Date.now();
533
- const lastActivity = () => Math.max(startedAt, idle.lastActivityAt?.() ?? 0);
524
+ /** Handle returned by {@link idleWatchdog}: the racing promise, its cleanup, and a
525
+ * `touch()` to call after each successfully received chunk. */
526
+ export interface StreamWatchdogHandle {
527
+ promise: Promise<never>;
528
+ cleanup: () => void;
529
+ touch: () => void;
530
+ }
531
+
532
+ /** One watchdog per stream attempt. Reused across `iter.next()` calls so a busy
533
+ * stream does not allocate a Promise+timer pair per yielded chunk.
534
+ *
535
+ * Idle measurement fallback: when the caller has no `lastActivityAt` (the common
536
+ * case — most providers never wire reasoning-progress tracking), idle time MUST be
537
+ * measured against the last moment a chunk actually arrived, not against `Date.now()`
538
+ * re-sampled on every internal re-arm — the latter makes `now - lastAct` collapse to
539
+ * ~0 forever, so the idle timer can never fire and a genuinely stalled stream hangs
540
+ * forever instead of timing out. `touch()` (called by the consumer after each
541
+ * received chunk) advances this fallback baseline; `lastActivityAt`, when provided,
542
+ * always takes precedence (reasoning-progress tracking is more precise). */
543
+ function idleWatchdog(idle: StreamIdleOptions): StreamWatchdogHandle {
534
544
  let timer: ReturnType<typeof setTimeout> | undefined;
535
- const watchdog = new Promise<never>((_, reject) => {
545
+ let lastChunkAt = Date.now();
546
+ const promise = new Promise<never>((_, reject) => {
536
547
  const arm = () => {
537
548
  const now = Date.now();
538
549
  const overallRemaining = idle.deadlineAt !== undefined ? idle.deadlineAt - now : Infinity;
@@ -541,10 +552,10 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptio
541
552
  reject(new Error(`stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted`));
542
553
  return;
543
554
  }
544
- const idleRemaining = idle.idleMs - (now - lastActivity());
555
+ const lastAct = idle.lastActivityAt ? idle.lastActivityAt() : lastChunkAt;
556
+ const idleRemaining = idle.idleMs - (now - lastAct);
545
557
  const wait = Math.min(idleRemaining, overallRemaining);
546
558
  if (wait <= 0) {
547
- // overallRemaining is > 0 here (pre-checked), so wait<=0 ⟺ the idle window lapsed.
548
559
  idle.onIdle?.();
549
560
  reject(new Error(`stream idle for ${idle.idleMs}ms (no chunk) — provider sent no token within the idle window (load or long thinking); retrying. Raise JEO_STREAM_IDLE_MS or lower the thinking level if this persists.`));
550
561
  return;
@@ -553,11 +564,16 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptio
553
564
  };
554
565
  arm();
555
566
  });
556
- try {
557
- return await Promise.race([next, watchdog]);
558
- } finally {
559
- if (timer) clearTimeout(timer);
560
- }
567
+ return { promise, cleanup: () => clearTimeout(timer), touch: () => { lastChunkAt = Date.now(); } };
568
+ }
569
+
570
+ /** `iter.next()`, racing the stream-attempt watchdog AND (when set) the overall deadline.
571
+ * The watchdog re-arms while reasoning activity (idle.lastActivityAt) keeps advancing,
572
+ * so a model that streams thinking tokens for longer than idleMs before emitting visible
573
+ * text is NOT mistaken for a stalled stream — only a genuinely silent stream aborts. */
574
+ async function nextMaybeIdle(iter: AsyncIterator<string>, watchdog?: Promise<never>): Promise<IteratorResult<string>> {
575
+ if (!watchdog) return iter.next();
576
+ return Promise.race([iter.next(), watchdog]);
561
577
  }
562
578
 
563
579
  /** Opt-in overall stream wall-clock from the environment; undefined = off (default). */
@@ -590,14 +606,30 @@ export async function* retryableStream(
590
606
  retry: RetryOptions,
591
607
  idle?: StreamIdleOptions,
592
608
  ): AsyncGenerator<string> {
593
- const { iter, first } = await withRetry(async () => {
594
- const it = makeIter();
595
- const f = await nextMaybeIdle(it, idle);
596
- return { iter: it, first: f };
597
- }, retry);
598
- if (!first.done) {
599
- yield first.value;
600
- for (let n = await nextMaybeIdle(iter, idle); !n.done; n = await nextMaybeIdle(iter, idle)) yield n.value;
609
+ let cleanupWatchdog = () => {};
610
+ let touchWatchdog = () => {};
611
+ try {
612
+ const { iter, first, watchdog } = await withRetry(async () => {
613
+ cleanupWatchdog();
614
+ const it = makeIter();
615
+ const w = idle ? idleWatchdog(idle) : undefined;
616
+ cleanupWatchdog = w?.cleanup ?? (() => {});
617
+ touchWatchdog = w?.touch ?? (() => {});
618
+ const f = await nextMaybeIdle(it, w?.promise);
619
+ if (!f.done) touchWatchdog();
620
+ return { iter: it, first: f, watchdog: w?.promise };
621
+ }, retry);
622
+ if (!first.done) {
623
+ yield first.value;
624
+ for (;;) {
625
+ const n = await nextMaybeIdle(iter, watchdog);
626
+ if (n.done) break;
627
+ touchWatchdog();
628
+ yield n.value;
629
+ }
630
+ }
631
+ } finally {
632
+ cleanupWatchdog();
601
633
  }
602
634
  }
603
635
 
@@ -126,13 +126,14 @@ export function codexResponsesRequest(
126
126
  // frame can show the model's thinking instead of a frozen "calling model (Ns)…".
127
127
  payload.reasoning = { effort: options.reasoningEffort, summary: "auto" };
128
128
  }
129
- // Cap the response length (gjc parity): the computed per-call budget must reach the wire,
130
- // otherwise the backend free-runs to its own default.
131
- if (options.maxTokens) payload.max_output_tokens = options.maxTokens;
129
+ // Cap the response length (gjc parity) on the PUBLIC Responses API only. The ChatGPT/Codex
130
+ // OAuth backend 400s on this parameter ({"detail":"Unsupported parameter: max_output_tokens"}),
131
+ // so the cap is applied inside the api_key branch below.
132
132
  // OAuth → the undocumented ChatGPT/Codex backend (codex headers + account-id).
133
133
  // API key → the public OpenAI Responses API (`/v1/responses`) with a plain Bearer.
134
134
  // Both speak the same Responses schema (the body above), so only url+headers differ.
135
135
  if (credential.kind === "api_key") {
136
+ if (options.maxTokens) payload.max_output_tokens = options.maxTokens;
136
137
  const base = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
137
138
  // Stateless reasoning replay (public Responses API): ask for encrypted reasoning content
138
139
  // so it can be captured and threaded back into a later `input` (store stays false).
package/src/ai/sse.ts CHANGED
@@ -5,6 +5,7 @@ export async function* readLines(
5
5
  const reader = stream.getReader();
6
6
  const decoder = new TextDecoder("utf-8");
7
7
  let buffer = "";
8
+ let searchFrom = 0;
8
9
  try {
9
10
  while (true) {
10
11
  const { done, value } = await reader.read();
@@ -19,19 +20,33 @@ export async function* readLines(
19
20
  onActivity?.();
20
21
  }
21
22
  buffer += decoder.decode(value, { stream: true });
22
- const parts = buffer.split("\n");
23
- buffer = parts.pop() ?? "";
24
- for (const part of parts) {
25
- const line = part.endsWith("\r") ? part.slice(0, -1) : part;
23
+ let idx;
24
+ while ((idx = buffer.indexOf("\n", searchFrom)) !== -1) {
25
+ const lineWithCr = buffer.slice(searchFrom, idx);
26
+ const line = lineWithCr.endsWith("\r") ? lineWithCr.slice(0, -1) : lineWithCr;
26
27
  if (line !== "") {
27
28
  yield line;
28
29
  }
30
+ searchFrom = idx + 1;
31
+ }
32
+ if (searchFrom > 0) {
33
+ buffer = buffer.slice(searchFrom);
34
+ searchFrom = 0;
29
35
  }
30
36
  }
31
37
  buffer += decoder.decode();
32
- const parts = buffer.split("\n");
33
- for (const part of parts) {
34
- const line = part.endsWith("\r") ? part.slice(0, -1) : part;
38
+ let idx;
39
+ while ((idx = buffer.indexOf("\n", searchFrom)) !== -1) {
40
+ const lineWithCr = buffer.slice(searchFrom, idx);
41
+ const line = lineWithCr.endsWith("\r") ? lineWithCr.slice(0, -1) : lineWithCr;
42
+ if (line !== "") {
43
+ yield line;
44
+ }
45
+ searchFrom = idx + 1;
46
+ }
47
+ if (searchFrom < buffer.length) {
48
+ const lineWithCr = buffer.slice(searchFrom);
49
+ const line = lineWithCr.endsWith("\r") ? lineWithCr.slice(0, -1) : lineWithCr;
35
50
  if (line !== "") {
36
51
  yield line;
37
52
  }
@@ -19,7 +19,16 @@ const CLOSE = "</think>";
19
19
  function partialTail(s: string, tag: string): number {
20
20
  const max = Math.min(s.length, tag.length - 1);
21
21
  for (let k = max; k > 0; k--) {
22
- if (s.endsWith(tag.slice(0, k))) return k;
22
+ // Check if s.endsWith(tag.slice(0, k)) without allocating substrings.
23
+ let match = true;
24
+ const sStart = s.length - k;
25
+ for (let i = 0; i < k; i++) {
26
+ if (s.charCodeAt(sStart + i) !== tag.charCodeAt(i)) {
27
+ match = false;
28
+ break;
29
+ }
30
+ }
31
+ if (match) return k;
23
32
  }
24
33
  return 0;
25
34
  }
@@ -128,6 +128,13 @@ function freezeReadiness(parsed: SocraticResponse | undefined): { ok: boolean; r
128
128
  return { ok: true };
129
129
  }
130
130
 
131
+ /** Normalize a question for repeat detection: case-, whitespace-, and trailing-
132
+ * punctuation-insensitive so the SAME question re-asked with trivial formatting
133
+ * drift still registers as a repeat. */
134
+ function normalizeQuestion(q: string): string {
135
+ return q.toLowerCase().replace(/\s+/g, " ").replace(/[\s?.!…。?!]+$/g, "").trim();
136
+ }
137
+
131
138
  function slugify(input: string): string {
132
139
  return input
133
140
  .toLowerCase()
@@ -499,7 +506,8 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
499
506
  `}\n` +
500
507
  `Ensure ambiguityScore drops dynamically as more detail is gathered. Do not report ambiguityScore <= ${threshold} unless acceptance_criteria is populated with concrete, testable checks.\n` +
501
508
  `Do not fill an ambiguous point with your own convenient assumption to push the score down — surface that assumption as the nextQuestion instead. Never supply unstated detail that makes the request look simpler or safer than the user actually wrote it.\n` +
502
- `Conversely, when the user's answers genuinely resolve a dimension, lower the score accordingly — do not keep a dimension ambiguous just to prolong the interview. If you notice yourself quietly narrowing the user's idea to make it easier to score, that narrowing is the signal the dimension is still open: ask, don't assume.`
509
+ `Conversely, when the user's answers genuinely resolve a dimension, lower the score accordingly — do not keep a dimension ambiguous just to prolong the interview. If you notice yourself quietly narrowing the user's idea to make it easier to score, that narrowing is the signal the dimension is still open: ask, don't assume.\n` +
510
+ `Never re-ask a question that was already asked and answered — every round must target a NEW gap or a different dimension.`
503
511
  },
504
512
  {
505
513
  role: "user",
@@ -568,6 +576,12 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
568
576
  let round = 1;
569
577
  let ambiguity = state.current_ambiguity ?? 1.0;
570
578
  let lastParsed: SocraticResponse | undefined;
579
+ // Repeat-question loop guard state (deep-interview's descendant of the agent
580
+ // loop's repeat_correct/repeat_stop taxonomy in src/agent/loop-guards.ts): an
581
+ // interviewer that re-asks the SAME question cannot converge by repetition —
582
+ // it just burns rounds and, in interactive mode, the user's patience.
583
+ let lastQuestionNorm = "";
584
+ let questionRepeats = 0;
571
585
 
572
586
  const freezeSeed = async (parsed: SocraticResponse): Promise<boolean> => {
573
587
  const readiness = freezeReadiness(parsed);
@@ -655,6 +669,40 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
655
669
  nextQuestion = interviewLanguage.acceptanceFollowup;
656
670
  }
657
671
 
672
+ // Repeat-question guard: first repeat gets ONE corrective bounce (the model
673
+ // is told the question was already answered and steered to a new gap, WITHOUT
674
+ // re-asking the user); a repeat that survives the correction stops this run
675
+ // instead of looping through the remaining rounds on the same question.
676
+ const qNorm = normalizeQuestion(nextQuestion);
677
+ if (qNorm && qNorm === lastQuestionNorm) {
678
+ questionRepeats++;
679
+ } else {
680
+ lastQuestionNorm = qNorm;
681
+ questionRepeats = 0;
682
+ }
683
+ if (questionRepeats === 1) {
684
+ log(`\n[REPEAT] The interviewer re-asked the same question — nudging it toward a new gap instead of re-asking you.`);
685
+ history.push({ role: "assistant", content: responseText });
686
+ history.push({
687
+ role: "user",
688
+ content:
689
+ `You already asked exactly this question and it was answered above — do not ask it again. ` +
690
+ `Ask a DIFFERENT question that targets the weakest unresolved dimension; or, if the prior answers genuinely resolve it, ` +
691
+ `lower ambiguityScore and populate goal/constraints/acceptance_criteria from what was already said. ` +
692
+ `Reply in the same strict JSON format.`,
693
+ });
694
+ round++;
695
+ continue;
696
+ }
697
+ if (questionRepeats >= 2) {
698
+ log(
699
+ `\n[LOOP GUARD] The interview is stuck re-asking the same question despite a corrective nudge — ` +
700
+ `stopping this run instead of looping. No progress is possible by repetition; ` +
701
+ `resume with 'jeo deep-interview' (adding more detail to the idea helps) to continue.`,
702
+ );
703
+ break;
704
+ }
705
+
658
706
  log(`\nQuestion: ${nextQuestion}`);
659
707
  if (opts.signal?.aborted) {
660
708
  return { ok: false, reason: "aborted" };
@@ -94,6 +94,38 @@ export function shellQuote(arg: string): string {
94
94
  return `'${arg.replace(/'/g, `'\\''`)}'`;
95
95
  }
96
96
 
97
+ /** Caller-terminal dimensions for a detached `tmux new-session` (gjc launch-tmux parity #1376). */
98
+ export interface TmuxTerminalSize {
99
+ columns: number;
100
+ rows: number;
101
+ }
102
+
103
+ function normalizeTmuxDimension(value: number | undefined): number | undefined {
104
+ return value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : undefined;
105
+ }
106
+
107
+ /**
108
+ * Resolve the caller terminal's size so a detached `tmux new-session` is created at
109
+ * the REAL terminal dimensions instead of tmux's 80x24 detached default. Without this
110
+ * the inner jeo renders its first frames (banner, status footer) at 80 columns, and
111
+ * that mis-wrapped output stays wrong in the pane scrollback even after the attach
112
+ * resizes the window. Returns undefined when stdout is not a TTY or either dimension
113
+ * is unknown/invalid — callers then omit sizing and let tmux use its default.
114
+ */
115
+ export function callerTmuxTerminalSize(
116
+ out: { isTTY?: boolean; columns?: number; rows?: number } = process.stdout,
117
+ ): TmuxTerminalSize | undefined {
118
+ if (!out.isTTY) return undefined;
119
+ const columns = normalizeTmuxDimension(out.columns);
120
+ const rows = normalizeTmuxDimension(out.rows);
121
+ return columns !== undefined && rows !== undefined ? { columns, rows } : undefined;
122
+ }
123
+
124
+ /** `-x/-y` args for `new-session`; empty when the size is unknown. */
125
+ export function tmuxNewSessionSizeArgs(size: TmuxTerminalSize | undefined): string[] {
126
+ return size ? ["-x", String(size.columns), "-y", String(size.rows)] : [];
127
+ }
128
+
97
129
 
98
130
 
99
131
 
@@ -126,6 +126,9 @@ import {
126
126
  resolveWorktree,
127
127
  shellQuote,
128
128
  reapStaleTmuxSessions,
129
+ callerTmuxTerminalSize,
130
+ tmuxNewSessionSizeArgs,
131
+ type TmuxTerminalSize,
129
132
  type TmuxCreateResult,
130
133
  type TmuxProfileCommand,
131
134
  } from "./launch/tmux";
@@ -218,6 +221,9 @@ export {
218
221
  resolveWorktree,
219
222
  shellQuote,
220
223
  reapStaleTmuxSessions,
224
+ callerTmuxTerminalSize,
225
+ tmuxNewSessionSizeArgs,
226
+ type TmuxTerminalSize,
221
227
  type TmuxCreateResult,
222
228
  type TmuxProfileCommand,
223
229
  type InFlightAbortHarness,
@@ -390,8 +396,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
390
396
  const innerCmd = `exec env JEO_TMUX_LAUNCHED=1 ${[...cmd, "launch", ...innerArgs].map(shellQuote).join(" ")}`;
391
397
 
392
398
  // Create a fresh, independent session (race-safe: the create is the guard).
399
+ // Size the DETACHED session to the caller terminal (gjc launch-tmux parity
400
+ // #1376): without -x/-y tmux creates it 80x24 and the inner jeo's first
401
+ // frames render mis-wrapped into the pane scrollback before attach resizes.
402
+ const termSize = callerTmuxTerminalSize();
393
403
  const alloc = allocateTmuxSession(sessionBase, name => {
394
- const created = Bun.spawnSync([tmuxBin, "new-session", "-d", "-s", name, "-c", cwd, innerCmd]);
404
+ const created = Bun.spawnSync([tmuxBin, "new-session", "-d", ...tmuxNewSessionSizeArgs(termSize), "-s", name, "-c", cwd, innerCmd]);
395
405
  if (created.exitCode === 0) return "ok";
396
406
  const err = created.stderr.toString().trim();
397
407
  if (/duplicate session/i.test(err)) return "taken"; // another jeo grabbed this name
@@ -429,6 +439,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
429
439
  : `Starting new independent tmux session: ${sessionName} (another live jeo session already owns ${sessionBase}; reattach later with: tmux attach -t ${sessionName})`,
430
440
  );
431
441
 
442
+ // Re-assert the caller dimensions right before attach (best-effort): profile
443
+ // commands or a slow first frame can land while the window is still detached.
444
+ if (termSize) {
445
+ try {
446
+ Bun.spawnSync([tmuxBin, "resize-window", "-t", `=${sessionName}`, "-x", String(termSize.columns), "-y", String(termSize.rows)]);
447
+ } catch { /* best-effort — old tmux without resize-window is fine */ }
448
+ }
449
+
432
450
  const attach = Bun.spawn([tmuxBin, "attach-session", "-t", `=${sessionName}`], {
433
451
  stdin: "inherit",
434
452
  stdout: "inherit",
package/src/tui/app.ts CHANGED
@@ -288,6 +288,10 @@ export class LaunchTui {
288
288
  // block above the heartbeat; pressing Ctrl+O again clears it and restores the
289
289
  // normal activity view. Kept as data, not scrollback text, so it can actually close.
290
290
  private historyLines: string[] | null = null;
291
+ // ponytail: cached wrapped lines for history panel to avoid O(history) re-wrap per tick
292
+ private cachedWrappedHistory: string[] | null = null;
293
+ private cachedHistoryLines: string[] | null = null;
294
+ private cachedHistoryWidth: number | null = null;
291
295
  // Ctrl+O detail panel scroll: a window offset so long/CJK content is fully
292
296
  // reachable (↑↓/PgUp/PgDn) instead of clipped at "… N more". Bound + page size
293
297
  // are recomputed each render from the visible body height.
@@ -323,6 +327,9 @@ export class LaunchTui {
323
327
  private readonly unicode: boolean = supportsUnicode();
324
328
  // Active color theme (JEO_TUI_THEME), default cosmic; `mono` disables color.
325
329
  private readonly theme = resolveTheme(process.env);
330
+ // Terminal color depth, detected once — TERM/COLORTERM/NO_COLOR are process-constant,
331
+ // so re-running the regex chain 2-4× per 120ms draw was pure waste.
332
+ private readonly colorLevel = detectColorLevel(process.env, isTTY());
326
333
  // Neon flow palette derived from the active theme (forge-card border + forge mark
327
334
  // animation), so the live glow tracks the theme instead of a fixed brand palette.
328
335
  private readonly themeFlow = themeFlowPalette(this.theme);
@@ -557,7 +564,9 @@ export class LaunchTui {
557
564
  // Sanitize raw child stdout (CR / EL / cursor-move escapes) before it enters the
558
565
  // frame — unsanitized control bytes tore the renderer's next \x1b[2K (literal "2K")
559
566
  // and hijacked the cursor, corrupting the live frame.
560
- this.liveToolOutput = sanitizeForFrame(partial);
567
+ // Only the display WINDOW is sanitized: renderLiveBlock tails the text anyway, so
568
+ // scanning the full cumulative buffer per event was O(n²) over a chatty stream.
569
+ this.liveToolOutput = sanitizeForFrame(tailForWrap(partial));
561
570
  if (Date.now() - this.lastStreamDraw >= 100) {
562
571
  this.lastStreamDraw = Date.now();
563
572
  this.draw();
@@ -1339,10 +1348,18 @@ export class LaunchTui {
1339
1348
  const inner = Math.max(10, boxWidth - 2);
1340
1349
  const accent = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
1341
1350
  const dim = this.theme.color ? chalk.dim : (s: string) => s;
1342
- const wrapped = this.historyLines.flatMap(line => {
1343
- const physical = line.split("\n");
1344
- return physical.flatMap(part => (visibleWidth(part) <= inner ? [part] : wrapTextWithAnsi(part, inner)));
1345
- });
1351
+ let wrapped: string[];
1352
+ if (this.historyLines === this.cachedHistoryLines && inner === this.cachedHistoryWidth && this.cachedWrappedHistory) {
1353
+ wrapped = this.cachedWrappedHistory;
1354
+ } else {
1355
+ wrapped = this.historyLines.flatMap(line => {
1356
+ const physical = line.split("\n");
1357
+ return physical.flatMap(part => (visibleWidth(part) <= inner ? [part] : wrapTextWithAnsi(part, inner)));
1358
+ });
1359
+ this.cachedHistoryLines = this.historyLines;
1360
+ this.cachedHistoryWidth = inner;
1361
+ this.cachedWrappedHistory = wrapped;
1362
+ }
1346
1363
  const bodyLimit = Math.max(1, maxRows - 4); // box borders (2) + title + divider
1347
1364
  const scrollable = wrapped.length > bodyLimit;
1348
1365
  // Window capacity at the bottom (worst case: both ↑ and ↓ indicators take a row),
@@ -1433,7 +1450,7 @@ export class LaunchTui {
1433
1450
  }): string[] {
1434
1451
  const { cols, rows, stepNow, elapsedMs, idx, isThinking } = args;
1435
1452
  const dim = this.theme.color ? chalk.dim : (s: string) => s;
1436
- const colorLevel = detectColorLevel(process.env, isTTY());
1453
+ const colorLevel = this.colorLevel;
1437
1454
  // One quantized animation clock for the whole frame: gradient phase cycles 20
1438
1455
  // steps, the prompt beat advances every 3 ticks. Quantization keeps repaints
1439
1456
  // coherent (status field + forge border move together) and bounds per-tick work.
@@ -1541,7 +1558,7 @@ export class LaunchTui {
1541
1558
  cols,
1542
1559
  unicode: this.unicode,
1543
1560
  color: this.theme.color,
1544
- colorLevel: detectColorLevel(process.env, isTTY()),
1561
+ colorLevel: this.colorLevel,
1545
1562
  gradient: themeGradient(this.theme, 2),
1546
1563
  });
1547
1564
  }
@@ -1584,7 +1601,7 @@ export class LaunchTui {
1584
1601
  frame: twist,
1585
1602
  unicode: this.unicode,
1586
1603
  color: this.theme.color,
1587
- colorLevel: detectColorLevel(process.env, isTTY()),
1604
+ colorLevel: this.colorLevel,
1588
1605
  palette: this.themeFlow,
1589
1606
  });
1590
1607
  this.cachedArt = fit ? centerBlock(art, innerWidth) : art;
@@ -1598,9 +1615,6 @@ export class LaunchTui {
1598
1615
  const trackCount = showArt ? 1 : 0;
1599
1616
  const headerHeight = artLinesCount + trackCount + (showArt ? 1 : 0);
1600
1617
 
1601
- const toolLines = this.tools.render(fit ? Math.max(3, rows - 15) : undefined, { color: this.theme.color, indexed: fit });
1602
- const toolListHeight = toolLines.length;
1603
-
1604
1618
  const planLines = this.renderPlan(this.theme.color);
1605
1619
  const planHeight = planLines.length;
1606
1620
 
@@ -1619,6 +1633,12 @@ export class LaunchTui {
1619
1633
  return;
1620
1634
  }
1621
1635
 
1636
+ // Tool rows are only consumed by the boxed (non-inline) layout below — computing
1637
+ // them before the inline early-return re-painted up to 500 chalk rows per tick
1638
+ // on the DEFAULT path that never uses them.
1639
+ const toolLines = this.tools.render(fit ? Math.max(3, rows - 15) : undefined, { color: this.theme.color, indexed: fit });
1640
+ const toolListHeight = toolLines.length;
1641
+
1622
1642
  // Bottom-pinned status + footer.
1623
1643
  const bottom: string[] = [];
1624
1644
  const statusMsg = this.currentActivity();
@@ -1626,7 +1646,7 @@ export class LaunchTui {
1626
1646
  // known price — then no $ is shown). Computed once per draw, fed to status + footer.
1627
1647
  const costUsd = costForUsage(this.footer.model, this.turnUsage) ?? undefined;
1628
1648
  if (isThinking) {
1629
- const colorLevel = detectColorLevel(process.env, isTTY());
1649
+ const colorLevel = this.colorLevel;
1630
1650
  const phase = (this.tickCount * 0.05) % 1;
1631
1651
  const grad = themeGradient(this.theme, idx);
1632
1652
  const palette = [grad.from, grad.to];
@@ -308,7 +308,17 @@ export function transitionMessage(index: number): string {
308
308
  * Applies a dimensional, animated gradient (foreground or background) to a string of text.
309
309
  * Driven by a phaseMs timestamp, it creates a slow, tasteful shimmer wave
310
310
  * and adds depth cues (bright core, dimmer edges).
311
+ *
312
+ * The clock is QUANTIZED to 225ms buckets (20 discrete phases over the 4.5s period,
313
+ * matching the forge art's quantized-clock rule): callers pass raw Date.now(), but two
314
+ * calls in the same bucket produce byte-identical output, so the differential renderer
315
+ * skips the row instead of rewriting ~2-4KB of truecolor escapes on every idle tick.
316
+ * A small memo returns the exact prior string for a repeated (bucket, style, text) —
317
+ * the common case between consecutive 120ms ticks.
311
318
  */
319
+ const GRADIENT_BUCKET_MS = 225;
320
+ const gradientMemo = new Map<string, string>();
321
+ const GRADIENT_MEMO_CAP = 16;
312
322
  export function applyDimensionalGradient(
313
323
  text: string,
314
324
  timeMs: number,
@@ -320,6 +330,10 @@ export function applyDimensionalGradient(
320
330
  ): string {
321
331
  const plain = stripAnsi(text);
322
332
  if (level === ColorLevel.None || plain.length === 0) return plain;
333
+ const bucket = timeMs - (timeMs % GRADIENT_BUCKET_MS);
334
+ const memoKey = `${bucket}|${fromHex}|${toHex}|${level}|${isBg ? 1 : 0}|${fgHex}|${plain}`;
335
+ const hit = gradientMemo.get(memoKey);
336
+ if (hit !== undefined) return hit;
323
337
 
324
338
  const from = hexToRgb(fromHex);
325
339
  const to = hexToRgb(toHex);
@@ -331,8 +345,8 @@ export function applyDimensionalGradient(
331
345
  out += fgEscape(fg, level);
332
346
  }
333
347
 
334
- // Slow period: 4.5 seconds for a complete wave cycle
335
- const p = (timeMs / 4500) % 1;
348
+ // Slow period: 4.5 seconds for a complete wave cycle (quantized bucket → 20 phases)
349
+ const p = (bucket / 4500) % 1;
336
350
 
337
351
  for (let i = 0; i < L; i++) {
338
352
  const ch = plain[i]!;
@@ -380,5 +394,11 @@ export function applyDimensionalGradient(
380
394
  }
381
395
  }
382
396
 
383
- return out + resetEscape(level);
397
+ const result = out + resetEscape(level);
398
+ if (gradientMemo.size >= GRADIENT_MEMO_CAP) {
399
+ const oldest = gradientMemo.keys().next().value;
400
+ if (oldest !== undefined) gradientMemo.delete(oldest);
401
+ }
402
+ gradientMemo.set(memoKey, result);
403
+ return result;
384
404
  }
@@ -217,9 +217,13 @@ export function wrapTextWithAnsi(text: string, cols: number): string[] {
217
217
  out.push(rawLine);
218
218
  continue;
219
219
  }
220
+ if (!rawLine.includes("\x1b") && !/[\t\u0300-\uffff]/.test(rawLine) && !/[\u{10000}-\u{10ffff}]/u.test(rawLine)) {
221
+ for (let i = 0; i < rawLine.length; i += width) out.push(rawLine.slice(i, i + width));
222
+ continue;
223
+ }
220
224
  let rest = rawLine;
221
225
  let active = ""; // SGR open at the wrap boundary, carried to the next line
222
- while (visibleWidth(rest) > width) {
226
+ while (true) {
223
227
  const head = truncateToWidth(rest, width);
224
228
  // Advance past exactly the consumed substring. truncateToWidth may append a
225
229
  // SYNTHETIC trailing reset (frame safety) that is NOT in `rest` — including it
@@ -230,6 +234,12 @@ export function wrapTextWithAnsi(text: string, cols: number): string[] {
230
234
  : head.endsWith(RESET) && rest.startsWith(head.slice(0, -RESET.length))
231
235
  ? head.slice(0, -RESET.length)
232
236
  : head;
237
+ if (consumed.length === rest.length) {
238
+ let line = active + rest;
239
+ if (active && !line.endsWith(RESET)) line += RESET;
240
+ out.push(line);
241
+ break;
242
+ }
233
243
  let line = active + head;
234
244
  const next = sgrStateAfter(active, consumed);
235
245
  // Close any color still open at the line end so it cannot tint the padding/border.
@@ -238,11 +248,6 @@ export function wrapTextWithAnsi(text: string, cols: number): string[] {
238
248
  active = next;
239
249
  rest = rest.slice(consumed.length);
240
250
  }
241
- if (rest.length > 0) {
242
- let line = active + rest;
243
- if (active && !line.endsWith(RESET)) line += RESET;
244
- out.push(line);
245
- }
246
251
  }
247
252
  return out;
248
253
  }