Codey grew a mouse, a settings panel, Markdown rendering and a compaction
floor while this branch grew subagents. The interesting part of the merge was
the mouse: master resolves a click against the chrome the frame drew, which is
a better answer than the hit rectangle this branch had been keeping, so the
sidebar joins that scheme rather than running a second one beside it.
- onMouse decides the column first: the pane's events are the pane's, and a
press anywhere else says you are working in the conversation again. A drag
already in flight keeps every event, so a selection can still be dragged
past the pane.
- The agents pill is a click on the status line, resolved like every other
piece of chrome. It is a plain switch rather than ctrl+b's three-way
reading, because the click that reaches it has just moved the keyboard.
- Selecting and copying read the transcript on screen, which is a subagent's
thread while one is open rather than always the conversation.
- One approval decision, shared by the keys and by a click on the choice,
and it puts the session back where it found it — a subagent can ask while
you are idle.
/mouse, the mouse state and the wheel are master's throughout; this branch's
duplicates are gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Showing 85 changed files with 5336 additions and 462 deletions.
| @@ -1,3 +1,8 @@ | ||
| 1 | - /.harness/ | |
| 1 | + /.codey/ | |
| 2 | 2 | /evals/runs/ |
| 3 | + /codey | |
| 4 | + /.superpowers/ | |
| 5 | + | |
| 6 | + # Left behind by builds from before the rename. | |
| 7 | + /.harness/ | |
| 3 | 8 | /harness |
| @@ -18,7 +18,7 @@ against a live model, not by anything written in advance. | ||
| 18 | 18 | ## Reading the numbers |
| 19 | 19 | |
| 20 | 20 | - **Pass rate** counts a task solved only when *every* repeat passed. A task |
| 21 | - that passes two runs in three is a task the harness cannot do reliably, and | |
| 21 | + that passes two runs in three is a task Codey cannot do reliably, and | |
| 22 | 22 | averaging that away hides the thing worth fixing. |
| 23 | 23 | - **Edit success** is `edit_file` calls that returned a diff, over all |
| 24 | 24 | `edit_file` calls. `write_file` is deliberately a separate tool so a |
| @@ -1,4 +1,4 @@ | ||
| 1 | - # Building a Coding Agent Harness in Go — Build Plan | |
| 1 | + # Building Codey, a Coding Agent Harness in Go — Build Plan | |
| 2 | 2 | |
| 3 | 3 | Target: a harness that can be pointed at a real Go or TypeScript repo, told |
| 4 | 4 | "make the failing test pass," and actually do it — with numbers proving how |
| @@ -19,8 +19,8 @@ comes first.** (See METRICS.md.) | ||
| 19 | 19 | ## Repo layout |
| 20 | 20 | |
| 21 | 21 | ``` |
| 22 | - harness/ | |
| 23 | - cmd/harness/main.go # flag parsing, wiring, REPL entry | |
| 22 | + codey/ | |
| 23 | + cmd/codey/main.go # flag parsing, wiring, REPL entry | |
| 24 | 24 | internal/ |
| 25 | 25 | llm/ |
| 26 | 26 | client.go # HTTP + SSE against the Messages API |
| @@ -70,7 +70,7 @@ rather than hand-writing JSON Schema. Hand-written schemas drift from the | ||
| 70 | 70 | structs and you won't notice until the model sends a field you don't parse. |
| 71 | 71 | |
| 72 | 72 | Also in this phase: `metrics.Log(event)` writing one JSON object per line to |
| 73 | - `./.harness/events.jsonl`. Wire it before you have anything to measure. | |
| 73 | + `./.codey/events.jsonl`. Wire it before you have anything to measure. | |
| 74 | 74 | Retrofitting instrumentation is how projects end up with no numbers. |
| 75 | 75 | |
| 76 | 76 | ## Phase 1 — The minimal loop |
| @@ -147,7 +147,7 @@ code-search story; you do not need embeddings. | ||
| 147 | 147 | New files only, or full rewrites the model explicitly chose. Keep it separate |
| 148 | 148 | from edit so your metrics don't blend them. |
| 149 | 149 | |
| 150 | - **Hard rule from this phase onward:** run the harness only against a throwaway | |
| 150 | + **Hard rule from this phase onward:** run Codey only against a throwaway | |
| 151 | 151 | git clone, or inside a container. There is no approval layer until Phase 6. |
| 152 | 152 | Assume it will run `rm -rf` on something eventually. |
| 153 | 153 | |
| @@ -205,6 +205,24 @@ eval numbers didn't drop. | ||
| 205 | 205 | touched and why, decisions made, things discovered the hard way ("the config |
| 206 | 206 | is loaded from X, not Y"), and the last few turns verbatim. Do it at 70%, not |
| 207 | 207 | 95% — compaction itself needs headroom. |
| 208 | + - **The 97% floor:** the 70% rule keeps the last six messages, so it declines a | |
| 209 | + history too short to cut six off — a few enormous tool results that have | |
| 210 | + nevertheless filled the window. That restraint is right until the window is | |
| 211 | + nearly full, at which point a verbatim tail buys nothing if the next request | |
| 212 | + cannot be sent. Past `-compact-floor` (0.97), compact anyway with a | |
| 213 | + one-message tail — the smallest that is still a valid conversation; if even | |
| 214 | + that finds nothing to fold, say so rather than let the next request fail as an | |
| 215 | + opaque API error. This is a backstop, not a second opinion about when to | |
| 216 | + compact: 70% is still where it normally happens. | |
| 217 | + - **`/compact`:** the same fold on demand, for starting a long task with a | |
| 218 | + clean slate. It waits for the current turn, since it rewrites the history | |
| 219 | + that turn is building on, and past the floor it takes the smaller tail too — | |
| 220 | + with auto-compaction off it is the only move left. | |
| 221 | + - **Turning it off:** `auto_compact: "off"` (or `-no-auto-compact`) for anyone | |
| 222 | + who would rather lose the turn than the transcript. The floor then stops the | |
| 223 | + turn instead of folding, refusing the prompt before it joins the history so | |
| 224 | + that compacting and retrying does not send it twice. `/compact` is unaffected: | |
| 225 | + the setting is about the automatic fold, not the manual one. | |
| 208 | 226 | - **Stale reads:** if a file was read 20 turns ago and edited since, its old |
| 209 | 227 | contents in history are actively harmful. Simplest fix that works: replace |
| 210 | 228 | superseded `read_file` results with a stub pointing at the current version. |
| @@ -231,7 +249,7 @@ correction without losing the session. | ||
| 231 | 249 | - Show diffs before applying, with color. |
| 232 | 250 | - Show running token and cost totals per session. Cheap to add, and it changes |
| 233 | 251 | how you design. |
| 234 | - - Persist sessions to `.harness/sessions/<id>.json` and add `--resume`. | |
| 252 | + - Persist sessions to `.codey/sessions/<id>.json` and add `--resume`. | |
| 235 | 253 | |
| 236 | 254 | Keep `agent` free of terminal dependencies. The eval runner depends on that. |
| 237 | 255 | |
| @@ -1,7 +1,7 @@ | ||
| 1 | - # harness | |
| 1 | + # codey | |
| 2 | 2 | |
| 3 | - A coding agent harness in Go. Point it at a repository, tell it to make the | |
| 4 | - failing test pass, and measure how often it actually does. | |
| 3 | + Codey is a coding agent harness in Go. Point it at a repository, tell it to | |
| 4 | + make the failing test pass, and measure how often it actually does. | |
| 5 | 5 | |
| 6 | 6 | The code is the byproduct. The deliverable is [METRICS.md](METRICS.md). |
| 7 | 7 | [PLAN.md](PLAN.md) is the build plan this follows. |
| @@ -9,13 +9,13 @@ The code is the byproduct. The deliverable is [METRICS.md](METRICS.md). | ||
| 9 | 9 | ## Quick start |
| 10 | 10 | |
| 11 | 11 | ```bash |
| 12 | - go build -o harness ./cmd/harness | |
| 12 | + go build -o codey ./cmd/codey | |
| 13 | 13 | |
| 14 | - ./harness -C /path/to/a/throwaway/clone # full-screen TUI | |
| 15 | - ./harness -C /path/to/repo -p "why does TestFoo fail?" # one prompt | |
| 14 | + ./codey -C /path/to/a/throwaway/clone # full-screen TUI | |
| 15 | + ./codey -C /path/to/repo -p "why does TestFoo fail?" # one prompt | |
| 16 | 16 | ``` |
| 17 | 17 | |
| 18 | - If you are logged into Claude Code, that is all the setup there is: the harness | |
| 18 | + If you are logged into Claude Code, that is all the setup there is: Codey | |
| 19 | 19 | borrows the OAuth credential from `~/.claude/.credentials.json` when nothing |
| 20 | 20 | else supplied one. Otherwise, or to use a different account: |
| 21 | 21 | |
| @@ -27,7 +27,7 @@ A different endpoint — a gateway, a proxy, a local server speaking the Message | ||
| 27 | 27 | API: |
| 28 | 28 | |
| 29 | 29 | ```bash |
| 30 | - ./harness -base-url http://127.0.0.1:4000 -api-key ... -C . | |
| 30 | + ./codey -base-url http://127.0.0.1:4000 -api-key ... -C . | |
| 31 | 31 | # or ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN |
| 32 | 32 | ``` |
| 33 | 33 | |
| @@ -41,7 +41,7 @@ points. | ||
| 41 | 41 | The Claude Code login is the last resort, tried only when no flag and no |
| 42 | 42 | environment variable supplied a credential, and only against Anthropic's own |
| 43 | 43 | endpoint — a credential found on disk is not one you chose to send, so it is |
| 44 | - never forwarded to a `-base-url` you pointed the harness at. `-no-claude-code-auth` | |
| 44 | + never forwarded to a `-base-url` you pointed Codey at. `-no-claude-code-auth` | |
| 45 | 45 | skips it entirely. Note that the credential is issued to Claude Code; using it |
| 46 | 46 | from another client is outside Anthropic's terms for it, which API keys and |
| 47 | 47 | gateway tokens do not carry. |
| @@ -53,14 +53,54 @@ which case the replacement is written back — leaving the dead one there is wha | ||
| 53 | 53 | would break your Claude Code login. |
| 54 | 54 | |
| 55 | 55 | Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`, |
| 56 | - `-resume latest`, `-no-stream`, `-no-cache`, `-v`, `-yolo`, `-no-tui`, `-no-skills`, | |
| 57 | - `-no-agents`, `-no-claude-code-auth`. | |
| 56 | + `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`, | |
| 57 | + `-no-tui`, `-no-skills`, `-no-agents`, `-no-claude-code-auth`. | |
| 58 | + | |
| 59 | + ### Flags and thresholds | |
| 60 | + | |
| 61 | + `-compact-at` (default 0.7) is where the conversation is normally folded into a | |
| 62 | + summary, keeping the last six messages verbatim. Early and rare is deliberate: | |
| 63 | + compaction is itself a model call with the history in it, and it is the single | |
| 64 | + biggest thing that breaks prompt caching. | |
| 65 | + | |
| 66 | + `-compact-floor` (default 0.97) is the fraction past which compaction stops | |
| 67 | + being optional. The normal threshold declines a history it cannot split safely — | |
| 68 | + a handful of enormous tool results is too few messages to cut six off the end of | |
| 69 | + — and below the floor that restraint is right. Past it, a verbatim tail is worth | |
| 70 | + nothing if the next request has no room to be sent, so the floor folds anyway | |
| 71 | + and settles for a one-message tail. `/compact` follows the same rule past the | |
| 72 | + floor, since with auto-compaction off it is the only move left. When even that | |
| 73 | + finds nothing to fold, Codey says so, rather than letting the next | |
| 74 | + request fail as a bare API error. | |
| 75 | + | |
| 76 | + `-context-window` (default 1,000,000) is what both fractions are measured | |
| 77 | + against; lower it for a model with less room. | |
| 78 | + | |
| 79 | + `-no-auto-compact` turns the automatic fold off for one run, the same as | |
| 80 | + `"auto_compact": "off"` in the config. The floor then stops the turn instead of | |
| 81 | + folding: the session stays intact, nothing is summarized behind your back, and | |
| 82 | + `/compact` is what makes room. In `-p` mode the stop is a non-zero exit. | |
| 83 | + | |
| 84 | + `-no-agents` leaves out the subagent tools, so the agent works alone. | |
| 58 | 85 | |
| 59 | 86 | ## The TUI |
| 60 | 87 | |
| 61 | 88 | On a terminal, the interactive mode is a full-screen [Bubble Tea](https://github.com/charmbracelet/bubbletea) |
| 62 | - app. It opens on a welcome block naming the model, the endpoint and the root, | |
| 63 | - and settles into a transcript above a prompt: | |
| 89 | + app. It opens on a welcome block — Codey, then the model, the endpoint and the | |
| 90 | + root: | |
| 91 | + | |
| 92 | + ``` | |
| 93 | + ▄██████▄ codey v0.6.0 | |
| 94 | + █ ██ █ Opus 5 · anthropic | |
| 95 | + ███▀▀███ ~/Work/GO/coding-harness | |
| 96 | + ▀█▀▀█▀ | |
| 97 | + ``` | |
| 98 | + | |
| 99 | + The gopher is drawn in half-blocks, so it needs no patched font, and it is | |
| 100 | + dropped rather than sliced on a terminal too narrow to hold it beside a | |
| 101 | + readable amount of text: half a mascot reads as a rendering bug, where the | |
| 102 | + three lines alone read as a choice. From there it settles into a transcript | |
| 103 | + above a prompt: | |
| 64 | 104 | |
| 65 | 105 | ``` |
| 66 | 106 | ⏺ Let me see what the test expects before changing anything. |
| @@ -87,12 +127,51 @@ is indented under that dot, so a screen of tool output still reads as a | ||
| 87 | 127 | sequence of things that happened. Diffs are shown in full and in colour with a |
| 88 | 128 | count of what changed; other output is elided after six lines. |
| 89 | 129 | |
| 130 | + What the model says is Markdown, and it is rendered as Markdown — headings, | |
| 131 | + weight, lists, block quotes, rules, and tables laid out in columns: | |
| 132 | + | |
| 133 | + ``` | |
| 134 | + ⏺ Findings | |
| 135 | + | |
| 136 | + The refresh path drops the rotated token. Three things are going on: | |
| 137 | + | |
| 138 | + 1. refresh() returns the new pair but never writes it back | |
| 139 | + 2. the old refresh token is already dead by then | |
| 140 | + | |
| 141 | + File │ Line │ Change | |
| 142 | + ────────────────────────┼───────────────────────┼─────────────────────── | |
| 143 | + refresh.go │ 84 │ write back | |
| 144 | + | |
| 145 | + func refresh(tok string) (Creds, error) { | |
| 146 | + // TODO: persist the rotation | |
| 147 | + } | |
| 148 | + ``` | |
| 149 | + | |
| 150 | + Fenced code is syntax-highlighted by [chroma](https://github.com/alecthomas/chroma), | |
| 151 | + and every colour — prose, headings, keywords, strings — comes from the theme in | |
| 152 | + force, so `/setup` repaints Markdown along with everything else. Headings drop | |
| 153 | + their hashes: the levels are told apart by colour and weight instead. It | |
| 154 | + renders as it streams, so a table fills in row by row. | |
| 155 | + | |
| 156 | + Only what the model says goes through this. Tool results and the output of your | |
| 157 | + own `!` and `$` commands are program output and are shown as they came, and | |
| 158 | + `-no-tui` prints raw text so `-p` stays pipeable. | |
| 159 | + | |
| 90 | 160 | The status bar reports the context gauge — the last turn's input size against |
| 91 | 161 | the window, which drops when the conversation is compacted — and separates |
| 92 | 162 | fresh input tokens from cache reads, because the gap between the two is what |
| 93 | 163 | [METRICS.md](METRICS.md) is about. The prompt box grows with what you type, up |
| 94 | 164 | to six lines. |
| 95 | 165 | |
| 166 | + The conversation folds itself into a summary at 70% of the window, and | |
| 167 | + `/compact` does it on demand — before a long task, say, or when the gauge is | |
| 168 | + climbing faster than the work is. `/compact` waits for the current turn to | |
| 169 | + finish, because it rewrites the history that turn is building on. Afterwards | |
| 170 | + the gauge goes blank rather than keep reporting a number the compaction just | |
| 171 | + made untrue; the next turn measures the window again and it comes back. At 97% | |
| 172 | + compaction stops being optional, and it can be turned off altogether: see | |
| 173 | + [the flags](#flags-and-thresholds) below. | |
| 174 | + | |
| 96 | 175 | | Key | Does | |
| 97 | 176 | | --- | --- | |
| 98 | 177 | | `enter` | send | |
| @@ -107,8 +186,37 @@ to six lines. | ||
| 107 | 186 | | `esc` | interrupt the turn | |
| 108 | 187 | | `ctrl+c` | interrupt the turn; again when idle to exit | |
| 109 | 188 | |
| 110 | - Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`, | |
| 111 | - `/mouse`, `/setup`, `/skills`, and one per discovered skill. | |
| 189 | + Commands: `/allowed`, `/clear`, `/compact`, `/effort`, `/exit`, `/help`, | |
| 190 | + `/model`, `/mouse`, `/settings`, `/setup`, `/skills`, and one per discovered | |
| 191 | + skill. | |
| 192 | + | |
| 193 | + ### The mouse | |
| 194 | + | |
| 195 | + | Does | How | | |
| 196 | + | --- | --- | | |
| 197 | + | scroll the transcript | the wheel, anywhere | | |
| 198 | + | select | drag; double-click a word, triple-click a line, shift+click to extend | | |
| 199 | + | copy | let go — the selection lands on the clipboard on release | | |
| 200 | + | choose | click a menu row, a model, the effort slider, an approval, the mode line | | |
| 201 | + | work with agents | click the agents pill or a row; click the prompt to type again | | |
| 202 | + | |
| 203 | + The prompt box is keyboard-driven: a click does not move the caret in it, and | |
| 204 | + what you have typed is the one thing the mouse cannot select. Dragging against | |
| 205 | + the top or bottom edge of the transcript scrolls, so a selection can be longer | |
| 206 | + than the screen; it keeps going while the pointer moves, because a terminal | |
| 207 | + reporting button events says nothing about a hand held still. | |
| 208 | + | |
| 209 | + Codey runs in the alternate screen, where the terminal has no scrollback | |
| 210 | + of its own to roll — so asking for mouse reporting is what makes the wheel work | |
| 211 | + at all. The cost is the terminal's own text selection, which stops being the | |
| 212 | + terminal's the moment drags are reported to the program. That is why the | |
| 213 | + selection above exists: it is the replacement, and copying goes out over both | |
| 214 | + OSC 52 (which works over ssh) and the desktop clipboard, because terminals | |
| 215 | + disagree about which of the two they allow. | |
| 216 | + | |
| 217 | + `/mouse` hands the mouse back. Selection and copy become the terminal's again, | |
| 218 | + exactly as before this existed, and the wheel goes quiet. `shift`+drag usually | |
| 219 | + reaches the terminal's own selection without turning reporting off at all. | |
| 112 | 220 | |
| 113 | 221 | A slash at the start of an empty prompt opens the command menu, which filters |
| 114 | 222 | as you type — a name that starts with what you typed before one that merely |
| @@ -214,8 +322,8 @@ force shows above the prompt on the right. A terminal too narrow for five | ||
| 214 | 322 | labels gets `◀ high ▶` and the same keys rather than a track with the words |
| 215 | 323 | sliced in half. |
| 216 | 324 | |
| 217 | - `/model` and `/effort` change the session. `/setup` changes what the next | |
| 218 | - session starts with — see [Setup](#setup). | |
| 325 | + `/model` and `/effort` change the session. `/settings` and `/setup` change what | |
| 326 | + the next session starts with — see [Setup](#setup). | |
| 219 | 327 | |
| 220 | 328 | `shift+tab` cycles between asking before every mutating call and accepting them |
| 221 | 329 | all, which is `-yolo` reached from the keyboard. The mode line says which is in |
| @@ -265,10 +373,10 @@ holding the task is the one making it: `model` picks what runs it — a small | ||
| 265 | 373 | fast model for mechanical search, a strong one for work that has to be right — |
| 266 | 374 | and `effort` picks how hard it thinks, `low` through `max`. Omit either and it |
| 267 | 375 | inherits the session's. A pinned id here would be a guess about someone else's |
| 268 | - endpoint as well as about the job: point the harness at a gateway that serves | |
| 376 | + endpoint as well as about the job: point Codey at a gateway that serves | |
| 269 | 377 | neither, and every spawn of that preset fails before its first turn. |
| 270 | 378 | |
| 271 | - The models it may choose between are in its own system prompt: the harness asks | |
| 379 | + The models it may choose between are in its own system prompt: Codey asks | |
| 272 | 380 | the endpoint what it serves at startup — `GET /v1/models`, the same route the |
| 273 | 381 | picker uses — and lists them newest first, with the rule that the newest is the |
| 274 | 382 | one to reach for unless you asked for another or it has already failed this |
| @@ -281,7 +389,7 @@ get this paragraph — they have no such choice to make. | ||
| 281 | 389 | `output_config.effort` is a per-model capability, not a universal parameter. |
| 282 | 390 | Haiku 4.5 rejects a request carrying it, and `xhigh` did not exist before Opus |
| 283 | 391 | 4.7 — so a request for a level its model does not take goes out without it, |
| 284 | - rather than failing with a 400 that reads like a bug in the harness. Where that | |
| 392 | + rather than failing with a 400 that reads like a bug in Codey. Where that | |
| 285 | 393 | happens to a subagent the answer says so outright (`claude-haiku-4-5 takes no |
| 286 | 394 | effort level, so "low" was not applied`) instead of letting the parent believe |
| 287 | 395 | it got what it asked for, and the effort pill in the status line reads `not on |
| @@ -399,9 +507,13 @@ agents at once. `-p` and `-no-tui` runs are not offered the tools at all, and | ||
| 399 | 507 | |
| 400 | 508 | The first run on a terminal opens a full-screen wizard instead of the chat: the |
| 401 | 509 | mark assembles, the name lights up, and it asks four questions one at a time. |
| 510 | + "First run" means the config file does not say `"setup": "done"` — the marker | |
| 511 | + the wizard stamps when the last question is answered — rather than that no file | |
| 512 | + exists, so a config written by hand, or by a build with fewer questions in it, | |
| 513 | + still gets asked the ones it is missing. | |
| 402 | 514 | |
| 403 | 515 | ``` |
| 404 | - harness setup step 1 of 4 | |
| 516 | + codey setup step 1 of 4 | |
| 405 | 517 | ◆━━━━━━━━━━━━━━━◇───────────────◇───────────────◇ |
| 406 | 518 | |
| 407 | 519 | Which model should the agent use? |
| @@ -435,7 +547,7 @@ The theme step previews by applying the palette for real — reading a swatch | ||
| 435 | 547 | tells you nothing about what a transcript looks like in it — and it recolours |
| 436 | 548 | the whole TUI, pickers and slider included. Cancelling puts the old one back. |
| 437 | 549 | |
| 438 | - Answers land in `~/.config/harness/config.json` (`XDG_CONFIG_HOME` is | |
| 550 | + Answers land in `~/.config/codey/config.json` (`XDG_CONFIG_HOME` is | |
| 439 | 551 | honoured): |
| 440 | 552 | |
| 441 | 553 | ```json |
| @@ -444,15 +556,32 @@ honoured): | ||
| 444 | 556 | "model": "claude-opus-5", |
| 445 | 557 | "effort": "high", |
| 446 | 558 | "approvals": "ask", |
| 447 | - "theme": "ember" | |
| 559 | + "theme": "ember", | |
| 560 | + "auto_compact": "on", | |
| 561 | + "setup": "done" | |
| 448 | 562 | } |
| 449 | 563 | ``` |
| 450 | 564 | |
| 565 | + `auto_compact` is the one key neither the wizard nor `/settings` offers a row | |
| 566 | + for — set it by hand, or pass `-no-auto-compact` for a single run. Both screens | |
| 567 | + write the whole file but only touch what they asked about, so it survives them. | |
| 568 | + Set to `"off"`, the conversation is never folded on its own: the window becomes | |
| 569 | + a wall rather than a trigger, and at `-compact-floor` the turn stops before | |
| 570 | + sending a request that would not fit, | |
| 571 | + naming `/compact` and `/clear` as the ways on. The prompt is refused *before* it | |
| 572 | + joins the history, so compacting and asking again sends it once rather than | |
| 573 | + twice. `/compact` keeps working throughout — the setting governs the automatic | |
| 574 | + fold, not the manual one. Anything other than `"on"` or `"off"` is dropped, and | |
| 575 | + a missing key means on, so a config written before this existed does not | |
| 576 | + suddenly start stopping the agent. | |
| 577 | + | |
| 451 | 578 | Nothing in that file is required, and none of it wins an argument with a flag: |
| 452 | 579 | precedence is **flag, then config, then the built-in default**, so |
| 453 | 580 | `-model claude-sonnet-5` is still a one-run override. `accept-all` saved as a |
| 454 | 581 | default is honoured, but `-yolo` can only turn approvals off, never on — a file |
| 455 | 582 | should not be able to quietly grant the agent more than the command line did. |
| 583 | + `-no-auto-compact` runs the same way round: the flag can only turn the automatic | |
| 584 | + fold off, and a config that never mentions it leaves it on. | |
| 456 | 585 | A value this build does not recognise is dropped rather than forwarded to the |
| 457 | 586 | API, and effort is checked against `llm.EffortLevels` rather than a second copy |
| 458 | 587 | of the same five strings. |
| @@ -466,9 +595,67 @@ model than it started. A config file that cannot be parsed is reported and then | ||
| 466 | 595 | ignored, and is never overwritten until you finish the wizard. |
| 467 | 596 | |
| 468 | 597 | `/model` and `/effort` change the running session and say "from the next turn |
| 469 | - on"; they do not write to the file. The wizard is the thing that decides what | |
| 470 | - the *next* session starts with, which is why it is a separate screen rather | |
| 471 | - than a fourth picker. | |
| 598 | + on"; they do not write to the file. The two screens that do are the wizard and | |
| 599 | + `/settings`. | |
| 600 | + | |
| 601 | + ### Where Codey keeps things | |
| 602 | + | |
| 603 | + Two directories: one global, one per repository. The second sits in your | |
| 604 | + working tree and is git-ignored, because sessions and an event log are yours | |
| 605 | + rather than the project's. | |
| 606 | + | |
| 607 | + | Path | What | | |
| 608 | + | --- | --- | | |
| 609 | + | `~/.config/codey/config.json` | the four answers, written by the wizard and `/settings` | | |
| 610 | + | `<repo>/.codey/sessions/` | saved conversations, which is what `-resume` reads | | |
| 611 | + | `<repo>/.codey/events.jsonl` | the event log METRICS.md is built from | | |
| 612 | + | |
| 613 | + Codey was called `harness` until recently, and both of those used to live under | |
| 614 | + that name. Neither rename costs you anything. The config is read from | |
| 615 | + `~/.config/harness/config.json` when there is nothing at the new path, so a | |
| 616 | + setup you have already done still counts and the wizard stays shut; that is a | |
| 617 | + read, and the old file is left where it is for an older build to keep using. | |
| 618 | + The per-repository directory is instead moved once, on the first run in a clone | |
| 619 | + that still has a `.harness/`, and Codey says so when it does — the event log is | |
| 620 | + append-only, so leaving half a repository's history under the old name would | |
| 621 | + quietly corrupt the numbers rather than preserve them. A clone that somehow has | |
| 622 | + both keeps the newer one and leaves `.harness/` alone. | |
| 623 | + | |
| 624 | + ### /settings | |
| 625 | + | |
| 626 | + `/settings` is the same four settings without the interview: one row each, | |
| 627 | + changed in place, applied as you move. | |
| 628 | + | |
| 629 | + ``` | |
| 630 | + Settings ↑↓ row · ←→ change · esc done | |
| 631 | + ❯ Model Opus 5 $5 / $25 per Mtok | |
| 632 | + Effort high default | |
| 633 | + Approvals Ask me first recommended | |
| 634 | + Theme ember | |
| 635 | + claude-opus-5 · enter to pick from anthropic | |
| 636 | + saved to ~/.config/codey/config.json | |
| 637 | + ``` | |
| 638 | + | |
| 639 | + `←→` cycles a row and applies it there and then — the theme repaints under the | |
| 640 | + panel, the way it previews in the wizard — and `esc` closes and writes the | |
| 641 | + file. There is no cancel, because there is nothing left to confirm: the session | |
| 642 | + already has every change. A panel that was only read leaves the file alone, and | |
| 643 | + so does a value cycled back to where it started. | |
| 644 | + | |
| 645 | + The rows come from the same option lists the wizard's steps are built from, so | |
| 646 | + neither screen can invent a name for a theme or a sentence about an effort | |
| 647 | + level the other does not use. The model row is the exception: its choices come | |
| 648 | + from the endpoint rather than from this build, so `enter` hands over to the | |
| 649 | + `/model` picker and lands back here once you have chosen. | |
| 650 | + | |
| 651 | + What the panel shows is the *session*, not the file — `/effort`, `shift+tab` | |
| 652 | + and `-yolo` all change a setting without the file hearing about it, and a panel | |
| 653 | + that disagreed with the status bar two lines below it would be worse than | |
| 654 | + useless. Only the rows actually touched are written back, which is what keeps a | |
| 655 | + `-yolo` run from quietly saving "accept everything" as the default for every | |
| 656 | + run after it. And the setup marker is *not* stamped here: changing a theme is | |
| 657 | + not answering the setup questions, so someone who has only ever used this panel | |
| 658 | + is still offered the wizard. | |
| 472 | 659 | |
| 473 | 660 | The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse to |
| 474 | 661 | their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20, |
| @@ -557,7 +744,7 @@ session memory. `/allowed` lists what you have agreed to. | ||
| 557 | 744 | |
| 558 | 745 | Shell commands are parsed with `mvdan.cc/sh/syntax` so the prompt can tell you |
| 559 | 746 | what a command will actually run — `git status; rm -rf ~` is shown as running |
| 560 | - both `git` and `rm`. **This is not a security boundary**, and the harness does | |
| 747 | + both `git` and `rm`. **This is not a security boundary**, and Codey does | |
| 561 | 748 | not pretend otherwise: a command containing `$(...)`, backticks, or an |
| 562 | 749 | interpreter with `-c` cannot be analysed at all, so it is marked opaque and |
| 563 | 750 | "always allow this exact command" is refused for it. String-matching |
| @@ -565,24 +752,24 @@ allowlists for shell commands do not work; this exists to make the prompt | ||
| 565 | 752 | specific, not to make the decision for you. |
| 566 | 753 | |
| 567 | 754 | **A sandbox.** This is the layer that actually contains a mistake, and the |
| 568 | - harness cannot provide it for you. The honest recipe on Linux: | |
| 755 | + Codey cannot provide it for you. The honest recipe on Linux: | |
| 569 | 756 | |
| 570 | 757 | ```bash |
| 571 | 758 | podman run --rm -it \ |
| 572 | 759 | -v "$PWD":/work:Z -w /work \ |
| 573 | 760 | -e ANTHROPIC_API_KEY \ |
| 574 | 761 | docker.io/library/golang:1.26 \ |
| 575 | - /work/harness -C /work -yolo | |
| 762 | + /work/codey -C /work -yolo | |
| 576 | 763 | ``` |
| 577 | 764 | |
| 578 | 765 | **Decide about egress explicitly.** The container above has network access, |
| 579 | - because the harness needs to reach the API — which means the agent's `bash` | |
| 766 | + because Codey needs to reach the API — which means the agent's `bash` | |
| 580 | 767 | has it too. That is the line between "broke my repo" and "read my `.env` and |
| 581 | 768 | made an HTTP request". If that matters for the tree you are pointing it at: |
| 582 | 769 | |
| 583 | 770 | - Treat every secret reachable from inside the container as exposed. |
| 584 | 771 | - Use a token scoped to nothing else, and a clone rather than your working tree. |
| 585 | - - For real isolation, run the harness on the host and put only `bash` in a | |
| 772 | + - For real isolation, run Codey on the host and put only `bash` in a | |
| 586 | 773 | network-less namespace. That is not implemented here. |
| 587 | 774 | |
| 588 | 775 | `-yolo` skips every prompt. It is correct inside a container or against a |
| @@ -592,12 +779,12 @@ is headless and operates on a temp directory it created. | ||
| 592 | 779 | ## Layout |
| 593 | 780 | |
| 594 | 781 | ``` |
| 595 | - cmd/harness flag parsing, wiring, REPL | |
| 782 | + cmd/codey flag parsing, wiring, REPL | |
| 596 | 783 | internal/llm Messages API: types, HTTP, SSE, pricing |
| 597 | 784 | internal/agent turn loop, conversation state, compaction, sessions |
| 598 | 785 | internal/tools the tool registry and the tools |
| 599 | 786 | internal/skills discovery and parsing of SKILL.md |
| 600 | - internal/config saved defaults: model, effort, approvals, theme | |
| 787 | + internal/config saved defaults: model, effort, approvals, theme, compaction | |
| 601 | 788 | internal/approval allow / deny / ask, with shell analysis |
| 602 | 789 | internal/metrics JSONL event log |
| 603 | 790 | internal/ui line-based terminal rendering |
| @@ -607,7 +794,7 @@ evals task suite and runner | ||
| 607 | 794 | |
| 608 | 795 | Two rules hold the shape: `agent` never imports `ui` or `tui`, so the eval |
| 609 | 796 | runner can drive the loop headless; and every tool returns `(string, error)` |
| 610 | - where the error is text the model reads, not an exception the harness swallows. | |
| 797 | + where the error is text the model reads, not an exception Codey swallows. | |
| 611 | 798 | A failed edit is a normal event in the transcript. |
| 612 | 799 | |
| 613 | 800 | The TUI obeys the first rule through two one-way hatches. Events reach it via |
| @@ -6,7 +6,7 @@ import ( | ||
| 6 | 6 | "path/filepath" |
| 7 | 7 | "testing" |
| 8 | 8 | |
| 9 | - "harness/internal/llm" | |
| 9 | + "codey/internal/llm" | |
| 10 | 10 | ) |
| 11 | 11 | |
| 12 | 12 | // fakeHome writes a credentials file with a token that is not expired. |
| @@ -1,4 +1,4 @@ | ||
| 1 | - // Command harness is the CLI entry point: flag parsing, wiring, and the REPL. | |
| 1 | + // Command codey is the CLI entry point: flag parsing, wiring, and the REPL. | |
| 2 | 2 | package main |
| 3 | 3 | |
| 4 | 4 | import ( |
| @@ -14,27 +14,28 @@ import ( | ||
| 14 | 14 | "sync/atomic" |
| 15 | 15 | "time" |
| 16 | 16 | |
| 17 | - "harness/internal/agent" | |
| 18 | - "harness/internal/approval" | |
| 19 | - "harness/internal/claudecode" | |
| 20 | - "harness/internal/config" | |
| 21 | - "harness/internal/llm" | |
| 22 | - "harness/internal/metrics" | |
| 23 | - "harness/internal/skills" | |
| 24 | - "harness/internal/subagent" | |
| 25 | - "harness/internal/tools" | |
| 26 | - "harness/internal/tui" | |
| 27 | - "harness/internal/ui" | |
| 17 | + "codey/internal/agent" | |
| 18 | + "codey/internal/approval" | |
| 19 | + "codey/internal/claudecode" | |
| 20 | + "codey/internal/config" | |
| 21 | + "codey/internal/llm" | |
| 22 | + "codey/internal/metrics" | |
| 23 | + "codey/internal/skills" | |
| 24 | + "codey/internal/statedir" | |
| 25 | + "codey/internal/subagent" | |
| 26 | + "codey/internal/tools" | |
| 27 | + "codey/internal/tui" | |
| 28 | + "codey/internal/ui" | |
| 28 | 29 | ) |
| 29 | 30 | |
| 30 | 31 | // version is what the TUI's welcome block reports. It is a constant rather |
| 31 | - // than build-stamped: the harness is built from this tree, and a stamp that | |
| 32 | + // than build-stamped: Codey is built from this tree, and a stamp that | |
| 32 | 33 | // can disagree with the source is worse than no stamp. |
| 33 | 34 | const version = "v0.6.0" |
| 34 | 35 | |
| 35 | 36 | func main() { |
| 36 | 37 | if err := run(); err != nil { |
| 37 | - fmt.Fprintln(os.Stderr, "harness:", err) | |
| 38 | + fmt.Fprintln(os.Stderr, "codey:", err) | |
| 38 | 39 | os.Exit(1) |
| 39 | 40 | } |
| 40 | 41 | } |
| @@ -49,6 +50,8 @@ func run() error { | ||
| 49 | 50 | maxTurns := flag.Int("max-turns", agent.DefaultMaxTurns, "turn cap per prompt") |
| 50 | 51 | contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact") |
| 51 | 52 | compactAt := flag.Float64("compact-at", agent.DefaultCompactAt, "fraction of the window at which to compact") |
| 53 | + compactFloor := flag.Float64("compact-floor", agent.DefaultCompactFloor, "fraction past which compaction is forced, keeping only a short tail") | |
| 54 | + noAutoCompact := flag.Bool("no-auto-compact", false, "never fold the conversation on its own; stop the turn when the window fills instead") | |
| 52 | 55 | noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints") |
| 53 | 56 | noStream := flag.Bool("no-stream", false, "wait for each complete response instead of streaming") |
| 54 | 57 | logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root") |
| @@ -67,10 +70,16 @@ func run() error { | ||
| 67 | 70 | return err |
| 68 | 71 | } |
| 69 | 72 | |
| 73 | + // A clone last used under the old name keeps its sessions and its event | |
| 74 | + // log. This runs before anything opens a file inside the state directory, | |
| 75 | + // and is reported rather than fatal: a stale directory that cannot be | |
| 76 | + // moved is a reason to say so, not a reason to refuse to start. | |
| 77 | + stateMoved, stateErr := statedir.Migrate(root) | |
| 78 | + | |
| 70 | 79 | // Saved preferences sit under the flags and over the built-in defaults. A |
| 71 | - // config that cannot be read is reported and then ignored: the harness | |
| 80 | + // config that cannot be read is reported and then ignored: Codey | |
| 72 | 81 | // still starts, and the wizard can write a good one over it. |
| 73 | - cfg, haveConfig, cfgErr := config.Load() | |
| 82 | + cfg, _, cfgErr := config.Load() | |
| 74 | 83 | |
| 75 | 84 | // Held as a slice because the Claude Code fallback below may have to |
| 76 | 85 | // rebuild the client with one more option, and replaying the same list is |
| @@ -128,6 +137,9 @@ func run() error { | ||
| 128 | 137 | // unattended, so -yolo can turn it on but a config can only be the default |
| 129 | 138 | // for a session where the flag said nothing. |
| 130 | 139 | acceptAll := *yolo || cfg.Approvals == config.ApprovalsAcceptAll |
| 140 | + // Same direction: the flag can only turn the automatic fold off, and a | |
| 141 | + // config that never mentioned it leaves it on. | |
| 142 | + autoCompactOff := *noAutoCompact || !cfg.AutoCompactEnabled() | |
| 131 | 143 | |
| 132 | 144 | session := time.Now().UTC().Format("20060102T150405Z") |
| 133 | 145 | log, err := metrics.Open(filepath.Join(root, *logPath), session) |
| @@ -149,12 +161,18 @@ func run() error { | ||
| 149 | 161 | banner = append(banner, msg) |
| 150 | 162 | return |
| 151 | 163 | } |
| 152 | - fmt.Fprintln(os.Stderr, "harness: "+msg) | |
| 164 | + fmt.Fprintln(os.Stderr, "codey: "+msg) | |
| 153 | 165 | } |
| 154 | 166 | |
| 155 | 167 | if cfgErr != nil { |
| 156 | 168 | notice("%v — using defaults; /setup will write a fresh one", cfgErr) |
| 157 | 169 | } |
| 170 | + if stateMoved { | |
| 171 | + notice("moved %s to %s — sessions and metrics came with it", statedir.LegacyDir, statedir.Dir) | |
| 172 | + } | |
| 173 | + if stateErr != nil { | |
| 174 | + notice("%v — starting a fresh %s; the old one is untouched", stateErr, statedir.Dir) | |
| 175 | + } | |
| 158 | 176 | |
| 159 | 177 | if !client.IsDefaultEndpoint() { |
| 160 | 178 | // Worth saying out loud: the prompt and every file the agent reads go |
| @@ -176,8 +194,8 @@ func run() error { | ||
| 176 | 194 | "error": err.Error(), "file": "~/" + claudecode.Filename, |
| 177 | 195 | }}) |
| 178 | 196 | if !useTUI { |
| 179 | - fmt.Fprintf(os.Stderr, "harness: your Claude Code login was renewed but ~/%s could not be updated: %v\n", claudecode.Filename, err) | |
| 180 | - fmt.Fprintln(os.Stderr, "harness: run `claude` when convenient — its stored login is now out of date.") | |
| 197 | + fmt.Fprintf(os.Stderr, "codey: your Claude Code login was renewed but ~/%s could not be updated: %v\n", claudecode.Filename, err) | |
| 198 | + fmt.Fprintln(os.Stderr, "codey: run `claude` when convenient — its stored login is now out of date.") | |
| 181 | 199 | } |
| 182 | 200 | } |
| 183 | 201 | } |
| @@ -229,13 +247,15 @@ func run() error { | ||
| 229 | 247 | policy.YOLO = acceptAll |
| 230 | 248 | if acceptAll && !useTUI { |
| 231 | 249 | // The TUI says this itself, in the banner. |
| 232 | - fmt.Fprintln(os.Stderr, "harness: -yolo — no approval prompts. Use a throwaway clone or a container.") | |
| 250 | + fmt.Fprintln(os.Stderr, "codey: -yolo — no approval prompts. Use a throwaway clone or a container.") | |
| 233 | 251 | } |
| 234 | 252 | base := agent.Options{ |
| 235 | 253 | MaxTurns: *maxTurns, |
| 236 | 254 | Effort: effortSetting, |
| 237 | 255 | ContextWindow: *contextWindow, |
| 238 | 256 | CompactAt: *compactAt, |
| 257 | + CompactFloor: *compactFloor, | |
| 258 | + NoAutoCompact: autoCompactOff, | |
| 239 | 259 | NoCache: *noCache, |
| 240 | 260 | Stream: !*noStream, |
| 241 | 261 | Skills: found, |
| @@ -313,12 +333,15 @@ func run() error { | ||
| 313 | 333 | |
| 314 | 334 | Banner: banner, |
| 315 | 335 | |
| 316 | - // No config on disk is what "first run" means, and it is the only | |
| 317 | - // thing that opens the wizard unasked. A flag-only invocation is | |
| 318 | - // still a first run: the flags say what this run does, not what | |
| 319 | - // the next one should default to. | |
| 336 | + // A config that does not say the setup was finished is what | |
| 337 | + // "first run" means, and it is the only thing that opens the | |
| 338 | + // wizard unasked. A flag-only invocation is still a first run: | |
| 339 | + // the flags say what this run does, not what the next one should | |
| 340 | + // default to. A config we could not read is not a first run | |
| 341 | + // either — the wizard's answers would be saved over settings that | |
| 342 | + // are probably still in there. | |
| 320 | 343 | Config: cfg, |
| 321 | - FirstRun: !haveConfig && cfgErr == nil, | |
| 344 | + FirstRun: !cfg.Done() && cfgErr == nil, | |
| 322 | 345 | // Applying the answers is the TUI's own job; persisting them is |
| 323 | 346 | // the only part that belongs out here. |
| 324 | 347 | SaveConfig: config.Save, |
| @@ -332,7 +355,7 @@ func run() error { | ||
| 332 | 355 | return once(ctx, a, render, sessionDir, session, root, client, *prompt) |
| 333 | 356 | } |
| 334 | 357 | |
| 335 | - fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n", | |
| 358 | + fmt.Printf("codey · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n", | |
| 336 | 359 | client.Model(), root, strings.Join(toolNames(reg), ", ")) |
| 337 | 360 | |
| 338 | 361 | for { |
| @@ -375,7 +398,7 @@ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, | ||
| 375 | 398 | render.Usage(usage, turns, client.Model()) |
| 376 | 399 | |
| 377 | 400 | if err := a.Save(sessionDir, id, root, client.Model()); err != nil { |
| 378 | - fmt.Fprintln(os.Stderr, "harness: saving session:", err) | |
| 401 | + fmt.Fprintln(os.Stderr, "codey: saving session:", err) | |
| 379 | 402 | } |
| 380 | 403 | if errors.Is(runErr, context.Canceled) { |
| 381 | 404 | // An interrupted turn is not an error: the point is to leave the |
| @@ -403,10 +426,10 @@ func interruptible(parent context.Context) (context.Context, func()) { | ||
| 403 | 426 | cancel := turnCancel.Load() |
| 404 | 427 | |
| 405 | 428 | if cancel == nil || (prev != 0 && now.Sub(time.Unix(0, prev)) < 2*time.Second) { |
| 406 | - fmt.Fprintln(os.Stderr, "\nharness: exiting") | |
| 429 | + fmt.Fprintln(os.Stderr, "\ncodey: exiting") | |
| 407 | 430 | os.Exit(130) |
| 408 | 431 | } |
| 409 | - fmt.Fprintln(os.Stderr, "\nharness: interrupting this turn (ctrl-c again to exit)") | |
| 432 | + fmt.Fprintln(os.Stderr, "\ncodey: interrupting this turn (ctrl-c again to exit)") | |
| 410 | 433 | (*cancel)() |
| 411 | 434 | } |
| 412 | 435 | }() |
| @@ -459,7 +482,7 @@ func resolveClaudeCode(client *llm.Client, home string, disabled bool) (*claudec | ||
| 459 | 482 | // is the only circumstance in which this process writes to Claude Code's |
| 460 | 483 | // file, and not writing then is what would break its login. |
| 461 | 484 | // |
| 462 | - // The refresh client is deliberately not the harness's: that one allows | |
| 485 | + // The refresh client is deliberately not Codey's: that one allows | |
| 463 | 486 | // ten minutes for a model to answer, which is the wrong budget for a token |
| 464 | 487 | // exchange running under a lock. NewSource supplies a short-timeout client. |
| 465 | 488 | return claudecode.NewSource(creds, nil, home), note, nil |
| @@ -41,7 +41,7 @@ func report(w io.Writer, results []Result) { | ||
| 41 | 41 | tw.Flush() |
| 42 | 42 | |
| 43 | 43 | // A task counts as solved if every repeat passed; anything less is a task |
| 44 | - // the harness cannot do reliably, which is the number worth reporting. | |
| 44 | + // Codey cannot do reliably, which is the number worth reporting. | |
| 45 | 45 | var solved int |
| 46 | 46 | for _, name := range order { |
| 47 | 47 | if countPass(byTask[name]) == len(byTask[name]) { |
| @@ -19,11 +19,11 @@ import ( | ||
| 19 | 19 | "sync" |
| 20 | 20 | "time" |
| 21 | 21 | |
| 22 | - "harness/internal/agent" | |
| 23 | - "harness/internal/approval" | |
| 24 | - "harness/internal/llm" | |
| 25 | - "harness/internal/metrics" | |
| 26 | - "harness/internal/tools" | |
| 22 | + "codey/internal/agent" | |
| 23 | + "codey/internal/approval" | |
| 24 | + "codey/internal/llm" | |
| 25 | + "codey/internal/metrics" | |
| 26 | + "codey/internal/tools" | |
| 27 | 27 | ) |
| 28 | 28 | |
| 29 | 29 | // Task is one directory under evals/tasks. |
| @@ -1,12 +1,15 @@ | ||
| 1 | - module harness | |
| 1 | + module codey | |
| 2 | 2 | |
| 3 | 3 | go 1.26.8 |
| 4 | 4 | |
| 5 | 5 | require ( |
| 6 | + github.com/alecthomas/chroma/v2 v2.20.0 | |
| 7 | + github.com/atotto/clipboard v0.1.4 | |
| 6 | 8 | github.com/bmatcuk/doublestar/v4 v4.10.0 |
| 7 | 9 | github.com/charmbracelet/bubbles v1.0.0 |
| 8 | 10 | github.com/charmbracelet/bubbletea v1.3.10 |
| 9 | - github.com/charmbracelet/lipgloss v1.1.0 | |
| 11 | + github.com/charmbracelet/glamour v1.0.0 | |
| 12 | + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 | |
| 10 | 13 | github.com/charmbracelet/x/ansi v0.11.6 |
| 11 | 14 | github.com/invopop/jsonschema v0.14.0 |
| 12 | 15 | github.com/muesli/termenv v0.16.0 |
| @@ -15,27 +18,36 @@ require ( | ||
| 15 | 18 | ) |
| 16 | 19 | |
| 17 | 20 | require ( |
| 18 | - github.com/atotto/clipboard v0.1.4 // indirect | |
| 19 | 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect |
| 22 | + github.com/aymerick/douceur v0.2.0 // indirect | |
| 20 | 23 | github.com/bahlo/generic-list-go v0.2.0 // indirect |
| 21 | 24 | github.com/buger/jsonparser v1.1.2 // indirect |
| 22 | 25 | github.com/charmbracelet/colorprofile v0.4.1 // indirect |
| 23 | 26 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect |
| 27 | + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect | |
| 24 | 28 | github.com/charmbracelet/x/term v0.2.2 // indirect |
| 25 | 29 | github.com/clipperhouse/displaywidth v0.9.0 // indirect |
| 26 | 30 | github.com/clipperhouse/stringish v0.1.1 // indirect |
| 27 | 31 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect |
| 32 | + github.com/dlclark/regexp2 v1.11.5 // indirect | |
| 28 | 33 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect |
| 34 | + github.com/gorilla/css v1.0.1 // indirect | |
| 29 | 35 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect |
| 30 | 36 | github.com/mattn/go-isatty v0.0.20 // indirect |
| 31 | 37 | github.com/mattn/go-localereader v0.0.1 // indirect |
| 32 | 38 | github.com/mattn/go-runewidth v0.0.19 // indirect |
| 39 | + github.com/microcosm-cc/bluemonday v1.0.27 // indirect | |
| 33 | 40 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect |
| 34 | 41 | github.com/muesli/cancelreader v0.2.2 // indirect |
| 42 | + github.com/muesli/reflow v0.3.0 // indirect | |
| 35 | 43 | github.com/pb33f/ordered-map/v2 v2.3.1 // indirect |
| 36 | 44 | github.com/rivo/uniseg v0.4.7 // indirect |
| 37 | 45 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect |
| 46 | + github.com/yuin/goldmark v1.7.13 // indirect | |
| 47 | + github.com/yuin/goldmark-emoji v1.0.6 // indirect | |
| 38 | 48 | go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect |
| 49 | + golang.org/x/net v0.38.0 // indirect | |
| 39 | 50 | golang.org/x/sys v0.47.0 // indirect |
| 40 | - golang.org/x/text v0.3.8 // indirect | |
| 51 | + golang.org/x/term v0.45.0 // indirect | |
| 52 | + golang.org/x/text v0.30.0 // indirect | |
| 41 | 53 | ) |
| @@ -1,11 +1,19 @@ | ||
| 1 | 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= |
| 2 | 2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= |
| 3 | + github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= | |
| 4 | + github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= | |
| 5 | + github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= | |
| 6 | + github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= | |
| 7 | + github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= | |
| 8 | + github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= | |
| 3 | 9 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= |
| 4 | 10 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= |
| 5 | 11 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= |
| 6 | 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= |
| 7 | 13 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= |
| 8 | 14 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= |
| 15 | + github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= | |
| 16 | + github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= | |
| 9 | 17 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= |
| 10 | 18 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= |
| 11 | 19 | github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= |
| @@ -18,12 +26,18 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv | ||
| 18 | 26 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= |
| 19 | 27 | github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= |
| 20 | 28 | github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= |
| 21 | - github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= | |
| 22 | - github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= | |
| 29 | + github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= | |
| 30 | + github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= | |
| 31 | + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= | |
| 32 | + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= | |
| 23 | 33 | github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= |
| 24 | 34 | github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= |
| 25 | 35 | github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= |
| 26 | 36 | github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= |
| 37 | + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= | |
| 38 | + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= | |
| 39 | + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= | |
| 40 | + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= | |
| 27 | 41 | github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= |
| 28 | 42 | github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= |
| 29 | 43 | github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= |
| @@ -34,12 +48,18 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w | ||
| 34 | 48 | github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= |
| 35 | 49 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| 36 | 50 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| 51 | + github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= | |
| 52 | + github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= | |
| 37 | 53 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= |
| 38 | 54 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= |
| 39 | 55 | github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= |
| 40 | 56 | github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= |
| 41 | 57 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
| 42 | 58 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= |
| 59 | + github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= | |
| 60 | + github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= | |
| 61 | + github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= | |
| 62 | + github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= | |
| 43 | 63 | github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= |
| 44 | 64 | github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= |
| 45 | 65 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= |
| @@ -52,18 +72,25 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE | ||
| 52 | 72 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| 53 | 73 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= |
| 54 | 74 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= |
| 75 | + github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= | |
| 55 | 76 | github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= |
| 56 | 77 | github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= |
| 78 | + github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= | |
| 79 | + github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= | |
| 57 | 80 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= |
| 58 | 81 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= |
| 59 | 82 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= |
| 60 | 83 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= |
| 84 | + github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= | |
| 85 | + github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= | |
| 61 | 86 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= |
| 62 | 87 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= |
| 63 | 88 | github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= |
| 64 | 89 | github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= |
| 65 | 90 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| 66 | 91 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| 92 | + github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | |
| 93 | + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | |
| 67 | 94 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= |
| 68 | 95 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= |
| 69 | 96 | github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= |
| @@ -72,16 +99,24 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu | ||
| 72 | 99 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= |
| 73 | 100 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= |
| 74 | 101 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= |
| 102 | + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= | |
| 103 | + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= | |
| 104 | + github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= | |
| 105 | + github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= | |
| 75 | 106 | go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= |
| 76 | 107 | go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= |
| 77 | 108 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= |
| 78 | 109 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= |
| 110 | + golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= | |
| 111 | + golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= | |
| 79 | 112 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 80 | 113 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 81 | 114 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| 82 | 115 | golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| 83 | - golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= | |
| 84 | - golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= | |
| 116 | + golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= | |
| 117 | + golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= | |
| 118 | + golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= | |
| 119 | + golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= | |
| 85 | 120 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
| 86 | 121 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 87 | 122 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| @@ -3,10 +3,11 @@ package agent | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "fmt" |
| 6 | + "strconv" | |
| 6 | 7 | "strings" |
| 7 | 8 | |
| 8 | - "harness/internal/llm" | |
| 9 | - "harness/internal/metrics" | |
| 9 | + "codey/internal/llm" | |
| 10 | + "codey/internal/metrics" | |
| 10 | 11 | ) |
| 11 | 12 | |
| 12 | 13 | // metricsCompaction records a compaction so its effect on later turns is |
| @@ -26,14 +27,30 @@ const DefaultContextWindow = 1_000_000 | ||
| 26 | 27 | // DefaultCompactAt is the fraction of the window at which to summarize. |
| 27 | 28 | // Compaction itself needs headroom — it is a model call with the history in it |
| 28 | 29 | // — so waiting until the window is nearly full means the compaction request is |
| 29 | - // the one that fails. | |
| 30 | + // the one that fails. It is also the single biggest thing that breaks prompt | |
| 31 | + // caching, which is the other argument for doing it early and rarely. | |
| 30 | 32 | const DefaultCompactAt = 0.7 |
| 31 | 33 | |
| 34 | + // DefaultCompactFloor is the fraction at which compaction stops being optional. | |
| 35 | + // The normal threshold refuses to cut a history it cannot split safely — a | |
| 36 | + // handful of enormous tool results is too few messages for keepRecentMessages — | |
| 37 | + // and below the floor that restraint is right. Once the window is this full it | |
| 38 | + // is not: a tail kept verbatim is worth nothing if the next request has no room | |
| 39 | + // to be sent. The floor folds with keepUrgentMessages instead. | |
| 40 | + const DefaultCompactFloor = 0.97 | |
| 41 | + | |
| 32 | 42 | // keepRecentMessages is how much of the tail survives compaction verbatim. The |
| 33 | 43 | // model is usually mid-thought, and a summary of the last two minutes of work |
| 34 | 44 | // is worse than the work itself. |
| 35 | 45 | const keepRecentMessages = 6 |
| 36 | 46 | |
| 47 | + // keepUrgentMessages is the tail the floor settles for. For any conversation of | |
| 48 | + // real length this changes nothing — the split lands on the midpoint either way | |
| 49 | + // — so its only job is to let a short history of enormous messages be cut at | |
| 50 | + // all. One is the smallest tail that is still a valid conversation: the folded | |
| 51 | + // summary takes the user slot, and one assistant message follows it. | |
| 52 | + const keepUrgentMessages = 1 | |
| 53 | + | |
| 37 | 54 | const compactPrompt = `Summarize the conversation above for a coding agent that will keep working on the same task with this summary in place of the transcript. |
| 38 | 55 | |
| 39 | 56 | Preserve, in this order: |
| @@ -46,23 +63,126 @@ Preserve, in this order: | ||
| 46 | 63 | Be specific. Names, paths and line numbers are worth more than prose. Omit |
| 47 | 64 | pleasantries and anything already visible in the current state of the files.` |
| 48 | 65 | |
| 49 | - // maybeCompact summarizes the older half of the conversation when the last | |
| 50 | - // request came close to filling the window. It reports whether it compacted. | |
| 51 | - func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, error) { | |
| 52 | - window := a.opts.ContextWindow | |
| 66 | + // thresholds resolves the two fractions against their defaults. The floor is | |
| 67 | + // never allowed below CompactAt: a -compact-at set higher than the floor would | |
| 68 | + // otherwise make every ordinary compaction take the aggressive keep and throw | |
| 69 | + // away the recent tail the model is still working from. | |
| 70 | + func (a *Agent) thresholds() (window int, at, floor float64) { | |
| 71 | + window = a.opts.ContextWindow | |
| 53 | 72 | if window <= 0 { |
| 54 | 73 | window = DefaultContextWindow |
| 55 | 74 | } |
| 56 | - at := a.opts.CompactAt | |
| 75 | + at = a.opts.CompactAt | |
| 57 | 76 | if at <= 0 { |
| 58 | 77 | at = DefaultCompactAt |
| 59 | 78 | } |
| 60 | - if float64(lastInputTokens) < at*float64(window) { | |
| 79 | + floor = a.opts.CompactFloor | |
| 80 | + if floor <= 0 { | |
| 81 | + floor = DefaultCompactFloor | |
| 82 | + } | |
| 83 | + if floor < at { | |
| 84 | + floor = at | |
| 85 | + } | |
| 86 | + return window, at, floor | |
| 87 | + } | |
| 88 | + | |
| 89 | + // ContextFullError ends a turn that cannot be sent: the window is full and | |
| 90 | + // auto-compaction is off, so nothing is going to make room on its own. It is a | |
| 91 | + // typed error because a front end should offer the ways out rather than print | |
| 92 | + // a failure and leave the user to guess at them. | |
| 93 | + type ContextFullError struct { | |
| 94 | + Used, Window int | |
| 95 | + Floor float64 | |
| 96 | + } | |
| 97 | + | |
| 98 | + func (e *ContextFullError) Error() string { | |
| 99 | + return fmt.Sprintf("the context window is %d%% full (%s of %s tokens) and auto-compaction is off", | |
| 100 | + e.Used*100/e.Window, thousands(e.Used), thousands(e.Window)) | |
| 101 | + } | |
| 102 | + | |
| 103 | + // thousands groups an integer for reading: 970000 becomes 970,000. | |
| 104 | + func thousands(n int) string { | |
| 105 | + s := strconv.Itoa(n) | |
| 106 | + for i := len(s) - 3; i > 0; i -= 3 { | |
| 107 | + s = s[:i] + "," + s[i:] | |
| 108 | + } | |
| 109 | + return s | |
| 110 | + } | |
| 111 | + | |
| 112 | + // contextFull is the hard stop that replaces compaction when auto-compaction is | |
| 113 | + // off. Below the floor it is nil and the turn proceeds as normal. It takes the | |
| 114 | + // token count rather than reading a.lastInputTokens so that it and maybeCompact | |
| 115 | + // cannot end up judging different numbers. | |
| 116 | + func (a *Agent) contextFull(lastInputTokens int) error { | |
| 117 | + if !a.opts.NoAutoCompact { | |
| 118 | + return nil | |
| 119 | + } | |
| 120 | + window, _, floor := a.thresholds() | |
| 121 | + if float64(lastInputTokens) < floor*float64(window) { | |
| 122 | + return nil | |
| 123 | + } | |
| 124 | + return &ContextFullError{Used: lastInputTokens, Window: window, Floor: floor} | |
| 125 | + } | |
| 126 | + | |
| 127 | + // maybeCompact summarizes the older half of the conversation when the last | |
| 128 | + // request came close to filling the window. It reports whether it compacted. | |
| 129 | + func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, error) { | |
| 130 | + // Off, the window is a wall rather than a trigger: stop while the session | |
| 131 | + // is still intact and let the user decide between /compact and /clear. | |
| 132 | + if a.opts.NoAutoCompact { | |
| 133 | + return false, a.contextFull(lastInputTokens) | |
| 134 | + } | |
| 135 | + | |
| 136 | + window, at, floor := a.thresholds() | |
| 137 | + used := float64(lastInputTokens) | |
| 138 | + urgent := used >= floor*float64(window) | |
| 139 | + if !urgent && used < at*float64(window) { | |
| 140 | + return false, nil | |
| 141 | + } | |
| 142 | + | |
| 143 | + did, err := a.compact(ctx, lastInputTokens, keepRecentMessages) | |
| 144 | + if err != nil || did || !urgent { | |
| 145 | + return did, err | |
| 146 | + } | |
| 147 | + | |
| 148 | + // Past the floor and the normal keep declined: fold with the smaller tail | |
| 149 | + // rather than let the next request run into the wall. | |
| 150 | + did, err = a.compact(ctx, lastInputTokens, keepUrgentMessages) | |
| 151 | + if err != nil || did { | |
| 152 | + return did, err | |
| 153 | + } | |
| 154 | + a.emit(Event{Kind: EventNotice, Text: fmt.Sprintf( | |
| 155 | + "the context window is %d%% full and there is nothing left that can be folded away — "+ | |
| 156 | + "the next request may not fit. /clear starts over, or narrow what the agent is holding open.", | |
| 157 | + int(floor*100))}) | |
| 158 | + return false, nil | |
| 159 | + } | |
| 160 | + | |
| 161 | + // Compact folds the conversation on demand, which is what /compact asks for. It | |
| 162 | + // keeps the same tail an automatic compaction keeps, and reports false rather | |
| 163 | + // than spending a model call when there is nothing worth folding. | |
| 164 | + // | |
| 165 | + // Past the floor it settles for the smaller tail, for the same reason the floor | |
| 166 | + // does — and because with auto-compaction off this is the only thing between a | |
| 167 | + // full window and /clear. Declining there would be Codey refusing the one | |
| 168 | + // move the user has left. | |
| 169 | + func (a *Agent) Compact(ctx context.Context) (bool, error) { | |
| 170 | + did, err := a.compact(ctx, a.lastInputTokens, keepRecentMessages) | |
| 171 | + if err != nil || did { | |
| 172 | + return did, err | |
| 173 | + } | |
| 174 | + window, _, floor := a.thresholds() | |
| 175 | + if float64(a.lastInputTokens) < floor*float64(window) { | |
| 61 | 176 | return false, nil |
| 62 | 177 | } |
| 178 | + return a.compact(ctx, a.lastInputTokens, keepUrgentMessages) | |
| 179 | + } | |
| 63 | 180 | |
| 181 | + // compact folds everything before the split into one summary message, keeping | |
| 182 | + // the last keep messages verbatim. It reports whether it found anything to fold. | |
| 183 | + func (a *Agent) compact(ctx context.Context, lastInputTokens, keep int) (bool, error) { | |
| 64 | 184 | history := a.state.History() |
| 65 | - split := compactionSplit(history) | |
| 185 | + split := compactionSplit(history, keep) | |
| 66 | 186 | if split < 0 { |
| 67 | 187 | return false, nil // nothing safe to fold away yet |
| 68 | 188 | } |
| @@ -81,20 +201,29 @@ func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, er | ||
| 81 | 201 | |
| 82 | 202 | a.state.Replace(append([]llm.Message{folded}, history[split:]...)) |
| 83 | 203 | a.log.Log(metricsCompaction(lastInputTokens, len(history), a.state.Len())) |
| 204 | + | |
| 205 | + // The recorded input size described the history that has just been thrown | |
| 206 | + // away, so it is no longer a fact about anything. Zero means "unknown", | |
| 207 | + // which is the truth until the next request measures the window again — | |
| 208 | + // and leaving the old number would keep the full-window stop firing on a | |
| 209 | + // conversation that is no longer full, with no way out but /clear. | |
| 210 | + a.lastInputTokens = 0 | |
| 211 | + | |
| 84 | 212 | a.emit(Event{Kind: EventCompact, Text: summary}) |
| 85 | 213 | return true, nil |
| 86 | 214 | } |
| 87 | 215 | |
| 88 | - // compactionSplit picks where to cut. The kept tail must begin with an | |
| 89 | - // assistant message: the folded summary takes the user slot before it, and a | |
| 90 | - // tail starting with a tool_result user message would reference a tool_use that | |
| 91 | - // no longer exists, which the API rejects. | |
| 92 | - func compactionSplit(history []llm.Message) int { | |
| 93 | - if len(history) <= keepRecentMessages+2 { | |
| 216 | + // compactionSplit picks where to cut, leaving at least keep messages in the | |
| 217 | + // tail. The kept tail must begin with an assistant message: the folded summary | |
| 218 | + // takes the user slot before it, and a tail starting with a tool_result user | |
| 219 | + // message would reference a tool_use that no longer exists, which the API | |
| 220 | + // rejects. | |
| 221 | + func compactionSplit(history []llm.Message, keep int) int { | |
| 222 | + if len(history) <= keep+2 { | |
| 94 | 223 | return -1 |
| 95 | 224 | } |
| 96 | 225 | want := len(history) / 2 |
| 97 | - if max := len(history) - keepRecentMessages; want > max { | |
| 226 | + if max := len(history) - keep; want > max { | |
| 98 | 227 | want = max |
| 99 | 228 | } |
| 100 | 229 | for i := want; i < len(history); i++ { |
| @@ -121,7 +250,7 @@ func (a *Agent) summarize(ctx context.Context, history []llm.Message) (string, e | ||
| 121 | 250 | } |
| 122 | 251 | // This is the one request in the agent that is built by hand rather than |
| 123 | 252 | // through (*Agent).request/systemBlocks, and it is deliberately bare — no |
| 124 | - // tools, no harness prompt — so it is easy to forget that an OAuth | |
| 253 | + // tools, no system prompt of ours — so it is easy to forget that an OAuth | |
| 125 | 254 | // credential still requires the Claude Code identity string as the FIRST |
| 126 | 255 | // system block. Skip it here and the API does not 401; it returns a 429 |
| 127 | 256 | // that (*APIError).Retryable treats as transient, so the request burns |
| @@ -4,12 +4,13 @@ import ( | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "context" |
| 6 | 6 | "encoding/json" |
| 7 | + "errors" | |
| 7 | 8 | "os" |
| 8 | 9 | "path/filepath" |
| 9 | 10 | "strings" |
| 10 | 11 | "testing" |
| 11 | 12 | |
| 12 | - "harness/internal/llm" | |
| 13 | + "codey/internal/llm" | |
| 13 | 14 | ) |
| 14 | 15 | |
| 15 | 16 | func TestCacheBreakpoints(t *testing.T) { |
| @@ -70,36 +71,46 @@ func TestBreakpointsDoNotLeakIntoHistory(t *testing.T) { | ||
| 70 | 71 | } |
| 71 | 72 | } |
| 72 | 73 | |
| 73 | - func TestCompactionSplitLandsOnAnAssistantMessage(t *testing.T) { | |
| 74 | - mk := func(roles string) []llm.Message { | |
| 75 | - var out []llm.Message | |
| 76 | - for _, r := range roles { | |
| 77 | - role := llm.RoleUser | |
| 78 | - if r == 'a' { | |
| 79 | - role = llm.RoleAssistant | |
| 80 | - } | |
| 81 | - out = append(out, llm.Message{Role: role, Content: []llm.ContentBlock{llm.TextBlock("x")}}) | |
| 74 | + // mkHistory turns "uaua" into alternating user/assistant messages, so a test | |
| 75 | + // can say what shape of conversation it means in one string. | |
| 76 | + func mkHistory(roles string) []llm.Message { | |
| 77 | + var out []llm.Message | |
| 78 | + for _, r := range roles { | |
| 79 | + role := llm.RoleUser | |
| 80 | + if r == 'a' { | |
| 81 | + role = llm.RoleAssistant | |
| 82 | 82 | } |
| 83 | - return out | |
| 83 | + out = append(out, llm.Message{Role: role, Content: []llm.ContentBlock{llm.TextBlock("x")}}) | |
| 84 | 84 | } |
| 85 | + return out | |
| 86 | + } | |
| 87 | + | |
| 88 | + func TestCompactionSplitLandsOnAnAssistantMessage(t *testing.T) { | |
| 85 | 89 | tests := []struct { |
| 86 | 90 | name string |
| 87 | 91 | roles string |
| 92 | + keep int | |
| 88 | 93 | want int |
| 89 | 94 | }{ |
| 90 | - {name: "too short to compact", roles: "uaua", want: -1}, | |
| 91 | - {name: "cuts at the first assistant past the midpoint", roles: "uauauauauaua", want: 7}, | |
| 95 | + {name: "too short to compact", roles: "uaua", keep: keepRecentMessages, want: -1}, | |
| 96 | + {name: "cuts at the first assistant past the midpoint", roles: "uauauauauaua", keep: keepRecentMessages, want: 7}, | |
| 92 | 97 | // The tail can end up one message shorter than the target when the |
| 93 | 98 | // boundary lands on a user message: correctness beats the constant. |
| 94 | - {name: "skips forward past a user message", roles: "uauauauaua", want: 5}, | |
| 99 | + {name: "skips forward past a user message", roles: "uauauauaua", keep: keepRecentMessages, want: 5}, | |
| 100 | + // The smaller keep is what the floor uses: it folds conversations the | |
| 101 | + // normal split refuses to touch, because at 97% doing nothing is worse | |
| 102 | + // than keeping a short tail. | |
| 103 | + {name: "a short history the normal keep declines", roles: "uauaua", keep: keepUrgentMessages, want: 3}, | |
| 104 | + {name: "urgently, four messages can still be cut", roles: "uaua", keep: keepUrgentMessages, want: 3}, | |
| 105 | + {name: "nothing is foldable even urgently", roles: "ua", keep: keepUrgentMessages, want: -1}, | |
| 95 | 106 | } |
| 96 | 107 | for _, tc := range tests { |
| 97 | 108 | t.Run(tc.name, func(t *testing.T) { |
| 98 | - got := compactionSplit(mk(tc.roles)) | |
| 109 | + got := compactionSplit(mkHistory(tc.roles), tc.keep) | |
| 99 | 110 | if got != tc.want { |
| 100 | 111 | t.Fatalf("compactionSplit = %d, want %d", got, tc.want) |
| 101 | 112 | } |
| 102 | - if got >= 0 && mk(tc.roles)[got].Role != llm.RoleAssistant { | |
| 113 | + if got >= 0 && mkHistory(tc.roles)[got].Role != llm.RoleAssistant { | |
| 103 | 114 | t.Error("the kept tail must start with an assistant message, or its tool_results reference nothing") |
| 104 | 115 | } |
| 105 | 116 | }) |
| @@ -293,3 +304,317 @@ func TestReadPathParsing(t *testing.T) { | ||
| 293 | 304 | t.Errorf("readPath = %q, want empty on malformed input", got) |
| 294 | 305 | } |
| 295 | 306 | } |
| 307 | + | |
| 308 | + // TestCompactionFloor covers the case the 70% rule cannot reach: a history too | |
| 309 | + // short for the normal split — a handful of enormous tool results — that has | |
| 310 | + // nevertheless filled the window. Below the floor the normal split declines and | |
| 311 | + // nothing happens; at the floor the smaller keep folds it anyway. | |
| 312 | + func TestCompactionFloor(t *testing.T) { | |
| 313 | + tests := []struct { | |
| 314 | + name string | |
| 315 | + inputTokens int | |
| 316 | + want bool | |
| 317 | + }{ | |
| 318 | + {name: "past CompactAt but under the floor, the normal split declines", inputTokens: 800, want: false}, | |
| 319 | + {name: "at the floor the smaller keep folds it", inputTokens: 980, want: true}, | |
| 320 | + } | |
| 321 | + for _, tc := range tests { | |
| 322 | + t.Run(tc.name, func(t *testing.T) { | |
| 323 | + f := newFakeAPI(t) | |
| 324 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 325 | + a.opts.ContextWindow = 1000 | |
| 326 | + // Six messages: more than keepUrgentMessages+2, fewer than | |
| 327 | + // keepRecentMessages+2, so the two keeps disagree about it. | |
| 328 | + a.state.Replace(mkHistory("uauaua")) | |
| 329 | + | |
| 330 | + got, err := a.maybeCompact(context.Background(), tc.inputTokens) | |
| 331 | + if err != nil { | |
| 332 | + t.Fatalf("maybeCompact: %v", err) | |
| 333 | + } | |
| 334 | + if got != tc.want { | |
| 335 | + t.Fatalf("maybeCompact = %v, want %v", got, tc.want) | |
| 336 | + } | |
| 337 | + if !tc.want && len(f.requests) != 0 { | |
| 338 | + t.Errorf("declining to compact should cost no model call, got %d", len(f.requests)) | |
| 339 | + } | |
| 340 | + }) | |
| 341 | + } | |
| 342 | + } | |
| 343 | + | |
| 344 | + // TestCompactionFloorWarnsWhenNothingIsFoldable is the case where the window is | |
| 345 | + // nearly full and there is genuinely nothing to fold. Saying so beats letting | |
| 346 | + // the next request hit the wall and surface as a bare API error. | |
| 347 | + func TestCompactionFloorWarnsWhenNothingIsFoldable(t *testing.T) { | |
| 348 | + f := newFakeAPI(t) | |
| 349 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 350 | + a.opts.ContextWindow = 1000 | |
| 351 | + a.state.Replace(mkHistory("ua")) // nothing either keep will cut | |
| 352 | + | |
| 353 | + var notices []string | |
| 354 | + a.Observe = func(ev Event) { | |
| 355 | + if ev.Kind == EventNotice { | |
| 356 | + notices = append(notices, ev.Text) | |
| 357 | + } | |
| 358 | + } | |
| 359 | + got, err := a.maybeCompact(context.Background(), 980) | |
| 360 | + if err != nil { | |
| 361 | + t.Fatalf("maybeCompact: %v", err) | |
| 362 | + } | |
| 363 | + if got { | |
| 364 | + t.Fatal("there was nothing to fold; maybeCompact should report false") | |
| 365 | + } | |
| 366 | + if len(notices) != 1 { | |
| 367 | + t.Fatalf("%d notices, want 1 warning that the window is nearly full", len(notices)) | |
| 368 | + } | |
| 369 | + if !strings.Contains(notices[0], "97%") { | |
| 370 | + t.Errorf("the warning should name the threshold it crossed: %q", notices[0]) | |
| 371 | + } | |
| 372 | + if len(f.requests) != 0 { | |
| 373 | + t.Errorf("nothing to fold should cost no model call, got %d", len(f.requests)) | |
| 374 | + } | |
| 375 | + } | |
| 376 | + | |
| 377 | + // TestCompactionFloorNeverSitsBelowCompactAt: -compact-at above the floor would | |
| 378 | + // otherwise make every ordinary compaction take the aggressive keep and throw | |
| 379 | + // away the recent tail the model is still working from. | |
| 380 | + func TestCompactionFloorNeverSitsBelowCompactAt(t *testing.T) { | |
| 381 | + f := newFakeAPI(t) | |
| 382 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 383 | + a.opts.ContextWindow = 1000 | |
| 384 | + a.opts.CompactAt = 0.99 | |
| 385 | + a.opts.CompactFloor = 0.5 | |
| 386 | + a.state.Replace(mkHistory("uauaua")) | |
| 387 | + | |
| 388 | + if got, err := a.maybeCompact(context.Background(), 600); err != nil || got { | |
| 389 | + t.Fatalf("maybeCompact = %v, %v; 600/1000 is under CompactAt, so nothing should happen", got, err) | |
| 390 | + } | |
| 391 | + } | |
| 392 | + | |
| 393 | + // TestCompactFoldsOnDemand is /compact: no threshold involved, and the recent | |
| 394 | + // tail is kept exactly as an automatic compaction would keep it. | |
| 395 | + func TestCompactFoldsOnDemand(t *testing.T) { | |
| 396 | + f := newFakeAPI(t) | |
| 397 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 398 | + a.state.Replace(mkHistory("uauauauauaua")) // 12 messages | |
| 399 | + | |
| 400 | + var compacted int | |
| 401 | + a.Observe = func(ev Event) { | |
| 402 | + if ev.Kind == EventCompact { | |
| 403 | + compacted++ | |
| 404 | + } | |
| 405 | + } | |
| 406 | + got, err := a.Compact(context.Background()) | |
| 407 | + if err != nil { | |
| 408 | + t.Fatalf("Compact: %v", err) | |
| 409 | + } | |
| 410 | + if !got { | |
| 411 | + t.Fatal("Compact should report that it folded the conversation") | |
| 412 | + } | |
| 413 | + if compacted != 1 { | |
| 414 | + t.Errorf("%d compaction events, want 1", compacted) | |
| 415 | + } | |
| 416 | + | |
| 417 | + history := a.State().History() | |
| 418 | + if n := len(history); n != 6 { | |
| 419 | + t.Fatalf("history has %d messages, want 6: the summary plus the kept tail", n) | |
| 420 | + } | |
| 421 | + if !strings.Contains(history[0].Content[0].Text, "[Earlier conversation, compacted]") { | |
| 422 | + t.Errorf("history should open with the folded summary, got %q", history[0].Content[0].Text) | |
| 423 | + } | |
| 424 | + if history[1].Role != llm.RoleAssistant { | |
| 425 | + t.Errorf("the kept tail starts with %q, want assistant", history[1].Role) | |
| 426 | + } | |
| 427 | + } | |
| 428 | + | |
| 429 | + // TestCompactOnDemandDeclinesAShortConversation: /compact on a conversation | |
| 430 | + // with nothing worth folding should say so rather than spend a model call | |
| 431 | + // summarizing three messages. | |
| 432 | + func TestCompactOnDemandDeclinesAShortConversation(t *testing.T) { | |
| 433 | + f := newFakeAPI(t) | |
| 434 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 435 | + a.state.Replace(mkHistory("uaua")) | |
| 436 | + | |
| 437 | + got, err := a.Compact(context.Background()) | |
| 438 | + if err != nil { | |
| 439 | + t.Fatalf("Compact: %v", err) | |
| 440 | + } | |
| 441 | + if got { | |
| 442 | + t.Error("Compact should report false when there is nothing to fold") | |
| 443 | + } | |
| 444 | + if len(f.requests) != 0 { | |
| 445 | + t.Errorf("declining should cost no model call, got %d", len(f.requests)) | |
| 446 | + } | |
| 447 | + } | |
| 448 | + | |
| 449 | + // With auto-compaction off Codey must not fold anything on its own, even | |
| 450 | + // well past the ordinary threshold. | |
| 451 | + func TestAutoCompactOffNeverFolds(t *testing.T) { | |
| 452 | + f := newFakeAPI(t) | |
| 453 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 454 | + a.opts.ContextWindow = 1000 | |
| 455 | + a.opts.NoAutoCompact = true | |
| 456 | + a.state.Replace(mkHistory("uauauauauaua")) | |
| 457 | + | |
| 458 | + did, err := a.maybeCompact(context.Background(), 900) // 90%: past CompactAt | |
| 459 | + if err != nil { | |
| 460 | + t.Fatalf("maybeCompact: %v", err) | |
| 461 | + } | |
| 462 | + if did { | |
| 463 | + t.Error("auto-compaction is off; nothing should have been folded") | |
| 464 | + } | |
| 465 | + if len(f.requests) != 0 { | |
| 466 | + t.Errorf("no model call should have been made, got %d", len(f.requests)) | |
| 467 | + } | |
| 468 | + if n := a.state.Len(); n != 12 { | |
| 469 | + t.Errorf("history has %d messages, want the original 12", n) | |
| 470 | + } | |
| 471 | + } | |
| 472 | + | |
| 473 | + // Off, the floor stops the turn instead of folding: sending a request that | |
| 474 | + // cannot fit only trades a clear message for an opaque API error. | |
| 475 | + func TestAutoCompactOffStopsAtTheFloor(t *testing.T) { | |
| 476 | + f := newFakeAPI(t) | |
| 477 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 478 | + a.opts.ContextWindow = 1000 | |
| 479 | + a.opts.NoAutoCompact = true | |
| 480 | + a.state.Replace(mkHistory("uauauauauaua")) | |
| 481 | + | |
| 482 | + _, err := a.maybeCompact(context.Background(), 980) | |
| 483 | + var full *ContextFullError | |
| 484 | + if !errors.As(err, &full) { | |
| 485 | + t.Fatalf("maybeCompact error = %v, want a *ContextFullError", err) | |
| 486 | + } | |
| 487 | + if full.Used != 980 || full.Window != 1000 { | |
| 488 | + t.Errorf("error carries %d of %d, want 980 of 1000", full.Used, full.Window) | |
| 489 | + } | |
| 490 | + if !strings.Contains(full.Error(), "98%") { | |
| 491 | + t.Errorf("the message should name how full the window is: %q", full.Error()) | |
| 492 | + } | |
| 493 | + } | |
| 494 | + | |
| 495 | + // The prompt must not join the history on a turn that never runs, or compacting | |
| 496 | + // and retrying would send it twice. | |
| 497 | + func TestAutoCompactOffRefusesBeforeTheHistoryGrows(t *testing.T) { | |
| 498 | + f := newFakeAPI(t) | |
| 499 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 500 | + a.opts.ContextWindow = 1000 | |
| 501 | + a.opts.NoAutoCompact = true | |
| 502 | + a.state.Replace(mkHistory("uaua")) | |
| 503 | + a.lastInputTokens = 980 | |
| 504 | + | |
| 505 | + _, err := a.Run(context.Background(), "carry on") | |
| 506 | + var full *ContextFullError | |
| 507 | + if !errors.As(err, &full) { | |
| 508 | + t.Fatalf("Run error = %v, want a *ContextFullError", err) | |
| 509 | + } | |
| 510 | + if n := a.state.Len(); n != 4 { | |
| 511 | + t.Errorf("history has %d messages, want the original 4: the refused prompt must not be kept", n) | |
| 512 | + } | |
| 513 | + if len(f.requests) != 0 { | |
| 514 | + t.Errorf("a refused turn should reach the API not at all, got %d requests", len(f.requests)) | |
| 515 | + } | |
| 516 | + } | |
| 517 | + | |
| 518 | + // /compact is the way out of a full window, so the setting that stops the | |
| 519 | + // automatic fold must not disable the manual one too. | |
| 520 | + func TestCompactWorksWithAutoCompactOff(t *testing.T) { | |
| 521 | + f := newFakeAPI(t) | |
| 522 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 523 | + a.opts.ContextWindow = 1000 | |
| 524 | + a.opts.NoAutoCompact = true | |
| 525 | + a.lastInputTokens = 980 | |
| 526 | + a.state.Replace(mkHistory("uauauauauaua")) | |
| 527 | + | |
| 528 | + did, err := a.Compact(context.Background()) | |
| 529 | + if err != nil { | |
| 530 | + t.Fatalf("Compact: %v", err) | |
| 531 | + } | |
| 532 | + if !did { | |
| 533 | + t.Error("/compact must still fold when auto-compaction is off") | |
| 534 | + } | |
| 535 | + } | |
| 536 | + | |
| 537 | + // Under the floor with auto-compaction off, an ordinary turn is untouched. | |
| 538 | + func TestAutoCompactOffLetsAnOrdinaryTurnThrough(t *testing.T) { | |
| 539 | + f := newFakeAPI(t, msg("end_turn", textBlock("fine"))) | |
| 540 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 541 | + a.opts.ContextWindow = 1000 | |
| 542 | + a.opts.NoAutoCompact = true | |
| 543 | + a.lastInputTokens = 500 | |
| 544 | + | |
| 545 | + if _, err := a.Run(context.Background(), "carry on"); err != nil { | |
| 546 | + t.Fatalf("Run: %v", err) | |
| 547 | + } | |
| 548 | + } | |
| 549 | + | |
| 550 | + // The dead end found by running it: auto-compaction off, four enormous | |
| 551 | + // messages, window full. The turn is refused, and if /compact also declined | |
| 552 | + // there would be nothing left but /clear — so past the floor the manual fold | |
| 553 | + // takes the same smaller tail the automatic one would have taken. | |
| 554 | + func TestCompactFoldsAShortHistoryWhenTheWindowIsFull(t *testing.T) { | |
| 555 | + f := newFakeAPI(t) | |
| 556 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 557 | + a.opts.ContextWindow = 1000 | |
| 558 | + a.opts.NoAutoCompact = true | |
| 559 | + a.lastInputTokens = 980 | |
| 560 | + a.state.Replace(mkHistory("uaua")) | |
| 561 | + | |
| 562 | + did, err := a.Compact(context.Background()) | |
| 563 | + if err != nil { | |
| 564 | + t.Fatalf("Compact: %v", err) | |
| 565 | + } | |
| 566 | + if !did { | |
| 567 | + t.Fatal("/compact must fold when it is the only way out of a full window") | |
| 568 | + } | |
| 569 | + history := a.State().History() | |
| 570 | + if len(history) >= 4 { | |
| 571 | + t.Errorf("history is still %d messages; nothing was folded away", len(history)) | |
| 572 | + } | |
| 573 | + if history[len(history)-1].Role != llm.RoleAssistant { | |
| 574 | + t.Error("the kept tail must still start and end on a valid boundary") | |
| 575 | + } | |
| 576 | + } | |
| 577 | + | |
| 578 | + // Below the floor the same short conversation is left alone: folding three | |
| 579 | + // messages costs a model call and buys nothing. | |
| 580 | + func TestCompactStillDeclinesAShortHistoryWithRoomLeft(t *testing.T) { | |
| 581 | + f := newFakeAPI(t) | |
| 582 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 583 | + a.opts.ContextWindow = 1000 | |
| 584 | + a.lastInputTokens = 100 | |
| 585 | + a.state.Replace(mkHistory("uaua")) | |
| 586 | + | |
| 587 | + did, err := a.Compact(context.Background()) | |
| 588 | + if err != nil { | |
| 589 | + t.Fatalf("Compact: %v", err) | |
| 590 | + } | |
| 591 | + if did { | |
| 592 | + t.Error("with the window nearly empty there is nothing worth folding") | |
| 593 | + } | |
| 594 | + if len(f.requests) != 0 { | |
| 595 | + t.Errorf("declining should cost no model call, got %d", len(f.requests)) | |
| 596 | + } | |
| 597 | + } | |
| 598 | + | |
| 599 | + // The wedge found by running it: /compact folded the history, but the recorded | |
| 600 | + // input size still described the conversation as it was before, so the next | |
| 601 | + // turn was refused on a number that had stopped being true — and no amount of | |
| 602 | + // compacting could ever clear it. | |
| 603 | + func TestCompactingUnwedgesAFullWindow(t *testing.T) { | |
| 604 | + f := newFakeAPI(t, msg("end_turn", textBlock("working again"))) | |
| 605 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 606 | + a.opts.ContextWindow = 1000 | |
| 607 | + a.opts.NoAutoCompact = true | |
| 608 | + a.lastInputTokens = 980 | |
| 609 | + a.state.Replace(mkHistory("uauauauauaua")) | |
| 610 | + | |
| 611 | + if _, err := a.Run(context.Background(), "refused"); err == nil { | |
| 612 | + t.Fatal("the turn should be refused while the window is full") | |
| 613 | + } | |
| 614 | + if did, err := a.Compact(context.Background()); err != nil || !did { | |
| 615 | + t.Fatalf("Compact = %v, %v; want it to fold", did, err) | |
| 616 | + } | |
| 617 | + if _, err := a.Run(context.Background(), "and now?"); err != nil { | |
| 618 | + t.Fatalf("the turn after a compaction must go through, got %v", err) | |
| 619 | + } | |
| 620 | + } | |
| @@ -9,10 +9,10 @@ import ( | ||
| 9 | 9 | "strings" |
| 10 | 10 | "sync" |
| 11 | 11 | |
| 12 | - "harness/internal/llm" | |
| 13 | - "harness/internal/metrics" | |
| 14 | - "harness/internal/skills" | |
| 15 | - "harness/internal/tools" | |
| 12 | + "codey/internal/llm" | |
| 13 | + "codey/internal/metrics" | |
| 14 | + "codey/internal/skills" | |
| 15 | + "codey/internal/tools" | |
| 16 | 16 | ) |
| 17 | 17 | |
| 18 | 18 | // DefaultMaxTurns caps one Run. An agent that loops without converging burns |
| @@ -101,10 +101,17 @@ type Options struct { | ||
| 101 | 101 | System string |
| 102 | 102 | // Effort maps to output_config.effort. Empty means the API default (high). |
| 103 | 103 | Effort string |
| 104 | - // ContextWindow and CompactAt control when the conversation gets folded | |
| 105 | - // into a summary. Zero means the defaults. | |
| 104 | + // ContextWindow, CompactAt and CompactFloor control when the conversation | |
| 105 | + // gets folded into a summary: CompactAt is the ordinary threshold, | |
| 106 | + // CompactFloor the fraction past which compaction stops being optional. | |
| 107 | + // Zero means the defaults. | |
| 106 | 108 | ContextWindow int |
| 107 | 109 | CompactAt float64 |
| 110 | + CompactFloor float64 | |
| 111 | + // NoAutoCompact turns the automatic fold off entirely. The window then | |
| 112 | + // becomes a hard stop at CompactFloor rather than a trigger, and /compact | |
| 113 | + // is the only thing that makes room. | |
| 114 | + NoAutoCompact bool | |
| 108 | 115 | // NoCache disables prompt cache breakpoints, for measuring what they buy. |
| 109 | 116 | NoCache bool |
| 110 | 117 | // Stream uses the SSE endpoint so output appears as it is produced. |
| @@ -122,7 +129,7 @@ type Options struct { | ||
| 122 | 129 | // to make, and the paragraph would be dead weight in its prompt. |
| 123 | 130 | Models []llm.Model |
| 124 | 131 | // SystemPrefix is prepended as a separate system block ahead of the |
| 125 | - // harness prompt. It carries the identity string an OAuth credential | |
| 132 | + // Codey's own system prompt. It carries the identity string an OAuth credential | |
| 126 | 133 | // requires, and is empty for every other credential. |
| 127 | 134 | SystemPrefix string |
| 128 | 135 | } |
| @@ -137,6 +144,10 @@ const ( | ||
| 137 | 144 | EventToolResult EventKind = "tool_result" |
| 138 | 145 | EventUsage EventKind = "usage" |
| 139 | 146 | EventCompact EventKind = "compact" |
| 147 | + // EventNotice is the loop telling the user something about the run itself | |
| 148 | + // rather than about the task — so far, only that the window is full and | |
| 149 | + // compaction can do nothing more about it. | |
| 150 | + EventNotice EventKind = "notice" | |
| 140 | 151 | // EventTextDelta is emitted instead of EventText while streaming. |
| 141 | 152 | EventTextDelta EventKind = "text_delta" |
| 142 | 153 | // EventPaused is emitted when the loop stops at a turn boundary, and |
| @@ -289,6 +300,12 @@ func (a *Agent) emit(ev Event) { | ||
| 289 | 300 | // Run sends one user message and drives the tool loop until the model stops |
| 290 | 301 | // asking for tools. It returns the final assistant text. |
| 291 | 302 | func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { |
| 303 | + // Checked before the prompt joins the history, so a turn refused for want | |
| 304 | + // of room leaves the conversation exactly as it was: compacting and asking | |
| 305 | + // again then sends the prompt once, not twice. | |
| 306 | + if err := a.contextFull(a.lastInputTokens); err != nil { | |
| 307 | + return "", err | |
| 308 | + } | |
| 292 | 309 | a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(prompt)}}) |
| 293 | 310 | return a.drive(ctx) |
| 294 | 311 | } |
| @@ -429,7 +446,7 @@ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock | ||
| 429 | 446 | |
| 430 | 447 | // authorizedExecute runs a tool once the approval policy allows it. A denial |
| 431 | 448 | // comes back as an ordinary tool error: the model should read it and change |
| 432 | - // course, not see a harness crash. | |
| 449 | + // course, not see Codey crash. | |
| 433 | 450 | func (a *Agent) authorizedExecute(ctx context.Context, call llm.ContentBlock) (string, error) { |
| 434 | 451 | if a.Approve != nil { |
| 435 | 452 | if err := a.Approve.Approve(ctx, call.Name, call.Input); err != nil { |
| @@ -15,10 +15,10 @@ import ( | ||
| 15 | 15 | "testing" |
| 16 | 16 | "time" |
| 17 | 17 | |
| 18 | - "harness/internal/llm" | |
| 19 | - "harness/internal/metrics" | |
| 20 | - "harness/internal/skills" | |
| 21 | - "harness/internal/tools" | |
| 18 | + "codey/internal/llm" | |
| 19 | + "codey/internal/metrics" | |
| 20 | + "codey/internal/skills" | |
| 21 | + "codey/internal/tools" | |
| 22 | 22 | ) |
| 23 | 23 | |
| 24 | 24 | // fakeAPI serves a scripted sequence of responses and records every request |
| @@ -54,7 +54,7 @@ func newFakeAPI(t *testing.T, responses ...string) *fakeAPI { | ||
| 54 | 54 | body, _ := io.ReadAll(r.Body) |
| 55 | 55 | var req llm.Request |
| 56 | 56 | if err := json.Unmarshal(body, &req); err != nil { |
| 57 | - t.Errorf("harness sent a body the API types cannot parse: %v\n%s", err, body) | |
| 57 | + t.Errorf("codey sent a body the API types cannot parse: %v\n%s", err, body) | |
| 58 | 58 | } |
| 59 | 59 | f.requests = append(f.requests, req) |
| 60 | 60 | if bytes.Contains(body, []byte("Summarize the conversation above")) { |
| @@ -63,7 +63,7 @@ func newFakeAPI(t *testing.T, responses ...string) *fakeAPI { | ||
| 63 | 63 | } |
| 64 | 64 | i := len(f.requests) - 1 - f.compactions() |
| 65 | 65 | if i >= len(f.responses) { |
| 66 | - t.Errorf("harness made %d requests, script only has %d", i+1, len(f.responses)) | |
| 66 | + t.Errorf("codey made %d requests, script only has %d", i+1, len(f.responses)) | |
| 67 | 67 | w.WriteHeader(http.StatusInternalServerError) |
| 68 | 68 | return |
| 69 | 69 | } |
| @@ -478,7 +478,7 @@ func TestDeniedToolBecomesAToolError(t *testing.T) { | ||
| 478 | 478 | |
| 479 | 479 | got, err := a.Run(context.Background(), "read it") |
| 480 | 480 | if err != nil { |
| 481 | - t.Fatalf("a denial is not a harness failure: %v", err) | |
| 481 | + t.Fatalf("a denial is not a failure of ours: %v", err) | |
| 482 | 482 | } |
| 483 | 483 | if got != "understood" { |
| 484 | 484 | t.Errorf("answer = %q", got) |
| @@ -548,7 +548,7 @@ func TestSystemPrefixBecomesTheFirstBlock(t *testing.T) { | ||
| 548 | 548 | t.Errorf("first block = %q, want the identity string", blocks[0].Text) |
| 549 | 549 | } |
| 550 | 550 | if !strings.Contains(blocks[1].Text, "coding agent") { |
| 551 | - t.Errorf("second block should be the harness prompt, got %q", blocks[1].Text) | |
| 551 | + t.Errorf("second block should be Codey's own system prompt, got %q", blocks[1].Text) | |
| 552 | 552 | } |
| 553 | 553 | } |
| 554 | 554 | |
| @@ -14,9 +14,9 @@ import ( | ||
| 14 | 14 | "testing" |
| 15 | 15 | "time" |
| 16 | 16 | |
| 17 | - "harness/internal/llm" | |
| 18 | - "harness/internal/metrics" | |
| 19 | - "harness/internal/tools" | |
| 17 | + "codey/internal/llm" | |
| 18 | + "codey/internal/metrics" | |
| 19 | + "codey/internal/tools" | |
| 20 | 20 | ) |
| 21 | 21 | |
| 22 | 22 | // settle is how long a test waits before concluding that nothing is going to |
| @@ -8,7 +8,8 @@ import ( | ||
| 8 | 8 | "sort" |
| 9 | 9 | "time" |
| 10 | 10 | |
| 11 | - "harness/internal/llm" | |
| 11 | + "codey/internal/llm" | |
| 12 | + "codey/internal/statedir" | |
| 12 | 13 | ) |
| 13 | 14 | |
| 14 | 15 | // Session is a conversation saved to disk so --resume can pick it up. |
| @@ -24,7 +25,7 @@ type Session struct { | ||
| 24 | 25 | } |
| 25 | 26 | |
| 26 | 27 | // SessionDir is where sessions live, relative to the working root. |
| 27 | - const SessionDir = ".harness/sessions" | |
| 28 | + const SessionDir = statedir.Dir + "/sessions" | |
| 28 | 29 | |
| 29 | 30 | // Save writes the session for id, overwriting any previous copy. |
| 30 | 31 | func (a *Agent) Save(dir, id, root, model string) error { |
| @@ -9,7 +9,7 @@ import ( | ||
| 9 | 9 | "testing" |
| 10 | 10 | "time" |
| 11 | 11 | |
| 12 | - "harness/internal/llm" | |
| 12 | + "codey/internal/llm" | |
| 13 | 13 | ) |
| 14 | 14 | |
| 15 | 15 | func TestSaveAndResumeSession(t *testing.T) { |
| @@ -10,8 +10,8 @@ import ( | ||
| 10 | 10 | "sync" |
| 11 | 11 | "time" |
| 12 | 12 | |
| 13 | - "harness/internal/llm" | |
| 14 | - "harness/internal/tools" | |
| 13 | + "codey/internal/llm" | |
| 14 | + "codey/internal/tools" | |
| 15 | 15 | ) |
| 16 | 16 | |
| 17 | 17 | // ReadRecord lives in tools so the edit tool can query the tracker without |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | // Package claudecode reads the OAuth credential Claude Code stores on disk, so |
| 2 | - // a harness run can borrow a login that already exists. | |
| 2 | + // a Codey run can borrow a login that already exists. | |
| 3 | 3 | // |
| 4 | 4 | // This is Claude Code's private storage, not a documented interface: the |
| 5 | 5 | // schema can change without notice. Everything that knows its shape lives |
| @@ -24,7 +24,7 @@ const tokenURLEnv = "CLAUDECODE_TOKEN_URL" | ||
| 24 | 24 | |
| 25 | 25 | const defaultTokenURL = "https://console.anthropic.com/v1/oauth/token" |
| 26 | 26 | |
| 27 | - // refreshTimeout bounds a token exchange. The harness's own HTTP client allows | |
| 27 | + // refreshTimeout bounds a token exchange. Codey's own HTTP client allows | |
| 28 | 28 | // ten minutes, which is right for a model response and badly wrong here: the |
| 29 | 29 | // refresh runs under a lock every other request is queued behind. |
| 30 | 30 | const refreshTimeout = 30 * time.Second |
| @@ -310,7 +310,7 @@ func NewSource(c *Credentials, h *http.Client, home string) *Source { | ||
| 310 | 310 | // |
| 311 | 311 | // The lock means concurrent requests — streaming and buffered can be in flight |
| 312 | 312 | // together — produce one refresh rather than a thundering herd. Refresh bounds |
| 313 | - // its own time, so the wait behind that lock is seconds, not the harness | |
| 313 | + // its own time, so the wait behind that lock is seconds, not Codey's | |
| 314 | 314 | // client's ten-minute model-response budget. |
| 315 | 315 | func (s *Source) Token(ctx context.Context) (string, error) { |
| 316 | 316 | s.mu.Lock() |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | // |
| 3 | 3 | // It holds defaults, never facts: every field is something a flag or an |
| 4 | 4 | // environment variable may override for a given run. Nothing here is required |
| 5 | - // for the harness to start, which is what lets a missing file mean "first | |
| 5 | + // for Codey to start, which is what lets a missing file mean "first | |
| 6 | 6 | // run" rather than "broken install". |
| 7 | 7 | package config |
| 8 | 8 | |
| @@ -14,12 +14,19 @@ import ( | ||
| 14 | 14 | "os" |
| 15 | 15 | "path/filepath" |
| 16 | 16 | |
| 17 | - "harness/internal/llm" | |
| 17 | + "codey/internal/llm" | |
| 18 | 18 | ) |
| 19 | 19 | |
| 20 | - // Filename is the config's name inside the harness config directory. | |
| 20 | + // Filename is the config's name inside Codey's config directory. | |
| 21 | 21 | const Filename = "config.json" |
| 22 | 22 | |
| 23 | + // Dir is Codey's directory inside the user's config directory. legacyDir is | |
| 24 | + // what it was called before the rename, and is still read from. | |
| 25 | + const ( | |
| 26 | + Dir = "codey" | |
| 27 | + legacyDir = "harness" | |
| 28 | + ) | |
| 29 | + | |
| 23 | 30 | // Version is stamped on every write. It exists so a future change of shape |
| 24 | 31 | // has something to branch on rather than guessing from which keys are present. |
| 25 | 32 | const Version = 1 |
| @@ -32,6 +39,19 @@ const ( | ||
| 32 | 39 | ApprovalsAcceptAll = "accept-all" |
| 33 | 40 | ) |
| 34 | 41 | |
| 42 | + // SetupDone is what the Setup field says once the wizard has been all the way | |
| 43 | + // through. It is a word rather than a bool for the same reason the approval | |
| 44 | + // modes are: a file that reads "setup": false looks like something failed. | |
| 45 | + const SetupDone = "done" | |
| 46 | + | |
| 47 | + // Auto-compaction settings, as written to the file. Spelled out for the same | |
| 48 | + // reason again: an empty value has to mean "no preference", and a bool cannot | |
| 49 | + // tell that apart from "off". | |
| 50 | + const ( | |
| 51 | + AutoCompactOn = "on" | |
| 52 | + AutoCompactOff = "off" | |
| 53 | + ) | |
| 54 | + | |
| 35 | 55 | // Themes are the TUI palettes, in the order the wizard offers them. The names |
| 36 | 56 | // live here rather than in the tui package so that reading a config never |
| 37 | 57 | // depends on a terminal front end being linked in — the eval runner has none. |
| @@ -51,16 +71,42 @@ type Config struct { | ||
| 51 | 71 | Approvals string `json:"approvals,omitempty"` |
| 52 | 72 | // Theme is one of Themes. |
| 53 | 73 | Theme string `json:"theme,omitempty"` |
| 74 | + // AutoCompact is AutoCompactOn or AutoCompactOff. Empty means on: a | |
| 75 | + // config written before this setting existed must not quietly start | |
| 76 | + // stopping the agent at a full window. | |
| 77 | + AutoCompact string `json:"auto_compact,omitempty"` | |
| 78 | + // Setup is SetupDone once the questions have been answered to the end. | |
| 79 | + // | |
| 80 | + // It is what decides whether the wizard opens by itself, in place of the | |
| 81 | + // older test of whether the file exists at all. The difference shows up on | |
| 82 | + // a config that exists but was never finished — written by hand, or by a | |
| 83 | + // build that had fewer questions in it — where "the file is there" would | |
| 84 | + // say the setup was done and this says who says so. | |
| 85 | + Setup string `json:"setup,omitempty"` | |
| 54 | 86 | } |
| 55 | 87 | |
| 56 | - // Path is where the config lives: $XDG_CONFIG_HOME/harness/config.json, or | |
| 57 | - // ~/.config/harness/config.json when XDG_CONFIG_HOME is unset. | |
| 58 | - func Path() (string, error) { | |
| 88 | + // AutoCompactEnabled reports whether the conversation should fold itself when | |
| 89 | + // the window fills. Only an explicit "off" turns it off. | |
| 90 | + func (c Config) AutoCompactEnabled() bool { return c.AutoCompact != AutoCompactOff } | |
| 91 | + | |
| 92 | + // Done reports whether the setup has been completed. It is the only thing | |
| 93 | + // that suppresses the wizard on startup. | |
| 94 | + func (c Config) Done() bool { return c.Setup == SetupDone } | |
| 95 | + | |
| 96 | + // Path is where the config lives: $XDG_CONFIG_HOME/codey/config.json, or | |
| 97 | + // ~/.config/codey/config.json when XDG_CONFIG_HOME is unset. | |
| 98 | + func Path() (string, error) { return pathIn(Dir) } | |
| 99 | + | |
| 100 | + // LegacyPath is where the config lived when Codey was called harness. It is | |
| 101 | + // read when there is nothing at Path, and never written. | |
| 102 | + func LegacyPath() (string, error) { return pathIn(legacyDir) } | |
| 103 | + | |
| 104 | + func pathIn(name string) (string, error) { | |
| 59 | 105 | dir, err := os.UserConfigDir() |
| 60 | 106 | if err != nil { |
| 61 | 107 | return "", fmt.Errorf("config: no config directory: %w", err) |
| 62 | 108 | } |
| 63 | - return filepath.Join(dir, "harness", Filename), nil | |
| 109 | + return filepath.Join(dir, name, Filename), nil | |
| 64 | 110 | } |
| 65 | 111 | |
| 66 | 112 | // Load reads the config. The boolean reports whether a usable one was found; |
| @@ -71,12 +117,25 @@ func Path() (string, error) { | ||
| 71 | 117 | // found=false. The caller is expected to carry on with defaults and say so: |
| 72 | 118 | // what it must not do is treat the file as absent, because the next Save would |
| 73 | 119 | // then overwrite settings the user still has. |
| 120 | + // | |
| 121 | + // With nothing at Path, the pre-rename location is tried before giving up, so | |
| 122 | + // a setup done under the old name is not thrown away and the wizard does not | |
| 123 | + // reappear for someone who has already answered it. The fallback is a read: | |
| 124 | + // the old file stays where it is, and the next Save writes only to Path. | |
| 74 | 125 | func Load() (Config, bool, error) { |
| 75 | 126 | path, err := Path() |
| 76 | 127 | if err != nil { |
| 77 | 128 | return Config{}, false, err |
| 78 | 129 | } |
| 79 | 130 | raw, err := os.ReadFile(path) |
| 131 | + if errors.Is(err, fs.ErrNotExist) { | |
| 132 | + legacy, lerr := LegacyPath() | |
| 133 | + if lerr != nil { | |
| 134 | + return Config{}, false, lerr | |
| 135 | + } | |
| 136 | + path = legacy | |
| 137 | + raw, err = os.ReadFile(path) | |
| 138 | + } | |
| 80 | 139 | if errors.Is(err, fs.ErrNotExist) { |
| 81 | 140 | return Config{}, false, nil |
| 82 | 141 | } |
| @@ -92,7 +151,7 @@ func Load() (Config, bool, error) { | ||
| 92 | 151 | } |
| 93 | 152 | |
| 94 | 153 | // sanitize drops values this build does not recognise. A config written by a |
| 95 | - // newer harness, or edited by hand, should degrade to the default for that | |
| 154 | + // newer Codey, or edited by hand, should degrade to the default for that | |
| 96 | 155 | // field rather than be forwarded to the API, which would reject the request |
| 97 | 156 | // with an error pointing at nothing the user can see. |
| 98 | 157 | func sanitize(c Config) Config { |
| @@ -107,6 +166,14 @@ func sanitize(c Config) Config { | ||
| 107 | 166 | if !ValidTheme(c.Theme) { |
| 108 | 167 | c.Theme = "" |
| 109 | 168 | } |
| 169 | + if c.AutoCompact != AutoCompactOn && c.AutoCompact != AutoCompactOff { | |
| 170 | + c.AutoCompact = "" | |
| 171 | + } | |
| 172 | + // Anything other than the one word means the marker was not written by a | |
| 173 | + // wizard that finished, so it does not count as one. | |
| 174 | + if c.Setup != SetupDone { | |
| 175 | + c.Setup = "" | |
| 176 | + } | |
| 110 | 177 | return c |
| 111 | 178 | } |
| 112 | 179 | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | package config |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | - "harness/internal/llm" | |
| 4 | + "codey/internal/llm" | |
| 5 | 5 | "os" |
| 6 | 6 | "path/filepath" |
| 7 | 7 | "strings" |
| @@ -9,12 +9,99 @@ import ( | ||
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | 11 | // isolate points the config at a temp directory, so a test never reads or |
| 12 | - // writes the developer's real ~/.config/harness. | |
| 12 | + // writes the developer's real ~/.config/codey. | |
| 13 | 13 | func isolate(t *testing.T) string { |
| 14 | 14 | t.Helper() |
| 15 | 15 | dir := t.TempDir() |
| 16 | 16 | t.Setenv("XDG_CONFIG_HOME", dir) |
| 17 | - return filepath.Join(dir, "harness", Filename) | |
| 17 | + return filepath.Join(dir, "codey", Filename) | |
| 18 | + } | |
| 19 | + | |
| 20 | + // legacyPath is the pre-rename location, alongside the isolated new one. | |
| 21 | + func legacyPath(t *testing.T) string { | |
| 22 | + t.Helper() | |
| 23 | + dir := os.Getenv("XDG_CONFIG_HOME") | |
| 24 | + if dir == "" { | |
| 25 | + t.Fatal("legacyPath called outside isolate") | |
| 26 | + } | |
| 27 | + return filepath.Join(dir, legacyDir, Filename) | |
| 28 | + } | |
| 29 | + | |
| 30 | + func writeConfig(t *testing.T, path, body string) { | |
| 31 | + t.Helper() | |
| 32 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 33 | + t.Fatal(err) | |
| 34 | + } | |
| 35 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 36 | + t.Fatal(err) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + // Someone who set Codey up before the rename should not be shown the | |
| 41 | + // wizard again: with no config at the new path, the old one still counts. | |
| 42 | + func TestLoadFallsBackToTheLegacyDirectory(t *testing.T) { | |
| 43 | + isolate(t) | |
| 44 | + writeConfig(t, legacyPath(t), `{"model":"claude-sonnet-5","setup":"done"}`) | |
| 45 | + | |
| 46 | + cfg, found, err := Load() | |
| 47 | + if err != nil { | |
| 48 | + t.Fatalf("Load: %v", err) | |
| 49 | + } | |
| 50 | + if !found { | |
| 51 | + t.Fatal("a config under the old name was not found") | |
| 52 | + } | |
| 53 | + if cfg.Model != "claude-sonnet-5" || !cfg.Done() { | |
| 54 | + t.Errorf("the old config did not survive the fallback: %+v", cfg) | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + // The fallback is a read, not a migration: the old file is left exactly where | |
| 59 | + // it was, so an older build still finds the settings it wrote. | |
| 60 | + func TestLoadLeavesTheLegacyFileAlone(t *testing.T) { | |
| 61 | + path := isolate(t) | |
| 62 | + old := legacyPath(t) | |
| 63 | + writeConfig(t, old, `{"model":"claude-sonnet-5"}`) | |
| 64 | + | |
| 65 | + if _, _, err := Load(); err != nil { | |
| 66 | + t.Fatalf("Load: %v", err) | |
| 67 | + } | |
| 68 | + if _, err := os.Stat(old); err != nil { | |
| 69 | + t.Errorf("the old config was moved or removed by a read: %v", err) | |
| 70 | + } | |
| 71 | + if _, err := os.Stat(path); err == nil { | |
| 72 | + t.Error("Load wrote to the new path; only Save may do that") | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + // Once there is a config under the new name it wins outright, even if a stale | |
| 77 | + // one is still sitting in the old directory. | |
| 78 | + func TestNewConfigBeatsTheLegacyOne(t *testing.T) { | |
| 79 | + path := isolate(t) | |
| 80 | + writeConfig(t, path, `{"model":"claude-opus-5"}`) | |
| 81 | + writeConfig(t, legacyPath(t), `{"model":"claude-sonnet-5"}`) | |
| 82 | + | |
| 83 | + cfg, found, err := Load() | |
| 84 | + if err != nil || !found { | |
| 85 | + t.Fatalf("Load: %v found=%v", err, found) | |
| 86 | + } | |
| 87 | + if cfg.Model != "claude-opus-5" { | |
| 88 | + t.Errorf("the stale config won: %+v", cfg) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + // A corrupt file under the old name is still the user's settings, so it is | |
| 93 | + // reported rather than skipped over as if nothing were there. | |
| 94 | + func TestLegacyConfigThatCannotBeParsedIsReported(t *testing.T) { | |
| 95 | + isolate(t) | |
| 96 | + writeConfig(t, legacyPath(t), "{not json") | |
| 97 | + | |
| 98 | + _, found, err := Load() | |
| 99 | + if err == nil { | |
| 100 | + t.Fatal("a corrupt config under the old name was read as absent") | |
| 101 | + } | |
| 102 | + if found { | |
| 103 | + t.Error("found=true alongside an error") | |
| 104 | + } | |
| 18 | 105 | } |
| 19 | 106 | |
| 20 | 107 | func TestLoadReportsMissingWithoutError(t *testing.T) { |
| @@ -152,3 +239,94 @@ func TestFlagsOverrideTheConfig(t *testing.T) { | ||
| 152 | 239 | t.Errorf("two empties produced %q", got) |
| 153 | 240 | } |
| 154 | 241 | } |
| 242 | + | |
| 243 | + // The marker is what suppresses the wizard, so it has to survive the file | |
| 244 | + // unchanged — and a config without it has to keep reading as unfinished. | |
| 245 | + func TestSetupMarkerRoundTrips(t *testing.T) { | |
| 246 | + isolate(t) | |
| 247 | + | |
| 248 | + if (Config{Model: "claude-opus-5"}).Done() { | |
| 249 | + t.Error("a config with no marker reported the setup as done") | |
| 250 | + } | |
| 251 | + | |
| 252 | + want := Config{Model: "claude-opus-5", Theme: "mono", Setup: SetupDone} | |
| 253 | + if err := Save(want); err != nil { | |
| 254 | + t.Fatal(err) | |
| 255 | + } | |
| 256 | + got, found, err := Load() | |
| 257 | + if err != nil || !found { | |
| 258 | + t.Fatalf("Load(found=%v): %v", found, err) | |
| 259 | + } | |
| 260 | + if !got.Done() { | |
| 261 | + t.Errorf("the setup marker did not survive the round trip: %+v", got) | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + // A config that exists but was never finished is the case the marker exists | |
| 266 | + // for: the file is there, and the wizard still has questions to ask. | |
| 267 | + func TestAnUnfinishedConfigIsNotDone(t *testing.T) { | |
| 268 | + path := isolate(t) | |
| 269 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 270 | + t.Fatal(err) | |
| 271 | + } | |
| 272 | + // A marker of the wrong type — "setup": true, the obvious wrong guess for | |
| 273 | + // someone editing by hand — is not in this list: that is a parse failure | |
| 274 | + // for the whole file, which Load already reports rather than papering over. | |
| 275 | + for _, body := range []string{ | |
| 276 | + `{"version":1,"theme":"mint"}`, | |
| 277 | + `{"version":1,"theme":"mint","setup":"maybe"}`, | |
| 278 | + `{"version":1,"theme":"mint","setup":""}`, | |
| 279 | + } { | |
| 280 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 281 | + t.Fatal(err) | |
| 282 | + } | |
| 283 | + cfg, _, _ := Load() | |
| 284 | + if cfg.Done() { | |
| 285 | + t.Errorf("%s reported the setup as done", body) | |
| 286 | + } | |
| 287 | + if cfg.Theme != "mint" { | |
| 288 | + t.Errorf("%s lost the settings it did have: %+v", body, cfg) | |
| 289 | + } | |
| 290 | + } | |
| 291 | + } | |
| 292 | + | |
| 293 | + func TestAutoCompactRoundTripsAndSanitizes(t *testing.T) { | |
| 294 | + isolate(t) | |
| 295 | + for _, v := range []string{AutoCompactOn, AutoCompactOff} { | |
| 296 | + if err := Save(Config{AutoCompact: v}); err != nil { | |
| 297 | + t.Fatal(err) | |
| 298 | + } | |
| 299 | + got, _, err := Load() | |
| 300 | + if err != nil { | |
| 301 | + t.Fatal(err) | |
| 302 | + } | |
| 303 | + if got.AutoCompact != v { | |
| 304 | + t.Errorf("%q did not survive a round trip: got %q", v, got.AutoCompact) | |
| 305 | + } | |
| 306 | + } | |
| 307 | + | |
| 308 | + if err := Save(Config{AutoCompact: "sometimes"}); err != nil { | |
| 309 | + t.Fatal(err) | |
| 310 | + } | |
| 311 | + got, _, err := Load() | |
| 312 | + if err != nil { | |
| 313 | + t.Fatal(err) | |
| 314 | + } | |
| 315 | + if got.AutoCompact != "" { | |
| 316 | + t.Errorf("an unrecognised value survived: %q", got.AutoCompact) | |
| 317 | + } | |
| 318 | + } | |
| 319 | + | |
| 320 | + // Empty means "no preference", and no preference has to mean on: a config | |
| 321 | + // written before this setting existed must not silently stop the agent at 97%. | |
| 322 | + func TestAutoCompactDefaultsToOn(t *testing.T) { | |
| 323 | + if !(Config{}).AutoCompactEnabled() { | |
| 324 | + t.Error("an empty config should leave auto-compaction on") | |
| 325 | + } | |
| 326 | + if !(Config{AutoCompact: AutoCompactOn}).AutoCompactEnabled() { | |
| 327 | + t.Error("an explicit on should be on") | |
| 328 | + } | |
| 329 | + if (Config{AutoCompact: AutoCompactOff}).AutoCompactEnabled() { | |
| 330 | + t.Error("an explicit off should be off") | |
| 331 | + } | |
| 332 | + } | |
| @@ -17,7 +17,7 @@ const ( | ||
| 17 | 17 | cacheWriteMultiplier = 1.25 |
| 18 | 18 | ) |
| 19 | 19 | |
| 20 | - // prices covers the models this harness is likely to be pointed at. A model | |
| 20 | + // prices covers the models Codey is likely to be pointed at. A model | |
| 21 | 21 | // that is not listed reports no cost rather than a wrong one. |
| 22 | 22 | var prices = map[string]Price{ |
| 23 | 23 | "claude-opus-5": {InputPerMTok: 5, OutputPerMTok: 25}, |
| @@ -42,7 +42,7 @@ func (u Usage) Cost(model string) (float64, bool) { | ||
| 42 | 42 | float64(u.CacheCreationInputTokens)*p.InputPerMTok*cacheWriteMultiplier) / 1_000_000, true |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | - // PriceOf reports the list pricing for a model, if this harness knows it. | |
| 45 | + // PriceOf reports the list pricing for a model, if Codey knows it. | |
| 46 | 46 | // The picker shows what it knows and says nothing about what it does not. |
| 47 | 47 | func PriceOf(model string) (Price, bool) { |
| 48 | 48 | p, ok := prices[basePrice(model)] |
| @@ -37,7 +37,7 @@ type Message struct { | ||
| 37 | 37 | Content []ContentBlock `json:"content"` |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | - // ContentBlock is the flattened union of every block type the harness handles. | |
| 40 | + // ContentBlock is the flattened union of every block type Codey handles. | |
| 41 | 41 | // Only the fields belonging to Type are serialized, so blocks survive the |
| 42 | 42 | // round-trip back to the API — which matters most for thinking blocks, whose |
| 43 | 43 | // signature must be echoed back unchanged. |
| @@ -12,10 +12,12 @@ import ( | ||
| 12 | 12 | "path/filepath" |
| 13 | 13 | "sync" |
| 14 | 14 | "time" |
| 15 | + | |
| 16 | + "codey/internal/statedir" | |
| 15 | 17 | ) |
| 16 | 18 | |
| 17 | - // DefaultPath is where the harness logs unless told otherwise. | |
| 18 | - const DefaultPath = ".harness/events.jsonl" | |
| 19 | + // DefaultPath is where Codey logs unless told otherwise. | |
| 20 | + const DefaultPath = statedir.Dir + "/events.jsonl" | |
| 19 | 21 | |
| 20 | 22 | // Event is one line of the log. Extra is flattened into the same JSON object so |
| 21 | 23 | // the file stays greppable with jq without nested lookups. |
| @@ -108,7 +108,7 @@ func TestNilLoggerIsANoOp(t *testing.T) { | ||
| 108 | 108 | } |
| 109 | 109 | |
| 110 | 110 | func TestOpenCreatesParentDirsAndAppends(t *testing.T) { |
| 111 | - path := filepath.Join(t.TempDir(), ".harness", "events.jsonl") | |
| 111 | + path := filepath.Join(t.TempDir(), ".codey", "events.jsonl") | |
| 112 | 112 | for i := 0; i < 2; i++ { |
| 113 | 113 | l, err := Open(path, "s") |
| 114 | 114 | if err != nil { |
| @@ -37,7 +37,7 @@ type Skill struct { | ||
| 37 | 37 | Source string // "personal" or "project" |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | - // frontmatter is the subset of SKILL.md's YAML header this harness reads. | |
| 40 | + // frontmatter is the subset of SKILL.md's YAML header Codey reads. | |
| 41 | 41 | // Unknown keys are ignored rather than rejected, so a skill written for |
| 42 | 42 | // another harness still loads here. |
| 43 | 43 | type frontmatter struct { |
| @@ -0,0 +1,49 @@ | ||
| 1 | + // Package statedir names the directory Codey keeps its per-repository state in, | |
| 2 | + // and moves the one the old name left behind. | |
| 3 | + // | |
| 4 | + // The name lives here rather than in the two packages that write into it, | |
| 5 | + // because a directory name that two packages spell independently is a name | |
| 6 | + // that will one day be spelled two ways — which is exactly what the rename | |
| 7 | + // from harness to Codey would have cost. | |
| 8 | + package statedir | |
| 9 | + | |
| 10 | + import ( | |
| 11 | + "fmt" | |
| 12 | + "os" | |
| 13 | + "path/filepath" | |
| 14 | + ) | |
| 15 | + | |
| 16 | + // Dir is where sessions and the event log live, relative to the working root. | |
| 17 | + // LegacyDir is what it was called before the rename. | |
| 18 | + const ( | |
| 19 | + Dir = ".codey" | |
| 20 | + LegacyDir = ".harness" | |
| 21 | + ) | |
| 22 | + | |
| 23 | + // Migrate renames a pre-rename state directory under root, reporting whether | |
| 24 | + // it moved one. It is called once at startup, before anything opens a file | |
| 25 | + // inside Dir. | |
| 26 | + // | |
| 27 | + // A rename rather than a read-time fallback, because the event log is | |
| 28 | + // append-only: leaving the old file where it is would split a repository's | |
| 29 | + // history across two names and quietly break the numbers METRICS.md reports. | |
| 30 | + // | |
| 31 | + // Three cases are deliberately left alone. Nothing to move is the common one, | |
| 32 | + // and must not create an empty directory. Both present means both builds have | |
| 33 | + // run here, so the new directory is the live state and the old one is history | |
| 34 | + // — merging them could only lose whichever copy it picked against. And a | |
| 35 | + // plain file of that name is not our state directory at all. | |
| 36 | + func Migrate(root string) (bool, error) { | |
| 37 | + old := filepath.Join(root, LegacyDir) | |
| 38 | + info, err := os.Lstat(old) | |
| 39 | + if err != nil || !info.IsDir() { | |
| 40 | + return false, nil | |
| 41 | + } | |
| 42 | + if _, err := os.Lstat(filepath.Join(root, Dir)); err == nil { | |
| 43 | + return false, nil | |
| 44 | + } | |
| 45 | + if err := os.Rename(old, filepath.Join(root, Dir)); err != nil { | |
| 46 | + return false, fmt.Errorf("state: moving %s to %s: %w", LegacyDir, Dir, err) | |
| 47 | + } | |
| 48 | + return true, nil | |
| 49 | + } | |
| @@ -0,0 +1,100 @@ | ||
| 1 | + package statedir | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "testing" | |
| 7 | + ) | |
| 8 | + | |
| 9 | + // mkdirWith makes dir under root with one file in it, so a test can tell a | |
| 10 | + // moved directory from a freshly created one. | |
| 11 | + func mkdirWith(t *testing.T, root, dir, name, body string) { | |
| 12 | + t.Helper() | |
| 13 | + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { | |
| 14 | + t.Fatal(err) | |
| 15 | + } | |
| 16 | + if err := os.WriteFile(filepath.Join(root, dir, name), []byte(body), 0o644); err != nil { | |
| 17 | + t.Fatal(err) | |
| 18 | + } | |
| 19 | + } | |
| 20 | + | |
| 21 | + func read(t *testing.T, path string) string { | |
| 22 | + t.Helper() | |
| 23 | + b, err := os.ReadFile(path) | |
| 24 | + if err != nil { | |
| 25 | + t.Fatal(err) | |
| 26 | + } | |
| 27 | + return string(b) | |
| 28 | + } | |
| 29 | + | |
| 30 | + // The event log is append-only and the sessions are what -resume reads, so a | |
| 31 | + // clone that predates the rename has its state carried over whole rather than | |
| 32 | + // started again under the new name. | |
| 33 | + func TestMigrateMovesTheLegacyDirectory(t *testing.T) { | |
| 34 | + root := t.TempDir() | |
| 35 | + mkdirWith(t, root, LegacyDir, "events.jsonl", "{\"event\":\"turn\"}\n") | |
| 36 | + | |
| 37 | + moved, err := Migrate(root) | |
| 38 | + if err != nil { | |
| 39 | + t.Fatalf("Migrate: %v", err) | |
| 40 | + } | |
| 41 | + if !moved { | |
| 42 | + t.Fatal("Migrate did not report the move it made") | |
| 43 | + } | |
| 44 | + if got := read(t, filepath.Join(root, Dir, "events.jsonl")); got != "{\"event\":\"turn\"}\n" { | |
| 45 | + t.Errorf("the event log did not survive the move: %q", got) | |
| 46 | + } | |
| 47 | + if _, err := os.Stat(filepath.Join(root, LegacyDir)); !os.IsNotExist(err) { | |
| 48 | + t.Error("the old directory is still there after a move") | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + // Nothing to migrate is the common case and must not leave a directory behind: | |
| 53 | + // an empty .codey in every repo is litter. | |
| 54 | + func TestMigrateIsAQuietNoOpWithNothingToMove(t *testing.T) { | |
| 55 | + root := t.TempDir() | |
| 56 | + | |
| 57 | + moved, err := Migrate(root) | |
| 58 | + if err != nil || moved { | |
| 59 | + t.Fatalf("Migrate on a clean tree: moved=%v err=%v", moved, err) | |
| 60 | + } | |
| 61 | + if _, err := os.Stat(filepath.Join(root, Dir)); !os.IsNotExist(err) { | |
| 62 | + t.Error("Migrate created a state directory with nothing to put in it") | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + // Both present means a build of each has run here. The new one is the live | |
| 67 | + // state, so it is never overwritten by the old one. | |
| 68 | + func TestMigrateLeavesBothAloneWhenBothExist(t *testing.T) { | |
| 69 | + root := t.TempDir() | |
| 70 | + mkdirWith(t, root, LegacyDir, "events.jsonl", "old\n") | |
| 71 | + mkdirWith(t, root, Dir, "events.jsonl", "new\n") | |
| 72 | + | |
| 73 | + moved, err := Migrate(root) | |
| 74 | + if err != nil || moved { | |
| 75 | + t.Fatalf("Migrate with both present: moved=%v err=%v", moved, err) | |
| 76 | + } | |
| 77 | + if got := read(t, filepath.Join(root, Dir, "events.jsonl")); got != "new\n" { | |
| 78 | + t.Errorf("live state was overwritten by the old directory: %q", got) | |
| 79 | + } | |
| 80 | + if got := read(t, filepath.Join(root, LegacyDir, "events.jsonl")); got != "old\n" { | |
| 81 | + t.Errorf("the old directory was disturbed: %q", got) | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + // A repo that happens to have a file of that name is not a state directory, | |
| 86 | + // and renaming it would be taking something that is not ours. | |
| 87 | + func TestMigrateIgnoresALegacyFile(t *testing.T) { | |
| 88 | + root := t.TempDir() | |
| 89 | + if err := os.WriteFile(filepath.Join(root, LegacyDir), []byte("not a directory"), 0o644); err != nil { | |
| 90 | + t.Fatal(err) | |
| 91 | + } | |
| 92 | + | |
| 93 | + moved, err := Migrate(root) | |
| 94 | + if err != nil || moved { | |
| 95 | + t.Fatalf("Migrate over a file: moved=%v err=%v", moved, err) | |
| 96 | + } | |
| 97 | + if got := read(t, filepath.Join(root, LegacyDir)); got != "not a directory" { | |
| 98 | + t.Errorf("the file was touched: %q", got) | |
| 99 | + } | |
| 100 | + } | |
| @@ -7,10 +7,10 @@ import ( | ||
| 7 | 7 | "testing" |
| 8 | 8 | "time" |
| 9 | 9 | |
| 10 | - "harness/internal/agent" | |
| 11 | - "harness/internal/approval" | |
| 12 | - "harness/internal/metrics" | |
| 13 | - "harness/internal/tools" | |
| 10 | + "codey/internal/agent" | |
| 11 | + "codey/internal/approval" | |
| 12 | + "codey/internal/metrics" | |
| 13 | + "codey/internal/tools" | |
| 14 | 14 | ) |
| 15 | 15 | |
| 16 | 16 | // Parallel agents are parallel spend, so only so many run at once and the rest |
| @@ -20,11 +20,11 @@ import ( | ||
| 20 | 20 | "sync" |
| 21 | 21 | "time" |
| 22 | 22 | |
| 23 | - "harness/internal/agent" | |
| 24 | - "harness/internal/approval" | |
| 25 | - "harness/internal/llm" | |
| 26 | - "harness/internal/metrics" | |
| 27 | - "harness/internal/tools" | |
| 23 | + "codey/internal/agent" | |
| 24 | + "codey/internal/approval" | |
| 25 | + "codey/internal/llm" | |
| 26 | + "codey/internal/metrics" | |
| 27 | + "codey/internal/tools" | |
| 28 | 28 | ) |
| 29 | 29 | |
| 30 | 30 | // Status is where one subagent is in its life. |
| @@ -649,7 +649,7 @@ func (s *Supervisor) ClaimReport(id string) bool { | ||
| 649 | 649 | } |
| 650 | 650 | |
| 651 | 651 | // Shutdown kills every subagent and waits for them to unwind. It is what |
| 652 | - // leaving the harness does: an agent outliving the session it belongs to would | |
| 652 | + // leaving Codey does: an agent outliving the session it belongs to would | |
| 653 | 653 | // keep editing a tree nobody is watching. |
| 654 | 654 | func (s *Supervisor) Shutdown() { |
| 655 | 655 | s.mu.Lock() |
| @@ -13,10 +13,10 @@ import ( | ||
| 13 | 13 | "testing" |
| 14 | 14 | "time" |
| 15 | 15 | |
| 16 | - "harness/internal/agent" | |
| 17 | - "harness/internal/llm" | |
| 18 | - "harness/internal/metrics" | |
| 19 | - "harness/internal/tools" | |
| 16 | + "codey/internal/agent" | |
| 17 | + "codey/internal/llm" | |
| 18 | + "codey/internal/metrics" | |
| 19 | + "codey/internal/tools" | |
| 20 | 20 | ) |
| 21 | 21 | |
| 22 | 22 | // settle is how long a test waits before concluding nothing more is coming. |
| @@ -5,7 +5,7 @@ import ( | ||
| 5 | 5 | "fmt" |
| 6 | 6 | "strings" |
| 7 | 7 | |
| 8 | - "harness/internal/tools" | |
| 8 | + "codey/internal/tools" | |
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | 11 | // spawnArgs is what the model sends to start an agent. |
| @@ -8,8 +8,8 @@ import ( | ||
| 8 | 8 | "testing" |
| 9 | 9 | "time" |
| 10 | 10 | |
| 11 | - "harness/internal/agent" | |
| 12 | - "harness/internal/tools" | |
| 11 | + "codey/internal/agent" | |
| 12 | + "codey/internal/tools" | |
| 13 | 13 | ) |
| 14 | 14 | |
| 15 | 15 | func toolNamed(t *testing.T, s *Supervisor, name string) tools.Tool { |
| @@ -10,6 +10,8 @@ import ( | ||
| 10 | 10 | "strings" |
| 11 | 11 | |
| 12 | 12 | "github.com/bmatcuk/doublestar/v4" |
| 13 | + | |
| 14 | + "codey/internal/statedir" | |
| 13 | 15 | ) |
| 14 | 16 | |
| 15 | 17 | // maxResults caps how many paths or matches a search tool returns. The count of |
| @@ -23,7 +25,8 @@ var skipDirs = map[string]bool{ | ||
| 23 | 25 | ".git": true, ".hg": true, ".svn": true, |
| 24 | 26 | "node_modules": true, "vendor": true, "target": true, |
| 25 | 27 | ".venv": true, "venv": true, "__pycache__": true, |
| 26 | - ".harness": true, ".next": true, ".cache": true, | |
| 28 | + statedir.Dir: true, statedir.LegacyDir: true, | |
| 29 | + ".next": true, ".cache": true, | |
| 27 | 30 | } |
| 28 | 31 | |
| 29 | 32 | // GlobArgs is the glob input. |
| @@ -199,9 +199,9 @@ func jailWith(root string, extra []string, path string) (string, error) { | ||
| 199 | 199 | } |
| 200 | 200 | } |
| 201 | 201 | if len(extra) > 0 { |
| 202 | - return "", fmt.Errorf("%s is outside the working root %s and every skill directory; this harness only touches files under the root", path, realRoot) | |
| 202 | + return "", fmt.Errorf("%s is outside the working root %s and every skill directory; codey only touches files under the root", path, realRoot) | |
| 203 | 203 | } |
| 204 | - return "", fmt.Errorf("%s is outside the working root %s; this harness only touches files under the root", path, realRoot) | |
| 204 | + return "", fmt.Errorf("%s is outside the working root %s; codey only touches files under the root", path, realRoot) | |
| 205 | 205 | } |
| 206 | 206 | |
| 207 | 207 | // realpath resolves symlinks, falling back to a lexical clean for a path that |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | // Package tools defines the tool interface the agent loop dispatches against. |
| 2 | 2 | // |
| 3 | 3 | // Tools return (string, error). The error is text for the model to read, not an |
| 4 | - // exception for the harness to swallow: a failed edit is a normal event in the | |
| 4 | + // exception for Codey to swallow: a failed edit is a normal event in the | |
| 5 | 5 | // transcript. |
| 6 | 6 | package tools |
| 7 | 7 | |
| @@ -13,7 +13,7 @@ import ( | ||
| 13 | 13 | "sync" |
| 14 | 14 | "time" |
| 15 | 15 | |
| 16 | - "harness/internal/metrics" | |
| 16 | + "codey/internal/metrics" | |
| 17 | 17 | |
| 18 | 18 | "github.com/invopop/jsonschema" |
| 19 | 19 | ) |
| @@ -8,7 +8,7 @@ import ( | ||
| 8 | 8 | "strings" |
| 9 | 9 | "testing" |
| 10 | 10 | |
| 11 | - "harness/internal/metrics" | |
| 11 | + "codey/internal/metrics" | |
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | 14 | type echoArgs struct { |
| @@ -15,11 +15,11 @@ import ( | ||
| 15 | 15 | func searchRepo(t *testing.T) string { |
| 16 | 16 | t.Helper() |
| 17 | 17 | dir := t.TempDir() |
| 18 | - writeFile(t, dir, "go.mod", "module harness\n") | |
| 19 | - writeFile(t, dir, "cmd/harness/main.go", "package main\n\nfunc main() { run() }\n") | |
| 18 | + writeFile(t, dir, "go.mod", "module codey\n") | |
| 19 | + writeFile(t, dir, "cmd/codey/main.go", "package main\n\nfunc main() { run() }\n") | |
| 20 | 20 | writeFile(t, dir, "internal/agent/loop.go", "package agent\n\nfunc Run() {}\n") |
| 21 | 21 | writeFile(t, dir, "internal/agent/loop_test.go", "package agent\n\nfunc TestRun(t *testing.T) {}\n") |
| 22 | - writeFile(t, dir, "README.md", "# harness\n\nRun it.\n") | |
| 22 | + writeFile(t, dir, "README.md", "# codey\n\nRun it.\n") | |
| 23 | 23 | writeFile(t, dir, "node_modules/junk/index.js", "function run() {}\n") |
| 24 | 24 | writeFile(t, dir, ".git/config", "[core]\n\trun = true\n") |
| 25 | 25 | if err := os.WriteFile(filepath.Join(dir, "bin.dat"), []byte{0x00, 0x01, 'r', 'u', 'n'}, 0o644); err != nil { |
| @@ -54,19 +54,19 @@ func TestGlob(t *testing.T) { | ||
| 54 | 54 | { |
| 55 | 55 | name: "bare pattern matches at any depth", |
| 56 | 56 | args: GlobArgs{Pattern: "*.go"}, |
| 57 | - want: []string{"cmd/harness/main.go", "internal/agent/loop.go", "internal/agent/loop_test.go"}, | |
| 57 | + want: []string{"cmd/codey/main.go", "internal/agent/loop.go", "internal/agent/loop_test.go"}, | |
| 58 | 58 | unwanted: []string{"go.mod", "README.md"}, |
| 59 | 59 | }, |
| 60 | 60 | { |
| 61 | 61 | name: "pattern with a slash is anchored to the subtree", |
| 62 | 62 | args: GlobArgs{Pattern: "internal/**/*_test.go"}, |
| 63 | 63 | want: []string{"internal/agent/loop_test.go"}, |
| 64 | - unwanted: []string{"cmd/harness/main.go"}, | |
| 64 | + unwanted: []string{"cmd/codey/main.go"}, | |
| 65 | 65 | }, |
| 66 | 66 | { |
| 67 | 67 | name: "path narrows the search", |
| 68 | 68 | args: GlobArgs{Pattern: "*.go", Path: "cmd"}, |
| 69 | - want: []string{"cmd/harness/main.go"}, | |
| 69 | + want: []string{"cmd/codey/main.go"}, | |
| 70 | 70 | unwanted: []string{"internal/agent/loop.go"}, |
| 71 | 71 | }, |
| 72 | 72 | { |
| @@ -171,7 +171,7 @@ func TestGrep(t *testing.T) { | ||
| 171 | 171 | { |
| 172 | 172 | name: "path narrows the search", |
| 173 | 173 | args: GrepArgs{Pattern: "package", Path: "cmd"}, |
| 174 | - want: []string{"cmd/harness/main.go"}, | |
| 174 | + want: []string{"cmd/codey/main.go"}, | |
| 175 | 175 | }, |
| 176 | 176 | } |
| 177 | 177 | // Both engines must produce identical output, or eval numbers would depend |
| @@ -7,7 +7,7 @@ import ( | ||
| 7 | 7 | "sort" |
| 8 | 8 | "strings" |
| 9 | 9 | |
| 10 | - "harness/internal/skills" | |
| 10 | + "codey/internal/skills" | |
| 11 | 11 | ) |
| 12 | 12 | |
| 13 | 13 | // SkillArgs is the skill input. The name is constrained to the discovered |
| @@ -5,7 +5,7 @@ import ( | ||
| 5 | 5 | "strings" |
| 6 | 6 | "testing" |
| 7 | 7 | |
| 8 | - "harness/internal/skills" | |
| 8 | + "codey/internal/skills" | |
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | 11 | func testSkills() []skills.Skill { |
| @@ -15,12 +15,12 @@ import ( | ||
| 15 | 15 | "github.com/charmbracelet/bubbles/spinner" |
| 16 | 16 | tea "github.com/charmbracelet/bubbletea" |
| 17 | 17 | |
| 18 | - "harness/internal/agent" | |
| 19 | - "harness/internal/approval" | |
| 20 | - "harness/internal/llm" | |
| 21 | - "harness/internal/metrics" | |
| 22 | - "harness/internal/subagent" | |
| 23 | - "harness/internal/tools" | |
| 18 | + "codey/internal/agent" | |
| 19 | + "codey/internal/approval" | |
| 20 | + "codey/internal/llm" | |
| 21 | + "codey/internal/metrics" | |
| 22 | + "codey/internal/subagent" | |
| 23 | + "codey/internal/tools" | |
| 24 | 24 | ) |
| 25 | 25 | |
| 26 | 26 | // scriptedAPI answers the main agent and its children differently, so one |
| @@ -181,16 +181,16 @@ func TestIgniteFinishesEveryLetter(t *testing.T) { | ||
| 181 | 181 | |
| 182 | 182 | // At t=1 every letter must be painted the target colour exactly: the same |
| 183 | 183 | // sequence lipgloss emits for a plain render in it. |
| 184 | - lit := ignite("harness", colFaint, colCoral, 1, false) | |
| 184 | + lit := ignite("codey", colFaint, colCoral, 1, false) | |
| 185 | 185 | want := termenv.TrueColor.Color(colCoral.Dark).Sequence(false) |
| 186 | - if n := strings.Count(lit, want); n != len("harness") { | |
| 187 | - t.Errorf("%d of %d letters reached the target colour at t=1", n, len("harness")) | |
| 186 | + if n := strings.Count(lit, want); n != len("codey") { | |
| 187 | + t.Errorf("%d of %d letters reached the target colour at t=1", n, len("codey")) | |
| 188 | 188 | } |
| 189 | 189 | |
| 190 | - if got := stripANSI(lit); got != "harness" { | |
| 190 | + if got := stripANSI(lit); got != "codey" { | |
| 191 | 191 | t.Errorf("ignite changed the text to %q", got) |
| 192 | 192 | } |
| 193 | - if got := stripANSI(ignite("harness", colFaint, colCoral, 0, false)); got != "harness" { | |
| 193 | + if got := stripANSI(ignite("codey", colFaint, colCoral, 0, false)); got != "codey" { | |
| 194 | 194 | t.Errorf("at t=0 the text is %q, want it present but unlit", got) |
| 195 | 195 | } |
| 196 | 196 | } |
| @@ -5,10 +5,10 @@ import ( | ||
| 5 | 5 | |
| 6 | 6 | tea "github.com/charmbracelet/bubbletea" |
| 7 | 7 | |
| 8 | - "harness/internal/agent" | |
| 9 | - "harness/internal/approval" | |
| 10 | - "harness/internal/llm" | |
| 11 | - "harness/internal/subagent" | |
| 8 | + "codey/internal/agent" | |
| 9 | + "codey/internal/approval" | |
| 10 | + "codey/internal/llm" | |
| 11 | + "codey/internal/subagent" | |
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | 14 | // The agent loop runs on its own goroutine and must not know a UI exists. It |
| @@ -38,6 +38,7 @@ type ( | ||
| 38 | 38 | } |
| 39 | 39 | evUsage struct{ usage llm.Usage } |
| 40 | 40 | evCompact struct{} |
| 41 | + evNotice struct{ text string } | |
| 41 | 42 | |
| 42 | 43 | // evSub is one subagent's event, tagged with the agent it came from. |
| 43 | 44 | evSub struct{ ev subagent.Event } |
| @@ -85,6 +86,8 @@ func (b *bridge) Observe(ev agent.Event) { | ||
| 85 | 86 | b.p.Send(evUsage{usage: ev.Usage}) |
| 86 | 87 | case agent.EventCompact: |
| 87 | 88 | b.p.Send(evCompact{}) |
| 89 | + case agent.EventNotice: | |
| 90 | + b.p.Send(evNotice{text: ev.Text}) | |
| 88 | 91 | } |
| 89 | 92 | } |
| 90 | 93 | |
| @@ -9,8 +9,8 @@ import ( | ||
| 9 | 9 | |
| 10 | 10 | tea "github.com/charmbracelet/bubbletea" |
| 11 | 11 | |
| 12 | - "harness/internal/agent" | |
| 13 | - "harness/internal/approval" | |
| 12 | + "codey/internal/agent" | |
| 13 | + "codey/internal/approval" | |
| 14 | 14 | ) |
| 15 | 15 | |
| 16 | 16 | // recorder stands in for tea.Program: it captures what the bridge sends |
| @@ -0,0 +1,76 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "os" | |
| 5 | + "strconv" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "github.com/atotto/clipboard" | |
| 9 | + tea "github.com/charmbracelet/bubbletea" | |
| 10 | + "github.com/charmbracelet/x/ansi" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + // Copying out of a full-screen terminal program is done twice, because | |
| 14 | + // neither way works everywhere. | |
| 15 | + // | |
| 16 | + // OSC 52 asks the terminal itself to take the text, which is the only way | |
| 17 | + // that works over ssh, in a container, or anywhere else the process is not | |
| 18 | + // sitting next to the desktop it is displayed on. Several terminals ship with | |
| 19 | + // it turned off, though, so the desktop's own clipboard is written too, with | |
| 20 | + // the same bytes — whichever of the two lands, the text is the text. | |
| 21 | + // | |
| 22 | + // The OSC 52 sequence is written straight to the terminal rather than through | |
| 23 | + // a frame. It carries no cursor movement and ends itself, so landing between | |
| 24 | + // two frames costs nothing on screen. | |
| 25 | + | |
| 26 | + // copyMax caps what is sent. Terminals have their own limit on an OSC 52 | |
| 27 | + // payload — commonly around 100k after base64 — and a selection large enough | |
| 28 | + // to hit it is a mis-drag rather than something a person means to paste. | |
| 29 | + const copyMax = 64 * 1024 | |
| 30 | + | |
| 31 | + // copiedMsg reports a finished copy. The text comes back with it so the | |
| 32 | + // chrome can say what was taken without reaching for the selection again, | |
| 33 | + // which by then may already be gone. | |
| 34 | + type copiedMsg struct { | |
| 35 | + text string | |
| 36 | + err error | |
| 37 | + } | |
| 38 | + | |
| 39 | + func copyToClipboard(text string) error { | |
| 40 | + if text == "" { | |
| 41 | + return nil | |
| 42 | + } | |
| 43 | + if len(text) > copyMax { | |
| 44 | + text = text[:copyMax] | |
| 45 | + } | |
| 46 | + // The desktop clipboard is best effort: no X display, no xclip, no | |
| 47 | + // pbcopy — all of them are ordinary on a remote machine, and none of them | |
| 48 | + // means the copy failed, because OSC 52 is still on its way. | |
| 49 | + _ = clipboard.WriteAll(text) | |
| 50 | + | |
| 51 | + _, err := os.Stdout.WriteString(ansi.SetSystemClipboard(text)) | |
| 52 | + return err | |
| 53 | + } | |
| 54 | + | |
| 55 | + // finishCopy is what the chrome says about it. A failure is worth a line in | |
| 56 | + // the transcript — the text is not on the clipboard and the person is about | |
| 57 | + // to try to paste it — but a success is not: it belongs on the status line, | |
| 58 | + // where it costs no scrollback. | |
| 59 | + func (m *model) finishCopy(msg copiedMsg) tea.Cmd { | |
| 60 | + if msg.err != nil { | |
| 61 | + m.tr.addError("could not copy: " + msg.err.Error()) | |
| 62 | + return m.sync() | |
| 63 | + } | |
| 64 | + m.copied = "✂ " + copyNote(msg.text) | |
| 65 | + return nil | |
| 66 | + } | |
| 67 | + | |
| 68 | + // copyNote says what was taken. One line is quoted back, because seeing the | |
| 69 | + // words is the fastest way to know the right thing was caught; more than one | |
| 70 | + // is counted, because a quote would not fit. | |
| 71 | + func copyNote(text string) string { | |
| 72 | + if n := strings.Count(text, "\n"); n > 0 { | |
| 73 | + return "copied " + plural(n+1, "line") | |
| 74 | + } | |
| 75 | + return "copied " + strconv.Quote(truncate(text, 28)) | |
| 76 | + } | |
| @@ -8,7 +8,7 @@ import ( | ||
| 8 | 8 | "github.com/charmbracelet/lipgloss" |
| 9 | 9 | "github.com/muesli/termenv" |
| 10 | 10 | |
| 11 | - "harness/internal/llm" | |
| 11 | + "codey/internal/llm" | |
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | 14 | // forceColour makes styles emit real escape sequences. Without it the test |
| @@ -22,8 +22,13 @@ func forceColour(t *testing.T) { | ||
| 22 | 22 | } |
| 23 | 23 | |
| 24 | 24 | // unbackedCells returns the printable characters that were emitted with no |
| 25 | - // background colour in force. | |
| 26 | - func unbackedCells(s string) string { | |
| 25 | + // background colour in force, and backedCells the ones that had one. Between | |
| 26 | + // them they say where a background actually landed: which cells a pill filled, | |
| 27 | + // and which cells a mouse selection marked. | |
| 28 | + func unbackedCells(s string) string { return cellsWithBackground(s, false) } | |
| 29 | + func backedCells(s string) string { return cellsWithBackground(s, true) } | |
| 30 | + | |
| 31 | + func cellsWithBackground(s string, want bool) string { | |
| 27 | 32 | var out []rune |
| 28 | 33 | active := "" |
| 29 | 34 | for i := 0; i < len(s); { |
| @@ -44,7 +49,7 @@ func unbackedCells(s string) string { | ||
| 44 | 49 | continue |
| 45 | 50 | } |
| 46 | 51 | r, size := utf8.DecodeRuneInString(s[i:]) |
| 47 | - if !strings.Contains(active, "48;") { | |
| 52 | + if strings.Contains(active, "48;") == want { | |
| 48 | 53 | out = append(out, r) |
| 49 | 54 | } |
| 50 | 55 | i += size |
| @@ -25,11 +25,13 @@ var builtins = []command{ | ||
| 25 | 25 | {name: "skills", desc: "the instruction sets found at startup"}, |
| 26 | 26 | {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"}, |
| 27 | 27 | {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"}, |
| 28 | + {name: "settings", desc: "change the saved defaults, a row at a time"}, | |
| 28 | 29 | {name: "setup", desc: "the full-screen wizard for the saved defaults"}, |
| 29 | - {name: "mouse", desc: "turn mouse reporting off, for selecting text with the terminal"}, | |
| 30 | + {name: "compact", desc: "fold the conversation into a summary now"}, | |
| 30 | 31 | {name: "clear", desc: "empty the transcript"}, |
| 32 | + {name: "mouse", desc: "hand the mouse back to the terminal, or take it again"}, | |
| 31 | 33 | {name: "allowed", desc: "what is permanently allowed this session"}, |
| 32 | - {name: "exit", desc: "leave the harness"}, | |
| 34 | + {name: "exit", desc: "leave Codey"}, | |
| 33 | 35 | } |
| 34 | 36 | |
| 35 | 37 | // commands is the catalogue: the built-ins, then every discovered skill. |
| @@ -79,7 +81,12 @@ func (m *model) helpText() string { | ||
| 79 | 81 | if n := len(m.opts.Skills); n > 0 { |
| 80 | 82 | fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them") |
| 81 | 83 | } |
| 82 | - b.WriteString("\nkeys: enter send · ctrl+j newline · shift+tab cycle approval mode" + | |
| 84 | + b.WriteString("\nmouse: wheel scrolls · drag selects and copies on release" + | |
| 85 | + "\n double-click a word · triple-click a line · shift+click extends" + | |
| 86 | + "\n click a menu row, a model, the slider or an approval to choose it" + | |
| 87 | + "\n click the prompt to type while the agents sidebar is open" + | |
| 88 | + "\n /mouse hands the mouse back to the terminal for its own selection" + | |
| 89 | + "\nkeys: enter send · ctrl+j newline · shift+tab cycle approval mode" + | |
| 83 | 90 | "\n ctrl+b the agents sidebar — ↑↓ move · enter read one · p pause · k kill" + |
| 84 | 91 | "\n ! runs a shell command · $ runs python — both on an empty prompt" + |
| 85 | 92 | "\n what they print goes to the agent too, so you can then say \"fix that\"" + |
| @@ -0,0 +1,80 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "time" | |
| 7 | + | |
| 8 | + tea "github.com/charmbracelet/bubbletea" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + // compactDone carries a finished /compact back to the update loop. did is | |
| 12 | + // false when the agent found nothing worth folding, which is not an error and | |
| 13 | + // must not be reported as one. | |
| 14 | + type compactDone struct { | |
| 15 | + did bool | |
| 16 | + err error | |
| 17 | + } | |
| 18 | + | |
| 19 | + // startCompact answers /compact. It is a model call with the whole older half | |
| 20 | + // of the conversation in it, so it gets the spinner and the interrupt key that | |
| 21 | + // a turn gets — the difference is that no prompt goes with it and no turn | |
| 22 | + // counter moves. | |
| 23 | + func (m *model) startCompact() tea.Cmd { | |
| 24 | + ctx, cancel := context.WithCancel(m.parent) | |
| 25 | + m.cancel = cancel | |
| 26 | + m.compacting = true | |
| 27 | + m.started = time.Now() | |
| 28 | + m.closeMenu() | |
| 29 | + | |
| 30 | + a := m.opts.Agent | |
| 31 | + run := func() tea.Msg { | |
| 32 | + did, err := a.Compact(ctx) | |
| 33 | + cancel() | |
| 34 | + if did && err == nil { | |
| 35 | + // The folded history is what -resume should pick up; leaving the | |
| 36 | + // pre-compaction transcript on disk would undo the work on the | |
| 37 | + // next start. | |
| 38 | + if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model()); saveErr != nil { | |
| 39 | + err = fmt.Errorf("saving session: %w", saveErr) | |
| 40 | + } | |
| 41 | + } | |
| 42 | + return compactDone{did: did, err: err} | |
| 43 | + } | |
| 44 | + return tea.Batch(m.sync(), m.spin.Tick, run) | |
| 45 | + } | |
| 46 | + | |
| 47 | + // finishCompact lands the result. The agent has already announced a successful | |
| 48 | + // fold through EventCompact, so the only things left to say are the two cases | |
| 49 | + // that produce no such event. | |
| 50 | + func (m *model) finishCompact(msg compactDone) tea.Cmd { | |
| 51 | + m.compacting = false | |
| 52 | + m.interrupting = false | |
| 53 | + m.cancel = nil | |
| 54 | + | |
| 55 | + switch { | |
| 56 | + case msg.err != nil: | |
| 57 | + m.tr.addError("compaction failed: " + msg.err.Error()) | |
| 58 | + case !msg.did: | |
| 59 | + m.tr.addNotice("nothing to compact yet — the conversation is still short enough to keep whole") | |
| 60 | + default: | |
| 61 | + // The context pill reports what the model read last turn. After a | |
| 62 | + // manual compaction nothing re-measures it until the next turn, so | |
| 63 | + // leaving it would park the pre-compaction figure on screen for as | |
| 64 | + // long as the session stays idle. Hiding it says "unknown", which is | |
| 65 | + // the truth; automatic compaction needs no equivalent because the | |
| 66 | + // request that corrects it goes out immediately. | |
| 67 | + m.ctxTokens = 0 | |
| 68 | + } | |
| 69 | + return m.sync() | |
| 70 | + } | |
| 71 | + | |
| 72 | + // compactActivity is the line above the prompt while a compaction runs. | |
| 73 | + func (m *model) compactActivity() string { | |
| 74 | + detail := fmt.Sprintf("%ds · esc to interrupt", int(time.Since(m.started).Seconds())) | |
| 75 | + verb := "Compacting" | |
| 76 | + if m.interrupting { | |
| 77 | + verb = "Interrupting" | |
| 78 | + } | |
| 79 | + return m.spin.View() + " " + styCoral.Render(verb+"…") + styDim.Render(" ("+detail+")") | |
| 80 | + } | |
| @@ -0,0 +1,113 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "errors" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | + | |
| 8 | + "codey/internal/agent" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + // /compact rewrites the history the in-flight request is built from, so it has | |
| 12 | + // to wait for the turn to end — the same reasoning that makes /setup refuse. | |
| 13 | + func TestCompactRefusesMidTurn(t *testing.T) { | |
| 14 | + m := newTestModel(t) | |
| 15 | + m.phase = phaseRunning | |
| 16 | + m.submit("/compact") | |
| 17 | + | |
| 18 | + if m.compacting { | |
| 19 | + t.Error("/compact should not have started while a turn is running") | |
| 20 | + } | |
| 21 | + if out := m.tr.render(); !strings.Contains(out, "interrupt first") { | |
| 22 | + t.Errorf("expected a refusal naming the way out, got %q", out) | |
| 23 | + } | |
| 24 | + } | |
| 25 | + | |
| 26 | + func TestCompactStartsWhenIdle(t *testing.T) { | |
| 27 | + m := newTestModel(t) | |
| 28 | + m.submit("/compact") | |
| 29 | + | |
| 30 | + if !m.compacting { | |
| 31 | + t.Fatal("/compact should put the model into its compacting state") | |
| 32 | + } | |
| 33 | + if !strings.Contains(plain(m.activity()), "Compacting") { | |
| 34 | + t.Errorf("the activity line should say what is happening, got %q", plain(m.activity())) | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + // The context pill reports the last turn's input size. After a manual | |
| 39 | + // compaction no request follows to correct it, so leaving it alone would park | |
| 40 | + // a pre-compaction number on screen indefinitely. | |
| 41 | + func TestCompactClearsTheContextPill(t *testing.T) { | |
| 42 | + m := newTestModel(t) | |
| 43 | + m.compacting = true | |
| 44 | + m.ctxTokens = 970_000 | |
| 45 | + | |
| 46 | + m.Update(compactDone{did: true}) | |
| 47 | + | |
| 48 | + if m.compacting { | |
| 49 | + t.Error("compactDone should end the compacting state") | |
| 50 | + } | |
| 51 | + if m.ctxTokens != 0 { | |
| 52 | + t.Errorf("ctxTokens = %d, want 0: the pre-compaction figure is no longer true", m.ctxTokens) | |
| 53 | + } | |
| 54 | + if _, ok := m.contextPill(); ok { | |
| 55 | + t.Error("the pill should be hidden until the next turn measures the window again") | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + func TestCompactReportsNothingToFold(t *testing.T) { | |
| 60 | + m := newTestModel(t) | |
| 61 | + m.compacting = true | |
| 62 | + m.ctxTokens = 1234 | |
| 63 | + | |
| 64 | + m.Update(compactDone{did: false}) | |
| 65 | + | |
| 66 | + if out := m.tr.render(); !strings.Contains(out, "nothing to compact") { | |
| 67 | + t.Errorf("expected a notice that nothing was folded, got %q", out) | |
| 68 | + } | |
| 69 | + if m.ctxTokens != 1234 { | |
| 70 | + t.Error("nothing was folded, so the context pill still reports the truth") | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + func TestCompactReportsFailure(t *testing.T) { | |
| 75 | + m := newTestModel(t) | |
| 76 | + m.compacting = true | |
| 77 | + | |
| 78 | + m.Update(compactDone{err: errors.New("the endpoint said no")}) | |
| 79 | + | |
| 80 | + out := m.tr.render() | |
| 81 | + if !strings.Contains(out, "the endpoint said no") { | |
| 82 | + t.Errorf("a failed compaction should say why, got %q", out) | |
| 83 | + } | |
| 84 | + if m.compacting { | |
| 85 | + t.Error("a failed compaction still ends the compacting state") | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + // A notice from the agent loop — the window is full and nothing can be folded | |
| 90 | + // — has to reach the transcript, or the next request fails with no explanation. | |
| 91 | + func TestAgentNoticeReachesTheTranscript(t *testing.T) { | |
| 92 | + m := newTestModel(t) | |
| 93 | + m.Update(evNotice{text: "the context window is 97% full"}) | |
| 94 | + | |
| 95 | + if out := m.tr.render(); !strings.Contains(out, "97% full") { | |
| 96 | + t.Errorf("expected the notice in the transcript, got %q", out) | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + // A turn stopped for want of room has two ways out, and neither is obvious | |
| 101 | + // from the agent's message on its own — the agent package does not know what a | |
| 102 | + // slash command is. | |
| 103 | + func TestContextFullErrorOffersTheWayOut(t *testing.T) { | |
| 104 | + m := newTestModel(t) | |
| 105 | + m.Update(turnDone{err: &agent.ContextFullError{Used: 970_000, Window: 1_000_000, Floor: 0.97}}) | |
| 106 | + | |
| 107 | + out := m.tr.render() | |
| 108 | + for _, want := range []string{"97% full", "/compact", "/clear"} { | |
| 109 | + if !strings.Contains(out, want) { | |
| 110 | + t.Errorf("the transcript should mention %q:\n%s", want, out) | |
| 111 | + } | |
| 112 | + } | |
| 113 | + } | |
| @@ -123,17 +123,7 @@ func (m *model) menuView() string { | ||
| 123 | 123 | if !m.menuOpen() { |
| 124 | 124 | return "" |
| 125 | 125 | } |
| 126 | - rows := m.menuRows() | |
| 127 | - shown := rows | |
| 128 | - if len(m.menu.items) > rows { | |
| 129 | - // The last row goes to the count of what did not fit. | |
| 130 | - shown = maxInt(1, rows-1) | |
| 131 | - } | |
| 132 | - start := 0 | |
| 133 | - if m.menu.idx >= shown { | |
| 134 | - start = m.menu.idx - shown + 1 | |
| 135 | - } | |
| 136 | - end := minInt(len(m.menu.items), start+shown) | |
| 126 | + start, end := m.menuWindow() | |
| 137 | 127 | |
| 138 | 128 | // One column for the names, so the descriptions line up. A long skill |
| 139 | 129 | // name is left to overflow its column rather than being truncated: the |
| @@ -166,6 +156,22 @@ func (m *model) menuView() string { | ||
| 166 | 156 | return strings.TrimRight(b.String(), "\n") |
| 167 | 157 | } |
| 168 | 158 | |
| 159 | + // menuWindow is the slice of the list that is on screen. The view draws it | |
| 160 | + // and a click is resolved against it, so the row under the pointer and the | |
| 161 | + // row that was drawn are the same row by construction. | |
| 162 | + func (m *model) menuWindow() (start, end int) { | |
| 163 | + rows := m.menuRows() | |
| 164 | + shown := rows | |
| 165 | + if len(m.menu.items) > rows { | |
| 166 | + // The last row goes to the count of what did not fit. | |
| 167 | + shown = maxInt(1, rows-1) | |
| 168 | + } | |
| 169 | + if m.menu.idx >= shown { | |
| 170 | + start = m.menu.idx - shown + 1 | |
| 171 | + } | |
| 172 | + return start, minInt(len(m.menu.items), start+shown) | |
| 173 | + } | |
| 174 | + | |
| 169 | 175 | // pad widens s to width cells, leaving anything already wider alone. |
| 170 | 176 | func pad(s string, width int) string { |
| 171 | 177 | if n := width - lipgloss.Width(s); n > 0 { |
| @@ -6,7 +6,7 @@ import ( | ||
| 6 | 6 | |
| 7 | 7 | tea "github.com/charmbracelet/bubbletea" |
| 8 | 8 | |
| 9 | - "harness/internal/skills" | |
| 9 | + "codey/internal/skills" | |
| 10 | 10 | ) |
| 11 | 11 | |
| 12 | 12 | // The menu belongs to a slash command being typed, and to nothing else: a |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.