jeo-code 0.7.41 → 0.7.42
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 +12 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/engine.ts +36 -1
- package/src/ai/model-manager.ts +12 -3
- package/src/tui/app.ts +26 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ 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.42] - 2026-07-06
|
|
10
|
+
_Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **The interactive streaming model call had no absolute wall-clock — a connected-but-never-terminating stream hung "thinking" forever.** Unlike `manager.call()`/the non-stream fallback (both wrapped in a hard ~300s `withTimeout`), `manager.stream()` (`src/ai/model-manager.ts`) only armed its overall deadline when `JEO_STREAM_MAX_MS` was explicitly set — off by default — and its idle watchdog re-arms on ANY wire activity, including SSE keepalive/ping bytes and reasoning deltas. A stream that stays connected and periodically emits *something* (provider/proxy keepalives, an endless-reasoning response, a proxy that never forwards the terminal stop event) could re-arm that watchdog forever and never resolve or reject. `streamMaxMs()` now defaults to the same ~300s ceiling `callTimeoutMs()` already uses when `JEO_STREAM_MAX_MS` is unset, giving the streaming path parity with the non-streaming path; explicit overrides (including `0` to disable, for genuinely long-running reasoning use cases) are unchanged.
|
|
14
|
+
- **The `JEO_TURN_MAX_MS` stall budget could never interrupt a hang inside a single blocked model call — the exact scenario it exists to catch.** It was implemented purely as a passive top-of-loop check (`Date.now() - lastProgressAt > turnBudgetMs`); with no armed timer anywhere in `engine.ts`, the check could only run *between* steps — a hang blocked inside one `await` on the model call (e.g. the non-terminating stream above) meant the loop never re-iterated and the budget never fired, even after 30+ minutes. `runAgentLoop` now arms a real `setTimeout` for the remaining budget immediately before each step's model call, aborting a dedicated controller (composed with the turn's existing external signal) when it fires; the timer is cleared and re-armed on every genuine progress reset (executed tool step, mid-turn steering) so a legitimately long multi-step turn is never prematurely cut off, and an internal stall-abort surfaces through the exact same error path as an external Esc/Ctrl-C cancel.
|
|
15
|
+
- **(Defensive, tmux-specific) Live TUI frame writes are now backpressure-aware.** `LaunchTui`'s ~120ms frame tick wrote directly to `process.stdout` every beat; under `--tmux`, `process.stdout` is the tmux pane pty drained by the tmux *server*, and POSIX TTY writes are synchronous — a backpressured/slow-draining tmux client could block the write and freeze the event loop entirely (spinner, timers, and the stall-budget check all stop simultaneously, on whatever phase — "thinking" or "executing" — was current). The frame tick now checks `write()`'s boolean return value and skips scheduled frames while backpressured, resuming automatically on the stream's `'drain'` event, instead of piling up or blocking on synchronous writes. No tmux-detection branching — this is a generic backpressure-safe write pattern that helps regardless of what's on the other end of stdout.
|
|
16
|
+
|
|
17
|
+
### Verified
|
|
18
|
+
- `bun run typecheck` clean; full suite green.
|
|
19
|
+
- New/updated coverage: `test/round-b.test.ts` (`streamMaxMs()` default/override/disable contract, plus a keepalive-forever stream aborted once the deadline elapses), `test/cycle-and-turn-budget.test.ts` (a never-resolving model call force-aborted once the remaining stall budget elapses, and a regression guard that a normal long multi-step turn is never prematurely aborted), `test/tui-app.test.ts` (the frame tick skips beats under simulated backpressure and resumes on `'drain'`).
|
|
20
|
+
|
|
9
21
|
## [0.7.41] - 2026-07-06
|
|
10
22
|
_Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success._
|
|
11
23
|
|
package/README.ja.md
CHANGED
|
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
259
259
|
## 変更履歴 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
262
263
|
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
263
264
|
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
264
265
|
- **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
|
|
265
266
|
- **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
|
|
266
|
-
- **[0.7.37]** (2026-07-05) — Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface only — no forum topics, no inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo intentionally does not replicate from gjc's full notification stack).
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -259,11 +259,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
259
259
|
## 변경 이력 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
262
263
|
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
263
264
|
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
264
265
|
- **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
|
|
265
266
|
- **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
|
|
266
|
-
- **[0.7.37]** (2026-07-05) — Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface only — no forum topics, no inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo intentionally does not replicate from gjc's full notification stack).
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -261,11 +261,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
261
261
|
## Changelog
|
|
262
262
|
|
|
263
263
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
264
|
+
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
264
265
|
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
265
266
|
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
266
267
|
- **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
|
|
267
268
|
- **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
|
|
268
|
-
- **[0.7.37]** (2026-07-05) — Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface only — no forum topics, no inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo intentionally does not replicate from gjc's full notification stack).
|
|
269
269
|
|
|
270
270
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
271
271
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
259
259
|
## 更新日志 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
262
263
|
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
263
264
|
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
264
265
|
- **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
|
|
265
266
|
- **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
|
|
266
|
-
- **[0.7.37]** (2026-07-05) — Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface only — no forum topics, no inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo intentionally does not replicate from gjc's full notification stack).
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -330,6 +330,18 @@ export function turnMaxMs(env: Record<string, string | undefined> = process.env)
|
|
|
330
330
|
}
|
|
331
331
|
return 30 * 60 * 1000;
|
|
332
332
|
}
|
|
333
|
+
/** Minimum delay (ms) before a re-armed stall-interrupt timer may fire — guards against
|
|
334
|
+
* a near-zero remaining budget scheduling a same-tick abort that would race the call
|
|
335
|
+
* it is meant to protect. */
|
|
336
|
+
const MIN_STALL_ARM_MS = 1_000;
|
|
337
|
+
|
|
338
|
+
/** Combine the turn's external cancel signal (Esc/Ctrl-C) with an internal one. Mirrors
|
|
339
|
+
* the composeAbort/boundedSignal pattern already used in src/ai/model-manager.ts and
|
|
340
|
+
* src/auth/flows/google-project.ts — kept local since neither is exported. */
|
|
341
|
+
function composeAbortSignal(outer: AbortSignal | undefined, inner: AbortSignal): AbortSignal {
|
|
342
|
+
if (!outer) return inner;
|
|
343
|
+
return typeof AbortSignal.any === "function" ? AbortSignal.any([outer, inner]) : inner;
|
|
344
|
+
}
|
|
333
345
|
|
|
334
346
|
|
|
335
347
|
/** Levenshtein distance (small inputs: tool/command names). */
|
|
@@ -408,6 +420,26 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
408
420
|
const turnBudgetMs = turnMaxMs();
|
|
409
421
|
// Reset on every executed tool step and on mid-turn steering.
|
|
410
422
|
let lastProgressAt = turnStartedAt;
|
|
423
|
+
// Real timer-based interrupt backing the passive check above: JEO_TURN_MAX_MS alone
|
|
424
|
+
// only re-evaluates at the TOP of the while loop, so it cannot fire while the loop is
|
|
425
|
+
// parked inside a single blocked model-call await (the exact "genuinely forever" hang
|
|
426
|
+
// this guards against — a stream that never resolves/rejects). Armed fresh before each
|
|
427
|
+
// model call for the REMAINING budget and disarmed as soon as that call settles, so it
|
|
428
|
+
// naturally re-arms on every lastProgressAt reset and never fires after the turn ends.
|
|
429
|
+
let stallTimer: ReturnType<typeof setTimeout> | undefined;
|
|
430
|
+
const armStallAbort = (): AbortSignal | undefined => {
|
|
431
|
+
if (turnBudgetMs <= 0) return opts.signal; // 0 disables the stall budget entirely
|
|
432
|
+
const ctrl = new AbortController();
|
|
433
|
+
const remaining = Math.max(MIN_STALL_ARM_MS, turnBudgetMs - (Date.now() - lastProgressAt));
|
|
434
|
+
stallTimer = setTimeout(() => {
|
|
435
|
+
ctrl.abort(new Error(`no tool progress for ${Math.round(turnBudgetMs / 60_000)}m — turn stall budget (JEO_TURN_MAX_MS) exceeded without done`));
|
|
436
|
+
}, remaining);
|
|
437
|
+
return composeAbortSignal(opts.signal, ctrl.signal);
|
|
438
|
+
};
|
|
439
|
+
const disarmStallAbort = () => {
|
|
440
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
441
|
+
stallTimer = undefined;
|
|
442
|
+
};
|
|
411
443
|
// "steps" | "time" — drives honest wording in the consolidation message.
|
|
412
444
|
let stopKind: "steps" | "time" = "steps";
|
|
413
445
|
let step = 1;
|
|
@@ -624,6 +656,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
624
656
|
ev.onReasoningArtifactStream?.(a);
|
|
625
657
|
};
|
|
626
658
|
let responseText: string;
|
|
659
|
+
const stepSignal = armStallAbort();
|
|
627
660
|
try {
|
|
628
661
|
responseText = await invokeCallLlm(history, {
|
|
629
662
|
jsonMode: true,
|
|
@@ -636,7 +669,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
636
669
|
model: opts.model,
|
|
637
670
|
maxTokens: opts.maxTokens,
|
|
638
671
|
reasoningEffort: opts.reasoningEffort,
|
|
639
|
-
signal:
|
|
672
|
+
signal: stepSignal,
|
|
640
673
|
sessionKey: opts.sessionKey,
|
|
641
674
|
onUsage: u => { acc.inputTokens += u.inputTokens ?? 0; acc.outputTokens += u.outputTokens ?? 0; sawUsage = true; },
|
|
642
675
|
onToken,
|
|
@@ -652,7 +685,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
652
685
|
ev.onNotice?.(`${what} — auto-retry #${attempt} in ${wait}s`);
|
|
653
686
|
},
|
|
654
687
|
});
|
|
688
|
+
disarmStallAbort(); // call settled successfully — the stall interrupt no longer applies
|
|
655
689
|
} catch (err) {
|
|
690
|
+
disarmStallAbort(); // clear before classifying: a fired stall-abort must not linger past this call
|
|
656
691
|
// Reactive context recovery: trim older tool results in place and retry the
|
|
657
692
|
// SAME step once. The provider's overflow signal beats the local estimate;
|
|
658
693
|
// a second overflow (or nothing left to trim) surfaces the friendly error.
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -576,11 +576,20 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, watchdog?: Promise<nev
|
|
|
576
576
|
return Promise.race([iter.next(), watchdog]);
|
|
577
577
|
}
|
|
578
578
|
|
|
579
|
-
/**
|
|
579
|
+
/** Overall stream wall-clock from the environment. Defaults to the same hard bound as
|
|
580
|
+
* callTimeoutMs() (DEFAULT_CALL_TIMEOUT_MS) so the streaming path has parity with the
|
|
581
|
+
* non-streaming call()/fallback path — a stream that stays connected and keeps emitting
|
|
582
|
+
* SOMETHING (SSE keepalives, reasoning deltas) re-arms the per-chunk idle watchdog
|
|
583
|
+
* forever and would otherwise never resolve or reject. JEO_STREAM_MAX_MS explicitly set
|
|
584
|
+
* to a positive number overrides the default; explicitly set to 0 disables the overall
|
|
585
|
+
* deadline entirely (mirrors JEO_TURN_MAX_MS's own 0-disables convention) for advanced
|
|
586
|
+
* users with genuinely long-running legitimate reasoning models. */
|
|
580
587
|
export function streamMaxMs(env?: Record<string, string | undefined>): number | undefined {
|
|
581
588
|
const raw = jeoEnv("STREAM_MAX_MS", env);
|
|
582
|
-
|
|
583
|
-
|
|
589
|
+
if (raw === undefined || raw === "") return DEFAULT_CALL_TIMEOUT_MS;
|
|
590
|
+
const n = parseInt(raw, 10);
|
|
591
|
+
if (!Number.isFinite(n) || n < 0) return DEFAULT_CALL_TIMEOUT_MS;
|
|
592
|
+
return n === 0 ? undefined : n;
|
|
584
593
|
}
|
|
585
594
|
|
|
586
595
|
/** Per-chunk idle cap (ms) from the environment, falling back to the built-in default.
|
package/src/tui/app.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface LaunchTuiOptions {
|
|
|
54
54
|
/** Resolved provider name for the footer (anthropic / openai / gemini / ollama). */
|
|
55
55
|
provider?: string;
|
|
56
56
|
sessionId?: string;
|
|
57
|
-
write?: (s: string) => void;
|
|
57
|
+
write?: (s: string) => void | boolean;
|
|
58
58
|
/** Step budget for this turn; drives the footer's `step N/M` denominator. */
|
|
59
59
|
maxSteps?: number;
|
|
60
60
|
/** Whether to treat the output as a TTY (drives alt-screen use). Defaults to isTTY(). */
|
|
@@ -213,6 +213,13 @@ export class LaunchTui {
|
|
|
213
213
|
private mutationGuarded = false;
|
|
214
214
|
private finished = false;
|
|
215
215
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
216
|
+
// Backpressure-aware live frame emission: process.stdout.write() returns false when the
|
|
217
|
+
// internal buffer is over the high-water mark (e.g. a tmux client draining slowly, or
|
|
218
|
+
// any slow consumer on the other end of stdout). Skipping scheduled frames while this is
|
|
219
|
+
// true — instead of piling up an unbounded queue of synchronous writes or blocking the
|
|
220
|
+
// event loop waiting on 'drain' — keeps the render loop responsive and non-blocking.
|
|
221
|
+
// Generic and tmux-agnostic: helps regardless of what's downstream of stdout.
|
|
222
|
+
private stdoutBackpressured = false;
|
|
216
223
|
// Resize handling (gjc-style responsiveness): repaint IMMEDIATELY on the first event
|
|
217
224
|
// so a deliberate resize never lags, then cap follow-ups to ~30fps so a drag-resize
|
|
218
225
|
// tracks the cursor live (instead of staying stale until the drag pauses — the lag a
|
|
@@ -345,7 +352,19 @@ export class LaunchTui {
|
|
|
345
352
|
private readonly thinkingLevel?: string;
|
|
346
353
|
|
|
347
354
|
constructor(opts: LaunchTuiOptions) {
|
|
348
|
-
|
|
355
|
+
// Backpressure-aware wrapper around the underlying writer (real stdout, or the
|
|
356
|
+
// caller-injected `write` used by tests). An explicit `false` return (Node/Bun stream
|
|
357
|
+
// convention: internal buffer over the high-water mark) arms the flag the live-frame
|
|
358
|
+
// interval checks; a real 'drain' event (asynchronous — never awaited here) clears it.
|
|
359
|
+
// A test double returning `undefined` (the existing convention throughout this file's
|
|
360
|
+
// tests) never trips the check, so non-backpressure-aware callers are unaffected.
|
|
361
|
+
const rawWrite = opts.write ?? ((s: string) => process.stdout.write(s));
|
|
362
|
+
this.write = (s: string) => {
|
|
363
|
+
if (rawWrite(s) === false && !this.stdoutBackpressured) {
|
|
364
|
+
this.stdoutBackpressured = true;
|
|
365
|
+
process.stdout.once("drain", () => { this.stdoutBackpressured = false; });
|
|
366
|
+
}
|
|
367
|
+
};
|
|
349
368
|
this.tty = opts.tty ?? isTTY();
|
|
350
369
|
this.inline = this.tty && jeoEnv("TUI_ALT_SCREEN") !== "1";
|
|
351
370
|
// Row reservation is only needed (and only safe) for the inline main-buffer frame;
|
|
@@ -1040,6 +1059,11 @@ export class LaunchTui {
|
|
|
1040
1059
|
this.timer = setInterval(() => {
|
|
1041
1060
|
try {
|
|
1042
1061
|
this.tickCount++;
|
|
1062
|
+
// Backpressure: skip this scheduled frame entirely rather than piling up writes
|
|
1063
|
+
// or blocking on 'drain' — the tick simply resumes once the writer drains (see
|
|
1064
|
+
// the this.write wrapper above). The spinner/tickCount stay static for the
|
|
1065
|
+
// skipped beat(s), matching the frozen frame this defers presenting.
|
|
1066
|
+
if (this.stdoutBackpressured) return;
|
|
1043
1067
|
// Self-healing resync: every ~3s drop the differential baseline so the next
|
|
1044
1068
|
// draw rewrites EVERY line. Any screen corruption (stray child output, wheel
|
|
1045
1069
|
// noise, terminal glitches) is repaired automatically without user action.
|