jeo-code 0.5.10 → 0.5.12

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 ADDED
@@ -0,0 +1,280 @@
1
+ # Changelog
2
+
3
+ All notable changes to **jeo-code** are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
+
9
+ ## [0.5.12] - 2026-06-15
10
+ _Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card._
11
+
12
+ ### Added
13
+ - The live status animation (spinner + activity gradient) turns amber/yellow while a tool/process is executing — "the agent is running/verifying a process" now reads at a glance, distinct from the cool thinking gradient (gjc `theme.fg("warning")` parity).
14
+ - Every completed tool card and light-tool ledger line now shows how long the tool ran, dim after the ✓/✗ glyph — `✓ Bash · (8ms)`, `✓ Read x.ts · (3ms)` (gjc duration-detail parity; sub-second as `Nms`, else `N.Ns`/`Nm Ns`).
15
+
16
+ ## [0.5.11] - 2026-06-15
17
+ _Backspace on an empty prompt line no longer quits jeo._
18
+
19
+ ### Fixed
20
+ - Pressing Backspace with an empty input line could terminate the process on some Bun builds: an empty-line Backspace is a no-op edit, but those builds turn it into a spurious readline `close`, which the REPL treats as a hard exit. The multi-line input filter now swallows a standalone Backspace (DEL `0x7f` / BS `0x08`) when the line buffer is already empty, so the byte never reaches readline and the close can't fire. Backspace with text in the buffer still deletes normally, and Ctrl-C / paste / Enter are untouched.
21
+
22
+ ## [0.5.10] - 2026-06-15
23
+ _`/resume` transcript no longer dumps raw JSON for batched tool calls._
24
+
25
+ ### Fixed
26
+ - Resuming a session that contains a BATCHED tool step (`{reasoning, tools:[…]}`) printed the raw JSON object into the transcript instead of a readable history. `formatTranscript` only recognized the single-call `{tool,arguments}` shape; the batch shape parsed to no `tool` field, fell through to the prose branch, and dumped the JSON. It now renders one compact `✔/✗ <tool> — <result>` ledger line per batched call (verdicts parsed in call order from the combined `Tool [x] result` message), matching how single calls already render. `/resume` and `/history` both go through this path, so both are fixed.
27
+
28
+ ## [0.5.9] - 2026-06-15
29
+ _Bounded per-frame wrap for the live thinking/tool-output blocks — re-render cost no longer grows with stream length._
30
+
31
+ ### Changed
32
+ - The live "thinking" and tool-output tail blocks only ever DISPLAY their last 6/8 wrapped rows, but they accumulate the whole step's text and were re-wrapping the FULL string on every 120ms repaint — so per-frame CPU and GC churn grew linearly with how much had streamed (a long reasoning trace or a chatty tool can reach hundreds of KB). The wrap input is now bounded to a fixed 16 KiB trailing window (`tailForWrap`), capping per-frame work at O(window) regardless of total size. The visible tail is byte-identical for normal multi-line content; an unbroken multi-hundred-KB blob still shows its genuine end. No memory leak existed (every live buffer — StreamRegion, ToolList, activityLog, forgeSummaries — is already ring-capped and the per-turn TUI instance is GC'd); this removes the one remaining O(stream-length) hot path in the repaint loop.
33
+
34
+ ## [0.5.8] - 2026-06-15
35
+ _Native Opik observability for the turn loop (opt-in `JEO_OPIK`, pure-TS no-op when unset) + autopilot convergence tracking._
36
+
37
+ ### Added
38
+ - Native Opik observability for the agent turn loop (opt-in via `JEO_OPIK`): each turn becomes one Opik trace, each step/tool a span, with token usage and the `completed` / `verified` / `efficiency` eval scores attached. Implemented in pure TypeScript over `fetch` — no Python, no `opik` npm package — per jeo's zero-native-dependency constraint. Hard invariants: an unset `JEO_OPIK` is a complete no-op (zero Opik HTTP calls); no tracer error ever escapes an events callback; the API key travels only in the `Authorization` header; and engine output is identical regardless of tracing outcome.
39
+
40
+ ### Changed
41
+ - Autopilot convergence tracking: a "keep" (a provable min/max improvement or a gate pass) now counts as forward progress, and anything else extends the no-progress streak toward the convergence cap.
42
+
43
+ ## [0.5.7] - 2026-06-15
44
+ _`/model` picker is default-only, `/clear` resets to the initial screen, ESC clears the input box, and a launch process-listener leak is fixed._
45
+
46
+ ### Changed
47
+ - The `/model` model-action picker now offers ONLY the DEFAULT section: "Set model only" plus the default thinking levels. The per-role rows ("Set as EXECUTOR/PLANNER/ARCHITECT/CRITIC") and the "Apply OpenAI Codex role preset" entry are gone — role model and thinking are configured exclusively in `/agents` (and `/agents edit`). The default heading now points there (`roles → /agents`). This completes the 0.5.6 split: `/model` owns the default, `/agents` owns the roles. Dead helpers (`orderedModelRoles`, `applyOpenAiCodexRolePreset`, `CORE_MODEL_ACTION_ROLE_ORDER`) were removed with the menus.
48
+
49
+ ### Added
50
+ - `/clear` now returns to the initial screen (fresh session view) and ESC clears the input box, matching the expected reset gestures.
51
+
52
+ ### Fixed
53
+ - Launch no longer leaks process listeners: the prompt-scoped stdin `data`/`keypress` and stdout `resize` handlers are now tracked and drained on every exit path, so repeated `launch()` (e.g. the test harness) no longer accumulates listeners past Node's 10-listener default (`MaxListenersExceededWarning` + a real leak).
54
+
55
+ ## [0.5.6] - 2026-06-15
56
+ _`/model` sets only the default thinking; per-role reasoning moved to `/agents`._
57
+
58
+ ### Changed
59
+ - Thinking ownership split: `/model` now configures reasoning for the DEFAULT agent only, and per-role (subagent) thinking is owned by `/agents`. In the model-action picker the role rows offer just "Set model only" (the per-level thinking rows are gone), `/model subagent <role> thinking <level>` redirects to `/agents` instead of saving, and picking a role's model no longer prompts for a reasoning level. Set role reasoning via `/agents <role> thinking <level|inherit>` or the `/agents edit` picker. `/model thinking <level>` still sets the default, so nothing the default knob did is lost.
60
+
61
+ ## [0.5.5] - 2026-06-15
62
+ _Full multi-line visibility — the input box scrolls to the caret and the submitted card shows every line._
63
+
64
+ ### Fixed
65
+ - Full multi-line visibility for the multi-line input added in 0.5.4: the input box now scrolls so the caret row always stays in view as you move through a long draft (`…` markers flag rows hidden above/below, so no line is unreachable), and a submitted multi-line query renders ALL of its wrapped lines in the user card — the card lives in scrollback rather than the bounded live frame, so nothing is truncated.
66
+
67
+ ## [0.5.4] - 2026-06-15
68
+ _Reliable multi-line input is ON by default — a paste fills the box and submits as one message._
69
+
70
+ ### Changed
71
+ - Multi-line input is now enabled by default on any interactive TTY (no flag needed): a bracketed paste arrives as ONE buffer, fills the input box, and submits as a single message instead of being split into one message per line. The prompt's stdin is routed through a filter that rewrites line breaks to a private-use sentinel before `node:readline` sees them, avoiding the per-line submit/race. The lone-`\n` Shift+Enter rule stays opt-in via `JEO_MULTILINE=1` (it needs ghostty's `keybind = shift+enter=text:\n` and could misfire on terminals that send LF for Enter), and `JEO_NO_MULTILINE=1` fully disables the filter (reads stdin directly, exactly as before).
72
+
73
+ ## [0.5.3] - 2026-06-15
74
+ _`$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter._
75
+
76
+ ### Added
77
+ - Multi-skill `$` chaining: a leading run of `$skill` tokens now invokes every resolved skill in order, sharing the trailing text as one intent — `$ralplan $team build the auth flow` runs ralplan then team, both with intent "build the auth flow". A lone `$skill` is just a chain of one, so existing single invocations are unchanged. Prefixes still resolve (`$te $ultra …`), `$UPPERCASE` env-var tokens end the chain and pass through to the model, and unknown tokens are collected so several typos report together (`No skills: $nope, $bad.`) instead of one at a time. Applies to both the interactive prompt and one-shot `-p`/`/skill:` runs.
78
+ - Multi-line paste merges into ONE message: a pasted block with embedded newlines is submitted as a single prompt instead of being split into one message per line.
79
+ - Shift+Enter multi-line input (opt-in via `JEO_MULTILINE=1`): Shift+Enter inserts a real line break instead of submitting. Terminal-specific Shift+Enter encodings (ghostty legacy `\x1b[27;2;13~`, kitty `\x1b[13;2u`, and a lone `\n`) are rewritten to a private-use sentinel before `node:readline` sees them, so it works through tmux (extended-keys off) without mangling; default OFF reads stdin exactly as before.
80
+
81
+ ### Changed
82
+ - The one-shot skill path was refactored to a shared `runOneSkillShot` runner so the single-invocation and chain paths execute identically (bundle workflow → engine; regular skill → agent turn).
83
+
84
+ ## [0.5.2] - 2026-06-14
85
+ _`$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode)._
86
+
87
+ ### Added
88
+ - `$skill` prompt invocation: typing `$<name>` runs a bundled/loaded skill directly. A unique prefix resolves precisely (`$te` → `$team`); an ambiguous prefix lists candidates (`Ambiguous skill '$te'. Did you mean: $team, …`); and an unknown `$word` lists the available skills (prefix-first, then fuzzy-subsequence) instead of silently sending the typo to the model. `$UPPERCASE` env-var-style tokens (e.g. `$HOME`) pass through untouched.
89
+ - Per-session input-box border hue: each newly opened jeo session gets a distinct border color so several concurrent sessions are tellable apart at a glance, and cmd-mode (`!`) overrides it with a caution amber so entering the shell escape is unmistakable.
90
+
91
+ ## [0.5.1] - 2026-06-14
92
+ _cmd-mode `!<command>` shell escape — run a shell command without engaging the agent._
93
+
94
+ ### Added
95
+ - gjc-parity cmd-mode shell escape: typing `!<command>` at the prompt runs the command directly in the shell and prints its output WITHOUT engaging the agent or touching conversation history (a REPL-style shell escape). Because the user is explicitly driving their own shell, the deep-interview mutation guard — which gates the AGENT's tools — does not apply; bare `!` prints a short usage hint.
96
+
97
+ ## [0.5.0] - 2026-06-14
98
+ _Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config._
99
+
100
+ ### Fixed
101
+ - A schema-invalid `config.json` no longer wipes stored OAuth / provider credentials: the invalid config falls back to defaults while the credential block is preserved, so a bad config edit can't silently sign you out of every provider.
102
+
103
+ ### Changed
104
+ - Workspace-tree scan is now LRU-cached per resolved cwd (cap 32), so a long session that touches many trees (subagents, worktrees, cross-tree `/view`) neither re-scans the same directory nor grows the cache without bound.
105
+ - Per-skill workflow-state JSON reads are mtime + size-validated cached. The mutation guard reads the workflow lock before every mutating tool, so re-reading and re-parsing each call was wasteful; the cache stays cross-process-safe — a write from another process bumps mtime/size and invalidates it, so an active interview lock can never be missed.
106
+ - DNA Claw HUD frames are memoized (LRU-capped) keyed on every input that affects output (grand/unicode/cols/color/level/phase/frame). The live HUD cycles a fixed ~60-frame set at ~120ms, so the 2nd+ cycle is now O(1) lookups instead of recomputing per-line ANSI gradients — lower steady-state HUD CPU.
107
+
108
+ ## [0.4.9] - 2026-06-14
109
+ _Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh._
110
+
111
+ ### Fixed
112
+ - Typed text now appears in the input box DURING a running turn — keystrokes entered mid-turn render live in the prompt box (and as a pending steering card) instead of staying invisible until the turn ends.
113
+ - Live-frame anchor drift: 0.4.8's constant-height padding could grow the reserve and drift the cursor anchor by one row, reintroducing a duplicate model bar mid-turn. The live frame is content-sized again, and every rendered line is width-clamped to the terminal width so a long line (e.g. the model bar with a deep cwd) can't soft-wrap into a second physical row and desync the differential renderer's 1-line = 1-row accounting — keeping completed cards visible in scrollback above the live frame.
114
+ - Renderer `reset()` → `insertAbove()` ordering now erase-line-clears the old frame rows the inserted block did not cover (`occupied = max(prev, coverRows)`), closing the remaining duplicate-model-bar / orphaned-border case.
115
+
116
+ ### Changed
117
+ - Regenerated every directory `AGENTS.md` guide and pruned stale working docs (the rolling improvements log, promo assets, and one-off review/analysis notes) so the tracked docs reflect the current tree.
118
+
119
+ ## [0.4.8] - 2026-06-14
120
+ _Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes._
121
+
122
+ ### Fixed
123
+ - The live turn now renders at a CONSTANT height: the in-flight tool-output / thinking block reserves a fixed row count (bottom-anchored, blank-padded at the top) and the whole frame is padded to exactly the terminal's rows. Streaming stdout growth no longer thrashes the frame height every 100ms — the height change that desynced the differential renderer and duplicated the model bar is gone.
124
+ - Renderer self-heal reset now remembers how many rows are physically on screen (`coverRows`), so a repaint of a SHORTER frame erase-line-clears the rows it no longer covers — fixing the persistent off-by-one that left a duplicate model bar / orphaned borders after a reset.
125
+ - Raw child stdout is sanitized before entering the live frame (`sanitizeForFrame`): carriage returns, erase-line/cursor-move escapes, OSC sequences, and incomplete trailing escapes are stripped (SGR color kept) so a streaming `bun test`'s `\r\x1b[2K` progress lines can no longer tear the renderer's own `\x1b[2K` (printing a literal "2K") or hijack the cursor.
126
+
127
+ ## [0.4.7] - 2026-06-14
128
+ _Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired._
129
+
130
+ ### Added
131
+ - Detached subagents: `task {detached:true}` launches a background subagent and returns immediately; a new `subagent` control tool lists, inspects, awaits (optionally bounded), and cancels them (gjc parity, in-process turn-scoped registry — `cancelAll()` on teardown prevents background-promise leaks).
132
+ - Live shaded in-flight output: the running tool's stdout (bash) and native thinking deltas stream as a DIMMED bounded block above the status line, then flush UN-dimmed into scrollback once the model commits — gjc's "shaded until complete" effect.
133
+ - Update-check disk cache (`~/.jeo`): the update banner is instant from cache with a background refresh, and clears itself after an interim upgrade.
134
+
135
+ ### Changed
136
+ - Provider registry bootstrap: `register-providers.ts` registers every built-in adapter; `model-manager` resolves adapters through the registry alone and no longer imports or names concrete providers — new built-ins register in one place.
137
+ - `read` default (no `lineRange`) now fills the model-visible output budget with WHOLE lines instead of a fixed 500-line cap, so a single read returns more of a file and forces far less needless pagination.
138
+ - Tool-output handling (model-visible budget, both-ends truncation, recoverable artifact spilling) extracted from `engine.ts` into `tool-output.ts`; `engine.ts` re-exports for compatibility.
139
+ - Final-report markdown: single `*italic*` / `_italic_` is now styled (list-bullet- and snake_case-safe), and a heading that follows content gets one blank line of breathing room above it.
140
+ - Mid-turn steering query now renders as a `user` card in scrollback; per-theme `userCard` palette and todo-card rendering refinements.
141
+
142
+ ### Removed
143
+ - The `gjc` command and the bundled `gjc` skill — the skills catalog now ships five workflows (deep-interview, deep-dive, ralplan, team, ultragoal).
144
+
145
+ ## [0.4.6] - 2026-06-14
146
+ _Width-correct forge cards for CJK/emoji, red borders on failed tool cards, aligned `ooo ralph` monitor HUD, and a per-theme user-card palette._
147
+
148
+ ### Fixed
149
+ - Forge tool cards no longer tear their right border when the body contains CJK/Hangul/emoji: content wraps by DISPLAY width (wide glyphs count 2 columns) instead of code-point count, so a Korean line that previously rendered ~2× wide now stays inside the card at every width.
150
+ - The `ooo ralph` monitoring HUD box borders stay flush: every row is padded by display width (ANSI-aware) and the box auto-sizes to its widest row, replacing `String.padEnd` on colored strings that counted SGR escape bytes and spilled the right edge.
151
+
152
+ ### Changed
153
+ - Failed forge tool cards now render with a red border (gjc-style state-encoded border) so failures pop out of scrollback at a glance; successful/neutral cards keep the theme accent identity.
154
+ - Every built-in theme now ships a `userCard` palette (accent/border/shadow/fill) for the mid-turn steering user card.
155
+ - `describeModel`/`resolveModelId` accept an already-read config to skip a redundant global-config read on the turn hot path.
156
+
157
+ ## [0.4.5] - 2026-06-14
158
+ _First-class filesystem make/remove tools._
159
+
160
+ ### Added
161
+ - `mkdir {dirPath}` tool: create a directory (parents included, idempotent) as a first-class tool instead of shelling out to `bash` — honors the deep-interview mutation lock and prefix-restricted roles.
162
+ - `delete {path, recursive?}` tool: remove a file (or a directory with `recursive:true`); refuses to wipe the working directory, treats a missing path as a soft error, and clears the file-freshness snapshot so a later write to the same path is not rejected as stale.
163
+
164
+ ### Changed
165
+ - Read-only subagent lanes (planner/architect/critic) now also drop `mkdir`/`delete`, keeping review roles physically unable to mutate the repo.
166
+
167
+ ## [0.4.4] - 2026-06-13
168
+ _Live subagent status mirroring, always-useful Ctrl+O activity tail, read lineRange crash guard._
169
+
170
+ ### Added
171
+ - Per-turn activity-history ring (bounded at 200 plain-text entries): Ctrl+O now always answers "what has been happening" — the detail panel appends a timestamped `+N.Ns` recent-activity tail even before the first reply or tool detail exists.
172
+
173
+ ### Changed
174
+ - The live status row now mirrors a delegated subagent's LATEST nested event (`EXECUTOR ✓ read src/…`) instead of a static `Task: executor …` title — a long `task` no longer reads as an opaque "calling model" stall.
175
+
176
+ ### Fixed
177
+ - `read` no longer crashes with `spec.split is not a function` when the model passes a numeric/JSON `lineRange` (field bug, reproduced live twice): numbers are coerced, junk degrades to a polite selector error.
178
+
179
+ ## [0.4.3] - 2026-06-13
180
+ _Readability pass for autopilot, subagent activity, and worked-history review._
181
+
182
+ ### Added
183
+ - `jeo autopilot status` now renders a yellow ratchet status field with task, eval, score direction, keep/revert counts, patience, and the recommended next action.
184
+ - `/history` transcript output now adds turn headers and folds the first tool-result line into each tool activity row, so scrollback reads as user → activity → jeo instead of raw protocol traffic.
185
+
186
+ ### Changed
187
+ - Subagent activity lines now render as an `AGENT` tree (`▸ ROLE`, `├─ ROLE`, `└─ ROLE`) in both TUI scrollback and non-TTY progress output for faster scanning.
188
+ - README command tables now call out `/history` and `autopilot` as first-class readable operation surfaces.
189
+ - Removed the standalone `jeo models` command/menu path; model discovery and assignment now stay inside `/model`, `/provider`, and setup/doctor flows.
190
+
191
+ ## [0.4.2] - 2026-06-13
192
+ _Thinking-loop termination guarantees (cycle guard + turn wall-clock budget), unboxed live status without step counters, self-contained `.jeo` namespace, live next-prompt input card, role-targeted model/thinking picker._
193
+
194
+ ### Added
195
+ - Agent-loop cycle guard: an A↔B tool-call ping-pong (re-reading one file ↔ re-running one command forever) now gets ONE corrective bounce, then a hard stop — the "stuck in thinking" spin the exact-repeat guard could never see.
196
+ - Turn wall-clock budget (`JEO_TURN_MAX_MS`, default 30 minutes, `0` disables): step budgets bound the COUNT of model calls, this bounds their total TIME — a turn that crosses it consolidates a wrap-up instead of spinning for hours.
197
+ - Live next-prompt input box in the TUI — text typed during a running turn stays in the same query surface instead of a separate queued row.
198
+ - jeo discovers skills from its own `~/.jeo/agent/skills` (+ project `.jeo/agent/skills`) and resolves hooks/rules under `.jeo` instead of referencing `.gjc`.
199
+ - Config-driven custom subagent roles: a non-bundled id declaring `title`/`description`/`prompt` becomes a first-class role at runtime.
200
+ - Ctrl+O mid-turn detail view: flush the full last reply + tool output into scrollback.
201
+ - `/fast [on|off|status]` slash command: enables minimal/low reasoning fast mode only when the active model advertises support.
202
+ - Task/team subagents now receive the same project context block as the parent agent, sourced from `JEO.md`, `AGENTS.md`, `.jeo/context.md`, `.agents/*`, and `.jeo/*` guidance — legacy `.gjc` context is not loaded.
203
+
204
+ ### Changed
205
+ - Live status is UNBOXED: a flat `⠙ thinking · <live activity> ⟦esc⟧` row plus one dim metrics row replaces the bordered status box — the message is never trapped inside a border.
206
+ - Removed meaningless `step N/M` counters everywhere (status row, footer, plain-stream `[step N/M]` headers, nested subagent lines) along with step-driven `eta`/`evo %`: the dynamic step budget keeps extending the denominator, so the counters carried no information. The evolution stage track stays.
207
+ - Tool-call signature bookkeeping (repeat/cycle guards, step-budget novelty set) now stores fixed-size FNV digests instead of full JSON argument strings — a long turn's guard memory stays flat even when `write` calls embed whole file bodies.
208
+ - Unified model targeting: `/model` can now set default thinking, pick a model, apply it to the default agent or any subagent role, and set that target's thinking level in one flow.
209
+ - `/model` picker now shows DEFAULT/role badges with each target's thinking level, and the post-pick action menu uses the unified Set-as-role format plus an OpenAI Codex role preset.
210
+ - `/model` action selection now uses a Ralph-style nested sub-list: each DEFAULT/role header expands into selectable thinking rows, so target and thinking are chosen in one TUI screen.
211
+ - During a live reasoning turn, typed next-user text now renders as a styled pending `user` card with dark background while the normal input box remains editable.
212
+ - Update availability now renders as a yellow full-width field instead of a boxed card, matching the status-field TUI treatment.
213
+ - Removed the legacy `/models` slash-menu path; `/model` and `/provider` own interactive model selection.
214
+ - Canonicalized runtime naming on `.jeo` and `JEO_` only.
215
+
216
+ ### Fixed
217
+ - jeo can no longer sit in "thinking" forever: every turn now terminates via the cycle guard, the wall-clock budget, or the existing step/repeat/failure guards — pathological spins consolidate a wrap-up instead of running unbounded.
218
+ - Ctrl-C now force-quits jeo immediately instead of being softened into an abort prompt.
219
+ - Done-time todo reconciliation gate — stale Todos can no longer survive a finished turn.
220
+ - MCP stdio framing for ralph tools.
221
+
222
+ ## [0.4.1] - 2026-06-12
223
+ _TUI card parity polish + done-time todo reconciliation._
224
+
225
+ ### Added
226
+ - gjc card parity: `⟦Ctrl+O for more⟧` clip hint, code highlighting in card bodies, and full tool output via Ctrl+O.
227
+
228
+ ### Fixed
229
+ - Clip hint also covers summarize-stage markers.
230
+ - Done-time todo reconciliation gate so a finished turn's checklist reflects what actually completed.
231
+
232
+ ## [0.4.0] - 2026-06-12
233
+ _Verified TUI, resilient engine, batch input, multilingual docs._
234
+
235
+ ### Added
236
+ - Bracketed-paste batch input — a multi-line paste runs one command per line, in order (prompt_toolkit paste contract).
237
+ - jeo-ref transcript parity: Todo Write tree cards, line-numbered write previews, agent-name reasoning blocks, tree-style skill detail.
238
+ - Seed writer/parser round-trip integrity with a freeze-time assert.
239
+ - Slow-drip stream deadline + acceptance-criteria quality floor.
240
+
241
+ ### Fixed
242
+ - Anthropic refusal recovery: context reset per the provider contract, neutral continuation note, OAuth/API-key guidance.
243
+ - Model discovery repaired against live endpoints (codex `client_version`, gemini pagination).
244
+
245
+ ## [0.3.0] - 2026-06-02
246
+ _OAuth credentials + local Ollama provider._
247
+
248
+ ### Added
249
+ - OAuth login (`jeo auth`) and a local Ollama provider.
250
+ - `jeo doctor`, multi-tool-call grouping, and CI/install hardening.
251
+
252
+ ## [0.2.1] - 2026-06-02
253
+ _Setup and model configuration._
254
+
255
+ ### Added
256
+ - `jeo setup` and `jeo models`; default Gemini 2.5-flash; verified real LLM turn.
257
+
258
+ ## [0.2.0] - 2026-06-02
259
+ _Real LLM coding agent._
260
+
261
+ ### Added
262
+ - Real LLM coding agent with provider + model configuration.
263
+
264
+ ## [0.1.0] - 2026-06-01
265
+ _Initial release._
266
+
267
+ ### Added
268
+ - Initial jeo-code agent and CLI.
269
+
270
+ [Unreleased]: https://github.com/akillness/jeo-code/compare/v0.4.5...HEAD
271
+ [0.4.5]: https://github.com/akillness/jeo-code/releases/tag/v0.4.5
272
+ [0.4.4]: https://github.com/akillness/jeo-code/releases/tag/v0.4.4
273
+ [0.4.3]: https://github.com/akillness/jeo-code/releases/tag/v0.4.3
274
+ [0.4.2]: https://github.com/akillness/jeo-code/releases/tag/v0.4.2
275
+ [0.4.1]: https://github.com/akillness/jeo-code/releases/tag/v0.4.1
276
+ [0.4.0]: https://github.com/akillness/jeo-code/releases/tag/v0.4.0
277
+ [0.3.0]: https://github.com/akillness/jeo-code/releases/tag/v0.3.0
278
+ [0.2.1]: https://github.com/akillness/jeo-code/releases/tag/v0.2.1
279
+ [0.2.0]: https://github.com/akillness/jeo-code/releases/tag/v0.2.0
280
+ [0.1.0]: https://github.com/akillness/jeo-code/releases/tag/v0.1.0
package/README.ja.md CHANGED
@@ -150,11 +150,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
150
150
  ## 変更履歴 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
154
+ - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
153
155
  - **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
154
156
  - **[0.5.9]** (2026-06-15) — Bounded per-frame wrap for the live thinking/tool-output blocks — re-render cost no longer grows with stream length.
155
157
  - **[0.5.8]** (2026-06-15) — Native Opik observability for the turn loop (opt-in `JEO_OPIK`, pure-TS no-op when unset) + autopilot convergence tracking.
156
- - **[0.5.7]** (2026-06-15) — `/model` picker is default-only, `/clear` resets to the initial screen, ESC clears the input box, and a launch process-listener leak is fixed.
157
- - **[0.5.6]** (2026-06-15) — `/model` sets only the default thinking; per-role reasoning moved to `/agents`.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -150,11 +150,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
150
150
  ## 변경 이력 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
154
+ - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
153
155
  - **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
154
156
  - **[0.5.9]** (2026-06-15) — Bounded per-frame wrap for the live thinking/tool-output blocks — re-render cost no longer grows with stream length.
155
157
  - **[0.5.8]** (2026-06-15) — Native Opik observability for the turn loop (opt-in `JEO_OPIK`, pure-TS no-op when unset) + autopilot convergence tracking.
156
- - **[0.5.7]** (2026-06-15) — `/model` picker is default-only, `/clear` resets to the initial screen, ESC clears the input box, and a launch process-listener leak is fixed.
157
- - **[0.5.6]** (2026-06-15) — `/model` sets only the default thinking; per-role reasoning moved to `/agents`.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -150,11 +150,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
150
150
  ## Changelog
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
154
+ - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
153
155
  - **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
154
156
  - **[0.5.9]** (2026-06-15) — Bounded per-frame wrap for the live thinking/tool-output blocks — re-render cost no longer grows with stream length.
155
157
  - **[0.5.8]** (2026-06-15) — Native Opik observability for the turn loop (opt-in `JEO_OPIK`, pure-TS no-op when unset) + autopilot convergence tracking.
156
- - **[0.5.7]** (2026-06-15) — `/model` picker is default-only, `/clear` resets to the initial screen, ESC clears the input box, and a launch process-listener leak is fixed.
157
- - **[0.5.6]** (2026-06-15) — `/model` sets only the default thinking; per-role reasoning moved to `/agents`.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -150,11 +150,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
150
150
  ## 更新日志 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
154
+ - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
153
155
  - **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
154
156
  - **[0.5.9]** (2026-06-15) — Bounded per-frame wrap for the live thinking/tool-output blocks — re-render cost no longer grows with stream length.
155
157
  - **[0.5.8]** (2026-06-15) — Native Opik observability for the turn loop (opt-in `JEO_OPIK`, pure-TS no-op when unset) + autopilot convergence tracking.
156
- - **[0.5.7]** (2026-06-15) — `/model` picker is default-only, `/clear` resets to the initial screen, ESC clears the input box, and a launch process-listener leak is fixed.
157
- - **[0.5.6]** (2026-06-15) — `/model` sets only the default thinking; per-role reasoning moved to `/agents`.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -12,6 +12,7 @@
12
12
  "scripts/install.sh",
13
13
  "scripts/uninstall.sh",
14
14
  "tsconfig.json",
15
+ "CHANGELOG.md",
15
16
  "README.md",
16
17
  "README.ko.md",
17
18
  "README.ja.md",
@@ -79,6 +79,8 @@ export const TOOL_PROTOCOL = [
79
79
  "Alternatively, you may batch up to 6 independent calls in a single turn using the following format:",
80
80
  '{ "reasoning": "<one short sentence>", "tools": [{ "tool": "<name>", "arguments": { ... } }, ...] }',
81
81
  "Batch only independent calls; NEVER batch 'done', and NEVER put a mutating tool (write/edit/bash) after another mutating tool in one batch whose inputs depend on the earlier one.",
82
+ "Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. Locate before you open: search/find first, then read the hit, instead of guessing paths.",
83
+ "web_search reflex: if the request hinges on a name, version, library, or event you do not actually recognize, search before answering instead of guessing; never claim a result's absence proves nonexistence.",
82
84
  ].join("\n");
83
85
 
84
86
  /** Restricted protocol for read-only subagent roles (planner/architect/critic):
@@ -109,25 +111,39 @@ export const WORKING_DISCIPLINE = [
109
111
  "- Correctness first, maintainability second, brevity third. Prefer boring, explicit code.",
110
112
  "- Never present partial work as complete; never suppress tests or warnings to make code pass.",
111
113
  "- Never fabricate tool results or test outcomes; verification claims must match what was actually run.",
114
+ "- Don't assume disk/state matches expectations or that a referenced file exists — read to verify first.",
115
+ "- Don't fabricate API/library surfaces from memory; check the source or --help for unfamiliar APIs.",
112
116
  "- Never ship stubs, placeholders, or TODO-only code as a delivered feature.",
113
117
  "- Never substitute the requested problem with an easier adjacent one.",
114
118
  "- Update directly affected callsites, tests, and docs — or state why they are unchanged.",
115
119
  "- Reuse existing patterns; parallel conventions are prohibited. Fix problems at their source.",
116
120
  "- You are not alone in the repository: treat unexpected changes as user work; never revert or delete them.",
117
- "- Re-read before acting if a tool fails or a file may have changed.",
121
+ "- Trust tool output as truth, but re-read/re-run if a tool fails, a file changed, or output looks stale or self-contradictory.",
118
122
  "- Prefer dedicated tools over shell pipelines: read (not cat), search (not grep), edit (not sed).",
119
123
  ].join("\n");
120
124
 
125
+ /** Reply discipline (FABLE-5 tone + gjc communication/soul): shapes the agent's
126
+ * user-facing prose. Injected into the interactive + executor system prompts only;
127
+ * read-only subagents carry their own output contracts. */
128
+ export const OUTPUT_DISCIPLINE = [
129
+ "Reply discipline:",
130
+ "- Lead with the answer or result; no preamble, no progress narration, no restating the task.",
131
+ "- Default to tight prose; use headers/bullets/tables ONLY when the content is genuinely multi-part or the user asked — never bullet a one-idea answer.",
132
+ "- Report only what is done or in progress; never announce future work instead of doing it.",
133
+ "- Match reply length to the task: a one-line change gets a one-line report.",
134
+ ].join("\n");
135
+
121
136
  export function executorSystemPrompt(
122
137
  role = "Executor Agent, a senior software developer",
123
138
  protocol: string = TOOL_PROTOCOL,
124
- verificationDirective = "Always verify (run tests / execute the program) before calling done.",
139
+ verificationDirective = "Before calling done, self-check: did I run the test or command that exercises this change, are directly-affected callsites/tests/docs updated, and does my claim match real output? If any answer is no, keep working — do not call done.",
125
140
  ): string {
126
141
  return (
127
142
  `You are the ${role}.\n` +
128
143
  `Accomplish the user's request by calling tools and verifying your work.\n\n` +
129
144
  `${protocol}\n\n` +
130
145
  `${WORKING_DISCIPLINE}\n\n` +
146
+ `${OUTPUT_DISCIPLINE}\n\n` +
131
147
  verificationDirective
132
148
  );
133
149
  }
@@ -503,7 +519,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
503
519
 
504
520
  let invocation: any;
505
521
  try {
506
- invocation = extractJsonObject<any>(responseText);
522
+ invocation = extractJsonObject<any>(responseText, { preferKeys: ["tool", "tools"] });
507
523
  } catch (err) {
508
524
  ev.onAssistant?.(responseText, null);
509
525
  // Prose salvage: a reply with no JSON object at all is a chat-style final
package/src/agent/json.ts CHANGED
@@ -4,45 +4,109 @@
4
4
  * Models (especially non-jsonMode backends like Anthropic/Ollama) routinely
5
5
  * wrap JSON in prose, ```json fences, or trailing commentary. This recovers the
6
6
  * first balanced top-level `{...}` object, respecting strings and escapes.
7
+ *
8
+ * gjc-robustness hardening (the JSON-mode path is the hot path for the default
9
+ * antigravity provider, which is text-only and cannot use native tool-calling):
10
+ * - tolerate trailing commas before `}`/`]` (a frequent small-model slip);
11
+ * - `preferKeys` lets the tool-call caller prefer the balanced object that
12
+ * actually carries a `tool`/`tools` field over an earlier stray JSON object.
7
13
  */
8
- export function extractJsonObject<T = unknown>(text: string): T {
14
+ export function extractJsonObject<T = unknown>(
15
+ text: string,
16
+ opts?: { preferKeys?: string[] },
17
+ ): T {
9
18
  const raw = text.trim();
19
+ const preferKeys = opts?.preferKeys;
10
20
 
11
- // Fast path: already pure JSON.
12
- try {
13
- return JSON.parse(raw) as T;
14
- } catch {
15
- /* fall through to recovery */
16
- }
21
+ // Fast path: already pure JSON (optionally with a trailing comma to repair).
22
+ const fast = tryParse<T>(raw);
23
+ if (fast !== undefined) return fast;
17
24
 
18
- // Strip common code fences and retry.
25
+ // Strip common code fences and retry (pure, then trailing-comma-repaired).
19
26
  const defenced = raw.replace(/```(?:json|JSON)?/g, "").trim();
20
- try {
21
- return JSON.parse(defenced) as T;
22
- } catch {
23
- /* fall through to brace scan */
24
- }
27
+ const fromDefencedWhole = tryParse<T>(defenced);
28
+ if (fromDefencedWhole !== undefined) return fromDefencedWhole;
25
29
 
26
- const parsedFromDefenced = findAndParseBalancedObject<T>(defenced);
27
- if (parsedFromDefenced !== null) {
30
+ const parsedFromDefenced = findAndParseBalancedObject<T>(defenced, preferKeys);
31
+ if (parsedFromDefenced !== undefined) {
28
32
  return parsedFromDefenced;
29
33
  }
30
- const parsedFromRaw = findAndParseBalancedObject<T>(raw);
31
- if (parsedFromRaw !== null) {
34
+ const parsedFromRaw = findAndParseBalancedObject<T>(raw, preferKeys);
35
+ if (parsedFromRaw !== undefined) {
32
36
  return parsedFromRaw;
33
37
  }
34
38
  throw new Error(`No parseable JSON object found in model output: ${truncate(raw, 200)}`);
35
39
  }
36
40
 
37
41
  /** Like {@link extractJsonObject} but returns null instead of throwing. */
38
- export function tryExtractJsonObject<T = unknown>(text: string): T | null {
42
+ export function tryExtractJsonObject<T = unknown>(
43
+ text: string,
44
+ opts?: { preferKeys?: string[] },
45
+ ): T | null {
39
46
  try {
40
- return extractJsonObject<T>(text);
47
+ return extractJsonObject<T>(text, opts);
41
48
  } catch {
42
49
  return null;
43
50
  }
44
51
  }
45
52
 
53
+ /**
54
+ * Parse `s` as JSON, tolerating a single class of common model slip: a trailing
55
+ * comma right before a closing `}` or `]`. Returns `undefined` (not null — a bare
56
+ * `null`/`false` is a legal JSON value) when nothing parses.
57
+ */
58
+ function tryParse<T>(s: string): T | undefined {
59
+ try {
60
+ return JSON.parse(s) as T;
61
+ } catch {
62
+ /* fall through to a trailing-comma repair */
63
+ }
64
+ const repaired = stripTrailingCommas(s);
65
+ if (repaired !== s) {
66
+ try {
67
+ return JSON.parse(repaired) as T;
68
+ } catch {
69
+ /* unrecoverable here */
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ /**
76
+ * Remove a comma that directly precedes a closing `}` or `]` (ignoring
77
+ * whitespace), but never a comma inside a string literal. String/escape state is
78
+ * tracked so a comma inside `"a,}"` is preserved.
79
+ */
80
+ function stripTrailingCommas(s: string): string {
81
+ let out = "";
82
+ let inString = false;
83
+ let escaped = false;
84
+ for (let i = 0; i < s.length; i++) {
85
+ const ch = s[i]!;
86
+ if (inString) {
87
+ out += ch;
88
+ if (escaped) escaped = false;
89
+ else if (ch === "\\") escaped = true;
90
+ else if (ch === '"') inString = false;
91
+ continue;
92
+ }
93
+ if (ch === '"') {
94
+ inString = true;
95
+ out += ch;
96
+ continue;
97
+ }
98
+ if (ch === ",") {
99
+ let j = i + 1;
100
+ while (j < s.length && (s[j] === " " || s[j] === "\t" || s[j] === "\n" || s[j] === "\r")) j++;
101
+ if (j < s.length && (s[j] === "}" || s[j] === "]")) {
102
+ continue; // drop the trailing comma
103
+ }
104
+ }
105
+ out += ch;
106
+ }
107
+ return out;
108
+ }
109
+
46
110
  /** Scan for a brace-balanced object starting at startIndex, ignoring braces inside strings. */
47
111
  function extractBalancedObject(text: string, startIndex: number): string | null {
48
112
  let depth = 0;
@@ -66,20 +130,36 @@ function extractBalancedObject(text: string, startIndex: number): string | null
66
130
  return null;
67
131
  }
68
132
 
69
- function findAndParseBalancedObject<T>(text: string): T | null {
133
+ /**
134
+ * Find the first balanced `{...}` that parses as JSON. When `preferKeys` is given,
135
+ * keep scanning past an earlier parseable object that lacks every preferred key and
136
+ * return the first object that DOES carry one (e.g. the real `{ "tool": ... }` call
137
+ * after a stray JSON-looking object in reasoning prose); fall back to the first
138
+ * parseable object when none carries a preferred key.
139
+ */
140
+ function findAndParseBalancedObject<T>(text: string, preferKeys?: string[]): T | undefined {
141
+ let firstParsed: T | undefined;
70
142
  for (let i = 0; i < text.length; i++) {
71
143
  if (text[i] === "{") {
72
144
  const candidate = extractBalancedObject(text, i);
73
145
  if (candidate) {
74
- try {
75
- return JSON.parse(candidate) as T;
76
- } catch {
77
- // ignore, try next
146
+ const parsed = tryParse<T>(candidate);
147
+ if (parsed !== undefined) {
148
+ if (firstParsed === undefined) firstParsed = parsed;
149
+ if (!preferKeys || preferKeys.length === 0) return parsed;
150
+ if (
151
+ parsed !== null &&
152
+ typeof parsed === "object" &&
153
+ preferKeys.some(k => k in (parsed as Record<string, unknown>))
154
+ ) {
155
+ return parsed;
156
+ }
157
+ // else: keep scanning for an object that carries a preferred key
78
158
  }
79
159
  }
80
160
  }
81
161
  }
82
- return null;
162
+ return firstParsed;
83
163
  }
84
164
 
85
165
  function truncate(s: string, n: number): string {
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { emitKeypressEvents } from "node:readline";
3
3
  import { PassThrough } from "node:stream";
4
- import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, type AgentLoopEvents } from "../agent/engine";
4
+ import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, type AgentLoopEvents } from "../agent/engine";
5
5
  import { createOpikTracer, wrapEvents } from "../agent/opik-tracer";
6
6
  import { initialDynamicStepLimit } from "../agent/step-budget";
7
7
  import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
@@ -27,6 +27,7 @@ import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
27
27
  import { checkForUpdate, readUpdateCache, writeUpdateCache } from "../util/update-check";
28
28
  import { jeoEnv } from "../util/env";
29
29
  import { renderUpdateBox } from "../tui/components/update-box";
30
+ import { consumeLaunchWhatsNew } from "../util/whats-new";
30
31
  import { supportsUnicode } from "../tui/components/capability";
31
32
  import pkg from "../../package.json";
32
33
  import chalk from "chalk";
@@ -498,6 +499,16 @@ interface AbortHarnessOptions {
498
499
  export const PASTE_START = "\u001b[200~";
499
500
  export const PASTE_END = "\u001b[201~";
500
501
 
502
+ /** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
503
+ * standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
504
+ * line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
505
+ * event, which the REPL would treat as a hard exit ("Backspace quits jeo"). The
506
+ * input filter swallows these when the line buffer is already empty so the byte never
507
+ * reaches readline and the close can't fire. */
508
+ export function isStandaloneBackspace(chunk: string): boolean {
509
+ return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
510
+ }
511
+
501
512
  export interface PromptInputQueue {
502
513
  pendingLines: string[];
503
514
  partial: string;
@@ -963,6 +974,7 @@ export function buildToolProtocol(allowedTools: Set<string>): string {
963
974
  lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
964
975
  lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
965
976
  lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
977
+ lines.push("Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. Locate before you open: search/find first, then read the hit, instead of guessing paths.");
966
978
  return lines.join("\n");
967
979
  }
968
980
 
@@ -1197,7 +1209,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1197
1209
  const baseSystemPrompt =
1198
1210
  preamble + "\n\n" + protocol + "\n\n" +
1199
1211
  WORKING_DISCIPLINE + "\n\n" +
1200
- "Always verify (run tests / execute the program) before calling done." +
1212
+ OUTPUT_DISCIPLINE + "\n\n" +
1213
+ "Before calling done, self-check: did I run the test or command that exercises this change, are directly-affected callsites/tests/docs updated, and does my claim match real output? If any answer is no, keep working — do not call done." +
1201
1214
  "\nWhen you have finished the user's request, or need to reply to or ask the user something, call done with {\"reason\": <your natural-language reply to the user>}. The reason text is shown to the user as your message." +
1202
1215
  (allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
1203
1216
  " Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
@@ -1770,6 +1783,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1770
1783
  };
1771
1784
  showUpdateBanner(await readUpdateCache(pkg.version));
1772
1785
  showUpdateBanner(await Promise.race([updatePromise, new Promise<null>(r => setTimeout(() => r(null), 1200))]));
1786
+ // First launch after a version bump: surface the bundled release notes ONCE
1787
+ // (offline, from the new package's CHANGELOG.md) and record the seen version
1788
+ // so it never repeats. Screen-safe: prints BEFORE the prompt is armed.
1789
+ try {
1790
+ const whatsNew = await consumeLaunchWhatsNew({
1791
+ cols: Math.min(100, Math.max(40, (process.stdout.columns ?? 80) - 2)),
1792
+ unicode: supportsUnicode(),
1793
+ color: welcomeTheme.color,
1794
+ });
1795
+ if (whatsNew && whatsNew.length) console.log(whatsNew.join("\n"));
1796
+ } catch { /* release notes are a courtesy; never block launch */ }
1773
1797
  if (!LaunchTui.usable(flags.noTui)) console.log("(plain output)");
1774
1798
 
1775
1799
  const useTui = LaunchTui.usable(flags.noTui);
@@ -1954,6 +1978,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1954
1978
  for (const off of promptListenerCleanups.splice(0)) { try { off(); } catch { /* best effort */ } }
1955
1979
  };
1956
1980
  let keyFilter: PassThrough | undefined;
1981
+ // Holder for the active readline so the input filter can see the current line
1982
+ // buffer (used by the empty-line backspace guard below). Set after rl is created.
1983
+ let activeRl: { line?: string } | undefined;
1957
1984
  if (multilineInput) {
1958
1985
  const kf = new PassThrough();
1959
1986
  (kf as unknown as { isTTY: boolean }).isTTY = true;
@@ -1975,6 +2002,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1975
2002
  let kfInPaste = false;
1976
2003
  const kfDataHandler = (chunk: Buffer) => {
1977
2004
  const data = chunk.toString("utf8");
2005
+ // Empty-line Backspace guard: a standalone Backspace with nothing to delete is a
2006
+ // no-op, but some Bun readline builds emit a spurious `close` for it — which the
2007
+ // REPL treats as a hard exit. Drop it before it reaches readline. (Inside a paste,
2008
+ // or with text in the buffer, backspace is forwarded normally so editing works.)
2009
+ if (!kfInPaste && isStandaloneBackspace(data) && !(activeRl?.line ?? "")) return;
1978
2010
  let out = "";
1979
2011
  let i = 0;
1980
2012
  while (i < data.length) {
@@ -2015,6 +2047,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2015
2047
  output: gatedStdout(process.stdout, () => previewArmed || promptActive || pickerActive || interactiveTurnActive),
2016
2048
  completer: (line: string) => readlineCompleter(line, completionContext()),
2017
2049
  });
2050
+ activeRl = rl; // wire the input filter's empty-line backspace guard to the live buffer
2018
2051
  const promptStdin = process.stdin as typeof process.stdin & { isRaw?: boolean; setRawMode?(raw: boolean): void };
2019
2052
  const promptWasRaw = !!promptStdin.isRaw;
2020
2053
  let promptRawChanged = false;
@@ -0,0 +1,62 @@
1
+ import pkg from "../../package.json";
2
+ import {
3
+ loadBundledChangelog,
4
+ parseChangelogSections,
5
+ releaseSections,
6
+ renderWhatsNew,
7
+ } from "../util/whats-new";
8
+ import { supportsUnicode } from "../tui/components/capability";
9
+
10
+ /** `jeo whats-new` — show the release notes bundled with the running version. */
11
+ export async function runWhatsNewCommand(args: string[] = []): Promise<void> {
12
+ const isHelp = args.includes("--help") || args.includes("-h");
13
+ const hasAll = args.includes("--all");
14
+ const hasJson = args.includes("--json");
15
+
16
+ const KNOWN = new Set(["--all", "--json", "-h", "--help"]);
17
+ for (const arg of args) {
18
+ if (!KNOWN.has(arg)) {
19
+ console.log(`Unknown flag: ${arg}`);
20
+ printUsage();
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+ }
25
+
26
+ if (isHelp) {
27
+ printUsage();
28
+ return;
29
+ }
30
+
31
+ const md = await loadBundledChangelog();
32
+ const all = md ? releaseSections(parseChangelogSections(md)) : [];
33
+ const sections = hasAll ? all : all.slice(0, 1);
34
+
35
+ if (hasJson) {
36
+ console.log(JSON.stringify({ version: pkg.version, entries: sections }, null, 2));
37
+ return;
38
+ }
39
+
40
+ if (sections.length === 0) {
41
+ console.log(`No release notes found for jeo-code ${pkg.version}.`);
42
+ return;
43
+ }
44
+
45
+ const cols = process.stdout.columns ?? 80;
46
+ console.log(renderWhatsNew(sections, {
47
+ cols: Math.min(100, Math.max(40, cols - 2)),
48
+ unicode: supportsUnicode(),
49
+ color: process.stdout.isTTY === true,
50
+ }).join("\n"));
51
+ }
52
+
53
+ function printUsage(): void {
54
+ console.log("Usage: jeo whats-new [options]");
55
+ console.log("");
56
+ console.log("Show the release notes bundled with the installed jeo-code version.");
57
+ console.log("");
58
+ console.log("Options:");
59
+ console.log(" --all Show notes for every released version, not just the latest");
60
+ console.log(" --json Output the notes as JSON");
61
+ console.log(" -h, --help Show this help message");
62
+ }
package/src/tui/app.ts CHANGED
@@ -32,7 +32,7 @@ import { renderMarkdownTables } from "./components/markdown-table";
32
32
  import { stripMarkdown, renderMarkdownAnsi } from "./components/markdown-text";
33
33
  import { visibleWidth, wrapTextWithAnsi, truncateToWidth, sanitizeForFrame } from "./components/width";
34
34
  import { categoryBadge } from "./components/category-index";
35
- import { formatStepTimeline, stepsFromTools, formatStepHeader, formatStepTimelineCompact, type StepState } from "./components/step-timeline";
35
+ import { formatStepTimeline, stepsFromTools, formatStepHeader, formatStepTimelineCompact, formatDuration as formatToolMs, type StepState } from "./components/step-timeline";
36
36
  import { formatHintBar } from "./components/hints";
37
37
  import { formatDuration, formatUsage } from "./components/duration";
38
38
  import { renderHud, derivePhase, type JeoPhase } from "./components/hud";
@@ -112,6 +112,12 @@ export const FRAME_WRAP_TAIL_CHARS = 16 * 1024;
112
112
  export function tailForWrap(text: string, maxChars = FRAME_WRAP_TAIL_CHARS): string {
113
113
  return text.length > maxChars ? text.slice(text.length - maxChars) : text;
114
114
  }
115
+
116
+ /** Status animation palette while a tool/process runs (background verification): an
117
+ * amber→yellow gradient, distinct from the cool thinking gradient, so "the agent is
118
+ * running a process / verifying" reads at a glance (gjc parity: `theme.fg("warning")`
119
+ * on the in-flight tool line). */
120
+ export const STATUS_VERIFY_PALETTE = ["#ffd24a", "#ffb300"] as const;
115
121
  const DEFAULT_MAX_STEPS = 100;
116
122
  // Tools light enough that they never get a forge card (gjc parity): completion is a
117
123
  // single ✓/✗ ledger line; only failures surface a result card with the error body.
@@ -169,6 +175,8 @@ export class LaunchTui {
169
175
  private thinking = false;
170
176
  private hudPhase: JeoPhase = "thinking";
171
177
  private runningTool = false;
178
+ // When the current tool started (Date.now()); drives the result card's elapsed `(Nms)`.
179
+ private toolStartedAt = 0;
172
180
  // Latest transient provider notice (rate-limit auto-retry countdown); pinned into the
173
181
  // [STEP] status row while waiting so backoff is visible at a glance. Cleared on the
174
182
  // next step / model reply.
@@ -412,6 +420,7 @@ export class LaunchTui {
412
420
  this.streamingActivity = "";
413
421
  if (invocation && invocation.tool !== "done") {
414
422
  this.runningTool = true;
423
+ this.toolStartedAt = Date.now();
415
424
  this.hudPhase = "executing";
416
425
  const toolName = invocation.tool || "(no tool)";
417
426
  this.pendingIndex = this.tools.start(toolName);
@@ -464,6 +473,12 @@ export class LaunchTui {
464
473
  // tool checklist — no category/status badge clutter.
465
474
  const mark = this.unicode ? (success ? "✓" : "✗") : success ? "v" : "x";
466
475
  const paintedMark = this.theme.color ? (success ? chalk.green(mark) : chalk.red(mark)) : mark;
476
+ // gjc-parity timing detail: the completed card shows how long the tool ran,
477
+ // dim after the ✓/✗ glyph (e.g. `✓ Bash · (438ms)`).
478
+ const toolMs = this.toolStartedAt ? Date.now() - this.toolStartedAt : 0;
479
+ this.toolStartedAt = 0;
480
+ const durDim = this.theme.color ? chalk.dim : (s: string) => s;
481
+ const durSuffix = toolMs > 0 ? durDim(` ${this.unicode ? "·" : "-"} (${formatToolMs(toolMs)})`) : "";
467
482
  const result = summarizeForgeResult(tool, success, output);
468
483
  const card = this.pendingForge;
469
484
  this.pendingForge = null;
@@ -471,7 +486,7 @@ export class LaunchTui {
471
486
  // gjc-style single Bash card: command echo + `Output` divider + body + exit
472
487
  // note, under one ✓/✗-marked header — mutated in place so the live frame and
473
488
  // the non-TTY summary both show the merged card.
474
- card.title = `${paintedMark} Bash`;
489
+ card.title = `${paintedMark} Bash${durSuffix}`;
475
490
  card.lines.push(...result.lines);
476
491
  this.flushForgeCard(card, success);
477
492
  } else if (card && t === "web_search" && success && webSearchCardLines(output, { unicode: this.unicode })) {
@@ -480,11 +495,11 @@ export class LaunchTui {
480
495
  // the structured tool output (provider chain — Anthropic native or the
481
496
  // keyless DuckDuckGo fallback).
482
497
  const ws = webSearchCardLines(output, { unicode: this.unicode })!;
483
- card.title = `${paintedMark} Web Search: ${ws.titleMeta}`;
498
+ card.title = `${paintedMark} Web Search: ${ws.titleMeta}${durSuffix}`;
484
499
  card.lines = ws.lines;
485
500
  this.flushForgeCard(card, success);
486
501
  } else if (card) {
487
- card.title = `${paintedMark} ${card.title}`;
502
+ card.title = `${paintedMark} ${card.title}${durSuffix}`;
488
503
  if (!success) this.rememberForge(result);
489
504
  this.flushForgeCard(card, success);
490
505
  if (!success) this.flushForgeCard(result, false);
@@ -492,7 +507,7 @@ export class LaunchTui {
492
507
  // Light tool: one ✓/✗ line, plus a dim result tree for list-shaped output
493
508
  // (find/search/ls) and an error card when the tool failed.
494
509
  const { suffix, children } = this.ledgerTree(tool, success, output);
495
- this.appendLedger(`${paintedMark} ${target}${suffix}\n${children.map(c => `${c}\n`).join("")}`, "tool");
510
+ this.appendLedger(`${paintedMark} ${target}${suffix}${durSuffix}\n${children.map(c => `${c}\n`).join("")}`, "tool");
496
511
  if (!success) {
497
512
  this.rememberForge(result);
498
513
  this.flushForgeCard(result, false);
@@ -1164,12 +1179,16 @@ export class LaunchTui {
1164
1179
  // the ⟦esc⟧ cancel hint visible without trapping the message inside a border.
1165
1180
  if (isThinking) {
1166
1181
  const grad = themeGradient(this.theme, idx);
1182
+ // While a tool/process runs (background verification), the status animation turns
1183
+ // amber/yellow — distinct from the cool thinking gradient (gjc warning-color parity).
1184
+ const verifying = this.runningTool;
1185
+ const verifySpin = verifying && this.theme.color ? chalk.yellow(this.spinner.current()) : this.spinner.current();
1167
1186
  const costUsd = costForUsage(this.footer.model, this.turnUsage) ?? undefined;
1168
1187
  const stats = this.tools.stats();
1169
1188
  tail.push(...renderStatusBox({
1170
1189
  cols: Math.max(24, Math.min(120, cols)),
1171
1190
  phaseLabel: this.workflowStatus ? `${this.workflowStatus.skill}:${this.workflowStatus.phase}` : this.hudPhase,
1172
- spinner: this.spinner.current(),
1191
+ spinner: verifySpin,
1173
1192
  activity: this.retryNotice ?? (this.streamingActivity || this.currentActivity()),
1174
1193
  escHint: true,
1175
1194
  elapsedMs,
@@ -1184,7 +1203,7 @@ export class LaunchTui {
1184
1203
  color: this.theme.color,
1185
1204
  colorLevel,
1186
1205
  phase,
1187
- palette: [grad.from, grad.to],
1206
+ palette: verifying ? [...STATUS_VERIFY_PALETTE] : [grad.from, grad.to],
1188
1207
  isThinking: true,
1189
1208
  usage: this.turnUsage,
1190
1209
  costUsd,
@@ -1350,7 +1369,7 @@ export class LaunchTui {
1350
1369
  for (const line of renderStatusBox({
1351
1370
  cols: innerWidth,
1352
1371
  phaseLabel: this.workflowStatus ? `${this.workflowStatus.skill}:${this.workflowStatus.phase}` : this.hudPhase,
1353
- spinner: this.spinner.current(),
1372
+ spinner: this.runningTool && this.theme.color ? chalk.yellow(this.spinner.current()) : this.spinner.current(),
1354
1373
  activity: this.retryNotice ?? (this.streamingActivity || statusMsg),
1355
1374
  escHint: true,
1356
1375
  elapsedMs,
@@ -1365,7 +1384,7 @@ export class LaunchTui {
1365
1384
  color: this.theme.color,
1366
1385
  colorLevel,
1367
1386
  phase,
1368
- palette,
1387
+ palette: this.runningTool ? [...STATUS_VERIFY_PALETTE] : palette,
1369
1388
  isThinking: true,
1370
1389
  usage: this.turnUsage,
1371
1390
  costUsd,
@@ -0,0 +1,272 @@
1
+ import pkg from "../../package.json";
2
+ import { compareVersions } from "../commands/update";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
6
+ import { jeoEnv } from "./env";
7
+ import chalk from "chalk";
8
+ import { boxBlock, BOX_UNICODE, BOX_ASCII } from "../tui/components/layout";
9
+
10
+ // ---- "What's New" release notes ---------------------------------------------
11
+ // Mirrors gjc's post-upgrade release-notes surface. The bundled CHANGELOG.md
12
+ // (shipped via package.json `files`) is always the RUNNING version's changelog,
13
+ // so after a `bun install -g jeo-code` upgrade the next launch reads the NEW
14
+ // notes offline. `jeo whats-new` shows them on demand; `jeo update --install`
15
+ // shows them right after a successful self-update.
16
+
17
+ export interface ChangelogGroup {
18
+ /** "Added" | "Changed" | "Fixed" | "" (ungrouped). */
19
+ label: string;
20
+ items: string[];
21
+ }
22
+
23
+ export interface ChangelogSection {
24
+ version: string; // "0.5.9" | "Unreleased"
25
+ date?: string;
26
+ summary: string; // the `_italic_` one-liner under the header
27
+ groups: ChangelogGroup[];
28
+ }
29
+
30
+ const HEADER = /^##\s+\[([^\]]+)\](?:\s*-\s*(\S+))?\s*$/;
31
+ const SUBHEADER = /^###\s+(.+?)\s*$/;
32
+ const BULLET = /^[-*]\s+(.+)$/;
33
+ const ITALIC = /^_(.+)_$/;
34
+
35
+ /** Parse `## [version] - date` sections with their summary line and grouped bullets. */
36
+ export function parseChangelogSections(markdown: string): ChangelogSection[] {
37
+ const lines = markdown.split(/\r?\n/);
38
+ const sections: ChangelogSection[] = [];
39
+ let current: ChangelogSection | null = null;
40
+ let group: ChangelogGroup | null = null;
41
+
42
+ for (const raw of lines) {
43
+ const line = raw.trimEnd();
44
+ const head = line.match(HEADER);
45
+ if (head) {
46
+ current = { version: head[1]!, date: head[2], summary: "", groups: [] };
47
+ group = null;
48
+ sections.push(current);
49
+ continue;
50
+ }
51
+ if (!current) continue;
52
+
53
+ const trimmed = line.trim();
54
+ if (trimmed === "") continue;
55
+
56
+ const sub = trimmed.match(SUBHEADER);
57
+ if (sub) {
58
+ group = { label: sub[1]!, items: [] };
59
+ current.groups.push(group);
60
+ continue;
61
+ }
62
+
63
+ const bullet = trimmed.match(BULLET);
64
+ if (bullet) {
65
+ if (!group) {
66
+ group = { label: "", items: [] };
67
+ current.groups.push(group);
68
+ }
69
+ group.items.push(bullet[1]!.trim());
70
+ continue;
71
+ }
72
+
73
+ const italic = trimmed.match(ITALIC);
74
+ if (italic && !current.summary && current.groups.length === 0) {
75
+ current.summary = italic[1]!.trim();
76
+ }
77
+ }
78
+
79
+ return sections;
80
+ }
81
+
82
+ /** Real releases only (drops an `Unreleased` heading), newest-first as written. */
83
+ export function releaseSections(sections: ChangelogSection[]): ChangelogSection[] {
84
+ return sections.filter(s => s.version.toLowerCase() !== "unreleased");
85
+ }
86
+
87
+ /**
88
+ * Sections strictly newer than `fromVersion` and at most `toVersion`.
89
+ * `fromVersion` null → every release up to and including `toVersion`.
90
+ */
91
+ export function selectNewSections(
92
+ sections: ChangelogSection[],
93
+ fromVersion: string | null,
94
+ toVersion: string,
95
+ ): ChangelogSection[] {
96
+ return releaseSections(sections).filter(s => {
97
+ const newerThanFrom = fromVersion ? compareVersions(s.version, fromVersion) > 0 : true;
98
+ const atMostTo = compareVersions(s.version, toVersion) <= 0;
99
+ return newerThanFrom && atMostTo;
100
+ });
101
+ }
102
+
103
+ export interface WhatsNewRenderOpts {
104
+ cols?: number;
105
+ unicode?: boolean;
106
+ color?: boolean;
107
+ /** Cap rendered body lines so a multi-release jump stays bounded. Default 22. */
108
+ maxBodyLines?: number;
109
+ }
110
+
111
+ /** Word-wrap plain text to `width` columns, hard-breaking any single over-long token. */
112
+ function wrapWords(text: string, width: number): string[] {
113
+ const w = Math.max(1, width);
114
+ const out: string[] = [];
115
+ let cur = "";
116
+ const flush = () => { if (cur) { out.push(cur); cur = ""; } };
117
+ for (let word of text.split(/\s+/).filter(Boolean)) {
118
+ while (word.length > w) {
119
+ flush();
120
+ out.push(word.slice(0, w));
121
+ word = word.slice(w);
122
+ }
123
+ if (!cur) cur = word;
124
+ else if (cur.length + 1 + word.length <= w) cur += " " + word;
125
+ else { out.push(cur); cur = word; }
126
+ }
127
+ flush();
128
+ return out.length ? out : [""];
129
+ }
130
+
131
+ /** Render a boxed "What's New" panel for the given sections (newest-first). */
132
+ export function renderWhatsNew(sections: ChangelogSection[], opts?: WhatsNewRenderOpts): string[] {
133
+ if (sections.length === 0) return [];
134
+
135
+ const cols = opts?.cols ?? 80;
136
+ const useColor = opts?.color !== false;
137
+ const useUnicode = opts?.unicode !== false;
138
+ const maxBody = opts?.maxBodyLines ?? 22;
139
+ const width = Math.max(32, Math.min(120, cols));
140
+ const inner = Math.max(8, width - 2);
141
+
142
+ const accent = useColor ? chalk.hex("#f2b84b") : (s: string) => s;
143
+ const bold = useColor ? (s: string) => chalk.bold(accent(s)) : (s: string) => s;
144
+ const boldPlain = useColor ? chalk.bold : (s: string) => s;
145
+ const dim = useColor ? chalk.dim : (s: string) => s;
146
+ const bulletChar = useUnicode ? "•" : "-";
147
+
148
+ const body: string[] = [];
149
+ let clipped = false;
150
+
151
+ // Wrap `text` to the inner width, paint each line, and push — bullets indent
152
+ // their continuation lines under the marker. Returns false once the cap is hit.
153
+ const emit = (text: string, paint: (s: string) => string, kind: "plain" | "bullet" = "plain"): boolean => {
154
+ const avail = kind === "bullet" ? inner - 2 : inner;
155
+ const wrapped = wrapWords(text, avail);
156
+ for (let i = 0; i < wrapped.length; i++) {
157
+ if (body.length >= maxBody) { clipped = true; return false; }
158
+ const prefix = kind === "bullet" ? (i === 0 ? `${bulletChar} ` : " ") : "";
159
+ body.push(paint(prefix + wrapped[i]!));
160
+ }
161
+ return true;
162
+ };
163
+
164
+ const headerLabel = sections.length === 1
165
+ ? `What's New in jeo ${sections[0]!.version}`
166
+ : `What's New (${sections.length} releases)`;
167
+ emit(headerLabel, bold);
168
+
169
+ outer:
170
+ for (const s of sections) {
171
+ if (sections.length > 1) {
172
+ if (body.length >= maxBody) { clipped = true; break; }
173
+ body.push("DIVIDER");
174
+ const when = s.date ? ` — ${s.date}` : "";
175
+ if (!emit(`${s.version}${when}`, accent)) break;
176
+ }
177
+ if (s.summary && !emit(s.summary, dim)) break;
178
+ for (const g of s.groups) {
179
+ if (g.label && !emit(g.label, boldPlain)) break outer;
180
+ for (const item of g.items) {
181
+ if (!emit(item, (l: string) => l, "bullet")) break outer;
182
+ }
183
+ }
184
+ }
185
+ if (clipped) body.push(dim("… see CHANGELOG.md for the full notes"));
186
+
187
+ return boxBlock(body, width, {
188
+ glyphs: useUnicode ? BOX_UNICODE : BOX_ASCII,
189
+ paint: accent,
190
+ align: "left",
191
+ });
192
+ }
193
+
194
+ // ---- Bundled changelog + last-seen-version state ----------------------------
195
+
196
+ /** Read the CHANGELOG.md that ships next to this package (running version's notes). */
197
+ export async function loadBundledChangelog(): Promise<string | null> {
198
+ // This module lives at src/util/whats-new.ts; CHANGELOG.md sits at the package
199
+ // root, i.e. two levels up. Resolve against the module dir so it works from a
200
+ // global install path just as from the repo.
201
+ const candidate = path.join(import.meta.dir, "..", "..", "CHANGELOG.md");
202
+ try {
203
+ return await readFile(candidate, "utf-8");
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ interface WhatsNewState {
210
+ lastSeenVersion: string;
211
+ updatedAt: number;
212
+ }
213
+
214
+ function stateDir(): string {
215
+ return jeoEnv("CONFIG_DIR") || path.join(os.homedir(), ".jeo");
216
+ }
217
+
218
+ function statePath(): string {
219
+ return path.join(stateDir(), "whats-new.json");
220
+ }
221
+
222
+ export async function readLastSeenVersion(): Promise<string | null> {
223
+ try {
224
+ const raw = await readFile(statePath(), "utf-8");
225
+ const data = JSON.parse(raw) as Partial<WhatsNewState>;
226
+ if (!data || typeof data.lastSeenVersion !== "string" || !data.lastSeenVersion) return null;
227
+ return data.lastSeenVersion;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /** Persist the last version the user has seen notes for (best-effort; never throws). */
234
+ export async function writeLastSeenVersion(version: string): Promise<void> {
235
+ if (typeof version !== "string" || !version) return;
236
+ try {
237
+ await mkdir(stateDir(), { recursive: true, mode: 0o700 });
238
+ const payload: WhatsNewState = { lastSeenVersion: version, updatedAt: Date.now() };
239
+ await writeFile(statePath(), JSON.stringify(payload, null, 2), { encoding: "utf-8", mode: 0o600 });
240
+ } catch {
241
+ // State is an optimization; a write failure must never break launch/update.
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Launch-time hook: returns rendered notes the FIRST time jeo runs after an
247
+ * upgrade, then records the current version so it never repeats. A fresh install
248
+ * (no prior state) records silently and shows nothing — only genuine upgrades
249
+ * surface notes, matching gjc / npm update-notice behaviour.
250
+ */
251
+ export async function consumeLaunchWhatsNew(opts?: WhatsNewRenderOpts): Promise<string[] | null> {
252
+ const current = pkg.version;
253
+ const lastSeen = await readLastSeenVersion();
254
+
255
+ if (!lastSeen) {
256
+ await writeLastSeenVersion(current);
257
+ return null;
258
+ }
259
+ if (compareVersions(current, lastSeen) <= 0) {
260
+ // Not an upgrade (equal, or a local downgrade). Keep state monotonic-ish.
261
+ if (compareVersions(current, lastSeen) < 0) await writeLastSeenVersion(current);
262
+ return null;
263
+ }
264
+
265
+ // Genuine upgrade: mark seen up front so a render/parse failure never repeats.
266
+ const md = await loadBundledChangelog();
267
+ await writeLastSeenVersion(current);
268
+ if (!md) return null;
269
+ const sections = selectNewSections(parseChangelogSections(md), lastSeen, current);
270
+ if (sections.length === 0) return null;
271
+ return renderWhatsNew(sections, opts);
272
+ }