Skip to content

io-cli 0.14.0 - #40

Merged
aakashpawar1999 merged 9 commits into
mainfrom
develop
Aug 25, 2026
Merged

io-cli 0.14.0#40
aakashpawar1999 merged 9 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

Release PR for io-cli 0.14.0. develop is byte-identical to feat/0.14.0,
which is the tree every gate ran against — git diff --quiet feat/0.14.0 origin/develop is clean, so nothing landed above the feature merge.

The configuration file the operator wrote is the session they get. Eleven
sections of io.toml were read and validated by io-harness, documented in this
product's README, and discarded by every interactive turn; they now reach a
session as they have reached io exec since 0.2.0, through one builder with a
stated and tested order of precedence. The budgets in force stand on the status
line with what is left of each, a turn ended by one says which ceiling it
reached rather than reporting a failure, three event kinds the harness has
always emitted arrive on screen, and /status commits the whole state into the
scrollback.

io exec gains io-cli's step floor. [app.io-cli] max_steps is deprecated,
still wins, and goes in 0.16.0.

Verification

  • 8/8 checks green on three platforms on the feature PR, no flake.
  • Suite 56 binaries, 696 passed, 0 failed, 24 ignored. 0.13.1 finished at 664.
  • Sabotage pass over seven arms; two live io exec runs against a real
    endpoint; the built binary driven through a pty.
  • Record pinned sha256:725a5564…, ultraship validate exit 0 over 44 checks.

What the process caught

The pre-implementation source audit falsified the stated mechanism of six of the
twelve acceptance criteria before a line was written. Running the binary then
found two defects no test could see. The sabotage pass found a third — a gate
written in 0.11.0 to catch "a name with no arm behind it" had itself been
satisfied by the name appearing in a comment, so a deleted renderer arm passed
it. All three are fixed here and recorded in the release record and its two
iteration records.

Eleven sections of io.toml were read and validated by io-harness, documented
in this product's README and configuration example, and silently discarded by
an interactive session. A developer who wrote `[run] max_tokens` and watched a
turn spend past it was reading a file that did nothing.

`contract::configured` is the config-derived half of a contract and both arms
now call it. The order of precedence is stated and tested, weakest to
strongest: io-harness's defaults, io-cli's step floor, `Config::apply_to`,
`[sandbox]`, then `[app.io-cli]`. The floor is applied before `apply_to` so a
`[run] max_steps` the operator actually wrote beats it, rather than a file that
lowers the cap being ignored while one that raises it works.

`[sandbox]` is attached by hand, because `Config::apply_to` does not apply it,
and only when the file has one: a default `SandboxConfig` carries real
ceilings where `TaskContract::workspace` starts from `SandboxLimits::none()`,
so attaching one unconditionally would cap a session whose operator asked for
nothing.

The two server scopes are merged rather than replaced. `with_mcp` and
`with_lsp` assign the whole collection, so applying `[[mcp]]` and then
`[[app.io-cli.mcp]]` in sequence would leave a contract holding only the
second list. They are concatenated and deduplicated by id, the `[app.io-cli]`
entry taking the wider entry's position, and a dropped duplicate is named once
at session start.

`io exec` gains the step floor too. It set no cap at all, so a headless run
took io-harness's twelve and ended `error: step_cap_reached` under
half-finished work with nobody watching. What stays arm-specific is the
responder, the plan gate and the system prompt: `PROMPT` tells the model it is
rendered in an eighty-column pane whose output has scrolled, which is false of
`io exec --json`.

The live arms are handed an empty configuration, so every one of them keeps
the contract it was written against.
Three facts the pinned harness emits on an ordinary session turn were reaching
the renderer and being dropped. All three were in the triage table already,
deliberately silent, so `Status::unknown` never moved for any of them and
nothing on screen said they had been discarded.

A dial carries the host as the command asked for it, the port, and whether the
policy permitted it. It is never a resolved address: the policy's patterns are
written against names, so a row showing an address would not match the rule
that decided it and its reader could not tell which rule to change. A refusal
draws in the refusal tone, whose word survives with no colour at all.

A sandbox draws four of the seven kinds that reach the channel. `cap_hit` is a
limit reached and not a failure — the sandbox did what its configuration told
it to, and reporting that through the error path would tell an operator their
run broke at the moment their cap held. The backend is carried where the event
has one and never invented where it does not: io-harness sets it on `create`
and `exec` alone.

The `dial` kind draws nothing, because io-harness builds it as a `destroy`
event with the kind overwritten and emits it beside the dial itself. Drawing
both would put every dial in the transcript twice, and the copy here is the
poorer one — it carries the word without the host, the port or the verdict.

A stall is composed from the run state around it, since the variant carries
nothing at all: the step it stopped on, and how long it has been there. No
clock is read in the renderer, so the line is arithmetic on two ages the
driver handed in rather than a timing, which is also what makes it assertable.

An absent dial line is not evidence of no egress. The event has one emit site
behind three conjoined preconditions, so a policy that names no hosts emits
none of these ever.
…g as a ceiling

A session that now obeys `[run]` can be bounded by a number the operator wrote
and never see it. The status line carries each budget that exists with what is
left of it — steps, tokens and duration — and omits the ones that do not, so a
session with no `[run]` looks exactly as it did.

Both renderers draw them, from one composer. `Status` has two — the three-row
footer the binary draws at an idle prompt, and the one-row form a short
terminal falls back to — and in 0.12.0 a field added to the second while the
test asserted the second was green everywhere and invisible on screen.
`Status::budgets_left` composes each remainder once and both renderers extend
from it, so they cannot disagree.

A turn ended by a budget now says which budget rather than reporting a failure.
`step_cap_reached`, `time_budget_exceeded` and `cost_budget_exceeded` join
`budget_ceiling_reached` in the ceiling tone: all four are ordinary `Ok`
outcomes, and a ceiling is the operator's own instruction being carried out.
Reporting one through the error path tells them their run broke at the moment
their limit held, which is the opposite of what happened.

The budgets are read off the contract the turn is about to run and never
recomposed from the configuration, because the only thing that knows the whole
order of precedence is that contract. The cost of not duplicating it is that
the fields appear once a turn has been built rather than at the very first idle
prompt; they persist afterwards, since the file does not change while a session
runs.

Whether a step budget exists is a question the contract cannot answer on its
own — `max_steps` is a plain `u32` and every turn carries io-cli's floor — so it
is answered by comparing against that floor. An operator who writes `[run]
max_steps = 1000` therefore gets no step field, which is preferred to drawing a
floor nobody asked for on every session.
…up notice be seen

`[app.io-cli] max_steps` exists because the flat turn once had no way to raise
io-harness's cap of twelve. This release gives `[run] max_steps` that job, so
there are two spellings for one number in one file and the less discoverable
one wins. It keeps working and keeps winning; what changes is that a file
carrying it says so once, naming the key, the value it took, and where the
number moves to. It is removed in 0.16.0, with the release that rewrites the
configuration surface — a deprecation that removes in the same breath is not a
deprecation.

The decision reads the field rather than the effective cap. Keying off the step
cap would fire on every session, and keying off `[app.io-cli]` being present
would fire on the file `io setup` writes — a notice on a session not using the
deprecated key is noise that teaches operators to stop reading the start-up
lines.

The startup notices are committed rather than said, which is what the comment
above that loop has always claimed they were. `App::say` sets the footer's
notice, and setting it replaces it, so a session with several things wrong with
its configuration file showed only the last of them — and the file with the most
wrong with it was the one losing the most. Six senders can put a line in that
list and two of them are new in this release, so what was survivable is not any
more.

The doc beside the key is corrected in the same release. It claimed io-cli
could not raise the cap on the steered path and that the key was honoured only
on a contained session; both have been false since 0.11.0 moved the flat arm
onto an entry point that takes a caller's contract.
… scrollback

A status line a few cells wide can carry the numbers and cannot carry the
reasons. `/status` writes the rest into the terminal's own scrollback, where
search and selection already work, as `Ctrl+T` and `/expand` do — the viewport
is a few rows and this product has no alternate screen.

Every field is a value io-harness supplied. The policy layers come off
`Policy::layers` by name with the acts each governs; the backend that actually
applied comes off `EventKind::Contained`, because the resolved containment is
not public and a live turn cannot be asked what contains it; the draw comes off
the `SpendDraw` stream, because the ledger is built inside the run and never
handed back; and what is connected is the configured roster confirmed against
the events that said a server came up, because neither session type is public
either. Where a fact is not yet known the page says so in words rather than
defaulting to a number that would read as measured.

The budgets are `Status::budgets_left()` rather than a third spelling of the
same remainder. A number has one spelling wherever it is met.

One fact per row and nothing aligned across rows, so there is no column for
anything to be squeezed out of. A row too long for the terminal folds with a
hanging indent and a very long path is split rather than allowed to overflow —
nothing is ever fitted, so no ellipsis appears. A status page that shortened
the thing its reader opened it to read would be the one surface here that
cannot be trusted.

It is a command and not a key: the keys are nearly all spoken for, nothing yet
shows this is checked often enough to spend one on, and a key is cheap to add
later and expensive to take back once it is in anybody's fingers.

The one-contract-per-turn gate now names its two call sites instead of counting
them. `/status` reads a contract to report the configured rosters and builds no
turn, and raising the count would have admitted a real second arm just as
readily as this reader.
…ng change nothing

Two defects a capture of the real binary found, neither of which any test in
the suite could see.

The budgets reached the status line only where a turn was built, so a session
whose io.toml sets three ceilings showed none of them until it had already
spent against one. The moment to learn a conversation is capped at forty steps
is before typing into it. They are read once at startup now, from the same
builder every turn uses — recomposing them from the file would be a second
answer to the precedence question, and it would drift the first time a layer
moved.

`/status` reported them by assigning that field before composing its page,
which answered the question and made a read-only command change what the
status line said the moment it was opened. It composes against the contract it
was handed instead. What is drawn against a ceiling is still the session's own,
because that is a fact about the session however the ceiling was arrived at.

The one-contract-per-turn gate names all three call sites rather than counting
them: the turn's, `/status`'s reading, and the opening one. Counting to three
would admit a genuine second arm just as readily, which is the failure that
test exists to make unrepresentable.
…is release ended

Eleven sections of io.toml stopped being ignored by an interactive session, so
every surface describing the old shape is now wrong. Five of those claims were
already wrong before this release and are corrected here for the first time.

The configuration example loses the block headed "Not read by an interactive
session" and the reason it gave, which went stale in 0.11.0. In its place is
what bounds a turn in a session and in `io exec` alike, with the precedence
order and the two migration notes: a `[run]` block written for CI now bounds a
conversation, and `[web]` is a capability rather than a preference — reaching a
session turn it grants provider-executed search and fetch, and the vendor dials
the URL, so the local `net` rule is not what governs it. That one is stated in
its own words rather than folded into a list.

The README loses four "Contained turns only" marks, the claim that the
capabilities and the fan-out are one switch, and the claim that an uncontained
session cannot be given a responder or a plan gate. The two causes are kept
apart on purpose: that half has been false since 0.11.0, while the
`[sandbox]`/`[run]`/`[[agent]]` half becomes false because of this release.

`[app.io-cli] max_steps` has existed since 0.10.0 and had never been documented
at all. It is in the table now, marked deprecated. The new gate is what found
it: the README's key table is asserted against a fully populated settings
value, so a key that exists and is undocumented fails by name rather than
waiting for somebody to notice.

Three more gates hold what the prose now claims — that the README quotes the
budget strings the status line actually draws, that no surface still carries
the stale phrases, and that the deprecation notice, the README and the
CHANGELOG agree on the replacement key and the release that removes it.

Also corrected: a doc comment dated the end of the capability coupling to
0.12.0, which is a different change. It was 0.11.0.
The sabotage pass deleted the `Dialed` arm from the renderer and this gate
stayed green. It asserted that `src/events.rs` contains the string
`EventKind::Dialed` anywhere in the file, and the `Sandbox` arm's own prose
names that variant to explain why it draws nothing itself — so the mention
answered the question and the deleted arm went unnoticed.

That is the defect this test was written in 0.11.0 to close, arriving through
the door the test itself left open: a name with no arm behind it. It now
requires the variant at the twelve spaces every match arm in that file sits at,
which is how `tests/glyphs.rs` has always read the same file, and no `use`, doc
line or expression is indented that way.

Verified both ways: green on the real tree, and red by name with the arm
removed.
@aakashpawar1999
aakashpawar1999 merged commit 1fd0f77 into main Aug 25, 2026
13 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