jeo-code 0.5.9 → 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 +280 -0
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +2 -1
- package/src/agent/engine.ts +19 -3
- package/src/agent/json.ts +105 -25
- package/src/commands/launch.ts +35 -2
- package/src/commands/whats-new.ts +62 -0
- package/src/tui/app.ts +28 -9
- package/src/tui/components/transcript.ts +44 -14
- package/src/util/whats-new.ts +272 -0
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.
|
|
155
|
+
- **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
|
|
153
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.
|
|
154
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.
|
|
155
|
-
- **[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.
|
|
156
|
-
- **[0.5.6]** (2026-06-15) — `/model` sets only the default thinking; per-role reasoning moved to `/agents`.
|
|
157
|
-
- **[0.5.5]** (2026-06-15) — Full multi-line visibility — the input box scrolls to the caret and the submitted card shows every line.
|
|
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.
|
|
155
|
+
- **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
|
|
153
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.
|
|
154
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.
|
|
155
|
-
- **[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.
|
|
156
|
-
- **[0.5.6]** (2026-06-15) — `/model` sets only the default thinking; per-role reasoning moved to `/agents`.
|
|
157
|
-
- **[0.5.5]** (2026-06-15) — Full multi-line visibility — the input box scrolls to the caret and the submitted card shows every line.
|
|
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.
|
|
155
|
+
- **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
|
|
153
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.
|
|
154
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.
|
|
155
|
-
- **[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.
|
|
156
|
-
- **[0.5.6]** (2026-06-15) — `/model` sets only the default thinking; per-role reasoning moved to `/agents`.
|
|
157
|
-
- **[0.5.5]** (2026-06-15) — Full multi-line visibility — the input box scrolls to the caret and the submitted card shows every line.
|
|
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.
|
|
155
|
+
- **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
|
|
153
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.
|
|
154
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.
|
|
155
|
-
- **[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.
|
|
156
|
-
- **[0.5.6]** (2026-06-15) — `/model` sets only the default thinking; per-role reasoning moved to `/agents`.
|
|
157
|
-
- **[0.5.5]** (2026-06-15) — Full multi-line visibility — the input box scrolls to the caret and the submitted card shows every line.
|
|
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.
|
|
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",
|
package/src/agent/engine.ts
CHANGED
|
@@ -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
|
-
"-
|
|
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 = "
|
|
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>(
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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 !==
|
|
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 !==
|
|
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>(
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
162
|
+
return firstParsed;
|
|
83
163
|
}
|
|
84
164
|
|
|
85
165
|
function truncate(s: string, n: number): string {
|
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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,
|
|
@@ -46,6 +46,20 @@ function firstToolResultLine(text: string | undefined): string {
|
|
|
46
46
|
.slice(0, 96) ?? "";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const TOOL_RESULT_GLOBAL = /^Tool \[([^\]]+)\] result \((ok|fail)\):/gm;
|
|
50
|
+
/** Split a tool-result user message — which for a BATCH holds several
|
|
51
|
+
* `Tool [x] result (ok|fail):` blocks joined by blank lines — into per-call
|
|
52
|
+
* verdicts in the order the engine emitted them (= the batch's call order). */
|
|
53
|
+
function parseToolVerdicts(text: string | undefined): { tool: string; status: string; firstLine: string }[] {
|
|
54
|
+
if (!text) return [];
|
|
55
|
+
const matches = [...text.matchAll(TOOL_RESULT_GLOBAL)];
|
|
56
|
+
return matches.map((mt, k) => {
|
|
57
|
+
const start = mt.index ?? 0;
|
|
58
|
+
const end = k + 1 < matches.length ? (matches[k + 1]!.index ?? text.length) : text.length;
|
|
59
|
+
return { tool: mt[1]!, status: mt[2]!, firstLine: firstToolResultLine(text.slice(start, end)) };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
/** Format engine history as a scrollback-friendly transcript. */
|
|
50
64
|
export function formatTranscript(messages: readonly Message[], opts: TranscriptOptions = {}): string[] {
|
|
51
65
|
const color = opts.color !== false;
|
|
@@ -92,25 +106,41 @@ export function formatTranscript(messages: readonly Message[], opts: TranscriptO
|
|
|
92
106
|
lines.push(...clipBody(m.content, bodyCap));
|
|
93
107
|
continue;
|
|
94
108
|
}
|
|
95
|
-
// assistant:
|
|
96
|
-
|
|
109
|
+
// assistant: one or more JSON tool calls (compact ledger lines) or a prose reply.
|
|
110
|
+
// Handles BOTH the single `{tool,arguments}` form AND the batched `{tools:[...]}`
|
|
111
|
+
// form — the batch case previously parsed to no `tool` field, fell through, and
|
|
112
|
+
// dumped the raw JSON object into the transcript (the "/resume shows JSON" bug).
|
|
113
|
+
let parsed: { tool?: unknown; tools?: unknown; arguments?: unknown } | null = null;
|
|
97
114
|
try {
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
115
|
+
const p: unknown = JSON.parse(m.content);
|
|
116
|
+
if (p && typeof p === "object") parsed = p as { tool?: unknown; tools?: unknown; arguments?: unknown };
|
|
100
117
|
} catch { /* prose reply */ }
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
const calls: { tool: string; arguments?: unknown }[] =
|
|
119
|
+
parsed && typeof parsed.tool === "string"
|
|
120
|
+
? [{ tool: parsed.tool, arguments: parsed.arguments }]
|
|
121
|
+
: parsed && Array.isArray(parsed.tools)
|
|
122
|
+
? (parsed.tools as { tool?: unknown; arguments?: unknown }[])
|
|
123
|
+
.filter(c => c && typeof c.tool === "string")
|
|
124
|
+
.map(c => ({ tool: c.tool as string, arguments: c.arguments }))
|
|
125
|
+
: [];
|
|
126
|
+
const toolCalls = calls.filter(c => c.tool !== "done");
|
|
127
|
+
if (toolCalls.length > 0) {
|
|
128
|
+
// The matching `Tool [x] result (ok|fail)` user message follows; for a batch it
|
|
129
|
+
// is ONE message with several blocks. Parse verdicts in call order.
|
|
103
130
|
const next = messages[i + 1];
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
131
|
+
const verdicts = next?.role === "user" ? parseToolVerdicts(next.content) : [];
|
|
132
|
+
toolCalls.forEach((c, ci) => {
|
|
133
|
+
const v = verdicts[ci] ?? verdicts.find(x => x.tool === c.tool);
|
|
134
|
+
const mark = v?.status === "fail" ? red(bad) : green(ok);
|
|
135
|
+
const title = summarizeForgeInvocation(c.tool, c.arguments).title;
|
|
136
|
+
const suffix = v?.firstLine ? dim(` — ${v.firstLine}`) : "";
|
|
137
|
+
lines.push(` ${mark} ${title}${suffix}`);
|
|
138
|
+
});
|
|
110
139
|
continue;
|
|
111
140
|
}
|
|
112
|
-
|
|
113
|
-
|
|
141
|
+
// A lone `done` (show its reason) or a plain prose reply.
|
|
142
|
+
const reason = parsed
|
|
143
|
+
? String((parsed.arguments as { reason?: unknown } | undefined)?.reason ?? "")
|
|
114
144
|
: m.content;
|
|
115
145
|
if (!reason.trim()) continue;
|
|
116
146
|
lines.push(`${magentaBold(`jeo ${jeoMark}`)}`);
|
|
@@ -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
|
+
}
|