jeo-code 0.8.20 → 0.8.22
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 +35 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +20 -2
- package/src/agent/prompt-router.ts +32 -15
- package/src/agent/step-budget.ts +44 -4
- package/src/agent/task-tool.ts +2 -25
- package/src/ai/model-catalog.ts +45 -7
- package/src/commands/launch/input.ts +13 -0
- package/src/commands/launch.ts +165 -20
- package/src/tui/app.ts +19 -1
- package/src/tui/terminal.ts +89 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,41 @@ 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.8.22] - 2026-07-11
|
|
10
|
+
_A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Full-transcript restore on `/resume`/`--continue`** (`src/commands/launch.ts`) — all three non-interactive startup resume paths (`--resume <id>`, `--continue`/`-c`, and the TTY-less bare `--resume`) previously printed only `"Resumed session <id> (N messages)."` with no reproduction of the prior conversation. They now render the same `formatTranscript` ledger (user prompt boxes, ✔/✗ tool-call lines, `jeo ◂` reply blocks) that the mid-session `/session resume` and `/history` commands already used, so resuming any session created going forward reproduces the prior work on screen instead of a one-line summary. Live-verified against a real on-disk session and a real `bun run src/cli.ts launch --resume <id>` child process.
|
|
14
|
+
- **Resize-detection safety net** (`src/tui/terminal.ts`'s `watchResize`/`liveWindowSize`, wired into `src/tui/app.ts`'s `LaunchTui`) — a 300ms poll against the real `TIOCGWINSZ` ioctl (`getWindowSize()`) that self-heals `process.stdout.columns`/`.rows` and repaints when a `'resize'` (SIGWINCH) event is missed — e.g. a tmux pane switch while jeo's pane isn't foregrounded, or a SIGCONT race after Ctrl-Z. Composes safely alongside the existing `'resize'` listener; started in `begin()`, torn down in `finish()`.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **`updateSessionDraft` trim bug** (`src/agent/session.ts`) — the no-op comparison checked `draft.trim()` for truthiness but persisted the untrimmed original string; now trims once and persists the trimmed value consistently.
|
|
18
|
+
|
|
19
|
+
### Verified
|
|
20
|
+
- Full `bun test` — 2906 pass / 0 fail across 291 files.
|
|
21
|
+
- `bun run typecheck` — no errors.
|
|
22
|
+
- Live PTY/CLI verification of the resume-transcript restore end-to-end (real session file, real child-process `launch --resume`).
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## [0.8.21] - 2026-07-10
|
|
26
|
+
_"반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping._
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- **Session draft-restore** (`src/agent/session.ts`, `src/commands/launch/session-slash.ts`, `src/commands/launch.ts`, `src/commands/launch/input.ts`) — an unsent, half-typed prompt now survives a hard exit (Ctrl+D/EOF/disconnected terminal) and is restored into the prompt box on `/resume` (gajae-code parity). The read-side plumbing (`updateSessionDraft`, `header.draft`, `runSessionSlash` surfacing it) already existed; `launch.ts` never consumed it. Added `persistSessionDraft` (mirrors the existing `persistSessionModel` pattern) and a new pure predicate `draftFromUnsentLine` (`launch/input.ts`), wired into all 3 resume paths — the interactive picker, the mid-loop `/session resume` command, and `--resume <id>`/`--continue`'s non-interactive startup path (this third path had NO draft restoration at all before this fix). Live-verified: seeded a real session's draft, launched the real `bun src/cli.ts --resume <id>` under a genuine PTY, confirmed the exact unsent text appeared in the live prompt box.
|
|
30
|
+
- **Evidence-gated goal verification** (`src/agent/goal-verifier.ts`, `src/agent/engine.ts`, `src/commands/launch.ts`) — an LLM-judged `MET` goal verdict is now deterministically downgraded to `NOT_MET` when the turn mutated files with no fresh verification evidence, closing the "gate theater" failure mode (an LLM asserting success without re-checked evidence). `applyEvidenceGate` (pure, already unit-tested with 5 tests) existed but was never called anywhere — the same disconnected-scaffolding pattern `RouteHistory` shipped with in 0.8.16/0.8.17. `onBeforeDone`'s signature now carries the SAME `sawMutation`/`sawVerification`/`verificationStale` signals the engine's own done-gate already computes; `launch.ts`'s goal-verifier closure now gates `verifyGoal`'s raw verdict through it before persisting or acting on it.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **`toolTarget` dedup** (`src/agent/step-budget.ts`, `src/agent/task-tool.ts`) — `task-tool.ts` had a private copy of the same coarse-call-target labeler `step-budget.ts` needed for a new edit-thrashing novelty rule (a model rewriting the same file with different content every attempt no longer earns endless step-budget extensions — only the first attempt at a given target counts as novel). Moved to `step-budget.ts` (exported), `task-tool.ts` now imports it — confirmed byte-identical, zero behavior change.
|
|
34
|
+
|
|
35
|
+
### Added (catalog)
|
|
36
|
+
- **`gpt-5.6`** (`src/ai/model-catalog.ts`) — OpenAI's newest catalogued model, consistent with existing entries; correctly excluded from the OAuth-only `CODEX_MODELS` allow-list (live discovery handles that drift per its own documented contract, not a static edit).
|
|
37
|
+
|
|
38
|
+
### Verified
|
|
39
|
+
- `bun test test/launch-flags.test.ts test/session-draft-restore.test.ts test/step-budget.test.ts test/goal-verifier.test.ts test/task-tool.test.ts test/doctor.test.ts test/prompt-router-tiers.test.ts test/model-catalog.test.ts` — 128 pass / 0 fail.
|
|
40
|
+
- Full `bun test` — 2906 pass / 0 fail across 291 files.
|
|
41
|
+
- `bun run typecheck` — no errors.
|
|
42
|
+
- Live PTY end-to-end: real session seeded with `updateSessionDraft`, real `bun src/cli.ts --resume <id>` subprocess, confirmed the exact restored text in the live prompt box.
|
|
43
|
+
|
|
9
44
|
## [0.8.20] - 2026-07-10
|
|
10
45
|
_"모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here._
|
|
11
46
|
|
package/README.ja.md
CHANGED
|
@@ -265,11 +265,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
265
265
|
## 変更履歴 (Changelog)
|
|
266
266
|
|
|
267
267
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
268
|
+
- **[0.8.22]** (2026-07-11) — A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping.
|
|
269
|
+
- **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
|
|
268
270
|
- **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
|
|
269
271
|
- **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
|
|
270
272
|
- **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
|
|
271
|
-
- **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
|
|
272
|
-
- **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
|
|
273
273
|
|
|
274
274
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
275
275
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -266,11 +266,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
266
266
|
## 변경 이력 (Changelog)
|
|
267
267
|
|
|
268
268
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
269
|
+
- **[0.8.22]** (2026-07-11) — A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping.
|
|
270
|
+
- **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
|
|
269
271
|
- **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
|
|
270
272
|
- **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
|
|
271
273
|
- **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
|
|
272
|
-
- **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
|
|
273
|
-
- **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
|
|
274
274
|
|
|
275
275
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
276
276
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -273,11 +273,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
273
273
|
## Changelog
|
|
274
274
|
|
|
275
275
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
276
|
+
- **[0.8.22]** (2026-07-11) — A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping.
|
|
277
|
+
- **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
|
|
276
278
|
- **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
|
|
277
279
|
- **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
|
|
278
280
|
- **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
|
|
279
|
-
- **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
|
|
280
|
-
- **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
|
|
281
281
|
|
|
282
282
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
283
283
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -265,11 +265,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
265
265
|
## 更新日志 (Changelog)
|
|
266
266
|
|
|
267
267
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
268
|
+
- **[0.8.22]** (2026-07-11) — A rescan of the working tree turned up a resume-fidelity gap plus two unwired TUI safety-net helpers left by a concurrent session; each was traced, wired where needed, and verified before shipping.
|
|
269
|
+
- **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
|
|
268
270
|
- **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
|
|
269
271
|
- **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
|
|
270
272
|
- **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
|
|
271
|
-
- **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
|
|
272
|
-
- **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
|
|
273
273
|
|
|
274
274
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
275
275
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -27,7 +27,8 @@ import { isContextOverflowError, isRefusalError, friendlyProviderError } from ".
|
|
|
27
27
|
import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
|
|
28
28
|
import { truncateToolOutput, formatToolResultBody } from "./tool-output";
|
|
29
29
|
export { TOOL_OUTPUT_MAX, READ_OUTPUT_MAX, TOOL_SPILL_THRESHOLD, MAX_TOOL_ARTIFACTS, truncateToolOutput, spillToolResult } from "./tool-output";
|
|
30
|
-
import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, type StepBudgetConfig } from "./step-budget";
|
|
30
|
+
import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, toolTarget, type StepBudgetConfig } from "./step-budget";
|
|
31
|
+
|
|
31
32
|
import { historyTokens, trimToolResultsInPlace, stripReasoningArtifactsInPlace } from "./compaction";
|
|
32
33
|
import { jeoEnv } from "../util/env";
|
|
33
34
|
import { GUARD_LIMITS, isVerificationSignal, repeatHint, classifyDoneGate } from "./loop-guards";
|
|
@@ -1058,6 +1059,22 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1058
1059
|
// once per call here (instead of joining full sigs, hashing, then re-hashing
|
|
1059
1060
|
// inside budget.record) avoids re-scanning a large write body 3× per step.
|
|
1060
1061
|
const callSigs = toolCalls.map(c => hashSignature(`${c.tool}:${JSON.stringify(c.arguments ?? {})}`));
|
|
1062
|
+
// Coarse per-call TARGET (e.g. `edit src/x.ts`) — feeds the budget's novelty rule
|
|
1063
|
+
// separately from the exact-args digest above. A model that keeps rewriting the
|
|
1064
|
+
// SAME file/command with slightly different content each attempt produces a fresh
|
|
1065
|
+
// `callSigs` entry every time (never "seen" before) but the SAME `callTargets`
|
|
1066
|
+
// entry, so that thrashing stops earning step-budget extensions after its first try.
|
|
1067
|
+
// `toolTarget` falls back to the bare tool name when it can't find an identifying
|
|
1068
|
+
// field (e.g. a custom/dynamic tool with no filePath/command/pattern argument) — that
|
|
1069
|
+
// generic case carries NO real identity ("work" for every call), so treat it as "no
|
|
1070
|
+
// coarse target" and let `budget.record` fall back to the exact-args signature
|
|
1071
|
+
// instead (unchanged legacy novelty for tools this helper doesn't specifically know).
|
|
1072
|
+
const callTargets = toolCalls.map(c => {
|
|
1073
|
+
const t = toolTarget(c.tool, c.arguments);
|
|
1074
|
+
return t.toLowerCase() === (c.tool || "").toLowerCase() ? undefined : t;
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
|
|
1061
1078
|
const sig = callSigs.length === 1 ? callSigs[0] : hashSignature(callSigs.join(" | "));
|
|
1062
1079
|
if (sig === lastSig) repeatCount++;
|
|
1063
1080
|
else {
|
|
@@ -1313,7 +1330,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1313
1330
|
// edits plus one trivial successful read must not look like a progressing
|
|
1314
1331
|
// step to the extension heuristic (that loophole earned endless extensions).
|
|
1315
1332
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
1316
|
-
if (results[i].executed) budget.record(callSigs[i], results[i].success);
|
|
1333
|
+
if (results[i].executed) budget.record(callSigs[i], results[i].success, callTargets[i]);
|
|
1334
|
+
|
|
1317
1335
|
}
|
|
1318
1336
|
// done-verification guard bookkeeping: write/edit successes mark the turn as
|
|
1319
1337
|
// mutating; a successful bash whose command/output looks like a test/build run
|
|
@@ -222,23 +222,43 @@ function hasAntigravityOauth(config: RoutingConfig): boolean {
|
|
|
222
222
|
return !!(config.oauth?.antigravity || config.oauth?.gemini);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/** Builds the routing catalog once per call: static `MODEL_CATALOG` rows need the
|
|
226
|
+
* full `isAutoSelectCandidate` gate (including the `isLiveProviderModel` check,
|
|
227
|
+
* when `openaiBaseUrl` is set), but rows already returned by
|
|
228
|
+
* `liveProviderCatalogModels()` are live BY CONSTRUCTION — re-verifying each one
|
|
229
|
+
* with `isLiveProviderModel` is pure O(n) waste (it dominated this function's
|
|
230
|
+
* cost: ~1.4ms/call at 2000 live models). Live rows still need the shared
|
|
231
|
+
* provider/OAuth gates (cloud provider, credential, antigravity/gemini), just not
|
|
232
|
+
* the live-membership check specific to static rows. */
|
|
225
233
|
function routingCatalog(config: RoutingConfig): CatalogModel[] {
|
|
226
|
-
const
|
|
227
|
-
const
|
|
234
|
+
const staticEligible = MODEL_CATALOG.filter(m => isAutoSelectCandidate(m, config));
|
|
235
|
+
const liveEligible = liveProviderCatalogModels(config).filter(m => isAutoSelectCandidateLive(m, config));
|
|
228
236
|
const byId = new Map<string, CatalogModel>();
|
|
229
|
-
for (const row of
|
|
237
|
+
for (const row of staticEligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
|
|
238
|
+
for (const row of liveEligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
|
|
230
239
|
return [...byId.values()];
|
|
231
240
|
}
|
|
232
241
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// modelServableWithConfig; keep aligned with resolveCall).
|
|
242
|
+
/** Shared provider/OAuth eligibility gates, independent of live-membership. */
|
|
243
|
+
function isCoreEligible(m: CatalogModel, config: RoutingConfig): boolean {
|
|
236
244
|
if (!isCloudProvider(m.provider) || m.limitedAvailability || !modelServableWithConfig(m.provider, m.canonical, config)) return false;
|
|
237
|
-
if (m.provider === "openai" && config.openaiBaseUrl && !isLiveProviderModel("openai", m.canonical, config)) return false;
|
|
238
245
|
if (hasAntigravityOauth(config) && m.provider === "gemini") return false;
|
|
239
246
|
return true;
|
|
240
247
|
}
|
|
241
248
|
|
|
249
|
+
function isAutoSelectCandidate(m: CatalogModel, config: RoutingConfig): boolean {
|
|
250
|
+
if (!isCoreEligible(m, config)) return false;
|
|
251
|
+
if (m.provider === "openai" && config.openaiBaseUrl && !isLiveProviderModel("openai", m.canonical, config)) return false;
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Same eligibility as `isAutoSelectCandidate`, for a row ALREADY sourced from
|
|
256
|
+
* `liveProviderCatalogModels()` — skips the live-membership re-check (see
|
|
257
|
+
* `routingCatalog`'s doc comment). */
|
|
258
|
+
function isAutoSelectCandidateLive(m: CatalogModel, config: RoutingConfig): boolean {
|
|
259
|
+
return isCoreEligible(m, config);
|
|
260
|
+
}
|
|
261
|
+
|
|
242
262
|
|
|
243
263
|
/** Size class jeo infers for a catalog model, used to group EQUIVALENT models
|
|
244
264
|
* across DIFFERENT providers for pool-based routing (see `tierModelPool`) — the
|
|
@@ -358,12 +378,10 @@ export function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
|
358
378
|
let best: CatalogModel | null = null;
|
|
359
379
|
let bestCost = Infinity;
|
|
360
380
|
for (const m of routingCatalog(config)) {
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
// re-verifies full readiness (including live OAuth expiry) before a turn uses it.
|
|
366
|
-
if (!isAutoSelectCandidate(m, config)) continue;
|
|
381
|
+
// `routingCatalog(config)` already filtered to eligible rows (static rows via
|
|
382
|
+
// `isAutoSelectCandidate`, live rows via `isAutoSelectCandidateLive`) — no need
|
|
383
|
+
// to re-check here. See cheapestCredentialed's own doc comment for the
|
|
384
|
+
// Antigravity/Gemini OAuth rationale behind that eligibility gate.
|
|
367
385
|
const price = priceForModel(m.canonical);
|
|
368
386
|
if (!price) continue;
|
|
369
387
|
const cost = price.inPerM + price.outPerM;
|
|
@@ -406,9 +424,8 @@ export function strongestCredentialed(
|
|
|
406
424
|
): string | null {
|
|
407
425
|
let best: CatalogModel | null = null;
|
|
408
426
|
for (const m of routingCatalog(config)) {
|
|
409
|
-
//
|
|
427
|
+
// `routingCatalog(config)` already filtered to eligible rows (see
|
|
410
428
|
// cheapestCredentialed above for the Antigravity/Gemini OAuth rationale).
|
|
411
|
-
if (!isAutoSelectCandidate(m, config)) continue;
|
|
412
429
|
if (filter && !filter(m)) continue;
|
|
413
430
|
if (!best) { best = m; continue; }
|
|
414
431
|
const xhighM = m.thinking.includes("xhigh") ? 1 : 0;
|
package/src/agent/step-budget.ts
CHANGED
|
@@ -168,17 +168,29 @@ export class StepBudget {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/** Record an executed tool call (ring-buffered to the scoring window).
|
|
171
|
-
* Stored as a fixed-size digest — see `hashSignature` (memory bound).
|
|
172
|
-
|
|
171
|
+
* Stored as a fixed-size digest — see `hashSignature` (memory bound).
|
|
172
|
+
*
|
|
173
|
+
* `targetKey` (optional) is the COARSE target (e.g. `edit src/x.ts`) the novelty rule
|
|
174
|
+
* scores against, instead of `signature`'s full-args hash. Without it, retrying an
|
|
175
|
+
* edit on the SAME file with slightly different content each attempt looks "novel"
|
|
176
|
+
* forever (the exact args hash never repeats) and the extension heuristic reads that
|
|
177
|
+
* edit-thrashing loop as endless progress. With it, only the FIRST attempt at a given
|
|
178
|
+
* target counts as novel; grinding on the same file/command afterwards does not, so a
|
|
179
|
+
* stuck rewrite loop stops earning extensions even though every individual call
|
|
180
|
+
* "succeeds" and every exact signature is technically unique. Falls back to `signature`
|
|
181
|
+
* when omitted (unchanged behavior for existing callers/tests). */
|
|
182
|
+
record(signature: string, success: boolean, targetKey?: string): void {
|
|
173
183
|
const sig = hashSignature(signature);
|
|
174
|
-
|
|
175
|
-
|
|
184
|
+
const noveltyKey = hashSignature(targetKey ?? signature);
|
|
185
|
+
if (!this.seen.has(noveltyKey)) {
|
|
186
|
+
this.seen.add(noveltyKey);
|
|
176
187
|
this.novelSinceExtension++;
|
|
177
188
|
}
|
|
178
189
|
this.window.push({ signature: sig, success });
|
|
179
190
|
if (this.window.length > this.cfg.windowSize) this.window.shift();
|
|
180
191
|
}
|
|
181
192
|
|
|
193
|
+
|
|
182
194
|
/** A mid-turn steering message arrived — fresh, user-driven work. Grant headroom
|
|
183
195
|
* (capped at the hard cap, without consuming the extension budget) and clear the
|
|
184
196
|
* scoring window so the new instruction is never declined by the previous
|
|
@@ -239,3 +251,31 @@ export class StepBudget {
|
|
|
239
251
|
};
|
|
240
252
|
}
|
|
241
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* A concise, gjc-style label for a tool call's actual TARGET (file / command / glob) —
|
|
256
|
+
* not just the bare tool name plus its full argument blob. Two calls that touch the SAME
|
|
257
|
+
* target (e.g. `edit src/x.ts` twice with different bodies) collapse to the same target
|
|
258
|
+
* key here even though `hashSignature` of their full arguments differs. Shared by the
|
|
259
|
+
* budget's novelty rule (below) and `task-tool.ts`'s live subagent monitor label — kept
|
|
260
|
+
* pure/local (no TUI dependency in the agent layer).
|
|
261
|
+
*/
|
|
262
|
+
export function toolTarget(tool: string, rawArgs: unknown): string {
|
|
263
|
+
const a = (rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) ? rawArgs : {}) as Record<string, unknown>;
|
|
264
|
+
const t = (tool || "").toLowerCase();
|
|
265
|
+
const str = (...keys: string[]): string => {
|
|
266
|
+
for (const k of keys) { const v = a[k]; if (typeof v === "string" && v.length > 0) return v; }
|
|
267
|
+
return "";
|
|
268
|
+
};
|
|
269
|
+
if (t === "bash") {
|
|
270
|
+
const cmd = str("command", "cmd").split("\n")[0]!.trim();
|
|
271
|
+
return cmd ? `bash: ${cmd.length > 80 ? cmd.slice(0, 79) + "…" : cmd}` : "bash";
|
|
272
|
+
}
|
|
273
|
+
if (t === "read" || t === "write" || t === "edit") {
|
|
274
|
+
const f = str("filePath", "path");
|
|
275
|
+
return f ? `${t} ${f}` : t;
|
|
276
|
+
}
|
|
277
|
+
if (t === "find") { const g = str("globPattern", "pattern"); return g ? `find ${g}` : "find"; }
|
|
278
|
+
if (t === "search") { const p = str("pattern"); return p ? `search ${p}` : "search"; }
|
|
279
|
+
if (t === "task") { const r = str("role"); return r ? `task ${r}` : "task"; }
|
|
280
|
+
return tool || "tool";
|
|
281
|
+
}
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* `task` itself, so delegation cannot recurse infinitely.
|
|
11
11
|
*/
|
|
12
12
|
import { runAgentLoop, type ToolHandler } from "./engine";
|
|
13
|
+
import { toolTarget } from "./step-budget";
|
|
14
|
+
|
|
13
15
|
import type { ToolResult } from "./tools";
|
|
14
16
|
import type { Message } from "./loop";
|
|
15
17
|
import { loadProjectContext, withProjectContext } from "./context-files";
|
|
@@ -146,31 +148,6 @@ export function taskToolProtocolLine(config?: Pick<Config, "subagents">): string
|
|
|
146
148
|
);
|
|
147
149
|
}
|
|
148
150
|
|
|
149
|
-
/**
|
|
150
|
-
* A concise, gjc-style label for a subagent's tool call — the actual TARGET (file / command /
|
|
151
|
-
* glob), not just the bare tool name — so the parent's live monitor shows "read src/x.ts" or
|
|
152
|
-
* "bash: bun test" instead of "read"/"bash". Kept local (no TUI dependency in the agent layer).
|
|
153
|
-
*/
|
|
154
|
-
function toolTarget(tool: string, rawArgs: unknown): string {
|
|
155
|
-
const a = (rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) ? rawArgs : {}) as Record<string, unknown>;
|
|
156
|
-
const t = (tool || "").toLowerCase();
|
|
157
|
-
const str = (...keys: string[]): string => {
|
|
158
|
-
for (const k of keys) { const v = a[k]; if (typeof v === "string" && v.length > 0) return v; }
|
|
159
|
-
return "";
|
|
160
|
-
};
|
|
161
|
-
if (t === "bash") {
|
|
162
|
-
const cmd = str("command", "cmd").split("\n")[0]!.trim();
|
|
163
|
-
return cmd ? `bash: ${cmd.length > 80 ? cmd.slice(0, 79) + "…" : cmd}` : "bash";
|
|
164
|
-
}
|
|
165
|
-
if (t === "read" || t === "write" || t === "edit") {
|
|
166
|
-
const f = str("filePath", "path");
|
|
167
|
-
return f ? `${t} ${f}` : t;
|
|
168
|
-
}
|
|
169
|
-
if (t === "find") { const g = str("globPattern", "pattern"); return g ? `find ${g}` : "find"; }
|
|
170
|
-
if (t === "search") { const p = str("pattern"); return p ? `search ${p}` : "search"; }
|
|
171
|
-
if (t === "task") { const r = str("role"); return r ? `task ${r}` : "task"; }
|
|
172
|
-
return tool || "tool";
|
|
173
|
-
}
|
|
174
151
|
|
|
175
152
|
function firstUsefulLine(output: string | undefined): string {
|
|
176
153
|
if (!output) return "";
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -235,6 +235,32 @@ interface LiveProviderModelRecord {
|
|
|
235
235
|
}
|
|
236
236
|
const liveProviderModels = new Map<string, LiveProviderModelRecord>();
|
|
237
237
|
|
|
238
|
+
/** Secondary index for `isLiveProviderModel`: `provider\u0000id` -> the set of
|
|
239
|
+
* `liveProviderModels` keys whose row `canonical` or `providerModel` equals that
|
|
240
|
+
* id. Keeps the lookup O(1) (average case) instead of an O(n) scan over every
|
|
241
|
+
* live-discovered model — the scan previously ran once per STATIC catalog row
|
|
242
|
+
* inside `isAutoSelectCandidate` (routingCatalog's OpenAI/`openaiBaseUrl` gate),
|
|
243
|
+
* making `routingCatalog()` O(n*m) whenever an aggregator-style base URL (e.g.
|
|
244
|
+
* OpenRouter, a local proxy) had discovered hundreds of live models — measured
|
|
245
|
+
* ~165ms/call at 2000 live models, on every routed turn.
|
|
246
|
+
*/
|
|
247
|
+
const liveModelIdIndex = new Map<string, Set<string>>();
|
|
248
|
+
|
|
249
|
+
function liveIndexKey(provider: ProviderName, id: string): string {
|
|
250
|
+
return `${provider}\u0000${id}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function addToLiveIndex(provider: ProviderName, id: string, recordKey: string): void {
|
|
254
|
+
if (!id) return;
|
|
255
|
+
const key = liveIndexKey(provider, id);
|
|
256
|
+
let set = liveModelIdIndex.get(key);
|
|
257
|
+
if (!set) {
|
|
258
|
+
set = new Set();
|
|
259
|
+
liveModelIdIndex.set(key, set);
|
|
260
|
+
}
|
|
261
|
+
set.add(recordKey);
|
|
262
|
+
}
|
|
263
|
+
|
|
238
264
|
function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
|
|
239
265
|
return baseUrl?.replace(/\/+$/, "");
|
|
240
266
|
}
|
|
@@ -302,11 +328,14 @@ export function recordLiveProviderModels(
|
|
|
302
328
|
const row = known
|
|
303
329
|
? { ...known, canonical, provider, providerModel }
|
|
304
330
|
: liveModelFallbackRow(provider, canonical, providerModel);
|
|
305
|
-
|
|
331
|
+
const recordKey = liveModelRecordKey(provider, canonical, baseUrl);
|
|
332
|
+
liveProviderModels.set(recordKey, {
|
|
306
333
|
row,
|
|
307
334
|
source: opts.source ?? "none",
|
|
308
335
|
baseUrl,
|
|
309
336
|
});
|
|
337
|
+
addToLiveIndex(provider, canonical, recordKey);
|
|
338
|
+
addToLiveIndex(provider, providerModel, recordKey);
|
|
310
339
|
}
|
|
311
340
|
}
|
|
312
341
|
|
|
@@ -324,21 +353,30 @@ export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string }):
|
|
|
324
353
|
.map(record => record.row);
|
|
325
354
|
}
|
|
326
355
|
|
|
327
|
-
/** True when a model came from the current provider/base URL's live model list.
|
|
356
|
+
/** True when a model came from the current provider/base URL's live model list.
|
|
357
|
+
* O(1) average case via `liveModelIdIndex` — see that Map's doc comment for why
|
|
358
|
+
* this must not be an O(n) scan over every live-discovered model (it runs once
|
|
359
|
+
* per STATIC catalog row inside `isAutoSelectCandidate`). */
|
|
328
360
|
export function isLiveProviderModel(provider: ProviderName, model: string, config?: { openaiBaseUrl?: string }): boolean {
|
|
329
361
|
const candidates = new Set([model, liveCanonicalId(provider, model), stripProviderPrefix(provider, model)]);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
(
|
|
334
|
-
|
|
362
|
+
for (const candidate of candidates) {
|
|
363
|
+
const recordKeys = liveModelIdIndex.get(liveIndexKey(provider, candidate));
|
|
364
|
+
if (!recordKeys) continue;
|
|
365
|
+
for (const recordKey of recordKeys) {
|
|
366
|
+
const record = liveProviderModels.get(recordKey);
|
|
367
|
+
if (record && liveRecordMatchesConfig(record, config)) return true;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
335
371
|
}
|
|
336
372
|
|
|
337
373
|
/** Test-only: clear live provider model supplements. */
|
|
338
374
|
export function resetLiveProviderModels(): void {
|
|
339
375
|
liveProviderModels.clear();
|
|
376
|
+
liveModelIdIndex.clear();
|
|
340
377
|
}
|
|
341
378
|
|
|
379
|
+
|
|
342
380
|
/**
|
|
343
381
|
* Model ids the Kimi Code OAuth backend (api.kimi.com/coding, Anthropic Messages
|
|
344
382
|
* format) actually serves. The subscription endpoint rejects Moonshot API-platform
|
|
@@ -415,6 +415,19 @@ export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: stri
|
|
|
415
415
|
return accepted;
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Whether an in-flight readline line buffer (captured right before a hard
|
|
420
|
+
* exit — Ctrl+D/EOF/disconnected terminal) should be persisted as an unsent
|
|
421
|
+
* draft (gajae-code "unsent prompt survives /resume" parity). Whitespace-only
|
|
422
|
+
* or empty input is never a real draft. Pure predicate so the exit-path
|
|
423
|
+
* wiring in launch.ts (which owns the actual persistence side effect) stays
|
|
424
|
+
* thin and this decision is independently testable.
|
|
425
|
+
*/
|
|
426
|
+
export function draftFromUnsentLine(line: string | undefined): string | undefined {
|
|
427
|
+
const trimmed = (line ?? "").trim();
|
|
428
|
+
return trimmed ? trimmed : undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
418
431
|
/**
|
|
419
432
|
* TTY "new input first" contract: fold any queued FULL lines (stray
|
|
420
433
|
* Enter-terminated buffer noise, or older persisted queues) into the editable
|
package/src/commands/launch.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/sta
|
|
|
50
50
|
import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
|
|
51
51
|
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
52
52
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
53
|
-
import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
|
|
53
|
+
import { readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate } from "../agent/goal-verifier";
|
|
54
54
|
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, type PromptTier, type RouteDecision } from "../agent/prompt-router";
|
|
55
55
|
import { RouteHistory } from "../agent/route-history";
|
|
56
56
|
|
|
@@ -110,10 +110,13 @@ import {
|
|
|
110
110
|
latestSessionId,
|
|
111
111
|
exportSession,
|
|
112
112
|
updateSessionModel,
|
|
113
|
+
updateSessionDraft,
|
|
113
114
|
appendCompaction,
|
|
114
115
|
resolveSessionRef,
|
|
115
116
|
} from "../agent/session";
|
|
116
|
-
import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearVisible, clearToEnd, enableModifyOtherKeys, disableModifyOtherKeys, enableKittyKeyboard, disableKittyKeyboard } from "../tui/terminal";
|
|
117
|
+
import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearVisible, clearToEnd, enableModifyOtherKeys, disableModifyOtherKeys, enableKittyKeyboard, disableKittyKeyboard, setCursorColor, resetCursorColor, isTTY, watchResize } from "../tui/terminal";
|
|
118
|
+
|
|
119
|
+
|
|
117
120
|
|
|
118
121
|
|
|
119
122
|
import {
|
|
@@ -163,6 +166,7 @@ import {
|
|
|
163
166
|
boxVerticalNavAction,
|
|
164
167
|
queuePromptInputChunk,
|
|
165
168
|
captureLivePromptInputChunk,
|
|
169
|
+
draftFromUnsentLine,
|
|
166
170
|
restoreQueuedLinesToPrefill,
|
|
167
171
|
createInFlightAbortHarness,
|
|
168
172
|
classifyMidTurnLine,
|
|
@@ -194,6 +198,8 @@ import {
|
|
|
194
198
|
contextUsageLines,
|
|
195
199
|
historyViewLines,
|
|
196
200
|
} from "./launch/slash-views";
|
|
201
|
+
import { formatTranscript } from "../tui/components/transcript";
|
|
202
|
+
|
|
197
203
|
|
|
198
204
|
import {
|
|
199
205
|
handleUsage,
|
|
@@ -262,6 +268,7 @@ export {
|
|
|
262
268
|
boxVerticalNavAction,
|
|
263
269
|
queuePromptInputChunk,
|
|
264
270
|
captureLivePromptInputChunk,
|
|
271
|
+
draftFromUnsentLine,
|
|
265
272
|
restoreQueuedLinesToPrefill,
|
|
266
273
|
createInFlightAbortHarness,
|
|
267
274
|
classifyMidTurnLine,
|
|
@@ -687,6 +694,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
687
694
|
let lastUserInput = "";
|
|
688
695
|
// Full untruncated text of the last assistant reply — surfaced in detail by Ctrl+O.
|
|
689
696
|
let lastReply = "";
|
|
697
|
+
// Unsent draft restored from a NON-interactive resume path (`--resume <id>`,
|
|
698
|
+
// `--continue`/`-c`) — the interactive-picker and mid-loop `/session resume`
|
|
699
|
+
// paths feed `queuedPromptInput.partial` directly (declared much later); this
|
|
700
|
+
// one exists because `queuedPromptInput` isn't in scope yet at startup. Read
|
|
701
|
+
// once by `queuedPromptInput`'s own initializer below, then not touched again.
|
|
702
|
+
let startupDraft: string | undefined;
|
|
690
703
|
// Full untruncated output of the most recent tool call — the clipped forge
|
|
691
704
|
// card's `⟦Ctrl+O for more⟧` hint resolves here.
|
|
692
705
|
let lastToolDetail: { tool: string; output: string } | null = null;
|
|
@@ -752,17 +765,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
752
765
|
|
|
753
766
|
// pi-style session persistence: resume an existing session or create a new one.
|
|
754
767
|
let sessionId: string | undefined;
|
|
768
|
+
// True once a resume actually restored PRIOR messages (not just an empty
|
|
769
|
+
// placeholder). Consumed by the "INTERACTIVE mode" welcome-banner block below to
|
|
770
|
+
// skip the fresh-start clearScreen()+banner render — found live: printing the
|
|
771
|
+
// resumed transcript ledger here, then unconditionally wiping the screen and
|
|
772
|
+
// drawing the generic welcome box a few hundred lines later, buried the actual
|
|
773
|
+
// restored work under a "fresh start" banner instead of leaving it on screen.
|
|
774
|
+
let resumedWithHistory = false;
|
|
775
|
+
|
|
755
776
|
let compactionSeq = 0;
|
|
756
777
|
if (!flags.noSession) {
|
|
757
778
|
if (flags.resumeInteractive) {
|
|
779
|
+
|
|
758
780
|
// Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): the interactive
|
|
759
781
|
// session picker — added right before the REPL loop starts, below — takes over
|
|
760
782
|
// and replaces this session before the user ever sees it, but ONLY in a TTY.
|
|
761
|
-
// Start with a fresh session here as the safe default either way.
|
|
762
|
-
sessionId = (await createSession(cwd, undefined, sessionModel)).id;
|
|
763
783
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
764
784
|
// No TTY means no picker is possible — fall back to the same silent-latest
|
|
765
|
-
// behavior as `--continue` rather than silently doing nothing.
|
|
785
|
+
// behavior as `--continue` rather than silently doing nothing. Resolve the
|
|
786
|
+
// TRUE latest session BEFORE creating any placeholder session below — creating
|
|
787
|
+
// one first would make IT the newest-mtime file, permanently masking the real
|
|
788
|
+
// prior session and turning this fallback into dead code (found live: a bare
|
|
789
|
+
// `--resume` outside a TTY always "resumed" a fresh empty session instead of
|
|
790
|
+
// the actual prior one).
|
|
766
791
|
const id = await latestSessionId(cwd);
|
|
767
792
|
if (id) {
|
|
768
793
|
try {
|
|
@@ -772,11 +797,35 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
772
797
|
if (!initialSessionModel && header.model) sessionModel = header.model;
|
|
773
798
|
const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
|
|
774
799
|
console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
|
|
800
|
+
// Reproduce the prior screen exactly (gjc/gajae-code parity): a bare
|
|
801
|
+
// `--resume` picker with no TTY falls back to the latest session, so
|
|
802
|
+
// this is the ONLY chance to show the prior work before the REPL
|
|
803
|
+
// starts — replay it as a scrollback ledger, same formatter `/session
|
|
804
|
+
// resume` uses mid-session, so a resumed screen always reads like the
|
|
805
|
+
// exact prior conversation instead of a bare message count.
|
|
806
|
+
if (messages.length > 0) {
|
|
807
|
+
resumedWithHistory = true;
|
|
808
|
+
const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
|
|
809
|
+
console.log([sep, ...formatTranscript(history, { maxTurns: 6, color: true, unicode: true }), sep].join("\n"));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
|
|
775
813
|
} catch (err) {
|
|
776
814
|
console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
|
|
777
815
|
}
|
|
778
816
|
}
|
|
817
|
+
// No prior session to resume (fresh cwd), or the resume attempt above failed —
|
|
818
|
+
// safe-default fresh session, same as every other "nothing to resume" path.
|
|
819
|
+
if (!sessionId) {
|
|
820
|
+
sessionId = (await createSession(cwd, undefined, sessionModel)).id;
|
|
821
|
+
}
|
|
822
|
+
} else {
|
|
823
|
+
// TTY: start with a fresh session as the safe default; the interactive picker
|
|
824
|
+
// below (right before the REPL loop) takes over and replaces it before the
|
|
825
|
+
// user ever sees this one, so creating it first here is harmless.
|
|
826
|
+
sessionId = (await createSession(cwd, undefined, sessionModel)).id;
|
|
779
827
|
}
|
|
828
|
+
|
|
780
829
|
} else if (flags.resume) {
|
|
781
830
|
let id: string | undefined;
|
|
782
831
|
let skipResume = false;
|
|
@@ -808,8 +857,23 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
808
857
|
// Restore the model this session was last using unless the CLI explicitly
|
|
809
858
|
// pinned one (flags.model/role/provider → initialSessionModel wins).
|
|
810
859
|
if (!initialSessionModel && header.model) sessionModel = header.model;
|
|
860
|
+
// Restore the unsent input-box draft (gajae-code parity) — see
|
|
861
|
+
// startupDraft's own doc comment for why this path can't feed
|
|
862
|
+
// queuedPromptInput.partial directly yet.
|
|
863
|
+
if (header.draft) startupDraft = header.draft;
|
|
811
864
|
const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
|
|
812
865
|
console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
|
|
866
|
+
// Reproduce the prior screen exactly (gjc/gajae-code parity): same
|
|
867
|
+
// scrollback-ledger replay `/session resume` uses mid-session, so
|
|
868
|
+
// `--resume`/`-c`/`--resume <id>` restores the prior work onto the
|
|
869
|
+
// screen instead of just a bare message count.
|
|
870
|
+
if (messages.length > 0) {
|
|
871
|
+
resumedWithHistory = true;
|
|
872
|
+
const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
|
|
873
|
+
console.log([sep, ...formatTranscript(history, { maxTurns: 6, color: true, unicode: true }), sep].join("\n"));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
|
|
813
877
|
} catch (err) {
|
|
814
878
|
console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
|
|
815
879
|
sessionId = (await createSession(cwd, undefined, sessionModel)).id;
|
|
@@ -841,6 +905,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
841
905
|
} catch { /* best-effort */ }
|
|
842
906
|
};
|
|
843
907
|
|
|
908
|
+
// Persist (or clear) the unsent input-box draft into the session header so a
|
|
909
|
+
// later `/resume` restores it into the prompt (gajae-code parity: an
|
|
910
|
+
// in-progress prompt survives a quit + resume instead of being silently
|
|
911
|
+
// lost). Best-effort, same contract as persistSessionModel above — a
|
|
912
|
+
// header-rewrite failure must never abort the exit path that calls this.
|
|
913
|
+
const persistSessionDraft = async (draft: string): Promise<void> => {
|
|
914
|
+
if (flags.noSession || !sessionId) return;
|
|
915
|
+
try {
|
|
916
|
+
await updateSessionDraft(sessionId, draft, cwd);
|
|
917
|
+
} catch { /* best-effort */ }
|
|
918
|
+
};
|
|
919
|
+
|
|
844
920
|
// `step N/M` display seed: the explicit --max-steps cap, else the dynamic budget's
|
|
845
921
|
// rolling base — the engine's onBudget event keeps the denominator honest as it grows.
|
|
846
922
|
const initialStepLimit = flags.maxSteps > 0 ? flags.maxSteps : initialDynamicStepLimit();
|
|
@@ -1282,7 +1358,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1282
1358
|
// Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
|
|
1283
1359
|
// because nothing ever forced the model to update item statuses).
|
|
1284
1360
|
let turnTodos: { title: string; status: string }[] = [];
|
|
1285
|
-
const onBeforeDone = async (
|
|
1361
|
+
const onBeforeDone = async (
|
|
1362
|
+
reason: string,
|
|
1363
|
+
evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean },
|
|
1364
|
+
): Promise<string | null> => {
|
|
1286
1365
|
const unfinished = turnTodos.filter(t => t.status !== "done");
|
|
1287
1366
|
if (turnTodos.length > 0 && unfinished.length > 0) {
|
|
1288
1367
|
return (
|
|
@@ -1304,7 +1383,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1304
1383
|
}
|
|
1305
1384
|
|
|
1306
1385
|
if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
|
|
1307
|
-
const
|
|
1386
|
+
const llmVerdict = await verifyGoal(goalState.condition, history, activeModel);
|
|
1387
|
+
// Deterministic downgrade: a fresh MET from the transcript-only LLM
|
|
1388
|
+
// judge still needs mutation/verification evidence to back it up —
|
|
1389
|
+
// see applyEvidenceGate's own docs (goal-verifier.ts). NOT_MET/
|
|
1390
|
+
// IMPOSSIBLE pass through unchanged; this only ever tightens MET.
|
|
1391
|
+
const verdict = applyEvidenceGate(llmVerdict, evidence);
|
|
1308
1392
|
|
|
1309
1393
|
goalState.verdicts.push({
|
|
1310
1394
|
at: Date.now(),
|
|
@@ -1881,18 +1965,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1881
1965
|
renderWelcome({ ...welcomeData, cols: terminalSize().cols });
|
|
1882
1966
|
// gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
|
|
1883
1967
|
// never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
|
|
1884
|
-
|
|
1885
|
-
//
|
|
1886
|
-
//
|
|
1887
|
-
//
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1968
|
+
// SKIPPED on a resume that actually restored prior messages: the transcript ledger
|
|
1969
|
+
// was already printed above (during session persistence, right after "Resumed
|
|
1970
|
+
// session ... (N messages)"), so clearing here would wipe it and the generic
|
|
1971
|
+
// "fresh start" banner below would bury the just-restored work — live-verified via
|
|
1972
|
+
// a real pty run of `--resume <id>`, which showed exactly that: the resumed
|
|
1973
|
+
// conversation flashed on screen then vanished under the welcome box a moment later.
|
|
1974
|
+
if (!resumedWithHistory) {
|
|
1975
|
+
if (process.stdout.isTTY) process.stdout.write(clearScreen());
|
|
1976
|
+
// Launch sweep: the forge mark's gradient loops seamlessly (default 2 full
|
|
1977
|
+
// cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
|
|
1978
|
+
// Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
|
|
1979
|
+
const sweepable =
|
|
1980
|
+
!!process.stdout.isTTY &&
|
|
1981
|
+
welcomeTheme.color &&
|
|
1982
|
+
detectColorLevel(process.env, true) === ColorLevel.TrueColor &&
|
|
1983
|
+
jeoEnv("NO_WELCOME_ANIM") !== "1";
|
|
1984
|
+
const sweepCycles = Math.min(10, Math.max(1, Number(jeoEnv("WELCOME_ANIM_CYCLES")) || 2));
|
|
1985
|
+
if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
|
|
1986
|
+
else console.log(renderWelcome(welcomeData).join("\n"));
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
|
|
1896
1990
|
|
|
1897
1991
|
// Surface the "New version" banner reliably: render ONCE from the on-disk cache
|
|
1898
1992
|
// instantly (no network wait, works offline — the common path after the first
|
|
@@ -2182,7 +2276,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2182
2276
|
// IMMEDIATELY on its next iteration, bypassing the "new input first" prefill contract
|
|
2183
2277
|
// (the user explicitly invoked them — no second Enter).
|
|
2184
2278
|
const pendingMidTurnCommands: string[] = [];
|
|
2185
|
-
const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: "", pastedLines: [], inPaste: false };
|
|
2279
|
+
const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: startupDraft ?? "", pastedLines: [], inPaste: false };
|
|
2186
2280
|
queueBusyInput = (chunk: string) => captureLivePromptInputChunk(queuedPromptInput, chunk);
|
|
2187
2281
|
queueBusyPasteActive = () => queuedPromptInput.inPaste;
|
|
2188
2282
|
queueBusySnapshot = () => ({
|
|
@@ -2251,6 +2345,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2251
2345
|
// hard break, not a graceful `/exit`. Some Bun/tmux paths leave stdin.isTTY
|
|
2252
2346
|
// false while stdout is still a TTY, so accept either side as interactive.
|
|
2253
2347
|
if ((process.stdin.isTTY || process.stdout.isTTY || previewEnabled) && !gracefulReadlineClose) {
|
|
2348
|
+
// Best-effort, fire-and-forget: persist whatever's still typed but unsent
|
|
2349
|
+
// (Ctrl+D / EOF / disconnected terminal — no explicit discard gesture, unlike
|
|
2350
|
+
// Ctrl+C's own "clear the box" contract, which stays untouched here) so
|
|
2351
|
+
// `/resume` can restore it (see persistSessionDraft above). Must not block
|
|
2352
|
+
// process.exit() below.
|
|
2353
|
+
const draft = draftFromUnsentLine((rl as unknown as { line?: string }).line);
|
|
2354
|
+
if (draft) void persistSessionDraft(draft);
|
|
2254
2355
|
hardExitOnLoopEnd = true;
|
|
2255
2356
|
forceExitFromCtrlC();
|
|
2256
2357
|
}
|
|
@@ -2584,11 +2685,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2584
2685
|
}
|
|
2585
2686
|
return out;
|
|
2586
2687
|
};
|
|
2688
|
+
// Match the REAL terminal cursor's color to the theme accent (OSC 12) so the caret
|
|
2689
|
+
// is never a same-toned block over the input box's typed text — the default terminal
|
|
2690
|
+
// cursor color (usually solid white) otherwise reads as a stray light patch on every
|
|
2691
|
+
// non-mono theme, especially the darker ones, making it hard to spot where typing
|
|
2692
|
+
// lands. Colorless (`mono`) intentionally skips this and any terminal that ignores
|
|
2693
|
+
// OSC 12 (rare) is unaffected either way. Reset on process exit (see below).
|
|
2694
|
+
const applyCursorColor = (): void => {
|
|
2695
|
+
if (isTTY() && uiTheme.color) {
|
|
2696
|
+
try { process.stdout.write(setCursorColor(uiTheme.accent)); } catch { /* terminal gone */ }
|
|
2697
|
+
}
|
|
2698
|
+
};
|
|
2587
2699
|
const refreshUiTheme = (): void => {
|
|
2588
2700
|
uiTheme = resolveTheme(process.env);
|
|
2589
2701
|
uiAccent = accentPaint(uiTheme);
|
|
2590
2702
|
uiAccentShadow = accentShadowPaint(uiTheme);
|
|
2703
|
+
applyCursorColor();
|
|
2591
2704
|
};
|
|
2705
|
+
applyCursorColor();
|
|
2706
|
+
process.once("exit", () => {
|
|
2707
|
+
try { process.stdout.write(resetCursorColor()); } catch { /* terminal gone */ }
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2592
2710
|
// The gjc-layout status bar pinned directly ABOVE the input box: bg-gradient
|
|
2593
2711
|
// identity block (model · thinking / branch / cwd) left, live ctx% right.
|
|
2594
2712
|
const statusBarLine = (cols: number): string => {
|
|
@@ -2905,7 +3023,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2905
3023
|
}, 16);
|
|
2906
3024
|
};
|
|
2907
3025
|
process.stdout.on("resize", onPickerResize);
|
|
3026
|
+
// Poll-based safety net (same rationale as the idle footer's watchResize): a
|
|
3027
|
+
// picker left open across a missed 'resize' event would otherwise stay pinned
|
|
3028
|
+
// to its opening geometry until a keypress happened to trigger a repaint.
|
|
3029
|
+
const stopPickerResizeWatch = watchResize(() => onPickerResize());
|
|
2908
3030
|
try {
|
|
3031
|
+
|
|
2909
3032
|
await new Promise<void>(resolve => {
|
|
2910
3033
|
const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; sequence?: string } | undefined) => {
|
|
2911
3034
|
// Same normalization as the footer handler: kitty CSI-u Esc/Ctrl+C must
|
|
@@ -2923,7 +3046,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2923
3046
|
});
|
|
2924
3047
|
} finally {
|
|
2925
3048
|
process.stdout.off("resize", onPickerResize);
|
|
3049
|
+
stopPickerResizeWatch();
|
|
2926
3050
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
3051
|
+
|
|
2927
3052
|
if (process.stdin.setRawMode && !wasRaw) {
|
|
2928
3053
|
process.stdin.setRawMode(false);
|
|
2929
3054
|
}
|
|
@@ -3676,12 +3801,22 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3676
3801
|
}, RESIZE_THROTTLE_MS);
|
|
3677
3802
|
};
|
|
3678
3803
|
process.stdout.on("resize", idleResizeHandler);
|
|
3804
|
+
// Poll-based safety net: the idle footer sits between turns for most of a
|
|
3805
|
+
// session's wall-clock time, so a missed 'resize' event here (tmux pane switch
|
|
3806
|
+
// while this pane isn't foreground, a SIGCONT race, etc. — see terminal.ts's
|
|
3807
|
+
// watchResize doc) is the single biggest source of "screen stays at its
|
|
3808
|
+
// starting size" reports. Cheap 300ms poll; idleResizeHandler already dedupes
|
|
3809
|
+
// via lastIdleAt/reReserveFooter so this composes safely with the real event.
|
|
3810
|
+
const stopIdleResizeWatch = watchResize(() => idleResizeHandler());
|
|
3679
3811
|
promptListenerCleanups.push(() => {
|
|
3680
3812
|
process.stdout.off("resize", idleResizeHandler);
|
|
3681
3813
|
if (idleResizeTimer) clearTimeout(idleResizeTimer);
|
|
3814
|
+
stopIdleResizeWatch();
|
|
3682
3815
|
});
|
|
3683
3816
|
}
|
|
3684
3817
|
|
|
3818
|
+
|
|
3819
|
+
|
|
3685
3820
|
// Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): show the SAME
|
|
3686
3821
|
// interactive session picker `/session resume` (no arg) uses, in place of the
|
|
3687
3822
|
// fresh session created above. TTY-only — non-TTY already fell back to
|
|
@@ -3695,6 +3830,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3695
3830
|
if (pickResult.sessionModel !== undefined) sessionModel = pickResult.sessionModel;
|
|
3696
3831
|
if (pickResult.lastUserInput !== undefined) lastUserInput = pickResult.lastUserInput;
|
|
3697
3832
|
if (pickResult.lastReply !== undefined) lastReply = pickResult.lastReply;
|
|
3833
|
+
// Restore the unsent input-box draft left over at last save (gajae-code
|
|
3834
|
+
// parity) — feeds the SAME "new input first" prefill path queued mid-turn
|
|
3835
|
+
// lines already use, so it's visible in the box, Enter to run, Esc/Ctrl+U
|
|
3836
|
+
// to discard, on the very first prompt of this resumed session.
|
|
3837
|
+
if (pickResult.draft) queuedPromptInput.partial = pickResult.draft;
|
|
3698
3838
|
}
|
|
3699
3839
|
|
|
3700
3840
|
// Remote (Telegram) free-text reply / config command routing — wired here,
|
|
@@ -3898,6 +4038,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3898
4038
|
if (result.sessionModel !== undefined) sessionModel = result.sessionModel;
|
|
3899
4039
|
if (result.lastUserInput !== undefined) lastUserInput = result.lastUserInput;
|
|
3900
4040
|
if (result.lastReply !== undefined) lastReply = result.lastReply;
|
|
4041
|
+
// Restore the unsent input-box draft (see the resumeInteractive call
|
|
4042
|
+
// site above for the full rationale) — `continue` below immediately
|
|
4043
|
+
// re-enters the loop top, which consumes `queuedPromptInput.partial`
|
|
4044
|
+
// as this next prompt's prefill.
|
|
4045
|
+
if (result.draft) queuedPromptInput.partial = result.draft;
|
|
3901
4046
|
continue;
|
|
3902
4047
|
}
|
|
3903
4048
|
if (input === "/retry") {
|
package/src/tui/app.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { Renderer } from "./renderer";
|
|
13
13
|
import { readWorkflowStateStrict } from "../agent/state";
|
|
14
|
-
import { size, isTTY, hideCursor, showCursor, enterAltScreen, leaveAltScreen } from "./terminal";
|
|
14
|
+
import { size, isTTY, hideCursor, showCursor, enterAltScreen, leaveAltScreen, watchResize } from "./terminal";
|
|
15
|
+
|
|
15
16
|
import { Spinner } from "./components/spinner";
|
|
16
17
|
import { ToolList } from "./components/tool-list";
|
|
17
18
|
import { StreamRegion } from "./components/stream";
|
|
@@ -251,6 +252,10 @@ export class LaunchTui {
|
|
|
251
252
|
private lastResizeAt = 0;
|
|
252
253
|
private lastCols = -1;
|
|
253
254
|
private lastRows = -1;
|
|
255
|
+
// Stops the poll-based resize safety net (terminal.ts's watchResize) started in
|
|
256
|
+
// begin(); cleared in finish() alongside the 'resize'/SIGCONT listeners.
|
|
257
|
+
private stopResizeWatch: (() => void) | undefined;
|
|
258
|
+
|
|
254
259
|
private pendingIndex: number | null = null;
|
|
255
260
|
private pendingTitle: string | null = null;
|
|
256
261
|
private pendingForge: ForgeSummary | null = null;
|
|
@@ -1172,7 +1177,15 @@ export class LaunchTui {
|
|
|
1172
1177
|
if (processMax > 0 && resumeCount + 1 >= processMax) process.setMaxListeners(resumeCount + 2);
|
|
1173
1178
|
process.on("SIGCONT", this.onResume);
|
|
1174
1179
|
}
|
|
1180
|
+
// Poll-based safety net (terminal.ts's watchResize): catches a resize the TTY
|
|
1181
|
+
// 'resize' event missed (e.g. a tmux pane switch while this pane wasn't the
|
|
1182
|
+
// foreground one — tmux only forwards SIGWINCH to the active pane). Cheap
|
|
1183
|
+
// (300ms comparison poll), fires resizeRepaint() only on an actual geometry
|
|
1184
|
+
// change, and is a no-op alongside a real 'resize' event (whichever notices
|
|
1185
|
+
// first wins; lastCols/lastRows dedupe the other).
|
|
1186
|
+
this.stopResizeWatch = watchResize(() => this.resizeRepaint());
|
|
1175
1187
|
}
|
|
1188
|
+
|
|
1176
1189
|
// Animate the spinner + elapsed clock while the model is thinking.
|
|
1177
1190
|
this.timer = setInterval(() => {
|
|
1178
1191
|
try {
|
|
@@ -1269,7 +1282,12 @@ export class LaunchTui {
|
|
|
1269
1282
|
if (process.platform !== "win32") {
|
|
1270
1283
|
process.removeListener("SIGCONT", this.onResume);
|
|
1271
1284
|
}
|
|
1285
|
+
if (this.stopResizeWatch) {
|
|
1286
|
+
this.stopResizeWatch();
|
|
1287
|
+
this.stopResizeWatch = undefined;
|
|
1288
|
+
}
|
|
1272
1289
|
}
|
|
1290
|
+
|
|
1273
1291
|
if (this.usedAltScreen) {
|
|
1274
1292
|
// Leave the alt screen (restores the main buffer + scrollback), then print the
|
|
1275
1293
|
// static summary below the prior output so the turn leaves exactly one record.
|
package/src/tui/terminal.ts
CHANGED
|
@@ -84,6 +84,75 @@ export function size(): { cols: number; rows: number } {
|
|
|
84
84
|
rows: process.stdout.rows || 24,
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
/** True live geometry via `getWindowSize()` — a real `TIOCGWINSZ` ioctl through libuv,
|
|
88
|
+
* bypassing `process.stdout.columns`/`.rows` entirely. Node/Bun only refresh THOSE
|
|
89
|
+
* cached fields when a `'resize'` (SIGWINCH) event fires, which is exactly the signal
|
|
90
|
+
* {@link watchResize} exists to work around (tmux pane/window switch while jeo's pane
|
|
91
|
+
* isn't foregrounded, a SIGCONT race after Ctrl-Z, an SSH/multiplexer layer that only
|
|
92
|
+
* propagates the ioctl update on the next write) — in every one of those, the cached
|
|
93
|
+
* fields go stale and NOTHING re-freshes them without a real event. Returns `null` when
|
|
94
|
+
* unavailable (older runtimes, non-TTY streams — Bun/Node only expose `getWindowSize`
|
|
95
|
+
* on a real `tty.WriteStream`), it throws (handle torn down), or it reports `0,0` (no
|
|
96
|
+
* controlling terminal ever set a winsize, e.g. a bare pty as `bun test` sees). */
|
|
97
|
+
function liveWindowSize(): { cols: number; rows: number } | null {
|
|
98
|
+
const stream = process.stdout as NodeJS.WriteStream & { getWindowSize?: () => [number, number] };
|
|
99
|
+
if (typeof stream.getWindowSize !== "function") return null;
|
|
100
|
+
try {
|
|
101
|
+
const [cols, rows] = stream.getWindowSize();
|
|
102
|
+
return cols > 0 && rows > 0 ? { cols, rows } : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Poll-based resize watcher — a SAFETY NET alongside the TTY `'resize'` event.
|
|
109
|
+
* `'resize'` (SIGWINCH) is missed in several real-world cases jeo actually hits: a
|
|
110
|
+
* tmux pane/window switch while jeo's pane isn't the foreground one (tmux only
|
|
111
|
+
* forwards SIGWINCH to the active pane), a SIGCONT race after Ctrl-Z where the resize
|
|
112
|
+
* happened entirely during the stop, and some SSH/multiplexer layers that only
|
|
113
|
+
* propagate the ioctl update on the next write rather than emitting a fresh event.
|
|
114
|
+
* In every one of these, `process.stdout.columns`/`.rows` (and therefore `size()`)
|
|
115
|
+
* go stale and NOTHING short of a real `'resize'` event ever refreshes them — so a
|
|
116
|
+
* naive poll comparing `size()` against itself can never observe a change that
|
|
117
|
+
* already happened, it would just keep comparing one stale cache to itself.
|
|
118
|
+
*
|
|
119
|
+
* Each tick instead asks the OS DIRECTLY via {@link liveWindowSize} (a cheap,
|
|
120
|
+
* allocation-free ioctl — safe every `intervalMs`). When that live read disagrees
|
|
121
|
+
* with the last-seen geometry, this SELF-HEALS the stale cache — writing the
|
|
122
|
+
* corrected values onto `process.stdout.columns`/`.rows` exactly like Node's own
|
|
123
|
+
* SIGWINCH handler would — before firing `onChange`, so every other reader of
|
|
124
|
+
* `size()` (the live-frame `draw()`, `resizeRepaint()`, `idleResizeHandler()`, a
|
|
125
|
+
* picker's `repaint()`) sees the corrected geometry too, not just this callback.
|
|
126
|
+
* When `getWindowSize` is unavailable, falls back to comparing `size()` against
|
|
127
|
+
* itself (the pre-existing behavior) — still catches a genuine cache update that
|
|
128
|
+
* arrived via some OTHER path (e.g. a real `'resize'` event firing between polls).
|
|
129
|
+
* Never fires spuriously — only on an actual geometry change — so it composes
|
|
130
|
+
* safely alongside an existing `'resize'` listener (whichever notices first wins,
|
|
131
|
+
* the other becomes a no-op once both routes converge on the same geometry).
|
|
132
|
+
* Returns a `stop()` to clear the interval; the timer is `unref()`d so it can never
|
|
133
|
+
* keep the process alive on its own. */
|
|
134
|
+
export function watchResize(onChange: (cols: number, rows: number) => void, intervalMs = 300): () => void {
|
|
135
|
+
let last = size();
|
|
136
|
+
const timer = setInterval(() => {
|
|
137
|
+
const live = liveWindowSize();
|
|
138
|
+
const cur = live ?? size();
|
|
139
|
+
if (cur.cols !== last.cols || cur.rows !== last.rows) {
|
|
140
|
+
last = cur;
|
|
141
|
+
if (live) {
|
|
142
|
+
try {
|
|
143
|
+
process.stdout.columns = live.cols;
|
|
144
|
+
process.stdout.rows = live.rows;
|
|
145
|
+
} catch { /* non-writable in this environment — onChange still fires below */ }
|
|
146
|
+
}
|
|
147
|
+
onChange(cur.cols, cur.rows);
|
|
148
|
+
}
|
|
149
|
+
}, intervalMs);
|
|
150
|
+
if (typeof (timer as unknown as { unref?: () => void }).unref === "function") {
|
|
151
|
+
(timer as unknown as { unref: () => void }).unref();
|
|
152
|
+
}
|
|
153
|
+
return () => clearInterval(timer);
|
|
154
|
+
}
|
|
155
|
+
|
|
87
156
|
|
|
88
157
|
export function isTTY(): boolean {
|
|
89
158
|
return !!process.stdout.isTTY;
|
|
@@ -137,3 +206,23 @@ export function enableKittyKeyboard(): string {
|
|
|
137
206
|
export function disableKittyKeyboard(): string {
|
|
138
207
|
return `${ESC}<u`;
|
|
139
208
|
}
|
|
209
|
+
/** Set the terminal's TEXT CURSOR color via OSC 12 (`\x1b]12;#rrggbb\x07`) — widely
|
|
210
|
+
* supported (xterm, iTerm2, kitty, wezterm, alacritty, ghostty, foot, most tmux/VTE
|
|
211
|
+
* terminals). jeo's input box parks the REAL terminal cursor at the caret (see
|
|
212
|
+
* `input-box.ts`'s `cursorRow`/`cursorCol`) rather than drawing a fake one, so the
|
|
213
|
+
* cursor's on-screen color is entirely up to the terminal's default — usually a
|
|
214
|
+
* solid white/light block. On every theme except `mono` (whose panels/prompt already
|
|
215
|
+
* render colorless), that default cursor color visually MATCHES the light card
|
|
216
|
+
* backgrounds used elsewhere in the UI (e.g. `cardFillPaint`), making the caret
|
|
217
|
+
* nearly impossible to spot against typed text. Setting it to the theme's accent hue
|
|
218
|
+
* gives the caret a color that's always distinct from body text and panel fills. */
|
|
219
|
+
export function setCursorColor(hex: string): string {
|
|
220
|
+
return `\x1b]12;${hex}\x07`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Restore the terminal's default cursor color (OSC 112, no color id) — the
|
|
224
|
+
* counterpart to {@link setCursorColor}, sent on exit so a shell/editor started
|
|
225
|
+
* afterward isn't left with jeo's accent-colored cursor. */
|
|
226
|
+
export function resetCursorColor(): string {
|
|
227
|
+
return `\x1b]112\x07`;
|
|
228
|
+
}
|