jeo-code 0.7.40 → 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 CHANGED
@@ -6,6 +6,32 @@ 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
+
21
+ ## [0.7.41] - 2026-07-06
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._
23
+
24
+ ### Fixed
25
+ - **Gemini `SAFETY`/`PROHIBITED_CONTENT`/`BLOCKLIST` refusals were never detected — masked by drifted tests since ~0.7.27.** `isRefusalError` (`src/util/retry.ts`) matched a bare `(SAFETY)` shape, but `gemini.ts`'s `blockedReason()` always emits a prefixed `blockReason=SAFETY`/`finishReason=SAFETY` (API-key call, API-key stream, and the Cloud Code Assist/OAuth path all affected). The regex now matches `(?:block|finish)Reason=(?:SAFETY|PROHIBITED_CONTENT|BLOCKLIST|RECITATION|SPII)` — the last two (`RECITATION`, `SPII`) were entirely uncovered before. Previously: a Gemini safety refusal was blindly transport-retried with the identical (deterministically re-refusing) payload, billed each time, then reached the engine with no refusal ladder and no friendly message — just the raw error. Test fixtures in `test/refusal-recovery.test.ts`/`test/retry.test.ts` that asserted against hand-written literals `blockedReason()` never actually produces (giving false-green coverage) are corrected to the real production shape.
26
+ - **Antigravity's empty-completion error carried no reason and wasn't even transport-retried.** `Antigravity Cloud Code Assist returned an empty response.` (both `call` and `stream`) is now `... returned no content (finishReason=<X>).` when the response carries a `finishReason` — aligning the "returned no content" wording with `defaultRetryable`'s existing transient-empty-200 branch (the old wording didn't match it at all, so a genuinely transient empty reply wasn't even retried once) and making Antigravity refusals detectable by the same fixed regex above.
27
+ - **The refusal ladder's final rung could spin for the full ~30-minute stall budget on a refusal that can never succeed.** A `Refusal (<category>)`-shaped refusal (e.g. Anthropic's `reasoning_extraction`, 0.7.39) is a classification of the request content itself — by the time rung 4 is reached, context has already been minimized (rungs 1-3), so every further identical resend re-refuses identically. Rung 4 now fails fast with the category-aware `friendlyProviderError` message for this shape instead of entering unbounded backoff, while every other (genuinely time-sensitive, rolling-classifier) refusal shape keeps the existing "never terminal" backoff behavior unchanged.
28
+ - **The rung-4 auto-retry notice's `(Esc to cancel)` hint is now only shown when a TUI is actually attached** (keyed off `ev.onModelStream`, which only interactive consumers set) — a one-shot/piped invocation no longer shows a cancel hint for a key it has no live handler for.
29
+ - **Anthropic's `stop_details.category` is now captured defensively in the 200-body and stream response paths, not just the HTTP-error path.** `emptyCompletionError` folds a present category into `(stop_reason=refusal, category=<X>)`, and `friendlyProviderError`'s category extraction now recognizes both the `Refusal (<X>)` (HTTP-error) and `category=<X>` (body/stream) shapes — so the `reasoning_extraction` clarifying note keeps applying regardless of which form Anthropic uses to deliver a given refusal.
30
+
31
+ ### Verified
32
+ - `bun run typecheck` clean; full suite green.
33
+ - New/updated coverage: `test/refusal-recovery.test.ts`, `test/retry.test.ts` (real Gemini production-shape fixtures + `RECITATION`/`SPII`/`blockReason=` cases, category-aware rung-4 fail-fast vs. regression guard that non-category refusals still back off), `test/antigravity.test.ts` (SAFETY/RECITATION/no-reason cases), `test/anthropic-stream.test.ts` (`stop_details.category` capture in both `call()` and `stream()`).
34
+
9
35
  ## [0.7.40] - 2026-07-06
10
36
  _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._
11
37
 
package/README.ja.md CHANGED
@@ -38,13 +38,15 @@
38
38
 
39
39
  ## ハイライト
40
40
 
41
- - **マルチプロバイダ・単一ループ** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama を均一な JSON ツールループで。入力欄から OAuth ログイン(`/provider login`)、モデル選択は即座にデフォルトとして永続化。
41
+ - **マルチプロバイダ・単一ループ** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio に加え、OpenAI・Anthropic 互換クラウド20以上(Groq、DeepSeek、Mistral、OpenRouter、xAI、Kimi、z.ai など)を均一な JSON ツールループで。入力欄から OAuth ログイン(`/provider login`)、モデル選択は即座にデフォルトとして永続化。
42
42
  - **編集の完全性** — read 出力にコンテンツアンカー(`42ab|`)が付き、アンカー付き編集は現在のファイルと照合・行移動時は自動再マッピング・不一致時は最新内容と共に拒否されます。
43
43
  - **自己修正の検証ループ** — post-edit フック(tsc / eslint / テスト)を設定すると、エージェントが診断を*自ら読み*ループ内で修正します。フックが赤のままだと `done` はブロックされます。
44
44
  - **芝居なしの本物のゲート** — `ralplan` の合議はリポジトリを実際に読む critic サブエージェントで、`[OKAY]` 評決が永続化され `jeo approve` がそれを*要求*します。`ultragoal` は誠実に報告します(スイート1回実行はグローバル信号であり、基準ごとの合格を捏造しません)。
45
45
  - **クラッシュ耐久・ローカルファースト** — 全状態は `.jeo/` 配下にアトミック書き込み、プロセス間ロック、失敗タスクマーカー + 再開時の部分編集警告。
46
46
  - **動的ステップ予算** — 直近のツール呼び出しが新規の進捗を示す間は延長され、停滞すれば要約に収束。サブエージェントは厳密なステップ契約を維持。
47
47
  - **インライン TUI** — 完了した作業は実スクロールバックに流れ(ターン中も tmux ホイール可)、エージェント実行中も通常のクエリ入力欄が表示されたまま編集できます。Ctrl+O の詳細トグル、テーマ、クリップボード画像貼り付け(Ctrl+V)、CJK/絵文字対応の幅計算。
48
+ - **ブラウザツール** — Playwright によるヘッドレス Chromium 自動化を第一級のエージェントツールとして搭載: 名前付きタブを再利用しつつ `open`/`close`/`run`/`act`、スクリーンショットより `observe` でタグ付けした要素 id を優先。`npx playwright install chromium` を一度実行する必要があります(バンドルされていません — jeo 自体はネイティブ依存ゼロのまま、ブラウザバイナリは Playwright 側の別ダウンロードです)。
49
+ - **リモートサブエージェント可視化(Telegram)** — ボットを一度ペアリング(`jeo notify setup`)すれば、`jeo daemon start` がサブエージェントの状態遷移(開始 → 完了/失敗/キャンセル)ごとにメッセージを送り、`/subagents`、`/steer <id> <subagentId> <msg>`、`/cancel <id> <subagentId>` を受け付けます — コマンドはペアリングされたチャットのみ許可されます。
48
50
 
49
51
  ## インストール
50
52
 
@@ -163,6 +165,31 @@ jeo ultragoal
163
165
 
164
166
  `--worktree <name>` は隔離された兄弟 git worktree で jeo を実行するため(パスがあれば再利用、なければ basename ブランチで作成)、リスクのある作業やレビュー対象の作業がメインのチェックアウトに触れることはありません。`jeo mcp serve` は stdio を介して MCP 対応のあらゆるコントローラーに jeo のツールを公開します(`jeo mcp tools` で一覧表示)。`-q`/`--quiet` (または `JEO_QUIET=1`) を追加すると、起動バナー・ウェルカムアニメーション・リリースノート・再開ヒントが抑制され、jeo を別のエージェントと並べて実行したりボットから駆動したりできます。`-p`/`--print` は quiet を含みます。
165
167
 
168
+ ## リモート監視・制御(Telegram)
169
+
170
+ ```bash
171
+ jeo notify setup # BotFather ボットを一度ペアリング(getMe 検証 + chat-id ペアリング)
172
+ jeo notify status # マスクされたトークン、ペアリング済み chat id、デーモン状態
173
+ jeo daemon start # シングルトンのバックグラウンドデーモンを起動
174
+ jeo daemon status # 実行中かどうかを確認
175
+ jeo daemon stop # SIGTERM で停止
176
+ ```
177
+
178
+ ```
179
+ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
180
+ │ interactive turn │◄──ws──►│ notify daemon │◄─poll──►│ Telegram bot │
181
+ │ SubagentRegistry │ │ (singleton) │ │ (paired chat) │
182
+ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
183
+ ```
184
+
185
+ オプトインかつ遅延バインド: `notifications.enabled` が設定され、かつ detached サブエージェント(`task {detached:true}`)が実際に実行されるまで何もバインドされません。デーモンは生存中のセッションディスカバリファイルをスキャンし、セッションごとにループバック WebSocket を接続、サブエージェントの状態 *遷移*(開始 → 完了/失敗/キャンセル)時にのみメッセージを送信します — 「実行中のまま」の繰り返し通知はありません。受信した Telegram コマンドはペアリング済みチャットのみ許可され、それ以外は黙って破棄されます。
186
+
187
+ | コマンド | 効果 |
188
+ | --- | --- |
189
+ | `/subagents` | 接続中の全セッションの実行中/最近のサブエージェント一覧 |
190
+ | `/steer <sessionId> <subagentId> <message>` | 実行中のサブエージェントへライブメッセージを送信 |
191
+ | `/cancel <sessionId> <subagentId>` | 実行中のサブエージェントをキャンセル |
192
+ | `/help` | コマンドリファレンスを表示 |
166
193
 
167
194
  ## ローカルモデル
168
195
 
@@ -186,11 +213,36 @@ JEO_TUI_ALT_SCREEN=1 # レガシー alt-screen ターン(デフォル
186
213
  JEO_STEP_BASE=24 # 動的ステップ予算のローリングベース
187
214
  JEO_STEP_HARD_CAP=600 # 絶対終了保証
188
215
  JEO_STREAM_MAX_MS=300000 # オプトインの全体ストリーム期限(デフォルト off; スロードリップ遮断)
216
+ JEO_STREAM_IDLE_MS=300000 # チャンク単位のアイドル上限(デフォルト300秒); 最初のトークン前が長い低速/ローカルバックエンドでは引き上げてください
189
217
  JEO_TOOL_OUTPUT_MAX=4000 # モデル可視のツール出力上限(全文はアーティファクトへ)
190
218
  ```
191
219
 
192
220
  リトライ動作は `~/.jeo/config.json` の `retry` で調整します(`requestMaxRetries`、`streamMaxRetries`、`rateLimitRetries`、`failFastStatuses` など)。ステップ予算はデフォルトで動的 — 新規進捗が見える間は延長され、停滞時は要約に収束します。`--max-steps N` で有限フローに戻ります。
193
221
 
222
+ ## スキル移行とバンドルスキルの確認
223
+
224
+ ワークフローを jeo に移す際は、何かをインストール・上書きする前にバンドルされたデフォルトを確認してください:
225
+
226
+ ```bash
227
+ jeo skills list # バンドル + ユーザー + プロジェクトのスキル、発見ディレクトリ付き
228
+ jeo skills read ralplan # 1つのスキルの完全な SKILL.md を出力
229
+ jeo skills sync --check # ~/.jeo/skills との差分をレポート(差分があれば非ゼロ終了)
230
+ ```
231
+
232
+ `jeo skills sync` はバンドルされたワークフロースキル(deep-interview、deep-dive、ralplan、team、ultragoal)を `~/.jeo/skills` にインストールし、**デフォルトで既存のローカルファイルを保持します** — 異なるローカルコピーは上書きされず `preserved` として報告されます。`--check` が欠落または差分のあるファイルを検出したら、まず `jeo skills read <name>` で比較してください。ローカルのデフォルトワークフロースキルファイルを意図的に置き換えたい場合のみ `jeo skills sync --force` を使用してください。末尾のパス引数(または `JEO_CONFIG_DIR`)で別のディレクトリを指定でき、`--json` で構造化された `SkillSyncResult` を取得できます。
233
+
234
+ ## 開発
235
+
236
+ jeo は Bun 上の純粋な TypeScript で **ネイティブ依存ゼロ** なので、グローバルの `jeo` コマンドはこのチェックアウトのソースを直接実行できます — ビルド不要、あらゆる編集に即座に反映されます。
237
+
238
+ ```bash
239
+ bun install
240
+ bun run dev:link # `jeo` を <repo>/src/cli.ts へシンボリックリンク -> ~/.local/bin
241
+ bun run dev:doctor # グローバル `jeo` がこのソースを実行しているか報告(linked/drift/missing)
242
+ ```
243
+
244
+ `dev:link` は `PATH` 上で管理対象リンクより先に別の `jeo` が存在する場合は進行を拒否し(宛先は `JEO_DEV_LINK_DIR` で上書き可能)、`--version` のスモークテストを実行します。`dev:doctor` は解決された `jeo` がこのソースではなくコンパイル済みバイナリやインストール済みコピーの場合に非ゼロで終了します。リンクせずソースから直接実行するには `bun src/cli.ts --help`。バンドルされたワークフロースキルはソース内の `src/prompts/skills/<name>/SKILL.md` にあります; `bun run typecheck` と `bun test` で検証してください。
245
+
194
246
  ## 公開 (Publishing)
195
247
 
196
248
  CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリース公開時に自動、または `workflow_dispatch` の手動実行(ドライラン可)。ワークフローは型チェック・テスト・トークン検証(`npm whoami`)の後、`npm publish --provenance` を実行します。
@@ -207,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
207
259
  ## 変更履歴 (Changelog)
208
260
 
209
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.
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.
210
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.
211
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.
212
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.
213
- - **[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).
214
- - **[0.7.36]** (2026-07-04) — Removes two specific guardrails per explicit user direction: `jeo approve`'s human-only identity gate (the agent can now approve its own reviewed `jeo ralplan` plan) and the disk-staleness write/edit clobber guard (a write/edit no longer refuses just because the file changed on disk since the last read). Both removals were scoped narrowly after enumerating every guardrail in the codebase — the catastrophic-command hard block (`rm -rf /`, fork bombs, raw-disk `dd`/`mkfs`), the read-only subagent role tool gating, the deep-interview mutation lock, and the blind-edit (never-read-this-session) guard are all UNCHANGED.
215
267
 
216
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
217
269
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -38,13 +38,15 @@
38
38
 
39
39
  ## 하이라이트
40
40
 
41
- - **멀티 프로바이더, 단일 루프** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama를 균일한 JSON 도구 루프로. 입력창에서 바로 OAuth 로그인(`/provider login`), 모델 선택은 즉시 기본값으로 영속.
41
+ - **멀티 프로바이더, 단일 루프** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio, 그리고 OpenAI·Anthropic 호환 클라우드 20종 이상(Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai 등)까지 균일한 JSON 도구 루프로. 입력창에서 바로 OAuth 로그인(`/provider login`), 모델 선택은 즉시 기본값으로 영속.
42
42
  - **편집 무결성** — read 출력에 콘텐츠 앵커(`42ab|`)가 붙고, 앵커 편집은 현재 파일과 대조 검증·줄 이동 시 자동 재매핑·불일치 시 최신 내용과 함께 거부 — 파일을 오염시키지 않습니다.
43
43
  - **자기수정 검증 루프** — post-edit 훅(tsc / eslint / 테스트)을 설정하면 에이전트가 진단을 *직접 읽고* 루프 안에서 수정합니다. 훅이 빨간 상태면 `done`이 차단됩니다.
44
44
  - **연극 없는 진짜 게이트** — `ralplan` 합의는 실제 저장소를 읽는 critic 서브에이전트이며 `[OKAY]` 평결이 영속되고 `jeo approve`가 이를 *요구*합니다. `ultragoal`은 정직하게 보고합니다(스위트 1회 실행은 전역 신호일 뿐, 기준별 통과를 조작하지 않음).
45
45
  - **크래시 내구·로컬 우선** — 모든 상태는 `.jeo/` 아래 원자적 쓰기, 크로스 프로세스 런 락, 실패 태스크 마커 + 재개 시 부분 편집 경고.
46
46
  - **동적 스텝 버짓** — 최근 도구 호출이 새로운 진전을 보이는 동안 연장되고, 정체되면 정리 요약으로 수렴. 서브에이전트는 정확한 스텝 계약 유지.
47
47
  - **인라인 TUI** — 완료된 작업은 실제 스크롤백으로 흘러가고(턴 중에도 tmux 휠 스크롤 가능), 에이전트가 실행 중이어도 기존 쿼리 입력창이 그대로 보이며 편집됩니다. Ctrl+O 상세 토글, 테마, 클립보드 이미지 붙여넣기(Ctrl+V), CJK/이모지 안전 폭 계산.
48
+ - **브라우저 도구** — Playwright 기반 헤드리스 Chromium 자동화가 1급 에이전트 도구로 포함: 이름 붙은 탭을 재사용하며 `open`/`close`/`run`/`act`, 스크린샷보다 `observe`로 태깅된 요소 id를 우선 사용. `npx playwright install chromium`을 한 번 실행해야 함(번들 아님 — jeo 자체는 여전히 네이티브 의존성 0, 브라우저 바이너리는 Playwright의 별도 다운로드).
49
+ - **원격 서브에이전트 가시성(Telegram)** — 봇을 한 번 페어링(`jeo notify setup`)하면, `jeo daemon start`가 서브에이전트 상태 전환(시작 → 완료/실패/취소)마다 메시지를 보내고 `/subagents`, `/steer <id> <subagentId> <msg>`, `/cancel <id> <subagentId>`를 되받습니다 — 명령은 페어링된 채팅에서만 허용됩니다.
48
50
 
49
51
  ## 설치
50
52
 
@@ -163,6 +165,31 @@ jeo ultragoal
163
165
 
164
166
  `--worktree <name>`는 격리된 형제 git worktree에서 jeo를 실행하므로(경로가 있으면 재사용, 없으면 basename 브랜치로 생성) 위험하거나 검토가 필요한 작업이 메인 체크아웃을 건드리지 않습니다. `jeo mcp serve`는 stdio를 통해 MCP를 지원하는 모든 컨트롤러에 jeo의 도구를 노출합니다(`jeo mcp tools`로 목록 확인). `-q`/`--quiet` (또는 `JEO_QUIET=1`)를 추가하면 시작 배너, 환영 애니메이션, 릴리스 노트, 재개 힌트가 억제되어 jeo를 다른 에이전트와 나란히 실행하거나 봇으로 구동할 수 있습니다. `-p`/`--print`는 quiet를 함의합니다.
165
167
 
168
+ ## 원격 모니터링 & 제어 (Telegram)
169
+
170
+ ```bash
171
+ jeo notify setup # BotFather 봇 한 번 페어링 (getMe 검증 + chat-id 페어링)
172
+ jeo notify status # 마스킹된 토큰, 페어링된 chat id, 데몬 상태
173
+ jeo daemon start # 싱글턴 백그라운드 데몬 실행
174
+ jeo daemon status # 실행 여부 확인
175
+ jeo daemon stop # SIGTERM으로 종료
176
+ ```
177
+
178
+ ```
179
+ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
180
+ │ interactive turn │◄──ws──►│ notify daemon │◄─poll──►│ Telegram bot │
181
+ │ SubagentRegistry │ │ (singleton) │ │ (paired chat) │
182
+ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
183
+ ```
184
+
185
+ 옵트인이며 지연 바인딩됩니다: `notifications.enabled`가 설정되고 detached 서브에이전트(`task {detached:true}`)가 실제로 실행되어야만 동작합니다. 데몬은 살아있는 세션 디스커버리 파일을 스캔해 세션별로 루프백 WebSocket을 연결하고, 서브에이전트 상태 *전환* 시점(시작 → 완료/실패/취소)에만 메시지를 보냅니다 — "여전히 실행 중" 같은 반복 알림은 없습니다. 수신되는 Telegram 명령은 페어링된 채팅에서만 허용되며, 그 외는 조용히 무시됩니다.
186
+
187
+ | 명령 | 동작 |
188
+ | --- | --- |
189
+ | `/subagents` | 연결된 모든 세션의 실행 중/최근 서브에이전트 목록 |
190
+ | `/steer <sessionId> <subagentId> <message>` | 실행 중인 서브에이전트에 실시간 메시지 전송 |
191
+ | `/cancel <sessionId> <subagentId>` | 실행 중인 서브에이전트 취소 |
192
+ | `/help` | 명령 안내 표시 |
166
193
 
167
194
  ## 로컬 모델
168
195
 
@@ -186,11 +213,36 @@ JEO_TUI_ALT_SCREEN=1 # 레거시 alt-screen 턴(기본: 인라인 스
186
213
  JEO_STEP_BASE=24 # 동적 스텝 버짓의 롤링 베이스
187
214
  JEO_STEP_HARD_CAP=600 # 절대 종료 보증
188
215
  JEO_STREAM_MAX_MS=300000 # 옵트인 전체 스트림 데드라인(기본 off; 슬로우드립 차단)
216
+ JEO_STREAM_IDLE_MS=300000 # 청크당 유휴 타임아웃(기본 300초); 첫 토큰 전 침묵이 긴 느린/로컬 백엔드는 값을 높이세요
189
217
  JEO_TOOL_OUTPUT_MAX=4000 # 모델 가시 도구 출력 캡(전체는 아티팩트로 스필)
190
218
  ```
191
219
 
192
220
  재시도 동작은 `~/.jeo/config.json`의 `retry`로 조정합니다(`requestMaxRetries`, `streamMaxRetries`, `rateLimitRetries`, `failFastStatuses`, …). 스텝 버짓은 기본 동적 — 새로운 진전이 보이는 동안 연장되고 정체 시 요약으로 수렴하며, `--max-steps N`이면 유한 플로로 복귀합니다.
193
221
 
222
+ ## 스킬 마이그레이션 및 번들 스킬 확인
223
+
224
+ 워크플로를 jeo로 옮기기 전에, 무언가를 설치하거나 덮어쓰기 전에 먼저 번들 기본값을 확인하세요:
225
+
226
+ ```bash
227
+ jeo skills list # 번들 + 사용자 + 프로젝트 스킬, 디스커버리 디렉터리 포함
228
+ jeo skills read ralplan # 스킬 하나의 전체 SKILL.md 출력
229
+ jeo skills sync --check # ~/.jeo/skills 대비 drift 리포트 (drift 시 non-zero exit)
230
+ ```
231
+
232
+ `jeo skills sync`는 번들 워크플로 스킬(deep-interview, deep-dive, ralplan, team, ultragoal)을 `~/.jeo/skills`에 설치하며 **기본적으로 기존 로컬 파일을 보존**합니다 — 다른 로컬 사본은 덮어쓰지 않고 `preserved`로 보고됩니다. `--check`가 누락되거나 다른 파일을 플래그하면 먼저 `jeo skills read <name>`으로 비교하세요; 로컬 기본 워크플로 스킬 파일을 의도적으로 교체하고 싶을 때만 `jeo skills sync --force`를 사용하세요. 후행 경로 인자(또는 `JEO_CONFIG_DIR`)로 다른 디렉터리를 지정할 수 있고, `--json`으로 구조화된 `SkillSyncResult`를 받을 수 있습니다.
233
+
234
+ ## 개발
235
+
236
+ jeo는 Bun 위의 순수 TypeScript이며 **네이티브 의존성이 0**이라, 전역 `jeo` 명령이 이 체크아웃의 소스를 직접 실행할 수 있습니다 — 빌드 단계 없이, 모든 수정이 즉시 반영됩니다.
237
+
238
+ ```bash
239
+ bun install
240
+ bun run dev:link # `jeo`를 <repo>/src/cli.ts에 심볼릭 링크 -> ~/.local/bin
241
+ bun run dev:doctor # 전역 `jeo`가 이 소스를 실행하는지 보고(linked/drift/missing)
242
+ ```
243
+
244
+ `dev:link`는 `PATH`에서 관리되는 링크보다 먼저 오는 다른 `jeo`가 있으면 진행을 거부하고(대상은 `JEO_DEV_LINK_DIR`로 재정의), `--version` 스모크 테스트를 실행합니다. `dev:doctor`는 해석된 `jeo`가 이 소스가 아니라 컴파일된 바이너리나 설치된 사본이면 non-zero로 종료합니다. 링크 없이 소스에서 바로 실행하려면 `bun src/cli.ts --help`. 번들 워크플로 스킬은 소스의 `src/prompts/skills/<name>/SKILL.md`에 있습니다; `bun run typecheck`와 `bun test`로 검증하세요.
245
+
194
246
  ## 배포 (Publishing)
195
247
 
196
248
  CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리즈 게시 시 자동, 또는 `workflow_dispatch` 수동 실행(드라이런 옵션). 워크플로는 타입체크·테스트·토큰 검증(`npm whoami`) 후 `npm publish --provenance`를 실행합니다.
@@ -207,11 +259,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
207
259
  ## 변경 이력 (Changelog)
208
260
 
209
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.
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.
210
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.
211
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.
212
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.
213
- - **[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).
214
- - **[0.7.36]** (2026-07-04) — Removes two specific guardrails per explicit user direction: `jeo approve`'s human-only identity gate (the agent can now approve its own reviewed `jeo ralplan` plan) and the disk-staleness write/edit clobber guard (a write/edit no longer refuses just because the file changed on disk since the last read). Both removals were scoped narrowly after enumerating every guardrail in the codebase — the catastrophic-command hard block (`rm -rf /`, fork bombs, raw-disk `dd`/`mkfs`), the read-only subagent role tool gating, the deep-interview mutation lock, and the blind-edit (never-read-this-session) guard are all UNCHANGED.
215
267
 
216
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
217
269
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -38,13 +38,15 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
38
38
 
39
39
  ## Highlights
40
40
 
41
- - **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama behind a uniform JSON tool loop. OAuth login from the input box (`/provider login`), every model pick persists as the new default.
41
+ - **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama / LM Studio, plus 20+ OpenAI- and Anthropic-compatible clouds (Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai, …), all behind one uniform JSON tool loop. OAuth login from the input box (`/provider login`), every model pick persists as the new default.
42
42
  - **Edit integrity** — read output carries content anchors (`42ab|`); anchored edits are verified against the current file, re-mapped when lines shifted, and rejected with fresh content instead of corrupting.
43
43
  - **Self-correcting verification loop** — configure a post-edit hook (tsc / eslint / tests) and the agent *sees* the diagnostics and fixes them in-loop; a red hook blocks `done` until resolved.
44
44
  - **Real gates, no theater** — `ralplan` consensus is a repo-grounded critic subagent whose `[OKAY]` verdict is persisted and *required* by `jeo approve`; `ultragoal` reports honestly (a suite run is a global signal, never fabricated per-criterion passes).
45
45
  - **Crash-durable, local-first** — all state under `.jeo/` with atomic writes, cross-process run locks, failed-task markers with partial-edit warnings on resume.
46
46
  - **Dynamic step budget** — turns extend while the tool window shows novel progress and consolidate gracefully when stalled; subagents keep exact step contracts.
47
47
  - **Inline TUI** — completed work flushes into real scrollback (tmux wheel works mid-turn), the normal query input box stays visible and editable while the agent runs, Ctrl+O toggles full detail, themes, clipboard image paste (Ctrl+V), CJK/emoji-safe width math.
48
+ - **Browser tool** — headless Chromium automation (Playwright) as a first-class agent tool: `open`/`close`/`run`/`act` on named, reused tabs, with `observe`-tagged element ids preferred over screenshots for driving pages. Requires `npx playwright install chromium` once (not bundled — jeo stays zero native deps itself, the browser binary is Playwright's separate download).
49
+ - **Remote subagent visibility (Telegram)** — pair a bot once (`jeo notify setup`), then `jeo daemon start` pushes a message on every subagent state edge (started → done/failed/cancelled) and accepts `/subagents`, `/steer <id> <subagentId> <msg>`, `/cancel <id> <subagentId>` back — commands are authorized to the paired chat only.
48
50
 
49
51
  ## Install
50
52
 
@@ -164,6 +166,31 @@ Non-zero hook output is appended to the tool result the model reads (deduped per
164
166
 
165
167
  `--worktree <name>` runs jeo in an isolated sibling git worktree (reused if the path exists, else created on a branch named after the basename) so risky or reviewable work never touches your main checkout. `jeo mcp serve` exposes jeo's tools to any MCP-capable controller over stdio (`jeo mcp tools` lists them). Add `-q`/`--quiet` (or `JEO_QUIET=1`) to suppress startup banners, the welcome animation, release notes, and resume hints so jeo runs cleanly beside another agent or is driven by a bot — `-p`/`--print` implies quiet.
166
168
 
169
+ ## Remote monitoring & control (Telegram)
170
+
171
+ ```bash
172
+ jeo notify setup # pair a BotFather bot once (getMe verification + chat-id pairing)
173
+ jeo notify status # masked token, paired chat id, daemon state
174
+ jeo daemon start # spawn the singleton background daemon
175
+ jeo daemon status # check whether it's running
176
+ jeo daemon stop # SIGTERM it
177
+ ```
178
+
179
+ ```
180
+ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
181
+ │ interactive turn │◄──ws──►│ notify daemon │◄─poll──►│ Telegram bot │
182
+ │ SubagentRegistry │ │ (singleton) │ │ (paired chat) │
183
+ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
184
+ ```
185
+
186
+ Opt-in and lazy: nothing binds until `notifications.enabled` is set AND a detached subagent (`task {detached:true}`) actually runs. The daemon scans live session discovery files, connects a loopback WebSocket per session, and pushes a message only on a subagent state *edge* (started → completed/failed/cancelled) — never a repeated "still running" ping. Inbound Telegram commands are authorized to the paired chat only; anything else is dropped silently.
187
+
188
+ | Command | Effect |
189
+ | --- | --- |
190
+ | `/subagents` | List running/recent subagents across every connected session |
191
+ | `/steer <sessionId> <subagentId> <message>` | Send a live message into a running subagent |
192
+ | `/cancel <sessionId> <subagentId>` | Cancel a running subagent |
193
+ | `/help` | Show the command reference |
167
194
 
168
195
  ## Local models
169
196
 
@@ -234,11 +261,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
234
261
  ## Changelog
235
262
 
236
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.
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.
237
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.
238
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.
239
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.
240
- - **[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).
241
- - **[0.7.36]** (2026-07-04) — Removes two specific guardrails per explicit user direction: `jeo approve`'s human-only identity gate (the agent can now approve its own reviewed `jeo ralplan` plan) and the disk-staleness write/edit clobber guard (a write/edit no longer refuses just because the file changed on disk since the last read). Both removals were scoped narrowly after enumerating every guardrail in the codebase — the catastrophic-command hard block (`rm -rf /`, fork bombs, raw-disk `dd`/`mkfs`), the read-only subagent role tool gating, the deep-interview mutation lock, and the blind-edit (never-read-this-session) guard are all UNCHANGED.
242
269
 
243
270
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
244
271
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -38,13 +38,15 @@
38
38
 
39
39
  ## 亮点
40
40
 
41
- - **多提供商、单一循环** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama 统一在一个 JSON 工具循环中。输入框内直接 OAuth 登录(`/provider login`),模型选择即刻持久化为默认值。
41
+ - **多提供商、单一循环** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio,以及 20+ 个 OpenAI、Anthropic 兼容云服务(Groq、DeepSeek、Mistral、OpenRouter、xAI、Kimi、z.ai 等),统一在一个 JSON 工具循环中。输入框内直接 OAuth 登录(`/provider login`),模型选择即刻持久化为默认值。
42
42
  - **编辑完整性** — read 输出携带内容锚点(`42ab|`);带锚点的编辑会与当前文件校验、行移动时自动重映射、不匹配时连同最新内容一起拒绝 — 绝不污染文件。
43
43
  - **自我修正的验证循环** — 配置 post-edit 钩子(tsc / eslint / 测试),代理会*亲自读取*诊断并在循环内修复;钩子未通过时 `done` 会被阻断。
44
44
  - **没有表演的真实门禁** — `ralplan` 共识由真正读取仓库的 critic 子代理执行,`[OKAY]` 裁决被持久化且 `jeo approve` *强制要求*它;`ultragoal` 诚实报告(套件运行只是全局信号,绝不伪造逐条通过)。
45
45
  - **崩溃耐久、本地优先** — 全部状态位于 `.jeo/`,原子写入、跨进程运行锁、失败任务标记 + 恢复时的部分编辑警告。
46
46
  - **动态步数预算** — 只要近期工具调用展现新的进展就持续延长,停滞时优雅收敛为总结;子代理保持精确的步数契约。
47
47
  - **内联 TUI** — 已完成的工作流入真实滚动缓冲区(回合中也可用 tmux 滚轮),代理运行时普通查询输入框仍保持可见并可编辑。Ctrl+O 详细信息切换、主题、剪贴板图片粘贴(Ctrl+V)、CJK/表情安全的宽度计算。
48
+ - **浏览器工具** — 基于 Playwright 的无头 Chromium 自动化,作为一等代理工具:对命名并复用的标签页执行 `open`/`close`/`run`/`act`,优先使用 `observe` 标记的元素 id 而非截图来驱动页面。需要执行一次 `npx playwright install chromium`(未捆绑 — jeo 本身仍是零原生依赖,浏览器二进制文件是 Playwright 的独立下载项)。
49
+ - **远程子代理可见性(Telegram)** — 配对一次机器人(`jeo notify setup`)后,`jeo daemon start` 会在子代理每次状态变化(启动 → 完成/失败/取消)时推送消息,并接受 `/subagents`、`/steer <id> <subagentId> <msg>`、`/cancel <id> <subagentId>` 回传 — 命令仅对配对的聊天授权。
48
50
 
49
51
  ## 安装
50
52
 
@@ -163,6 +165,31 @@ jeo ultragoal
163
165
 
164
166
  `--worktree <name>` 在隔离的同级 git worktree 中运行 jeo(路径存在则复用,否则以 basename 分支创建),因此有风险或需审查的工作绝不会触及您的主检出。`jeo mcp serve` 通过 stdio 向任何支持 MCP 的控制器公开 jeo 的工具(用 `jeo mcp tools` 列出)。添加 `-q`/`--quiet`(或 `JEO_QUIET=1`)可抑制启动横幅、欢迎动画、发布说明和恢复提示,使 jeo 能够与其他代理并行运行或由机器人驱动。`-p`/`--print` 隐含 quiet。
165
167
 
168
+ ## 远程监控与控制 (Telegram)
169
+
170
+ ```bash
171
+ jeo notify setup # 配对一次 BotFather 机器人(getMe 校验 + chat-id 配对)
172
+ jeo notify status # 已遮蔽的令牌、已配对 chat id、守护进程状态
173
+ jeo daemon start # 启动单例后台守护进程
174
+ jeo daemon status # 检查是否正在运行
175
+ jeo daemon stop # 发送 SIGTERM 停止
176
+ ```
177
+
178
+ ```
179
+ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
180
+ │ interactive turn │◄──ws──►│ notify daemon │◄─poll──►│ Telegram bot │
181
+ │ SubagentRegistry │ │ (singleton) │ │ (paired chat) │
182
+ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
183
+ ```
184
+
185
+ 默认关闭、延迟绑定:只有设置了 `notifications.enabled` 且真正运行了一个 detached 子代理(`task {detached:true}`)才会绑定。守护进程扫描存活的会话发现文件,为每个会话建立一条回环 WebSocket,并且只在子代理状态发生*变化*时(启动 → 完成/失败/取消)推送消息 — 绝不会重复推送"仍在运行"这类状态。收到的 Telegram 命令仅对已配对的聊天授权,其余一律静默丢弃。
186
+
187
+ | 命令 | 效果 |
188
+ | --- | --- |
189
+ | `/subagents` | 列出所有已连接会话中正在运行/最近的子代理 |
190
+ | `/steer <sessionId> <subagentId> <message>` | 向正在运行的子代理发送实时消息 |
191
+ | `/cancel <sessionId> <subagentId>` | 取消正在运行的子代理 |
192
+ | `/help` | 显示命令参考 |
166
193
 
167
194
  ## 本地模型
168
195
 
@@ -186,11 +213,36 @@ JEO_TUI_ALT_SCREEN=1 # 旧版 alt-screen 回合(默认: 内联滚动
186
213
  JEO_STEP_BASE=24 # 动态步数预算的滚动基数
187
214
  JEO_STEP_HARD_CAP=600 # 绝对终止保证
188
215
  JEO_STREAM_MAX_MS=300000 # 可选的整体流截止(默认关闭; 约束慢滴流)
216
+ JEO_STREAM_IDLE_MS=300000 # 单次分块空闲上限(默认300秒); 首个 token 前静默较久的慢速/本地后端可调高
189
217
  JEO_TOOL_OUTPUT_MAX=4000 # 模型可见的工具输出上限(全文溢出到 artifacts)
190
218
  ```
191
219
 
192
220
  重试行为通过 `~/.jeo/config.json` 的 `retry` 调整(`requestMaxRetries`、`streamMaxRetries`、`rateLimitRetries`、`failFastStatuses` 等)。步数预算默认动态 — 只要看到新的进展就延长,停滞时收敛为总结;`--max-steps N` 恢复有界流程。
193
221
 
222
+ ## 技能迁移与内置技能检查
223
+
224
+ 把某个工作流迁移到 jeo 之前,先检查内置默认值,再决定是否安装或覆盖任何内容:
225
+
226
+ ```bash
227
+ jeo skills list # 内置 + 用户 + 项目技能,附带发现目录
228
+ jeo skills read ralplan # 打印某个技能的完整 SKILL.md
229
+ jeo skills sync --check # 报告与 ~/.jeo/skills 的差异(有差异时非零退出)
230
+ ```
231
+
232
+ `jeo skills sync` 会把内置的工作流技能(deep-interview、deep-dive、ralplan、team、ultragoal)安装到 `~/.jeo/skills`,并且**默认保留已有的本地文件** —— 不同的本地副本会被报告为 `preserved`,绝不会被覆盖。如果 `--check` 标记出缺失或不同的文件,先用 `jeo skills read <name>` 对比;只有在确实想替换本地默认工作流技能文件时才使用 `jeo skills sync --force`。可通过末尾路径参数(或 `JEO_CONFIG_DIR`)指定不同目录,并加上 `--json` 获取结构化的 `SkillSyncResult`。
233
+
234
+ ## 开发
235
+
236
+ jeo 是运行在 Bun 上的纯 TypeScript,**零原生依赖**,因此全局 `jeo` 命令可以直接运行本仓库的源码 —— 无需构建步骤,每次编辑立即生效。
237
+
238
+ ```bash
239
+ bun install
240
+ bun run dev:link # 将 `jeo` 软链接到 <repo>/src/cli.ts -> ~/.local/bin
241
+ bun run dev:doctor # 报告全局 `jeo` 是否运行的是本源码(linked/drift/missing)
242
+ ```
243
+
244
+ 如果 `PATH` 中在受管链接之前还有另一个 `jeo` 遮蔽了它,`dev:link` 会拒绝继续(可用 `JEO_DEV_LINK_DIR` 覆盖目标位置),并运行一次 `--version` 冒烟测试。当解析出的 `jeo` 是编译后的二进制文件或已安装副本而非本源码时,`dev:doctor` 以非零状态退出。无需链接直接从源码运行: `bun src/cli.ts --help`。内置工作流技能位于源码 `src/prompts/skills/<name>/SKILL.md`;用 `bun run typecheck` 和 `bun test` 验证。
245
+
194
246
  ## 发布 (Publishing)
195
247
 
196
248
  CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release 时自动触发,或手动 `workflow_dispatch`(可选 dry-run)。工作流执行类型检查、测试、令牌校验(`npm whoami`)后运行 `npm publish --provenance`。
@@ -207,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
207
259
  ## 更新日志 (Changelog)
208
260
 
209
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.
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.
210
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.
211
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.
212
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.
213
- - **[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).
214
- - **[0.7.36]** (2026-07-04) — Removes two specific guardrails per explicit user direction: `jeo approve`'s human-only identity gate (the agent can now approve its own reviewed `jeo ralplan` plan) and the disk-staleness write/edit clobber guard (a write/edit no longer refuses just because the file changed on disk since the last read). Both removals were scoped narrowly after enumerating every guardrail in the codebase — the catastrophic-command hard block (`rm -rf /`, fork bombs, raw-disk `dd`/`mkfs`), the read-only subagent role tool gating, the deep-interview mutation lock, and the blind-edit (never-read-this-session) guard are all UNCHANGED.
215
267
 
216
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
217
269
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.40",
3
+ "version": "0.7.42",
4
4
 
5
5
 
6
6
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
@@ -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: opts.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.
@@ -735,12 +770,32 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
735
770
  }
736
771
  // Nothing left to strip — fall through to the backoff rung below.
737
772
  }
738
- // Rung 4: every context mutation is spent. Backoff-resend forever (abortable).
773
+ // Rung 4: every context mutation is spent. A `Refusal (<category>)`-shaped
774
+ // error (the structural shape Anthropic uses for a classification of the
775
+ // REQUEST CONTENT ITSELF, e.g. reasoning_extraction) is deterministic — by
776
+ // this rung context is already minimized, so every further resend is
777
+ // identical and will re-refuse identically. Fail fast with the category-aware
778
+ // message instead of spinning the backoff for the full stall budget. Every
779
+ // OTHER refusal shape (bare stop_reason=refusal, finish_reason=content_filter,
780
+ // Gemini finishReason=SAFETY/etc.) genuinely can clear as a rolling classifier
781
+ // resets, so those still get the unbounded capped backoff below.
782
+ const category = /Refusal \(\w+\)/i.test((err as Error)?.message ?? String(err));
783
+ if (category) {
784
+ return finish({ done: false, steps: step, doneReason: `Error: ${friendlyProviderError(err)}` });
785
+ }
786
+ // Backoff-resend forever (abortable).
739
787
  const attempt = Math.max(1, refusalRetries - MAX_REFUSAL_RETRIES);
740
788
  const baseRaw = Number(jeoEnv("REFUSAL_BACKOFF_BASE_MS") ?? NaN);
741
789
  const baseMs = Number.isFinite(baseRaw) && baseRaw >= 0 ? baseRaw : GUARD_LIMITS.REFUSAL_BACKOFF_BASE_MS;
742
790
  const delayMs = Math.min(baseMs * 2 ** (attempt - 1), GUARD_LIMITS.REFUSAL_BACKOFF_MAX_MS);
743
- ev.onNotice?.(`provider refused again — auto-retry #${attempt} in ${Math.max(1, Math.round(delayMs / 1000))}s (Esc to cancel)`);
791
+ // "(Esc to cancel)" only makes sense where a live interactive Esc handler
792
+ // exists — that's the TUI, which is exactly the consumer that attaches
793
+ // onModelStream (see the streaming comment above: "the engine streams
794
+ // solely for the TUI"). One-shot/non-interactive callers (e.g. `jeo team`)
795
+ // leave onModelStream unset and have no live Esc handler, only the
796
+ // JEO_TURN_MAX_MS stall budget — showing the hint there is misleading.
797
+ const escHint = ev.onModelStream ? " (Esc to cancel)" : "";
798
+ ev.onNotice?.(`provider refused again — auto-retry #${attempt} in ${Math.max(1, Math.round(delayMs / 1000))}s${escHint}`);
744
799
  await waitAbortable(delayMs, opts.signal);
745
800
  continue; // loop top surfaces "Cancelled." when the wait was aborted
746
801
  }
@@ -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
- /** Opt-in overall stream wall-clock from the environment; undefined = off (default). */
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
- const n = raw !== undefined ? parseInt(raw, 10) : NaN;
583
- return Number.isFinite(n) && n > 0 ? n : undefined;
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.
@@ -407,12 +407,19 @@ export function totalInputTokens(u: AnthropicUsage): number {
407
407
 
408
408
  /** Round-5 #1: HTTP-200-with-no-text must surface its CAUSE (stop_reason) instead
409
409
  * of returning "" — an empty reply just bounces in the JSON loop, burning billed
410
- * calls until the step budget dies. Mirrors gemini's blockedReason contract. */
411
- function emptyCompletionError(stopReason: string | undefined): Error {
410
+ * calls until the step budget dies. Mirrors gemini's blockedReason contract.
411
+ * `category` (from `stop_details.category`, e.g. "reasoning_extraction") is
412
+ * defensive: today's live refusal-category delivery is an HTTP-error message
413
+ * (see providerHttpError), but if Anthropic ever ships a category via a 200
414
+ * body/stream event instead, folding it in here keeps friendlyProviderError's
415
+ * category-aware clarifying note working instead of silently degrading to the
416
+ * generic refusal copy. Absent category keeps the existing plain shape unchanged. */
417
+ function emptyCompletionError(stopReason: string | undefined, category?: string): Error {
412
418
  const hint = stopReason === "max_tokens"
413
419
  ? " — output budget exhausted before any text; raise maxTokens or lower the thinking level"
414
420
  : "";
415
- return new Error(`Anthropic returned no content${stopReason ? ` (stop_reason=${stopReason})` : ""}${hint}.`);
421
+ const reason = stopReason ? (category ? `stop_reason=${stopReason}, category=${category}` : `stop_reason=${stopReason}`) : undefined;
422
+ return new Error(`Anthropic returned no content${reason ? ` (${reason})` : ""}${hint}.`);
416
423
  }
417
424
 
418
425
  export const anthropicAdapter: ProviderAdapter = {
@@ -420,7 +427,7 @@ export const anthropicAdapter: ProviderAdapter = {
420
427
  supportsNativeTools: true,
421
428
  async call(messages, options, credential) {
422
429
  const response = await postAnthropic(messages, options, credential, false);
423
- const result = (await response.json()) as { content: { type: string; text?: string; name?: string; input?: unknown; thinking?: string; signature?: string; data?: string }[]; stop_reason?: string; usage?: AnthropicUsage };
430
+ const result = (await response.json()) as { content: { type: string; text?: string; name?: string; input?: unknown; thinking?: string; signature?: string; data?: string }[]; stop_reason?: string; stop_details?: { category?: string }; usage?: AnthropicUsage };
424
431
  if (result.usage) options.onUsage?.({ inputTokens: totalInputTokens(result.usage), outputTokens: result.usage.output_tokens });
425
432
  // Capture thinking/redacted blocks as replay artifacts (parity with the stream path).
426
433
  for (const c of result.content) {
@@ -438,7 +445,7 @@ export const anthropicAdapter: ProviderAdapter = {
438
445
  );
439
446
  if (toolCall) return toolCall;
440
447
  const text = result.content.find(c => c.type === "text")?.text ?? "";
441
- if (!text) throw emptyCompletionError(result.stop_reason);
448
+ if (!text) throw emptyCompletionError(result.stop_reason, result.stop_details?.category);
442
449
  return text;
443
450
  },
444
451
  async *stream(messages, options, credential) {
@@ -447,6 +454,7 @@ export const anthropicAdapter: ProviderAdapter = {
447
454
  let cachedInput: number | undefined;
448
455
  let yieldedAny = false;
449
456
  let stopReason: string | undefined;
457
+ let stopCategory: string | undefined;
450
458
  // Native tool_use streams as content_block_start (name) + input_json_delta fragments,
451
459
  // never as text_delta — accumulate per block index, then re-serialize to canonical
452
460
  // JSON and yield it once at the end (concatenation still equals call()).
@@ -460,8 +468,8 @@ export const anthropicAdapter: ProviderAdapter = {
460
468
  type?: string;
461
469
  index?: number;
462
470
  content_block?: { type?: string; name?: string; data?: string };
463
- delta?: { type?: string; text?: string; partial_json?: string; thinking?: string; signature?: string; stop_reason?: string };
464
- message?: { usage?: AnthropicUsage; stop_reason?: string };
471
+ delta?: { type?: string; text?: string; partial_json?: string; thinking?: string; signature?: string; stop_reason?: string; stop_details?: { category?: string } };
472
+ message?: { usage?: AnthropicUsage; stop_reason?: string; stop_details?: { category?: string } };
465
473
  usage?: { output_tokens?: number };
466
474
  };
467
475
  try {
@@ -499,6 +507,7 @@ export const anthropicAdapter: ProviderAdapter = {
499
507
  thinkBlocks.set(evt.index, tb);
500
508
  } else if (evt.type === "message_start" && evt.message) {
501
509
  if (evt.message.stop_reason) stopReason = evt.message.stop_reason;
510
+ if (evt.message.stop_details?.category) stopCategory = evt.message.stop_details.category;
502
511
  if (evt.message.usage) {
503
512
  // Cache only — usage is reported ONCE at message_delta so an accumulating
504
513
  // sink can't double-count input (and a pre-first-chunk retry that replays
@@ -507,6 +516,7 @@ export const anthropicAdapter: ProviderAdapter = {
507
516
  }
508
517
  } else if (evt.type === "message_delta") {
509
518
  if (evt.delta?.stop_reason) stopReason = evt.delta.stop_reason;
519
+ if (evt.delta?.stop_details?.category) stopCategory = evt.delta.stop_details.category;
510
520
  if (evt.usage) options.onUsage?.({ inputTokens: cachedInput, outputTokens: evt.usage.output_tokens });
511
521
  }
512
522
  }
@@ -519,7 +529,7 @@ export const anthropicAdapter: ProviderAdapter = {
519
529
  }
520
530
  const envelope = serializeAccumulatedToolCalls(toolBlocks);
521
531
  if (envelope) { yieldedAny = true; yield envelope; }
522
- if (!yieldedAny) throw emptyCompletionError(stopReason);
532
+ if (!yieldedAny) throw emptyCompletionError(stopReason, stopCategory);
523
533
  },
524
534
  };
525
535
  function mapStainlessOs(platform: string): "MacOS" | "Windows" | "Linux" | "FreeBSD" | `Other::${string}` {
@@ -234,6 +234,16 @@ interface CcaChunk {
234
234
  usageMetadata?: CcaUsage;
235
235
  };
236
236
  }
237
+ /** When Antigravity (Cloud Code Assist) returns HTTP 200 with no text, surface the
238
+ * real cause (safety block / RECITATION / etc.) instead of a silent, reason-free
239
+ * empty response — mirrors gemini.ts's `blockedReason` (same message wording, so
240
+ * `defaultRetryable`'s "returned no content" transient-empty-200 branch and
241
+ * `isRefusalError`'s `finishReason=` matcher both apply consistently). */
242
+ function finishReasonOf(chunk: CcaChunk): string | undefined {
243
+ const finish = chunk.response?.candidates?.[0]?.finishReason;
244
+ if (finish && finish !== "STOP") return `finishReason=${finish}`;
245
+ return undefined;
246
+ }
237
247
 
238
248
  function textOf(chunk: CcaChunk): string {
239
249
  return chunk.response?.candidates?.[0]?.content?.parts?.filter(p => !p.thought).map(p => p.text ?? "").join("") ?? "";
@@ -279,13 +289,16 @@ export const antigravityAdapter: ProviderAdapter = {
279
289
  if (!response.body) return "";
280
290
  let out = "";
281
291
  let usage: CcaUsage | undefined;
292
+ let lastEmptyReason: string | undefined;
282
293
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
283
294
  for await (const data of readSse(response.body)) {
284
295
  let chunk: CcaChunk;
285
296
  try { chunk = JSON.parse(data); } catch { continue; }
286
297
  const thought = thoughtOf(chunk);
287
298
  if (thought) options.onReasoning?.(thought);
288
- out += textOf(chunk);
299
+ const delta = textOf(chunk);
300
+ if (delta) out += delta;
301
+ else lastEmptyReason = finishReasonOf(chunk) ?? lastEmptyReason;
289
302
  fnCalls.push(...functionCallsOf(chunk));
290
303
  if (chunk.response?.usageMetadata) usage = chunk.response.usageMetadata;
291
304
  }
@@ -293,7 +306,7 @@ export const antigravityAdapter: ProviderAdapter = {
293
306
  // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
294
307
  const envelope = serializeToolCalls(fnCalls);
295
308
  if (envelope) return envelope;
296
- if (!out) throw new Error("Antigravity Cloud Code Assist returned an empty response.");
309
+ if (!out) throw new Error(`Antigravity Cloud Code Assist returned no content${lastEmptyReason ? ` (${lastEmptyReason})` : ""}.`);
297
310
  return out;
298
311
  },
299
312
  async *stream(messages, options, credential) {
@@ -304,6 +317,7 @@ export const antigravityAdapter: ProviderAdapter = {
304
317
  if (antigravityThinkingActive(options)) options.onReasoningStart?.();
305
318
  let yielded = false;
306
319
  let usage: CcaUsage | undefined;
320
+ let lastEmptyReason: string | undefined;
307
321
  const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
308
322
  for await (const data of readSse(response.body, options.onStreamActivity)) {
309
323
  let chunk: CcaChunk;
@@ -312,6 +326,7 @@ export const antigravityAdapter: ProviderAdapter = {
312
326
  if (thought) options.onReasoning?.(thought);
313
327
  const delta = textOf(chunk);
314
328
  if (delta) { yielded = true; yield delta; }
329
+ else lastEmptyReason = finishReasonOf(chunk) ?? lastEmptyReason;
315
330
  fnCalls.push(...functionCallsOf(chunk));
316
331
  if (chunk.response?.usageMetadata) usage = chunk.response.usageMetadata;
317
332
  }
@@ -319,6 +334,6 @@ export const antigravityAdapter: ProviderAdapter = {
319
334
  // Native tool calls have no text deltas — yield the re-serialized envelope once at end.
320
335
  const envelope = serializeToolCalls(fnCalls);
321
336
  if (envelope) { yielded = true; yield envelope; }
322
- if (!yielded) throw new Error("Antigravity Cloud Code Assist returned an empty response.");
337
+ if (!yielded) throw new Error(`Antigravity Cloud Code Assist returned no content${lastEmptyReason ? ` (${lastEmptyReason})` : ""}.`);
323
338
  },
324
339
  };
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
- this.write = opts.write ?? ((s: string) => process.stdout.write(s));
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.
@@ -62,7 +62,7 @@ export function friendlyProviderError(err: unknown): string {
62
62
  }
63
63
  if (isRefusalError(err)) {
64
64
  const base = `${provider} declined to answer (safety refusal — no content returned). Usually a content classifier tripped on recently read file/search content: /retry, /compact or /new to drop the triggering context, or switch model with /model. If this persists on a Claude subscription (OAuth) login, Anthropic restricts third-party OAuth clients — set ANTHROPIC_API_KEY or use another provider.`;
65
- const category = /Refusal \((\w+)\)/i.exec(msg)?.[1];
65
+ const category = /Refusal \((\w+)\)/i.exec(msg)?.[1] ?? /category=(\w+)/i.exec(msg)?.[1];
66
66
  if (category === "reasoning_extraction") {
67
67
  return `${base} (Category: reasoning_extraction — this classifier flags requests that look like probing the model's internal reasoning; jeo's own thinking-block replay across multi-step tool use is a common benign trigger, not an actual violation. /retry after a /compact usually clears it.)`;
68
68
  }
package/src/util/retry.ts CHANGED
@@ -282,5 +282,5 @@ export function isUsageLimitError(err: unknown): boolean {
282
282
  * capped backoff — gjc parity: a refusal is never terminal) is the correct recovery
283
283
  * layer because it MUTATES the context between attempts. */
284
284
  export function isRefusalError(err: unknown): boolean {
285
- return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)|Refusal \(\w+\)/i.test(errorMessageOf(err));
285
+ return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|(?:block|finish)Reason=(?:SAFETY|PROHIBITED_CONTENT|BLOCKLIST|RECITATION|SPII)|Refusal \(\w+\)/i.test(errorMessageOf(err));
286
286
  }