Repository navigation
io-cli 0.39.0 - #108
Merged
Merged
io-cli 0.39.0#108
Conversation
Regenerated by scripts/update-tap.sh from the published Release's own SHA256SUMS, so the four checksums are the ones an installer will verify against rather than ones computed here.
chore(packaging): tap and bucket at 0.38.2
…deact The pin moves to the newest published io-harness, which is what answers #105: 0.81.0 built the whole context-sizing path and shipped it inert, so every provider this crate can construct still assembled under a flat 24,000. 0.82.0 gives all four providers a source and splits the fallback into 128,000 remote and 24,000 loopback. The three feature flags are 0.39.0's fourth part. Each is claimed by io-harness to add no crate, and the claim is verified rather than believed: `cargo tree --depth 1` is still exactly ten names with all six features on. `otel` writes OTLP over the reqwest and serde_json already in the default build, `mcp-server` is two more types inside tokio, and `codeact` turns nothing on at all because its interpreter is a host binary discovered the way a browser is. Enabling `codeact` does not hand the agent a tool. This is worth stating because `media` did exactly that in 0.9.0 and it was a capability change arriving inside an interface release: `run_program` is advertised only when a contract carries a `[codeact]` configuration, because `codeact_ready` opens with `contract.codeact.clone()?`. With the section absent the tool catalogue is byte for byte what 0.38.2 sent. `libc` is the second dev-dependency this crate has ever had. It is here for the pty harness the four keystroke contracts need, and it is declared unconditionally rather than under a target table because `tests/dependencies.rs` refuses the latter by name -- a target-gated dev-dependency is a test-only crate arriving where the count cannot see it. The platform gate lives on the code.
Closes #105. Every release before this one assembled inside 24,000 tokens on every provider this crate could build, whatever model was configured. 0.38.2 delegated `context_window` and `max_output_tokens` through all four wrappers and moved three surfaces onto the announced ceiling, then said plainly that the number would not move -- because in io-harness 0.81.0 only `Compatible` answered the window, and only from a catalogue nobody had fetched. That was initorigin/io-harness#266, and 0.82.0 is what answers it. io-cli's half: - The Anthropic and OpenAI providers are built `with_reference_catalogue` by default, in `maker_for` and nowhere else. OpenRouter needs no opt-in -- its catalogue is its own completions endpoint, so for that vendor a reference is not a reference. One `Reference` is cloned into every link that takes one and its cache is an `Arc<OnceLock>`, so a chain of three reads the catalogue once. - `[app.io-cli] reference_catalogue = false` turns it off, and that is the whole escape hatch for an air-gapped box or a proxy that lies. Which catalogue is read is `[app.io-cli.prices] source_url`'s question and has been since 0.24.0; a second key naming the same address would be a second thing to keep true. - `Vendor`, `Chain`, `Watched` and `Printable` now forward `warm_sizing` and `assumed_window`. Both are defaulted, so a wrapper that forgot one would have substituted the default silently -- io-harness's own 0.82.0 review found `Record`, `Fallback` and `eval::Capture` doing exactly that. The delegation gate caught all four here on the pin bump, named the wrapper and named the method. `Chain` takes the minimum for both the window and the assumption, because both are divided into, and warms every link rather than its head. - `/context` says which rung decided the number. `EventKind::ContextCeiling` has carried `source` since io-harness 0.81.0 and this crate destructured it away with `..`. It is rendered as a sentence rather than as the bare word: an operator reading `fallback` learns which enum arm fired, not that nothing sized their provider and what to do about it. The number and the word now travel as one `context::Ceiling`, set and cleared together, so no caller can draw a `model` rung over a contract's number. - A run refused because the egress policy denies the catalogue host says so and names the key. Turning the catalogue on puts a second host in `Provider::endpoints`, io-harness authorises every endpoint before the first step, and a policy that denies this one refuses the run rather than skipping the lookup. That is the sharp edge of turning it on by default, and the operator otherwise meets a refusal naming a host they never configured for a request they never made. The host is recorded by the builder rather than threaded to fourteen call sites in `main.rs`. - No shipped surface names a number for the fallback rung any more. It stopped meaning 24,000 in io-harness 0.82.0, and a gate now fails on any sentence that quotes one of the harness's constants. Sabotaged before being believed: dropping the builder from the Anthropic arm fails F16's vendor arm alone and names the vendor; ignoring the switch and building unconditionally fails the escape-hatch arm and, independently, the pre-existing egress host-count gate; deleting the rung block from the page fails the page arm while every sentence arm stays green. The 35 `io-harness-0.81.0/...` source citations were re-verified against 0.82.0 rather than rewritten: twelve of the thirteen cited files are byte-identical between the two, and the four line numbers that moved are all in one comment about resume entry points, whose claim still holds.
…n be read Two of the three ways the 2026-09-05 field test lost an operator's work, both in the surface that asks for a permission. **A key this modal does not take now says so.** Every key that was not `y`, `a`, `n`, an arrow or `Enter` returned `None` and changed nothing on screen, so an operator who had begun typing a sentence saw their words go nowhere with no sign that the interface was waiting for one of three keys. The answers row now leads with `press y, a or n` when the last keystroke was refused, built through `Theme::notice` like every other toned line in the product so the meaning survives `NO_COLOR` -- this row is where an operator learns their typing did nothing, and a colour alone would tell only half of them. It is cleared by the next key the surface does take, so it describes the keystroke that just arrived rather than one at some point in the past. **The proposed change can be read to its end.** It was cut at whatever the viewport had with `... N more lines` on the last row, which is a fair summary of a change and no way to read one: an operator approving a write whose end they cannot see is approving the elision. `Up`, `Down`, `PageUp`, `PageDown` and `Home` move the diff under a header that stays put, because the counts are what the decision is about. None of the five can answer anything -- reading a change is not agreeing to it -- and the row that says how much is left now says how to reach it. Clamping happens where the rows are counted rather than where the key was pressed, since the row count depends on the width the frame turns out to have. **The `+`/`-` header counts the diff drawn under it.** io-harness is explicit that `Edit::measure` is handed the fragment an `edit_file` replaced while `Edit::with_hunk` is computed from the file's two whole texts, and that keeping them apart is deliberate: folding them together would silently change every number in every trace the harness has written. Correct for the library, wrong for a header sitting on the diff it claims to count -- the field test met the pair as `+3 -9` on an approval above an edit the transcript then recorded as `-9 +10`, two measurements of one change with nothing on screen saying they measured different things. `diff::counted` counts the hunk, both surfaces use it, and with no hunk the recorded fields are used unchanged because then there is nothing drawn for them to disagree with. Each arm was sabotaged before being believed: dropping the nudge assignment fails the refusal sweep and names the character; removing the clear fails only the arm about clearing; ignoring the scroll offset fails only the reachability arm; and putting the recorded fields back in the header fails only the counting arm in tests/diff.rs, where an `edit_file` row is the shape that can tell them apart.
The third way the 2026-09-05 field test lost an operator's work: `Esc` dismissed a picker and also armed the undo, so the next `Esc` rewound the last turn and put two files back, with a footer line as the only warning. The rewind was bound to `esc esc` -- the first press armed and wrote what it would undo into the scrollback, the second acted -- on the reasoning that arming is a property of a keystroke while a typed command has already been deliberate once. That is a warning only for an operator who pressed the key meaning to undo something. Pressed for any other reason, the footer is a line that has already scrolled and the second press is not consent to anything. So the consent moved rather than went away. The binding is one chord, and the keystroke raises `/undo`'s own confirmation -- a surface that waits for an answer, naming what would be undone, declining at row 0 like the two beside it. Both paths reach one construction in `undo::confirm_turn`, so the word and the chord cannot come to mean different things about what an undo is. `Command::ArmRewind` is gone rather than left unproduced. The arming machinery itself is untouched and still means what it meant -- `Hit::Arm` is a binding whose second chord has not arrived -- so an operator who binds `ctrl+r ctrl+r` still presses both, and the question is raised by the second. What changed is that this action's consent no longer depends on the number of keystrokes, which is a stronger property than the one that shipped: a rebinding to a single chord used to collapse a two-press confirmation into one press that acted. The key tables, the README, the guide and the in-product key reference all now say `Esc`, and the gate that holds them to one another is what found each of them.
`/effort high` matched no palette row and `Enter` did nothing at all, so a line copied out of the guides -- or sent into a session by a script -- sat behind `No row matches` with no way forward. The row is `/effort`; the argument is not part of it, so the failure to match said nothing about whether the command existed. `Outcome::Typed` is the palette handing the whole line back, and the line itself comes through `Picker::query` so the enum stays `Copy` -- nine call sites match on it. Only the palette opts in, through `Picker::taking_a_line`: its rows are a vocabulary, while every other picker in the product offers the sessions that exist, the models a provider serves, the files a rewind would restore, and a query matching none of those is a query for something that is not there. At an idle prompt it runs on that one `Enter`. The line goes into the composer with its `/` put back -- the slash never reached the prompt, because opening the palette is what it did -- and the picker block deliberately does not `continue`, so the same keystroke falls through to `App::key` and is submitted exactly as a hand-typed line would be. A `continue` there would leave it waiting for a second press, which is better than doing nothing and still not what a pasted line should need. Mid-turn it fills the prompt and stops. Submitting mid-turn does not run a command -- it queues a prompt for the turn after this one -- so a line that ran itself there would be queueing work on a keystroke the operator pressed to close a palette. `Tab` never submits: completion on a query that completes to nothing is nothing, and an operator reaching for completion half-way through a command must not run it. An empty query is not a line either, or `Enter` on a freshly opened palette would submit a bare `/`. Every other `Outcome` match is named rather than wildcarded, so the next variant is a compile error at all nine sites rather than silence at eight of them. Sabotage: defaulting `takes_a_line` to true fails the arm that says why the flag exists.
`src/main.rs` cannot be linked from anything under `tests/`, and the recorder backend every other test uses is not a tty. Between those two facts sits every decision the driver makes about a keystroke -- which surface owns the keyboard, what a key that reaches no surface does, whether a keystroke that closed one thing also armed another. All three ways the 2026-09-05 field test lost an operator's work live there, and 2,000 offline tests were green over every one of them. `tests/pty.rs` opens a pty with `libc::openpty`, gives the child the terminal as its controlling one, and drives `io` the way the field test drove it. Four arms run in CI: the session starts and draws a prompt, `Esc` at an empty prompt is answered rather than armed, a command line with an argument runs on one `Enter`, and `Ctrl+D` ends the process rather than only saying so. The harness answers `ESC[6n` itself -- `io` will not draw a frame until the terminal says where the cursor is, and a pty with nothing behind it is exactly the case `src/term.rs` carries a two-second wait and an error message for. **It found a production defect on its first run.** The session lock was taken in `home::path()` -- the crate's own `~/.io-cli` -- while `lock::paths` keys the file on a session id, and a session id is only unique within one `runs.db`: `Session::open` numbers from 1 in a fresh store. So every store's session 1 shared one `session-1.lock`, and two `io` processes pointed at different `IO_CONFIG_HOME`s refused each other over a collision that means nothing. The product said so itself and printed the refusal anyway -- its wording is "that should not be possible for a session just created". The lock now lives in `home::in_force`, which is the directory `settings::store_path` puts `runs.db` in. For an operator who has set neither variable it is the same directory as before. Three smaller things the harness taught, each written into the file rather than worked around: a `[[provider]]` in a file inside the workspace is refused by io-harness, so the configuration lives outside the tree `-C` points at; `IO_CONFIG` alone does not isolate a test, because the store follows `IO_CONFIG_HOME`; and a harness that stops reading the pty while it waits for a child blocks that child inside the writes that hand the terminal back. `tests/timing.rs` bans clocks and sleeps in tests, and this file needs both to wait for a process. It is exempt by exact path -- compared with `==`, never a substring -- and held to properties instead of trusted: every bound must be a liveness deadline measured in seconds, and a sub-five-second one is refused as a performance assertion wearing a liveness bound's clothes. Sabotage: dropping `WAIT` to two seconds fails that guard and names the line. The two contracts that need a turn in flight -- an approval raised by a real write, and `Ctrl+C` interrupting a step -- need a provider that answers and are not here yet.
… the footer `App::say` writes the footer, which is gone at the next keystroke; `App::record` writes the transcript, and its own documentation names "what a turn ended as" as record's half. The one ending that went to the wrong one was the one an operator reaches for when something is going wrong: the 2026-09-05 field test stopped a turn and found no record in the scrollback that it had ever started -- the prompt echo above it, the work above that, and the only line tying them to an ending gone on the next key pressed. It now says what was kept as well as that it stopped. "stopped" alone leaves an operator looking at a half-finished transcript with no way to tell a turn that was interrupted from one that failed silently. A turn taken back before it did anything still annotates nothing: the echo is rewound with it, so there is nothing left to annotate.
The running tok figure read as though every re-sent catalogue were paid for in full, and for most sessions most of it was not: the 2026-09-05 field test moved it from 8.1k to 52k over four one-word turns at a spend of $0.0001 each. The tokens were real; the implication that they cost what fresh tokens cost was not, and an operator deciding whether to clear the conversation reads this field. It now draws 52.0k tok, 44.0k cached, from io-harness 0.81.0's StepUsage -- a separate event rather than a field on Finished or Step, so a step no provider answered contributes nothing rather than four zeroes. Accumulated across the session, because one step's cache read drawn as the conversation's would be a different number every turn, and cleared with the run, because a conversation that was put down took its provider-side cache with it. The cached figure is drawn as a part of the total and never added to it: io-harness puts cache_read_tokens inside prompt_tokens, and cost.rs has carried that note since it was written. cache_write_tokens is left out of the footer entirely -- it is charged differently, and its None means not reported rather than none written. The /cost half of this was already built and is verified rather than rewritten: it splits read from written from fresh on the store's durable rows, and already renders an unreported write count, and the fresh remainder that inherits its doubt, as not reported rather than as zero. The release contract's F9 said both halves were new and now says what is true.
io-harness has admitted folder skills all along: `Skills::discover` takes a subdirectory exactly when it holds a `SKILL.md`, and `skillview::disable` has parked one as a folder since it was written. This verb refused a shape the rest of the product already supported, so an operator whose skill ships a checklist, a template or a directory of examples had to copy it in by hand and hope the layout was right. `io skill add ./my-skill` now installs the whole directory as `~/.io-cli/skills/my-skill/`. Everything the loose-file path already checked still runs, unchanged, because it all reads the manifest: the frontmatter name, the one-path-component refusal, the collision against discovered and disabled skills, and the count against io-harness's ceiling. Only two things differ -- where the name comes from and what is copied. A directory with no `SKILL.md` is refused and the refusal names the file it looked for, which is the mistake an operator actually makes: pointing the verb one level too high. Symbolic links inside the folder are skipped rather than followed -- the directory is somebody else's, and following a link out of it would copy whatever it points at into io's own home, which is the reason `src/import.rs` flattens Claude's plugin tree. The gate installs a folder with a nested companion and then asserts io-harness can discover it, because an install that copies files io-harness cannot see is a copy and not an install. Sabotage: copy only the manifest -- the name and the path still check out and the companion arm fails.
…e figure reaches the footer Two silences that 0.38.2 recorded as close calls, both answered by the release it said would answer them. **`image_attached` becomes a line.** The old note refused it on the grounds that "a line naming a picture without showing it would be the class of sentence this release exists to delete", and it was right about the sentence it was refusing. What it did not weigh is that the alternative was silence about something that entered the model's context: an operator whose window filled with a browser screenshot they never asked for could read `/context`, see the conversation swollen, and find nothing anywhere saying a picture had arrived. The row names the door it came through -- io-harness's own `mcp`, `browser`, `view_image` or `caller`, passed through so a fifth one reads as itself -- the media type, and the size, spelled with `picture::bytes` like every other size in the product. It does not show the picture, and cannot: `ImageAttached` carries a digest and never the bytes, by design. The digest is left off the row, being io-harness's bookkeeping and meaningless to a reader of a transcript. Images the operator attached are still drawn where they are attached. **`step_usage` becomes a status field.** Its old route said the cached-versus- fresh split "is a release rather than a row"; this is that release. The cache read accumulates onto `Status::cached` and the footer draws `52k tok, 44k cached`. A field and not a line, because the event arrives beside every committed step and a row per step would be a breakdown scrolling past the work it describes. Wired in `note_context`, beside the ceiling and for the ceiling's reason: it is the one function every door already calls per event, so an arm written beside one of the three call sites would leave the headless path or the resume path reporting a bill that looks entirely fresh. The glyph sweep caught the new row immediately -- every event that draws a line of its own must be exercised under the ASCII set, or a glyph in that arm reaches a terminal that cannot draw it.
The sandbox create, exec and destroy rows draw nothing now. They were three muted lines saying a sandbox was made, a command ran in it, and it was torn down -- around one command whose own row sits directly beneath them and says what ran and how it ended. The three that went carry no fact the operator did not already have: that the command was contained is a standing property of the session and is on the status line, and how it was contained is the backend named there beside it. The two that stay are the two that are news -- a limit reached changed what the command did, and a gate that ran and did not pass decided whether the turn was finished. The rest of F6 is withdrawn rather than quietly missed, and recorded in US-IO-CLI-0.39.0-I03. The argv beside the binary and the first lines of output under the row are not reachable from the event stream: ToolCall is emitted before the call runs and carries nothing about what came back, and its target is chosen from a fixed list of argument names an exec call does not use, so the event has the binary and never the arguments. Both would need a store read per completed call on the render path -- a different design with a different cost, and not one to adopt by accident on the way to a smaller change. The output is still reachable through the durable trace that /expand already reads. The glyph sweep was pointed at a sandbox kind that now renders nothing and would have passed without looking at anything; it drives cap_hit and gate_output instead, which are the two remaining shapes. Two more records land with this: I01 on the pty arms that need a provider and therefore cannot run in CI, and I02 on Ctrl+C's bound moving from the harness's shutdown to the operator's keystrokes, which the owner decided on 2026-09-06.
The README's own sentence is that `io exec` shows what it refused. That was true in a session and true in the JSON stream and false on the path most operators reach first: without `--json` this door installed `Ignore` and said nothing at all, so a run that edited four files and was refused a fifth printed the answer and no sign of the refusal. `Narrating` writes one line per tool call and one per refusal. The tool call is drawn at the moment the run announces it -- before it runs, because that is when io-harness emits it -- so the lines read as a commentary on work in progress rather than a summary afterwards. That is the right shape here: a long headless run with no output at all is indistinguishable from a hung one. Everything goes to stderr and stdout stays exactly what a script reads. The whole contract of this door is that stdout carries the agent's reply and nothing else, so `$(io exec ...)` is the answer. `Narrating` therefore holds no writer and names `eprintln!` directly, rather than being generic over one the way `Ndjson` is -- a writer parameter is a way to pass stdout by mistake, and the gate asserts the type never names stdout at all. Both headless doors take it. A resumed run that went quiet where the first half spoke would leave an operator carrying work on with less to read than they had starting it. Gated twice, because the two halves need different instruments: a source gate for the property that makes the mistake impossible, and a pty arm that runs the real binary against a provider that cannot answer and asserts the split between the streams -- a headless run that fails after doing some work being exactly when an operator most needs to know what it did.
…at it knows io-harness has shipped an OTLP exporter since its 0.78.0, behind a feature flag this crate did not enable -- so a capability the harness released was unreleased in practice for anyone whose only interface is `io`. The flag is on now, and `OtelExporter` is one more observer on the fan-out every door already builds. `src/fanout.rs` has been able to hold several since 0.19.0, so there was nothing to invent. The `[otel]` section is io-harness's own, deserialized by io-harness, and io-cli names no key of its own for it and holds no copy of the defaults. It reaches the session and both headless doors -- a trace configured for unattended runs and delivered only in a terminal would be a trace of the half somebody was already watching, which is the asymmetry `[[hook]]` was given `io exec` to avoid. **What the announcement may not say is the part worth the care.** io-harness reports export success or loss through no public value: a batch the collector refused, or one that failed three times and was dropped, goes to a `tracing::warn` and nowhere else, and `Export::send` propagates no error because it runs on a task nobody awaits. Reading that would take a logging subscriber, which is a dependency in a set whose whole argument is that it is ten names. So `io` says what it configured and never that anything arrived -- and a gate sweeps the sentence for six words an operator would read as delivery, with a second sweeping every shipped page for the same claim. An operator staring at an empty collector should be looking at their network, not trusting a line from a process that cannot see it. The sentence is split out pure because it could not otherwise be gated at all: io-harness refuses an `[otel]` section in any file inside a workspace -- a collector is a host every span of every run is posted to and `io.toml` arrives with a `git clone` -- so a config carrying one cannot be built from a string. That refusal is asserted rather than worked around, and documented, because a repository that could declare a collector could forward every run of everyone who cloned it. A section that will not open is said and the session still starts. Telemetry is a report on the work and never the work.
…ript says so io-harness has shipped CodeAct since its 0.79.0 behind a feature flag this crate did not enable. The switch is a `TaskContract` field, and it goes in `contract::configured` -- the one function on the build path of every door -- because a turn that could write a program in the terminal and not in CI would be the 0.14.0 asymmetry rebuilt, on the door where an unattended run has the most to gain from doing in one step what it otherwise does in twelve. **Enabling the feature hands the agent nothing on its own**, and that is the fact gated hardest. io-harness advertises `run_program` only where a contract carries a `[codeact]` section, so with it absent the tool catalogue is byte for byte what 0.38.2 sent. Two arms hold it -- the contract carries no CodeAct configuration when nothing asked, and the whole contract stays Debug-identical to `default_contract` -- and an unconditional `with_codeact` fails both. That double-gating is deliberate: turning on `media` in 0.9.0 gave every run a `view_image` tool as a side effect of a flag, and this is the same shape of mistake with a bigger tool at the end of it. `EventKind::Program` becomes a line. Its triage note refused one on the grounds that "a line here would announce that a program ran and then say nothing about what it did" -- true of the event and false of the transcript. Every act a program takes re-enters dispatch and arrives as its own tool call, so the cells beneath the row are exactly what it did; what was missing was the row saying they belong to one program rather than to the model calling tools one at a time, which matters because a program's acts are not separately approved. A withheld capability draws once and an available one draws nothing. The documented fallback is that a host with no interpreter composes and steps as it would with the feature off, which is right -- and a silent version of it is not, because an operator who configured this and is watching twelve round trips needs to know the host had no interpreter. The sentence is io-harness's own, naming what it looked for, rather than a second opinion about a probe this crate did not run. The program's source is not on the event and the row does not pretend otherwise.
io-harness has shipped an MCP server mode since its 0.78.0 behind a feature flag
this crate did not enable. `io mcp serve` is the door: newline-delimited JSON-RPC
on stdio, ending when the client closes its end.
A verb on `mcp` rather than a tenth argv variant. The other verbs here manage
servers io talks to and this one makes io a server, but the surface is MCP either
way and an operator looking for anything to do with MCP looks at `io mcp`. It
plans nothing, like `probe`, because it writes no configuration -- and unlike
every other verb it never returns on its own.
**The disclosure is the argument for the feature.** The reason another agent
would borrow this boundary is that it can see it, so a server that started
silently would be asking a client to trust something it has no way to inspect.
`servers::serving` names the root, the protocol version, the posture in force and
the eleven tools io-harness does not serve -- read from the harness's own
`MCP_SERVER_UNSERVED`, because a second copy here goes stale the first time it
moves a name, and because a bounded server that quietly declines to offer `spawn`
looks like a broken one.
Every one of those lines goes to stderr. Stdout is the protocol from the moment
the loop starts, and one human-readable byte on it corrupts the stream for the
client -- which is a real risk in this file rather than a theoretical one, since
every neighbouring verb prints its answer with `println!` and is supposed to. A
gate slices the arm out of the driver and asserts it.
Two things came from driving the real argv door rather than trusting the tests,
which is why that habit exists. The policy line was a `Debug` dump -- hundreds of
columns of `Rule { act: Read, effect: Deny, pattern: ".env" }` -- and is now the
posture in the words the rest of the product uses, with an honest fallback for a
policy that is not one of the three. And the server introduced itself to clients
as `io-harness`, the library inside it, which would have sent anyone reading their
server list to the wrong documentation.
Verified end to end: a real initialize handshake and a `tools/list` over the built
binary, answering as `io` with this install's tools and nothing but frames on
stdout.
Two claims this release could otherwise have shipped, swept out of every page that is not the changelog -- the changelog is a diary and has to be able to quote what used to be true. **A thought does not stream.** The roadmap entry for this release said reasoning would arrive in the working rows as it is written. It cannot: `EventKind:: Reasoning` carries the whole thought and arrives once, io-harness has no reasoning delta event, and `EventKind::Token` is assistant text and nothing else. What io-harness 0.80.0 fixed is the order -- the thought now arrives before the tokens it produced rather than after them -- which means the reorder 0.38.1 added is belt and braces rather than the fix. A page promising a thought appearing word by word would leave an operator watching `Pondering` and waiting for something that is never coming. **And the fallback ceiling is no longer one number.** io-harness 0.82.0 split it into one default for a remote endpoint and a much smaller one for a loopback endpoint, so a page still naming 24,000 states something that stopped being true under the pin this release moves -- on the one page an operator would check a surprising ceiling against. The sibling of the arm in tests/context.rs that holds the same line in the code. F7 is amended in the contract to say what is true: its behaviour shipped in 0.38.1 and is verified here, and what this release owes is the claim.
`[run] collapse` shortens an observation too large to include whole rather than leaving it out. io-harness made it a configuration key in 0.81.0, `Config:: apply_to` applies it, and `contract::configured` has called that on both doors since 0.38.2 -- so it has been working for anyone who wrote the key and visible on no surface at all. That is the worst state a context lever can be in: an operator reading the page about what fills their window, with a setting silently reshaping it. **It needed no key of io-cli's own, which is what this release found.** The criterion assumed an `[app.io-cli]` key would be required. A second spelling there would have been a second thing to keep true and a second answer to a settled question -- the mistake `[app.io-cli] max_steps` was, and which 0.16.0 removed. So the work is to draw it, not to add it. `/context` now says when it is on and how much of an oversized entry survives, one row from the tool mask and **explicitly as a saving** -- because the mask is not one. A collapsed entry really does contribute fewer characters; a withheld tool costs the same catalogue plus a sentence naming what was withheld. Two levers one row apart meaning opposite things is exactly why each has to say which it is, and the guide draws the same contrast. Both cases are gated. The absent arm is the one that matters as much as the present one: a page about what a turn costs must not grow furniture for a lever nobody turned on.
`/contain on` with no `[app.io-cli.containment]` named `max_total_agents`, `max_concurrent_agents`, `max_depth` and `max_total_tokens` and stopped. Technically correct, and it asked an operator to choose a token ceiling for a mode they had not tried, out of a documentation page they were not reading. It now offers one: four agents, two at once, one deep, and an aggregate token ceiling. Small on purpose -- enough for a fan-out to be worth having, small enough that a mistake is legible -- and every number is spelled out on the row that acts rather than summarised, because a row reading "write a default" would be asking somebody to agree to figures they were never shown. Row 0 declines, like every other confirmation here, asserted by index. **The token ceiling is the number that has to exist for the offer to be honest.** A fan-out without an aggregate ceiling is the one shape of this feature that can spend without a bound anybody chose. The two io-harness leaves optional stay unset, for different reasons: a cost ceiling is documented as reserved and not enforced, so a number there would be one that does nothing; a duration is a real ceiling and is a property of the work rather than something io can guess. Written as one inline table, which is also what an operator opening the file afterwards wants to read, into user scope -- `[app.io-cli]` is this crate's own section and a fan-out is a property of how somebody works rather than of the repository they are in. `configure::write` round-trips through `Config::discover` and undoes the write if io-harness refuses it, so a section composed wrongly here cannot leave a session unable to start. Accepted, it is in force for the next turn rather than the next session: an operator who typed `/contain on` meant now. `/contain off` and bare `/contain` still write nothing. The structure gate that counts every configuration write in the driver caught the new one immediately and now names it as the thirteenth confirmed door under `Outcome::Chosen`.
The arm said "run `io setup` from the shell to change the configuration" -- a command telling somebody to leave the program in order to change the program. It now calls the same `wizard` function `io setup` calls, and that is the whole implementation. That function already owns a screen and an input stream, drives its own loop, and handles the credential check, the catalogue read and the write; the session loop has both of those to lend it. A second driver would be a second wizard to keep in step, and the one that drifted would be the one nobody runs from a fresh install. **What is not live is said, rather than left to be discovered.** The session runs inside the closure that built the provider chain, and that closure takes a model name and nothing else -- so a changed provider or credential cannot reach the session that is already running. The theme applies immediately, everything the per-turn configuration reload carries arrives with the next message, and a new provider or key needs `io` restarted. An operator who has just retyped an API key and watches the next turn fail with the old one has been told nothing by a wizard that said it was done. Gated by a pty arm, which is the only instrument that can see it: this is entirely driver wiring in `src/main.rs`, which nothing under `tests/` links. The arm opens the wizard, backs out with `Esc`, and then types into the session -- because a wizard that took the screen and did not give it back would be worse than the sentence it replaced. The arm also documents a standing behaviour it had to work with: `/setup` matches a palette row, so the first Enter chooses it into the prompt and the second submits. That is the palette's browse path and not something this release changed; F5's one-Enter case is a line matching no row, which the arm below it drives.
The two limitations 0.36.0 shipped named and 0.38.0 left standing. **Several sessions.** 0.36.0 held one and refused a second, which was the honest thing to do rather than multiplex badly. An editor with two files open wanting two conversations is the ordinary case, so `session/new` now opens as many as a client asks for, keyed on the wire id it issues. `Session::open` writes a new row per call, so each is its own conversation in the store -- the same thing two terminals in one repository are. A prompt now names its session, and one naming a session this agent does not hold is refused. That parameter could be ignored while there was one; with a map, a prompt naming nothing would go to whichever conversation happened to be first, which is the multiplexing-badly failure arriving by the back door. **`session/load`.** It was declared unsupported because the correspondence between an ACP session id and a stored conversation "had to be designed rather than guessed". The design turned out to be the one the id already carried: this adapter issues `io-<session id>`, so loading is reading that number back and reopening it. Its absence was the sharpest thing missing from an editor integration -- an operator who closed their editor yesterday had no way back in, while `io resume` in a terminal had one all along. The history is replayed as ordinary `session/update` notifications in the shape a live turn already sends, so a client renders a loaded conversation with the code that renders a running one. A bespoke transcript in the result would be a second rendering path, and it would be the one that rots, because nobody looks at it until they reopen an old session. It walks `Session::history` and never `Store::session_turns` -- the second is the whole tree rather than one path through it, and a rewind moves the head without deleting the turn, so replaying the tree would show a client work that was taken back. `src/export.rs` makes the same choice for the same reason. A turn with no reply contributes only its prompt: an empty agent chunk would draw the model as having answered with silence rather than as not having answered. Gated by driving the real `io acp` over pipes -- the session map is private and needs a provider to construct, and none of these frames needs a completion. The load arm runs two processes over one home, because a load that only worked inside one process is the half that needs no persistence and proves the least.
The whole release rather than the pin: three ways a session could lose an operator's work, the four surfaces a running turn now shows, the three small doors, the three io-harness capabilities that were unreleased in practice, and the ceiling. Two things an upgrader has to find are stated as such rather than left in a feature list -- the rewind's shipped binding changed from `esc esc` to `esc`, and a run now contacts a catalogue host before its first step and costs more per step because it stops trimming at 24,000. `docs/CONTRACT.md` gains `io mcp serve`: what it takes, what it will not take and why, that stdout is the protocol on that door too, and that eleven tools are deliberately not served.
… status line The adversarial review found ten things behind a green suite. The one that mattered: **`session/load` would open a session belonging to another workspace.** There is one `runs.db` for every workspace on the machine and `Session::reopen` reads a session's root out of its stored row, so a client walking `io-1`, `io-2`, `io-3` would have been handed the history of every conversation the operator has ever had, from every repository — and a prompt afterwards would have edited that other workspace's files under *this* workspace's policy, because the contract is rooted at the session while the policy was resolved once at the `-C` root. The refusal is the same sentence a malformed id gets, because a message that told them apart would be an oracle for which ids exist. Gated with two workspaces over one home, which the existing arm could not see. Also from the review: - `io acp` opened a session at startup that nothing used once sessions became a map, leaving an empty conversation in the store per editor launch. - A loaded session is keyed on the id io would have issued, not the string the client sent: `i64` parsing accepts `+7` and `007`, so three spellings were three map entries over one conversation head. - `End` fell into the arm that flashes `press y, a or n`, so a reader pressing it to reach the bottom of a diff was told to answer. Every other navigation key was taken silently; the refusal sweep only walks printable ASCII, so it never pressed it. - A doc link named `failure::reference_refusal`, which does not exist. The nightly rustdoc gate would have caught it; the suite could not. - `tests/timing.rs` tested for a `sleep` twice and the second test was unreachable, so the rule was enforced entirely by the first. - `servers::serving`'s doc claimed it reports which tools are served. It reports which are not, and now says why that is the half worth writing. - The OTel exporter reaches three doors and not `io acp`, which composes no observers at all. Named at the call site, in the function's doc and in the guide rather than left to be found as an empty collector. **And the splash card is dated.** After `/model` the status line said one model and the card said the one the session started with — two names on screen with nothing saying which the next turn would use. The card is committed scrollback, so it cannot be redrawn; it now opens with `opened with — the status line is current`. One caption rather than a model-specific fix, because `policy` and `workspace` go stale the same way, on a posture change and on `/resume`. Several doc comments written for this release had been spliced onto the wrong item — a new item inserted above an existing one absorbs its neighbour's doc block — which left four sabotage instructions naming the wrong test. Corrected where found.
…bours A new item inserted immediately above an existing one absorbs that neighbour's doc block -- Rust attaches a run of `///` lines to whatever item follows it, and nothing in the toolchain sees the mistake: it formats, lints and tests clean, and only `cargo doc` would show it. Fourteen items were affected across this release's diff. Four of them carried sabotage instructions, which is what makes this worth a commit rather than a tidy-up: `f8_the_headless_commentary_cannot_be_written_to_stdout` ended with the sabotage for `f7_every_headless_turn_still_refuses_every_approval`, and three others were the same. A sabotage argument attached to the wrong test is worse than none, because the next author reads it, applies it, sees a green suite and concludes the gate is sound. Every block is back on the item it was written for, in `src/context.rs`, `src/contract.rs`, `src/servers.rs`, `src/settings.rs` and six test files. No prose changed except where it was already wrong: `servers::serving` no longer claims to report which tools are served, because it reports which are not.
…ment
`live_f6_a_withheld_tool_is_refused_by_the_mask_and_says_so` was failing on its
own guard: the run came back `Finished { steps: 0 }`, no tool was reached, and
the arm said so rather than passing on an absence -- which is exactly what it was
written to do.
The cause is not a defect. `contract::session` starts at `Verification::None`,
and io-harness reads `conversational.unwrap_or(matches!(verify,
Verification::None))`, so an ungated turn may be answered in one completion
whenever its classifier decides the goal is only a question. It decided that
here. That makes the arm's instrument a classifier's judgement, which is not a
thing to gate a tool mask on.
`[app.io-cli] conversational = false` opens a run for every prompt, which is what
the key exists for, and leaves the model with the tools as its only route. The
arm passes again.
Checked against `develop` before touching it: the same arm fails identically on
0.38.2, so this is a pre-existing gate that stopped holding as provider behaviour
moved and not something this release broke.
The release's headline, and the only arm that can decide it. 0.38.2 built this whole path -- four wrappers delegating, three surfaces reading the announced ceiling -- and shipped it inert, because io-harness 0.81.0 answered `context_window` on `Compatible` alone and only from a catalogue nothing primed. Every provider this crate could construct assembled under a flat 24,000, and US-IO-CLI-0.38.2-I01 had to withdraw the claim. So an offline gate over the plumbing is exactly what was green through that release. Nothing but a live run tells this release apart from the last one, and until now the live suite had no arm that looked at a ceiling at all. It asserts the two things the release claims rather than a number: the rung is no longer `fallback`, and the ceiling is no longer io-harness's old flat constant. The number itself is whatever the configured model declares and moves when the catalogue does. Measured: 1,171,456 tokens, source `model`. Closes #105.
`libc::openpty` takes `*const winsize` on Linux and `*mut winsize` on macOS, so `&mut size` was a borrow only one of the two signatures wanted. Clippy's `unnecessary_mut_passed` fired on Linux while every macOS gate stayed green.
io-cli 0.39.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
All five parts of the 0.39.0 roadmap entry, which was four releases compressed into one.
Closes #105.
No keystroke loses the operator's work
The three ways a session could lose work, all found in one field test on 2026-09-05. These are behaviour changes rather than corrections, which is why this is a minor.
y,a,n, an arrow orEnterwas ignored in silence, so a typed sentence went nowhere with nothing on screen explaining it. The answers row now leads withpress y, a or nwhen the last keystroke was refused, in a word rather than a colour.⋯ N more lines; approving a write whose end you cannot see is approving the elision. Five keys scroll it and none of them can answer.+/-header counts the diff drawn under it. io-harness measures the fragment anedit_filereplaced while the hunk is the whole file's — the field test met that as+3 -9above an edit the transcript recorded as-9 +10.esc esc: press to warn, press to act. Pressed to dismiss a picker, that footer is a line that has already scrolled. One chord now, raising/undo's own confirmation. If you have reboundrewind, the shipped default changed fromesc esctoesc.Enterruns a typed command line./effort highmatched no palette row and did nothing at all.What a running turn shows
A command costs one row rather than four; the footer separates cached input from fresh; a picture the agent was handed is named with the door it came through;
io execwithout--jsonsays what it did on stderr — which the README has promised since it was written and was true only in a session and in the JSON stream.Three doors, and three harness capabilities
/setupruns the wizard in the session.io skill addtakes a folder with its companions./contain onoffers a fan-out instead of naming four keys.[run] collapseneeded no key of io-cli's own and is now drawn by/context.otel,mcp-serverandcodeactare enabled: a run exports as spans,io mcp serveoffers this install's tools to another agent under this install's policy, and a turn can write one contained program. None adds a crate —cargo tree --depth 1is still exactly ten names with all six features on, measured rather than believed. Enablingcodeacthands the agent nothing on its own, double-gated becausemediabehaved differently in 0.9.0.io acpholds several sessions and answerssession/load, closing both limitations 0.36.0 shipped named.The ceiling
0.38.2 built the whole path and shipped it inert;
US-IO-CLI-0.38.2-I01withdrew the claim and filed io-harness#266. The pin moves to io-harness 0.82.0, the reference catalogue is on by default for the two vendor providers with a key to turn it off,/contextsays which of three rungs decided the number, and a run refused because an egress policy denies the catalogue host names the key in the same message.Measured on a live run: 1,171,456 tokens, source
model, against the 24,000 every provider took before.Two things to know when upgrading. A run now contacts one more host before its first step and costs more per step, because it stops trimming at 24,000;
[app.io-cli] reference_catalogue = falserestores the old behaviour. And a local endpoint reports a much smaller ceiling than a hosted one — that is io-harness being careful about a runtime that publishes nothing.What the review found
The adversarial pass found ten things behind a suite of 2,030 green tests, clean clippy and clean fmt — the fifteenth consecutive release where it has. The serious one:
session/loadreopened any session in the store by id, with no check that it belonged to this workspace. Oneruns.dbper machine andSession::reopenreads a root out of the stored row, so a client walkingio-1,io-2,io-3would have been streamed every conversation the operator has ever had, from every repository — and a prompt afterwards would have edited that tree under this tree's policy. Fixed, gated with two workspaces over one home, sabotage-verified.Nine more, all fixed, from an orphan session row per editor launch to
Endbeing refused by the approval modal.A new pty harness drives the built binary at a real terminal over
openpty, which is the instrument the driver's keystroke decisions never had —src/main.rscannot be linked fromtests/. It found a production defect on its first run: the session lock was keyed on a session id unique only within one store while living in one fixed directory, so twoioprocesses with differentIO_CONFIG_HOMEs refused each other.And the splash card is dated rather than stale: after
/modelit named one model while the status line named another.Gates
cargo test --all-targets --no-fail-fast— 109 binaries, 2,031 passed, 0 failedcargo test --doc— 1 passedcargo fmt --all -- --check,cargo clippy --all-targets -- -D warnings— both exit 0Three iteration records —
I01,I02,I03— carry what was amended and why: two pty arms that need a provider and cannot run in CI,Ctrl+C's bound moving from the harness's shutdown to the operator's keystrokes, and the argv and output that are not on the event stream.