Vectrum
Sign In

Merge master: subagents meet the mouse, the settings panel and the rename

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>
Jjonicommitted 2 parents 3d783c0 752dcdccommit7ada7cf

Showing 85 changed files with 5336 additions and 462 deletions.

Merge commit — showing changes against the first parent.

+6−1.gitignoreView File
@@ -1,3 +1,8 @@
1- /.harness/
1+ /.codey/
22 /evals/runs/
3+ /codey
4+ /.superpowers/
5+
6+ # Left behind by builds from before the rename.
7+ /.harness/
38 /harness
+1−1METRICS.mdView File
@@ -18,7 +18,7 @@ against a live model, not by anything written in advance.
1818 ## Reading the numbers
1919
2020 - **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
2222 averaging that away hides the thing worth fixing.
2323 - **Edit success** is `edit_file` calls that returned a diff, over all
2424 `edit_file` calls. `write_file` is deliberately a separate tool so a
+24−6PLAN.mdView File
@@ -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
22
33 Target: a harness that can be pointed at a real Go or TypeScript repo, told
44 "make the failing test pass," and actually do it — with numbers proving how
@@ -19,8 +19,8 @@ comes first.** (See METRICS.md.)
1919 ## Repo layout
2020
2121 ```
22- harness/
23- cmd/harness/main.go # flag parsing, wiring, REPL entry
22+ codey/
23+ cmd/codey/main.go # flag parsing, wiring, REPL entry
2424 internal/
2525 llm/
2626 client.go # HTTP + SSE against the Messages API
@@ -70,7 +70,7 @@ rather than hand-writing JSON Schema. Hand-written schemas drift from the
7070 structs and you won't notice until the model sends a field you don't parse.
7171
7272 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.
7474 Retrofitting instrumentation is how projects end up with no numbers.
7575
7676 ## Phase 1 — The minimal loop
@@ -147,7 +147,7 @@ code-search story; you do not need embeddings.
147147 New files only, or full rewrites the model explicitly chose. Keep it separate
148148 from edit so your metrics don't blend them.
149149
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
151151 git clone, or inside a container. There is no approval layer until Phase 6.
152152 Assume it will run `rm -rf` on something eventually.
153153
@@ -205,6 +205,24 @@ eval numbers didn't drop.
205205 touched and why, decisions made, things discovered the hard way ("the config
206206 is loaded from X, not Y"), and the last few turns verbatim. Do it at 70%, not
207207 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.
208226 - **Stale reads:** if a file was read 20 turns ago and edited since, its old
209227 contents in history are actively harmful. Simplest fix that works: replace
210228 superseded `read_file` results with a stub pointing at the current version.
@@ -231,7 +249,7 @@ correction without losing the session.
231249 - Show diffs before applying, with color.
232250 - Show running token and cost totals per session. Cheap to add, and it changes
233251 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`.
235253
236254 Keep `agent` free of terminal dependencies. The eval runner depends on that.
237255
+221−34README.mdView File
@@ -1,7 +1,7 @@
1- # harness
1+ # codey
22
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.
55
66 The code is the byproduct. The deliverable is [METRICS.md](METRICS.md).
77 [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).
99 ## Quick start
1010
1111 ```bash
12- go build -o harness ./cmd/harness
12+ go build -o codey ./cmd/codey
1313
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
1616 ```
1717
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
1919 borrows the OAuth credential from `~/.claude/.credentials.json` when nothing
2020 else supplied one. Otherwise, or to use a different account:
2121
@@ -27,7 +27,7 @@ A different endpoint — a gateway, a proxy, a local server speaking the Message
2727 API:
2828
2929 ```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 .
3131 # or ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN
3232 ```
3333
@@ -41,7 +41,7 @@ points.
4141 The Claude Code login is the last resort, tried only when no flag and no
4242 environment variable supplied a credential, and only against Anthropic's own
4343 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`
4545 skips it entirely. Note that the credential is issued to Claude Code; using it
4646 from another client is outside Anthropic's terms for it, which API keys and
4747 gateway tokens do not carry.
@@ -53,14 +53,54 @@ which case the replacement is written back — leaving the dead one there is wha
5353 would break your Claude Code login.
5454
5555 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.
5885
5986 ## The TUI
6087
6188 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:
64104
65105 ```
66106 ⏺ 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
87127 sequence of things that happened. Diffs are shown in full and in colour with a
88128 count of what changed; other output is elided after six lines.
89129
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+
90160 The status bar reports the context gauge — the last turn's input size against
91161 the window, which drops when the conversation is compacted — and separates
92162 fresh input tokens from cache reads, because the gap between the two is what
93163 [METRICS.md](METRICS.md) is about. The prompt box grows with what you type, up
94164 to six lines.
95165
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+
96175 | Key | Does |
97176 | --- | --- |
98177 | `enter` | send |
@@ -107,8 +186,37 @@ to six lines.
107186 | `esc` | interrupt the turn |
108187 | `ctrl+c` | interrupt the turn; again when idle to exit |
109188
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.
112220
113221 A slash at the start of an empty prompt opens the command menu, which filters
114222 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
214322 labels gets `◀ high ▶` and the same keys rather than a track with the words
215323 sliced in half.
216324
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).
219327
220328 `shift+tab` cycles between asking before every mutating call and accepting them
221329 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
265373 fast model for mechanical search, a strong one for work that has to be right —
266374 and `effort` picks how hard it thinks, `low` through `max`. Omit either and it
267375 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
269377 neither, and every spawn of that preset fails before its first turn.
270378
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
272380 the endpoint what it serves at startup — `GET /v1/models`, the same route the
273381 picker uses — and lists them newest first, with the rule that the newest is the
274382 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.
281389 `output_config.effort` is a per-model capability, not a universal parameter.
282390 Haiku 4.5 rejects a request carrying it, and `xhigh` did not exist before Opus
283391 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
285393 happens to a subagent the answer says so outright (`claude-haiku-4-5 takes no
286394 effort level, so "low" was not applied`) instead of letting the parent believe
287395 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
399507
400508 The first run on a terminal opens a full-screen wizard instead of the chat: the
401509 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.
402514
403515 ```
404- harness setup step 1 of 4
516+ codey setup step 1 of 4
405517 ◆━━━━━━━━━━━━━━━◇───────────────◇───────────────◇
406518
407519 Which model should the agent use?
@@ -435,7 +547,7 @@ The theme step previews by applying the palette for real — reading a swatch
435547 tells you nothing about what a transcript looks like in it — and it recolours
436548 the whole TUI, pickers and slider included. Cancelling puts the old one back.
437549
438- Answers land in `~/.config/harness/config.json` (`XDG_CONFIG_HOME` is
550+ Answers land in `~/.config/codey/config.json` (`XDG_CONFIG_HOME` is
439551 honoured):
440552
441553 ```json
@@ -444,15 +556,32 @@ honoured):
444556 "model": "claude-opus-5",
445557 "effort": "high",
446558 "approvals": "ask",
447- "theme": "ember"
559+ "theme": "ember",
560+ "auto_compact": "on",
561+ "setup": "done"
448562 }
449563 ```
450564
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+
451578 Nothing in that file is required, and none of it wins an argument with a flag:
452579 precedence is **flag, then config, then the built-in default**, so
453580 `-model claude-sonnet-5` is still a one-run override. `accept-all` saved as a
454581 default is honoured, but `-yolo` can only turn approvals off, never on — a file
455582 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.
456585 A value this build does not recognise is dropped rather than forwarded to the
457586 API, and effort is checked against `llm.EffortLevels` rather than a second copy
458587 of the same five strings.
@@ -466,9 +595,67 @@ model than it started. A config file that cannot be parsed is reported and then
466595 ignored, and is never overwritten until you finish the wizard.
467596
468597 `/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.
472659
473660 The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse to
474661 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.
557744
558745 Shell commands are parsed with `mvdan.cc/sh/syntax` so the prompt can tell you
559746 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
561748 not pretend otherwise: a command containing `$(...)`, backticks, or an
562749 interpreter with `-c` cannot be analysed at all, so it is marked opaque and
563750 "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
565752 specific, not to make the decision for you.
566753
567754 **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:
569756
570757 ```bash
571758 podman run --rm -it \
572759 -v "$PWD":/work:Z -w /work \
573760 -e ANTHROPIC_API_KEY \
574761 docker.io/library/golang:1.26 \
575- /work/harness -C /work -yolo
762+ /work/codey -C /work -yolo
576763 ```
577764
578765 **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`
580767 has it too. That is the line between "broke my repo" and "read my `.env` and
581768 made an HTTP request". If that matters for the tree you are pointing it at:
582769
583770 - Treat every secret reachable from inside the container as exposed.
584771 - 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
586773 network-less namespace. That is not implemented here.
587774
588775 `-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.
592779 ## Layout
593780
594781 ```
595- cmd/harness flag parsing, wiring, REPL
782+ cmd/codey flag parsing, wiring, REPL
596783 internal/llm Messages API: types, HTTP, SSE, pricing
597784 internal/agent turn loop, conversation state, compaction, sessions
598785 internal/tools the tool registry and the tools
599786 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
601788 internal/approval allow / deny / ask, with shell analysis
602789 internal/metrics JSONL event log
603790 internal/ui line-based terminal rendering
@@ -607,7 +794,7 @@ evals task suite and runner
607794
608795 Two rules hold the shape: `agent` never imports `ui` or `tui`, so the eval
609796 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.
611798 A failed edit is a normal event in the transcript.
612799
613800 The TUI obeys the first rule through two one-way hatches. Events reach it via
+1−1cmd/harness/auth_test.go → cmd/codey/auth_test.gorenamedView File
@@ -6,7 +6,7 @@ import (
66 "path/filepath"
77 "testing"
88
9- "harness/internal/llm"
9+ "codey/internal/llm"
1010 )
1111
1212 // fakeHome writes a credentials file with a token that is not expired.
+53−30cmd/harness/main.go → cmd/codey/main.gorenamedView File
@@ -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.
22 package main
33
44 import (
@@ -14,27 +14,28 @@ import (
1414 "sync/atomic"
1515 "time"
1616
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"
2829 )
2930
3031 // 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
3233 // can disagree with the source is worse than no stamp.
3334 const version = "v0.6.0"
3435
3536 func main() {
3637 if err := run(); err != nil {
37- fmt.Fprintln(os.Stderr, "harness:", err)
38+ fmt.Fprintln(os.Stderr, "codey:", err)
3839 os.Exit(1)
3940 }
4041 }
@@ -49,6 +50,8 @@ func run() error {
4950 maxTurns := flag.Int("max-turns", agent.DefaultMaxTurns, "turn cap per prompt")
5051 contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact")
5152 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")
5255 noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints")
5356 noStream := flag.Bool("no-stream", false, "wait for each complete response instead of streaming")
5457 logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root")
@@ -67,10 +70,16 @@ func run() error {
6770 return err
6871 }
6972
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+
7079 // 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
7281 // still starts, and the wizard can write a good one over it.
73- cfg, haveConfig, cfgErr := config.Load()
82+ cfg, _, cfgErr := config.Load()
7483
7584 // Held as a slice because the Claude Code fallback below may have to
7685 // rebuild the client with one more option, and replaying the same list is
@@ -128,6 +137,9 @@ func run() error {
128137 // unattended, so -yolo can turn it on but a config can only be the default
129138 // for a session where the flag said nothing.
130139 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()
131143
132144 session := time.Now().UTC().Format("20060102T150405Z")
133145 log, err := metrics.Open(filepath.Join(root, *logPath), session)
@@ -149,12 +161,18 @@ func run() error {
149161 banner = append(banner, msg)
150162 return
151163 }
152- fmt.Fprintln(os.Stderr, "harness: "+msg)
164+ fmt.Fprintln(os.Stderr, "codey: "+msg)
153165 }
154166
155167 if cfgErr != nil {
156168 notice("%v — using defaults; /setup will write a fresh one", cfgErr)
157169 }
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+ }
158176
159177 if !client.IsDefaultEndpoint() {
160178 // Worth saying out loud: the prompt and every file the agent reads go
@@ -176,8 +194,8 @@ func run() error {
176194 "error": err.Error(), "file": "~/" + claudecode.Filename,
177195 }})
178196 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.")
181199 }
182200 }
183201 }
@@ -229,13 +247,15 @@ func run() error {
229247 policy.YOLO = acceptAll
230248 if acceptAll && !useTUI {
231249 // 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.")
233251 }
234252 base := agent.Options{
235253 MaxTurns: *maxTurns,
236254 Effort: effortSetting,
237255 ContextWindow: *contextWindow,
238256 CompactAt: *compactAt,
257+ CompactFloor: *compactFloor,
258+ NoAutoCompact: autoCompactOff,
239259 NoCache: *noCache,
240260 Stream: !*noStream,
241261 Skills: found,
@@ -313,12 +333,15 @@ func run() error {
313333
314334 Banner: banner,
315335
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.
320343 Config: cfg,
321- FirstRun: !haveConfig && cfgErr == nil,
344+ FirstRun: !cfg.Done() && cfgErr == nil,
322345 // Applying the answers is the TUI's own job; persisting them is
323346 // the only part that belongs out here.
324347 SaveConfig: config.Save,
@@ -332,7 +355,7 @@ func run() error {
332355 return once(ctx, a, render, sessionDir, session, root, client, *prompt)
333356 }
334357
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",
336359 client.Model(), root, strings.Join(toolNames(reg), ", "))
337360
338361 for {
@@ -375,7 +398,7 @@ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir,
375398 render.Usage(usage, turns, client.Model())
376399
377400 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)
379402 }
380403 if errors.Is(runErr, context.Canceled) {
381404 // 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()) {
403426 cancel := turnCancel.Load()
404427
405428 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")
407430 os.Exit(130)
408431 }
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)")
410433 (*cancel)()
411434 }
412435 }()
@@ -459,7 +482,7 @@ func resolveClaudeCode(client *llm.Client, home string, disabled bool) (*claudec
459482 // is the only circumstance in which this process writes to Claude Code's
460483 // file, and not writing then is what would break its login.
461484 //
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
463486 // ten minutes for a model to answer, which is the wrong budget for a token
464487 // exchange running under a lock. NewSource supplies a short-timeout client.
465488 return claudecode.NewSource(creds, nil, home), note, nil
+1−1evals/report.goView File
@@ -41,7 +41,7 @@ func report(w io.Writer, results []Result) {
4141 tw.Flush()
4242
4343 // 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.
4545 var solved int
4646 for _, name := range order {
4747 if countPass(byTask[name]) == len(byTask[name]) {
+5−5evals/runner.goView File
@@ -19,11 +19,11 @@ import (
1919 "sync"
2020 "time"
2121
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"
2727 )
2828
2929 // Task is one directory under evals/tasks.
+16−4go.modView File
@@ -1,12 +1,15 @@
1- module harness
1+ module codey
22
33 go 1.26.8
44
55 require (
6+ github.com/alecthomas/chroma/v2 v2.20.0
7+ github.com/atotto/clipboard v0.1.4
68 github.com/bmatcuk/doublestar/v4 v4.10.0
79 github.com/charmbracelet/bubbles v1.0.0
810 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
1013 github.com/charmbracelet/x/ansi v0.11.6
1114 github.com/invopop/jsonschema v0.14.0
1215 github.com/muesli/termenv v0.16.0
@@ -15,27 +18,36 @@ require (
1518 )
1619
1720 require (
18- github.com/atotto/clipboard v0.1.4 // indirect
1921 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
22+ github.com/aymerick/douceur v0.2.0 // indirect
2023 github.com/bahlo/generic-list-go v0.2.0 // indirect
2124 github.com/buger/jsonparser v1.1.2 // indirect
2225 github.com/charmbracelet/colorprofile v0.4.1 // indirect
2326 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
27+ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
2428 github.com/charmbracelet/x/term v0.2.2 // indirect
2529 github.com/clipperhouse/displaywidth v0.9.0 // indirect
2630 github.com/clipperhouse/stringish v0.1.1 // indirect
2731 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
32+ github.com/dlclark/regexp2 v1.11.5 // indirect
2833 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
34+ github.com/gorilla/css v1.0.1 // indirect
2935 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3036 github.com/mattn/go-isatty v0.0.20 // indirect
3137 github.com/mattn/go-localereader v0.0.1 // indirect
3238 github.com/mattn/go-runewidth v0.0.19 // indirect
39+ github.com/microcosm-cc/bluemonday v1.0.27 // indirect
3340 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3441 github.com/muesli/cancelreader v0.2.2 // indirect
42+ github.com/muesli/reflow v0.3.0 // indirect
3543 github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
3644 github.com/rivo/uniseg v0.4.7 // indirect
3745 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
3848 go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
49+ golang.org/x/net v0.38.0 // indirect
3950 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
4153 )
+39−4go.sumView File
@@ -1,11 +1,19 @@
11 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
22 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=
39 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
410 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
511 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
612 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
713 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
814 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=
917 github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
1018 github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
1119 github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
@@ -18,12 +26,18 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
1826 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
1927 github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
2028 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=
2333 github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
2434 github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
2535 github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
2636 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=
2741 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
2842 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
2943 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
3448 github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
3549 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3650 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=
3753 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
3854 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
3955 github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
4056 github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
4157 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4258 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=
4363 github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
4464 github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
4565 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
5272 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
5373 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
5474 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=
5576 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
5677 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=
5780 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
5881 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
5982 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
6083 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=
6186 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
6287 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
6388 github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
6489 github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
6590 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
6691 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=
6794 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
6895 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
6996 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
7299 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
73100 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
74101 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=
75106 go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
76107 go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
77108 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
78109 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=
79112 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
80113 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
81114 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
82115 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=
85120 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
86121 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
87122 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+147−18internal/agent/compact.goView File
@@ -3,10 +3,11 @@ package agent
33 import (
44 "context"
55 "fmt"
6+ "strconv"
67 "strings"
78
8- "harness/internal/llm"
9- "harness/internal/metrics"
9+ "codey/internal/llm"
10+ "codey/internal/metrics"
1011 )
1112
1213 // metricsCompaction records a compaction so its effect on later turns is
@@ -26,14 +27,30 @@ const DefaultContextWindow = 1_000_000
2627 // DefaultCompactAt is the fraction of the window at which to summarize.
2728 // Compaction itself needs headroom — it is a model call with the history in it
2829 // — 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.
3032 const DefaultCompactAt = 0.7
3133
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+
3242 // keepRecentMessages is how much of the tail survives compaction verbatim. The
3343 // model is usually mid-thought, and a summary of the last two minutes of work
3444 // is worse than the work itself.
3545 const keepRecentMessages = 6
3646
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+
3754 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.
3855
3956 Preserve, in this order:
@@ -46,23 +63,126 @@ Preserve, in this order:
4663 Be specific. Names, paths and line numbers are worth more than prose. Omit
4764 pleasantries and anything already visible in the current state of the files.`
4865
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
5372 if window <= 0 {
5473 window = DefaultContextWindow
5574 }
56- at := a.opts.CompactAt
75+ at = a.opts.CompactAt
5776 if at <= 0 {
5877 at = DefaultCompactAt
5978 }
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) {
61176 return false, nil
62177 }
178+ return a.compact(ctx, a.lastInputTokens, keepUrgentMessages)
179+ }
63180
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) {
64184 history := a.state.History()
65- split := compactionSplit(history)
185+ split := compactionSplit(history, keep)
66186 if split < 0 {
67187 return false, nil // nothing safe to fold away yet
68188 }
@@ -81,20 +201,29 @@ func (a *Agent) maybeCompact(ctx context.Context, lastInputTokens int) (bool, er
81201
82202 a.state.Replace(append([]llm.Message{folded}, history[split:]...))
83203 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+
84212 a.emit(Event{Kind: EventCompact, Text: summary})
85213 return true, nil
86214 }
87215
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 {
94223 return -1
95224 }
96225 want := len(history) / 2
97- if max := len(history) - keepRecentMessages; want > max {
226+ if max := len(history) - keep; want > max {
98227 want = max
99228 }
100229 for i := want; i < len(history); i++ {
@@ -121,7 +250,7 @@ func (a *Agent) summarize(ctx context.Context, history []llm.Message) (string, e
121250 }
122251 // This is the one request in the agent that is built by hand rather than
123252 // 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
125254 // credential still requires the Claude Code identity string as the FIRST
126255 // system block. Skip it here and the API does not 401; it returns a 429
127256 // that (*APIError).Retryable treats as transient, so the request burns
+341−16internal/agent/compact_test.goView File
@@ -4,12 +4,13 @@ import (
44 "bytes"
55 "context"
66 "encoding/json"
7+ "errors"
78 "os"
89 "path/filepath"
910 "strings"
1011 "testing"
1112
12- "harness/internal/llm"
13+ "codey/internal/llm"
1314 )
1415
1516 func TestCacheBreakpoints(t *testing.T) {
@@ -70,36 +71,46 @@ func TestBreakpointsDoNotLeakIntoHistory(t *testing.T) {
7071 }
7172 }
7273
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
8282 }
83- return out
83+ out = append(out, llm.Message{Role: role, Content: []llm.ContentBlock{llm.TextBlock("x")}})
8484 }
85+ return out
86+ }
87+
88+ func TestCompactionSplitLandsOnAnAssistantMessage(t *testing.T) {
8589 tests := []struct {
8690 name string
8791 roles string
92+ keep int
8893 want int
8994 }{
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},
9297 // The tail can end up one message shorter than the target when the
9398 // 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},
95106 }
96107 for _, tc := range tests {
97108 t.Run(tc.name, func(t *testing.T) {
98- got := compactionSplit(mk(tc.roles))
109+ got := compactionSplit(mkHistory(tc.roles), tc.keep)
99110 if got != tc.want {
100111 t.Fatalf("compactionSplit = %d, want %d", got, tc.want)
101112 }
102- if got >= 0 && mk(tc.roles)[got].Role != llm.RoleAssistant {
113+ if got >= 0 && mkHistory(tc.roles)[got].Role != llm.RoleAssistant {
103114 t.Error("the kept tail must start with an assistant message, or its tool_results reference nothing")
104115 }
105116 })
@@ -293,3 +304,317 @@ func TestReadPathParsing(t *testing.T) {
293304 t.Errorf("readPath = %q, want empty on malformed input", got)
294305 }
295306 }
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+ }
+25−8internal/agent/loop.goView File
@@ -9,10 +9,10 @@ import (
99 "strings"
1010 "sync"
1111
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"
1616 )
1717
1818 // DefaultMaxTurns caps one Run. An agent that loops without converging burns
@@ -101,10 +101,17 @@ type Options struct {
101101 System string
102102 // Effort maps to output_config.effort. Empty means the API default (high).
103103 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.
106108 ContextWindow int
107109 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
108115 // NoCache disables prompt cache breakpoints, for measuring what they buy.
109116 NoCache bool
110117 // Stream uses the SSE endpoint so output appears as it is produced.
@@ -122,7 +129,7 @@ type Options struct {
122129 // to make, and the paragraph would be dead weight in its prompt.
123130 Models []llm.Model
124131 // 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
126133 // requires, and is empty for every other credential.
127134 SystemPrefix string
128135 }
@@ -137,6 +144,10 @@ const (
137144 EventToolResult EventKind = "tool_result"
138145 EventUsage EventKind = "usage"
139146 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"
140151 // EventTextDelta is emitted instead of EventText while streaming.
141152 EventTextDelta EventKind = "text_delta"
142153 // EventPaused is emitted when the loop stops at a turn boundary, and
@@ -289,6 +300,12 @@ func (a *Agent) emit(ev Event) {
289300 // Run sends one user message and drives the tool loop until the model stops
290301 // asking for tools. It returns the final assistant text.
291302 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+ }
292309 a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(prompt)}})
293310 return a.drive(ctx)
294311 }
@@ -429,7 +446,7 @@ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock
429446
430447 // authorizedExecute runs a tool once the approval policy allows it. A denial
431448 // 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.
433450 func (a *Agent) authorizedExecute(ctx context.Context, call llm.ContentBlock) (string, error) {
434451 if a.Approve != nil {
435452 if err := a.Approve.Approve(ctx, call.Name, call.Input); err != nil {
+8−8internal/agent/loop_test.goView File
@@ -15,10 +15,10 @@ import (
1515 "testing"
1616 "time"
1717
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"
2222 )
2323
2424 // fakeAPI serves a scripted sequence of responses and records every request
@@ -54,7 +54,7 @@ func newFakeAPI(t *testing.T, responses ...string) *fakeAPI {
5454 body, _ := io.ReadAll(r.Body)
5555 var req llm.Request
5656 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)
5858 }
5959 f.requests = append(f.requests, req)
6060 if bytes.Contains(body, []byte("Summarize the conversation above")) {
@@ -63,7 +63,7 @@ func newFakeAPI(t *testing.T, responses ...string) *fakeAPI {
6363 }
6464 i := len(f.requests) - 1 - f.compactions()
6565 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))
6767 w.WriteHeader(http.StatusInternalServerError)
6868 return
6969 }
@@ -478,7 +478,7 @@ func TestDeniedToolBecomesAToolError(t *testing.T) {
478478
479479 got, err := a.Run(context.Background(), "read it")
480480 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)
482482 }
483483 if got != "understood" {
484484 t.Errorf("answer = %q", got)
@@ -548,7 +548,7 @@ func TestSystemPrefixBecomesTheFirstBlock(t *testing.T) {
548548 t.Errorf("first block = %q, want the identity string", blocks[0].Text)
549549 }
550550 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)
552552 }
553553 }
554554
+3−3internal/agent/pause_test.goView File
@@ -14,9 +14,9 @@ import (
1414 "testing"
1515 "time"
1616
17- "harness/internal/llm"
18- "harness/internal/metrics"
19- "harness/internal/tools"
17+ "codey/internal/llm"
18+ "codey/internal/metrics"
19+ "codey/internal/tools"
2020 )
2121
2222 // settle is how long a test waits before concluding that nothing is going to
+3−2internal/agent/session.goView File
@@ -8,7 +8,8 @@ import (
88 "sort"
99 "time"
1010
11- "harness/internal/llm"
11+ "codey/internal/llm"
12+ "codey/internal/statedir"
1213 )
1314
1415 // Session is a conversation saved to disk so --resume can pick it up.
@@ -24,7 +25,7 @@ type Session struct {
2425 }
2526
2627 // SessionDir is where sessions live, relative to the working root.
27- const SessionDir = ".harness/sessions"
28+ const SessionDir = statedir.Dir + "/sessions"
2829
2930 // Save writes the session for id, overwriting any previous copy.
3031 func (a *Agent) Save(dir, id, root, model string) error {
+1−1internal/agent/session_test.goView File
@@ -9,7 +9,7 @@ import (
99 "testing"
1010 "time"
1111
12- "harness/internal/llm"
12+ "codey/internal/llm"
1313 )
1414
1515 func TestSaveAndResumeSession(t *testing.T) {
+2−2internal/agent/state.goView File
@@ -10,8 +10,8 @@ import (
1010 "sync"
1111 "time"
1212
13- "harness/internal/llm"
14- "harness/internal/tools"
13+ "codey/internal/llm"
14+ "codey/internal/tools"
1515 )
1616
1717 // ReadRecord lives in tools so the edit tool can query the tracker without
+1−1internal/claudecode/claudecode.goView File
@@ -1,5 +1,5 @@
11 // 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.
33 //
44 // This is Claude Code's private storage, not a documented interface: the
55 // schema can change without notice. Everything that knows its shape lives
+2−2internal/claudecode/refresh.goView File
@@ -24,7 +24,7 @@ const tokenURLEnv = "CLAUDECODE_TOKEN_URL"
2424
2525 const defaultTokenURL = "https://console.anthropic.com/v1/oauth/token"
2626
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
2828 // ten minutes, which is right for a model response and badly wrong here: the
2929 // refresh runs under a lock every other request is queued behind.
3030 const refreshTimeout = 30 * time.Second
@@ -310,7 +310,7 @@ func NewSource(c *Credentials, h *http.Client, home string) *Source {
310310 //
311311 // The lock means concurrent requests — streaming and buffered can be in flight
312312 // 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
314314 // client's ten-minute model-response budget.
315315 func (s *Source) Token(ctx context.Context) (string, error) {
316316 s.mu.Lock()
+75−8internal/config/config.goView File
@@ -2,7 +2,7 @@
22 //
33 // It holds defaults, never facts: every field is something a flag or an
44 // 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
66 // run" rather than "broken install".
77 package config
88
@@ -14,12 +14,19 @@ import (
1414 "os"
1515 "path/filepath"
1616
17- "harness/internal/llm"
17+ "codey/internal/llm"
1818 )
1919
20- // Filename is the config's name inside the harness config directory.
20+ // Filename is the config's name inside Codey's config directory.
2121 const Filename = "config.json"
2222
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+
2330 // Version is stamped on every write. It exists so a future change of shape
2431 // has something to branch on rather than guessing from which keys are present.
2532 const Version = 1
@@ -32,6 +39,19 @@ const (
3239 ApprovalsAcceptAll = "accept-all"
3340 )
3441
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+
3555 // Themes are the TUI palettes, in the order the wizard offers them. The names
3656 // live here rather than in the tui package so that reading a config never
3757 // depends on a terminal front end being linked in — the eval runner has none.
@@ -51,16 +71,42 @@ type Config struct {
5171 Approvals string `json:"approvals,omitempty"`
5272 // Theme is one of Themes.
5373 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"`
5486 }
5587
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) {
59105 dir, err := os.UserConfigDir()
60106 if err != nil {
61107 return "", fmt.Errorf("config: no config directory: %w", err)
62108 }
63- return filepath.Join(dir, "harness", Filename), nil
109+ return filepath.Join(dir, name, Filename), nil
64110 }
65111
66112 // Load reads the config. The boolean reports whether a usable one was found;
@@ -71,12 +117,25 @@ func Path() (string, error) {
71117 // found=false. The caller is expected to carry on with defaults and say so:
72118 // what it must not do is treat the file as absent, because the next Save would
73119 // 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.
74125 func Load() (Config, bool, error) {
75126 path, err := Path()
76127 if err != nil {
77128 return Config{}, false, err
78129 }
79130 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+ }
80139 if errors.Is(err, fs.ErrNotExist) {
81140 return Config{}, false, nil
82141 }
@@ -92,7 +151,7 @@ func Load() (Config, bool, error) {
92151 }
93152
94153 // 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
96155 // field rather than be forwarded to the API, which would reject the request
97156 // with an error pointing at nothing the user can see.
98157 func sanitize(c Config) Config {
@@ -107,6 +166,14 @@ func sanitize(c Config) Config {
107166 if !ValidTheme(c.Theme) {
108167 c.Theme = ""
109168 }
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+ }
110177 return c
111178 }
112179
+181−3internal/config/config_test.goView File
@@ -1,7 +1,7 @@
11 package config
22
33 import (
4- "harness/internal/llm"
4+ "codey/internal/llm"
55 "os"
66 "path/filepath"
77 "strings"
@@ -9,12 +9,99 @@ import (
99 )
1010
1111 // 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.
1313 func isolate(t *testing.T) string {
1414 t.Helper()
1515 dir := t.TempDir()
1616 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+ }
18105 }
19106
20107 func TestLoadReportsMissingWithoutError(t *testing.T) {
@@ -152,3 +239,94 @@ func TestFlagsOverrideTheConfig(t *testing.T) {
152239 t.Errorf("two empties produced %q", got)
153240 }
154241 }
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+ }
+2−2internal/llm/pricing.goView File
@@ -17,7 +17,7 @@ const (
1717 cacheWriteMultiplier = 1.25
1818 )
1919
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
2121 // that is not listed reports no cost rather than a wrong one.
2222 var prices = map[string]Price{
2323 "claude-opus-5": {InputPerMTok: 5, OutputPerMTok: 25},
@@ -42,7 +42,7 @@ func (u Usage) Cost(model string) (float64, bool) {
4242 float64(u.CacheCreationInputTokens)*p.InputPerMTok*cacheWriteMultiplier) / 1_000_000, true
4343 }
4444
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.
4646 // The picker shows what it knows and says nothing about what it does not.
4747 func PriceOf(model string) (Price, bool) {
4848 p, ok := prices[basePrice(model)]
+1−1internal/llm/types.goView File
@@ -37,7 +37,7 @@ type Message struct {
3737 Content []ContentBlock `json:"content"`
3838 }
3939
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.
4141 // Only the fields belonging to Type are serialized, so blocks survive the
4242 // round-trip back to the API — which matters most for thinking blocks, whose
4343 // signature must be echoed back unchanged.
+4−2internal/metrics/log.goView File
@@ -12,10 +12,12 @@ import (
1212 "path/filepath"
1313 "sync"
1414 "time"
15+
16+ "codey/internal/statedir"
1517 )
1618
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"
1921
2022 // Event is one line of the log. Extra is flattened into the same JSON object so
2123 // the file stays greppable with jq without nested lookups.
+1−1internal/metrics/log_test.goView File
@@ -108,7 +108,7 @@ func TestNilLoggerIsANoOp(t *testing.T) {
108108 }
109109
110110 func TestOpenCreatesParentDirsAndAppends(t *testing.T) {
111- path := filepath.Join(t.TempDir(), ".harness", "events.jsonl")
111+ path := filepath.Join(t.TempDir(), ".codey", "events.jsonl")
112112 for i := 0; i < 2; i++ {
113113 l, err := Open(path, "s")
114114 if err != nil {
+1−1internal/skills/skills.goView File
@@ -37,7 +37,7 @@ type Skill struct {
3737 Source string // "personal" or "project"
3838 }
3939
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.
4141 // Unknown keys are ignored rather than rejected, so a skill written for
4242 // another harness still loads here.
4343 type frontmatter struct {
+49−0internal/statedir/dir.goaddedView File
@@ -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+ }
+100−0internal/statedir/dir_test.goaddedView File
@@ -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+ }
+4−4internal/subagent/control_test.goView File
@@ -7,10 +7,10 @@ import (
77 "testing"
88 "time"
99
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"
1414 )
1515
1616 // Parallel agents are parallel spend, so only so many run at once and the rest
+6−6internal/subagent/supervisor.goView File
@@ -20,11 +20,11 @@ import (
2020 "sync"
2121 "time"
2222
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"
2828 )
2929
3030 // Status is where one subagent is in its life.
@@ -649,7 +649,7 @@ func (s *Supervisor) ClaimReport(id string) bool {
649649 }
650650
651651 // 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
653653 // keep editing a tree nobody is watching.
654654 func (s *Supervisor) Shutdown() {
655655 s.mu.Lock()
+4−4internal/subagent/supervisor_test.goView File
@@ -13,10 +13,10 @@ import (
1313 "testing"
1414 "time"
1515
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"
2020 )
2121
2222 // settle is how long a test waits before concluding nothing more is coming.
+1−1internal/subagent/tools.goView File
@@ -5,7 +5,7 @@ import (
55 "fmt"
66 "strings"
77
8- "harness/internal/tools"
8+ "codey/internal/tools"
99 )
1010
1111 // spawnArgs is what the model sends to start an agent.
+2−2internal/subagent/tools_test.goView File
@@ -8,8 +8,8 @@ import (
88 "testing"
99 "time"
1010
11- "harness/internal/agent"
12- "harness/internal/tools"
11+ "codey/internal/agent"
12+ "codey/internal/tools"
1313 )
1414
1515 func toolNamed(t *testing.T, s *Supervisor, name string) tools.Tool {
+4−1internal/tools/glob.goView File
@@ -10,6 +10,8 @@ import (
1010 "strings"
1111
1212 "github.com/bmatcuk/doublestar/v4"
13+
14+ "codey/internal/statedir"
1315 )
1416
1517 // maxResults caps how many paths or matches a search tool returns. The count of
@@ -23,7 +25,8 @@ var skipDirs = map[string]bool{
2325 ".git": true, ".hg": true, ".svn": true,
2426 "node_modules": true, "vendor": true, "target": true,
2527 ".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,
2730 }
2831
2932 // GlobArgs is the glob input.
+2−2internal/tools/read.goView File
@@ -199,9 +199,9 @@ func jailWith(root string, extra []string, path string) (string, error) {
199199 }
200200 }
201201 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)
203203 }
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)
205205 }
206206
207207 // realpath resolves symlinks, falling back to a lexical clean for a path that
+2−2internal/tools/registry.goView File
@@ -1,7 +1,7 @@
11 // Package tools defines the tool interface the agent loop dispatches against.
22 //
33 // 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
55 // transcript.
66 package tools
77
@@ -13,7 +13,7 @@ import (
1313 "sync"
1414 "time"
1515
16- "harness/internal/metrics"
16+ "codey/internal/metrics"
1717
1818 "github.com/invopop/jsonschema"
1919 )
+1−1internal/tools/registry_test.goView File
@@ -8,7 +8,7 @@ import (
88 "strings"
99 "testing"
1010
11- "harness/internal/metrics"
11+ "codey/internal/metrics"
1212 )
1313
1414 type echoArgs struct {
+7−7internal/tools/search_test.goView File
@@ -15,11 +15,11 @@ import (
1515 func searchRepo(t *testing.T) string {
1616 t.Helper()
1717 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")
2020 writeFile(t, dir, "internal/agent/loop.go", "package agent\n\nfunc Run() {}\n")
2121 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")
2323 writeFile(t, dir, "node_modules/junk/index.js", "function run() {}\n")
2424 writeFile(t, dir, ".git/config", "[core]\n\trun = true\n")
2525 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) {
5454 {
5555 name: "bare pattern matches at any depth",
5656 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"},
5858 unwanted: []string{"go.mod", "README.md"},
5959 },
6060 {
6161 name: "pattern with a slash is anchored to the subtree",
6262 args: GlobArgs{Pattern: "internal/**/*_test.go"},
6363 want: []string{"internal/agent/loop_test.go"},
64- unwanted: []string{"cmd/harness/main.go"},
64+ unwanted: []string{"cmd/codey/main.go"},
6565 },
6666 {
6767 name: "path narrows the search",
6868 args: GlobArgs{Pattern: "*.go", Path: "cmd"},
69- want: []string{"cmd/harness/main.go"},
69+ want: []string{"cmd/codey/main.go"},
7070 unwanted: []string{"internal/agent/loop.go"},
7171 },
7272 {
@@ -171,7 +171,7 @@ func TestGrep(t *testing.T) {
171171 {
172172 name: "path narrows the search",
173173 args: GrepArgs{Pattern: "package", Path: "cmd"},
174- want: []string{"cmd/harness/main.go"},
174+ want: []string{"cmd/codey/main.go"},
175175 },
176176 }
177177 // Both engines must produce identical output, or eval numbers would depend
+1−1internal/tools/skill.goView File
@@ -7,7 +7,7 @@ import (
77 "sort"
88 "strings"
99
10- "harness/internal/skills"
10+ "codey/internal/skills"
1111 )
1212
1313 // SkillArgs is the skill input. The name is constrained to the discovered
+1−1internal/tools/skill_test.goView File
@@ -5,7 +5,7 @@ import (
55 "strings"
66 "testing"
77
8- "harness/internal/skills"
8+ "codey/internal/skills"
99 )
1010
1111 func testSkills() []skills.Skill {
+6−6internal/tui/agents_live_test.goView File
@@ -15,12 +15,12 @@ import (
1515 "github.com/charmbracelet/bubbles/spinner"
1616 tea "github.com/charmbracelet/bubbletea"
1717
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"
2424 )
2525
2626 // scriptedAPI answers the main agent and its children differently, so one
+5−5internal/tui/anim_test.goView File
@@ -181,16 +181,16 @@ func TestIgniteFinishesEveryLetter(t *testing.T) {
181181
182182 // At t=1 every letter must be painted the target colour exactly: the same
183183 // 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)
185185 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"))
188188 }
189189
190- if got := stripANSI(lit); got != "harness" {
190+ if got := stripANSI(lit); got != "codey" {
191191 t.Errorf("ignite changed the text to %q", got)
192192 }
193- if got := stripANSI(ignite("harness", colFaint, colCoral, 0, false)); got != "harness" {
193+ if got := stripANSI(ignite("codey", colFaint, colCoral, 0, false)); got != "codey" {
194194 t.Errorf("at t=0 the text is %q, want it present but unlit", got)
195195 }
196196 }
+7−4internal/tui/bridge.goView File
@@ -5,10 +5,10 @@ import (
55
66 tea "github.com/charmbracelet/bubbletea"
77
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"
1212 )
1313
1414 // The agent loop runs on its own goroutine and must not know a UI exists. It
@@ -38,6 +38,7 @@ type (
3838 }
3939 evUsage struct{ usage llm.Usage }
4040 evCompact struct{}
41+ evNotice struct{ text string }
4142
4243 // evSub is one subagent's event, tagged with the agent it came from.
4344 evSub struct{ ev subagent.Event }
@@ -85,6 +86,8 @@ func (b *bridge) Observe(ev agent.Event) {
8586 b.p.Send(evUsage{usage: ev.Usage})
8687 case agent.EventCompact:
8788 b.p.Send(evCompact{})
89+ case agent.EventNotice:
90+ b.p.Send(evNotice{text: ev.Text})
8891 }
8992 }
9093
+2−2internal/tui/bridge_test.goView File
@@ -9,8 +9,8 @@ import (
99
1010 tea "github.com/charmbracelet/bubbletea"
1111
12- "harness/internal/agent"
13- "harness/internal/approval"
12+ "codey/internal/agent"
13+ "codey/internal/approval"
1414 )
1515
1616 // recorder stands in for tea.Program: it captures what the bridge sends
+76−0internal/tui/clipboard.goaddedView File
@@ -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+ }
+9−4internal/tui/colour_test.goView File
@@ -8,7 +8,7 @@ import (
88 "github.com/charmbracelet/lipgloss"
99 "github.com/muesli/termenv"
1010
11- "harness/internal/llm"
11+ "codey/internal/llm"
1212 )
1313
1414 // forceColour makes styles emit real escape sequences. Without it the test
@@ -22,8 +22,13 @@ func forceColour(t *testing.T) {
2222 }
2323
2424 // 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 {
2732 var out []rune
2833 active := ""
2934 for i := 0; i < len(s); {
@@ -44,7 +49,7 @@ func unbackedCells(s string) string {
4449 continue
4550 }
4651 r, size := utf8.DecodeRuneInString(s[i:])
47- if !strings.Contains(active, "48;") {
52+ if strings.Contains(active, "48;") == want {
4853 out = append(out, r)
4954 }
5055 i += size
+10−3internal/tui/commands.goView File
@@ -25,11 +25,13 @@ var builtins = []command{
2525 {name: "skills", desc: "the instruction sets found at startup"},
2626 {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"},
2727 {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"},
2829 {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"},
3031 {name: "clear", desc: "empty the transcript"},
32+ {name: "mouse", desc: "hand the mouse back to the terminal, or take it again"},
3133 {name: "allowed", desc: "what is permanently allowed this session"},
32- {name: "exit", desc: "leave the harness"},
34+ {name: "exit", desc: "leave Codey"},
3335 }
3436
3537 // commands is the catalogue: the built-ins, then every discovered skill.
@@ -79,7 +81,12 @@ func (m *model) helpText() string {
7981 if n := len(m.opts.Skills); n > 0 {
8082 fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them")
8183 }
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" +
8390 "\n ctrl+b the agents sidebar — ↑↓ move · enter read one · p pause · k kill" +
8491 "\n ! runs a shell command · $ runs python — both on an empty prompt" +
8592 "\n what they print goes to the agent too, so you can then say \"fix that\"" +
+80−0internal/tui/compact.goaddedView File
@@ -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+ }
+113−0internal/tui/compact_test.goaddedView File
@@ -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+ }
+17−11internal/tui/complete.goView File
@@ -123,17 +123,7 @@ func (m *model) menuView() string {
123123 if !m.menuOpen() {
124124 return ""
125125 }
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()
137127
138128 // One column for the names, so the descriptions line up. A long skill
139129 // name is left to overflow its column rather than being truncated: the
@@ -166,6 +156,22 @@ func (m *model) menuView() string {
166156 return strings.TrimRight(b.String(), "\n")
167157 }
168158
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+
169175 // pad widens s to width cells, leaving anything already wider alone.
170176 func pad(s string, width int) string {
171177 if n := width - lipgloss.Width(s); n > 0 {
+1−1internal/tui/complete_test.goView File
@@ -6,7 +6,7 @@ import (
66
77 tea "github.com/charmbracelet/bubbletea"
88
9- "harness/internal/skills"
9+ "codey/internal/skills"
1010 )
1111
1212 // The menu belongs to a slash command being typed, and to nothing else: a
+43−1internal/tui/effort.goView File

Large diffs are not rendered by default.

+1−1internal/tui/effort_test.goView File

Large diffs are not rendered by default.

+1−1internal/tui/format.goView File

Large diffs are not rendered by default.

+2−2internal/tui/local.goView File

Large diffs are not rendered by default.

+4−4internal/tui/local_test.goView File

Large diffs are not rendered by default.

+6−6internal/tui/look_test.goView File

Large diffs are not rendered by default.

+449−0internal/tui/markdown.goaddedView File

Large diffs are not rendered by default.

+18−0internal/tui/markdown_bench_test.goaddedView File

Large diffs are not rendered by default.

+336−0internal/tui/markdown_test.goaddedView File

Large diffs are not rendered by default.

+177−67internal/tui/model.goView File

Large diffs are not rendered by default.

+24−15internal/tui/model_picker.goView File

Large diffs are not rendered by default.

+2−2internal/tui/model_picker_test.goView File

Large diffs are not rendered by default.

+5−5internal/tui/model_test.goView File

Large diffs are not rendered by default.

+410−0internal/tui/mouse.goaddedView File

Large diffs are not rendered by default.

+561−0internal/tui/mouse_test.goaddedView File

Large diffs are not rendered by default.

+272−0internal/tui/selection.goaddedView File

Large diffs are not rendered by default.

+175−0internal/tui/selection_test.goaddedView File

Large diffs are not rendered by default.

+375−0internal/tui/settings.goaddedView File

Large diffs are not rendered by default.

+375−0internal/tui/settings_test.goaddedView File

Large diffs are not rendered by default.

+39−49internal/tui/sidebar.goView File

Large diffs are not rendered by default.

+61−33internal/tui/sidebar_test.goView File

Large diffs are not rendered by default.

+13−7internal/tui/splash.goView File

Large diffs are not rendered by default.

+6−0internal/tui/styles.goView File

Large diffs are not rendered by default.

+40−3internal/tui/testdata/mockapi/main.goView File

Large diffs are not rendered by default.

+34−5internal/tui/theme.goView File

Large diffs are not rendered by default.

+55−9internal/tui/transcript.goView File

Large diffs are not rendered by default.

+84−0internal/tui/transcript_test.goView File

Large diffs are not rendered by default.

+14−2internal/tui/wizard.goView File

Large diffs are not rendered by default.

+66−6internal/tui/wizard_test.goView File

Large diffs are not rendered by default.

+5−5internal/tui/wizard_view.goView File

Large diffs are not rendered by default.

+1−1internal/ui/approve.goView File

Large diffs are not rendered by default.

+5−2internal/ui/render.goView File

Large diffs are not rendered by default.

+2−2internal/ui/render_test.goView File

Large diffs are not rendered by default.