Skip to content

io-cli 0.39.0 - #108

Merged
aakashpawar1999 merged 29 commits into
mainfrom
develop
Sep 6, 2026
Merged

aakashpawar1999 merged 29 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

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.

  • An approval is a modal and says so. Every key that is not y, a, n, an arrow or Enter was ignored in silence, so a typed sentence went nowhere with nothing on screen explaining it. The answers row now leads with press y, a or n when the last keystroke was refused, in a word rather than a colour.
  • The whole change can be read before it is approved. It was cut with ⋯ 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.
  • The +/- header counts the diff drawn under it. io-harness measures the fragment an edit_file replaced while the hunk is the whole file's — the field test met that as +3 -9 above an edit the transcript recorded as -9 +10.
  • The rewind asks. It was 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 rebound rewind, the shipped default changed from esc esc to esc.
  • A stopped turn leaves a record in the transcript rather than the footer, which is gone at the next keystroke.
  • Enter runs a typed command line. /effort high matched 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 exec without --json says 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

/setup runs the wizard in the session. io skill add takes a folder with its companions. /contain on offers a fan-out instead of naming four keys. [run] collapse needed no key of io-cli's own and is now drawn by /context.

otel, mcp-server and codeact are enabled: a run exports as spans, io mcp serve offers 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 1 is still exactly ten names with all six features on, measured rather than believed. Enabling codeact hands the agent nothing on its own, double-gated because media behaved differently in 0.9.0.

io acp holds several sessions and answers session/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-I01 withdrew 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, /context says 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 = false restores 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/load reopened any session in the store by id, with no check that it belonged to this workspace. One runs.db per machine and Session::reopen reads a root out of the stored row, so a client walking io-1, io-2, io-3 would 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 End being 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.rs cannot be linked from tests/. 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 two io processes with different IO_CONFIG_HOMEs refused each other.

And the splash card is dated rather than stale: after /model it named one model while the status line named another.

Gates

  • cargo test --all-targets --no-fail-fast — 109 binaries, 2,031 passed, 0 failed
  • cargo test --doc — 1 passed
  • cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings — both exit 0
  • Live suite against a real provider — 41 passed, 0 failed
  • Eleven sabotage arms, eleven killed, each restored byte for byte

Three 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.

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.
@aakashpawar1999
aakashpawar1999 merged commit b321d35 into main Sep 6, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant