Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,82 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

## [0.36.0] - 2026-09-03

The release that makes io reachable from an editor. The Agent Client Protocol is the field's
integration surface — Zed's, JetBrains's, and a registry of agents that answer it — and having
none of it kept this product out of every editor that has adopted one. `io acp` is a protocol
adapter over a door that already existed: ACP is newline-delimited JSON-RPC 2.0 over stdio, which
is byte for byte the output discipline `io exec --json` has kept since 0.5.0.

The wire is served with the dependencies already in the tree. The official
`agent-client-protocol` crate was read and declined: it brings twenty-six further lockfile crates
including `async-io`, `blocking` and `polling`, a second async executor family reading real stdin
beside this one — and a second owner of stdin is not a hypothetical hazard here. The direct
dependency set is still exactly ten names.

### Added

**`io acp` serves the Agent Client Protocol on stdin and stdout.** An editor spawns it and speaks
JSON-RPC at it: `initialize`, `session/new`, `session/prompt`, `session/cancel`. The agent's
answer streams back as `session/update` notifications — message chunks, reasoning on its own
channel so a client can fold it, tool calls with ACP's own kinds, and plan changes. A turn answers
with ACP's `stopReason`. See the new [From an editor](docs/guide/editors.md) guide.

**An approval in an editor session is refused, and the client is told which action and why.** A
`session/update` marks the tool call failed and says the approval could not be routed; the model
is told the same, rather than that the operator denied it, because the operator was never asked.
io does **not** send `session/request_permission` in 0.36.0 — raising that request means awaiting
the answer, and routing it back into the running turn is not wired, so a prompt whose outcome is
ignored would be worse than no prompt. `docs/CONTRACT.md`'s standing sentence — io "declines every
approval rather than deferring one" — therefore still holds on every door this release ships.

### Changed

**The io-harness pin moves to 0.76.0**, two minor versions.

**A cache-write count the provider never reported now reads `unknown`, where it previously read a
number that was wrong.** io-harness 0.76.0 makes `Usage::cache_write_tokens` an `Option`, which is
the shape `/cost`'s own first honesty rule has wanted since 0.13.0: a count a provider reported no
usage for is unknown, never zero. **Every OpenRouter call reports none**, so this is most turns
rather than an edge case. The "of which fresh" figure inherits the silence, because it is computed
from the count that does not exist.

**`io exec --json` writes a second object per committed step.** io-harness 0.75.0 added
`EventKind::StepAttributed` — a per-step timing breakdown — and the `--json` stream forwards every
event without a filter. A consumer that counted objects, or assumed one object per step, sees
different output. The variant draws no line in the transcript: it arrives beside every step and a
line would restate the span the step line already draws.

**A harness-native `[routing]` section is now reported rather than silently discarded.** 0.76.0
lets a user-scope `io.toml` carry `[routing]`, and io-cli then applies `[app.io-cli.routing]`
through a builder that **replaces** rather than merges — so an operator with both lost the
harness's without being told, and an operator with only the harness's saw it in no io-cli surface.
io now says which is in force. The precedence is unchanged: deciding it here would be this crate
taking a second opinion about somebody else's section.

**Four more tool calls can now run concurrently and arrive out of order** in the transcript:
0.75.0 made `list_dir`, `git_log`, `git_status` and `git_diff` speculable. Nothing in io-cli
changed; the ordering did.

### Known limitations

- **An editor session refuses every grey-tier action and does not ask.** `session/request_permission`
is not sent; the tool call is marked failed with the reason. Nothing is granted that was not
granted, but the posture has to be configured rather than approved in the moment.
- **`session/cancel` takes effect between turns, not during one.** A turn in flight runs to its own
end, so `stopReason: "cancelled"` is reachable only where the cancel arrived before the turn
began.
- **The client's filesystem and terminal are not used.** io-harness owns the disk and process
execution inside its own sandbox and publishes no seam to route either through the client, so io
cannot see unsaved editor buffers. This is an upstream absence rather than a deferral.
- One ACP session per process; a second `session/new` is refused with a sentence.
- `session/load` and `session/set_mode` are not served, and `loadSession` is declared unsupported.
- A `plan` update carries no entries — a client is told the plan moved, which is true, rather than
a fabricated list.
- A turn that errors answers `end_turn` with the reason on stderr, because ACP has no stop reason
for a transport failure.

## [0.35.0] - 2026-09-02

The release that renders a boundary somebody measured. This interface has drawn the
Expand Down Expand Up @@ -3246,7 +3322,8 @@ client, tool, sandbox, policy engine or session store of its own.
- There is no crates.io publish and `cargo install` is not an install path.
- No test in this release asserts on wall-clock time.

[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.35.0...HEAD
[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.36.0...HEAD
[0.36.0]: https://github.com/initorigin/io-cli/compare/v0.35.0...v0.36.0
[0.35.0]: https://github.com/initorigin/io-cli/compare/v0.34.1...v0.35.0
[0.34.1]: https://github.com/initorigin/io-cli/compare/v0.34.0...v0.34.1
[0.34.0]: https://github.com/initorigin/io-cli/compare/v0.33.0...v0.34.0
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 24 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "io-cli"
version = "0.35.0"
version = "0.36.0"
edition = "2021"
# io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!`
# macro from its build script. Nothing here can be lower than what the harness
Expand Down Expand Up @@ -63,7 +63,7 @@ path = "src/lib.rs"
# still correct about what this *renderer* can be handed; it is simply no longer
# load-bearing on the build. Turning `barcode` back off is what would restore
# it, and `documents` would then have to be spelled out as its other four.
io-harness = { version = "0.74", features = ["media", "browser", "documents"] }
io-harness = { version = "0.76", features = ["media", "browser", "documents"] }
# **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.**
# The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28
# because the composer was built out of that widget and the 0.30 line carried
Expand Down Expand Up @@ -98,7 +98,28 @@ ratatui = { version = "0.30", default-features = false, features = [
] }
crossterm = "0.29"
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
# `io-util` and `io-std` are 0.36.0's, for the ACP adapter: it reads
# newline-delimited JSON-RPC frames off stdin and writes them to stdout, and
# `AsyncBufReadExt::read_line` over `tokio::io::stdin()` is what does it. They are
# features of a name already here rather than a name of their own, which is the
# whole reason `src/acp.rs` speaks the wire itself instead of taking the official
# `agent-client-protocol` crate — that one is correct and well made, and its
# dependency graph brings a second async executor family beside this one:
# `async-io`, `async-process`, `async-task`, `blocking` and `polling`, with its
# stdio transport putting `blocking::Unblock` over the real stdin and stdout.
# (The crate count that walk produced is not repeated here: it came from
# crates.io metadata rather than a resolve, and a number this file cannot check
# is the kind of claim that goes stale unread. The five names are the argument.)
# Two owners of stdin is not a hypothetical failure here; see `src/stdin.rs`,
# which exists because of what it cost in 0.13.1.
tokio = { version = "1", features = [
"rt-multi-thread",
"macros",
"sync",
"time",
"io-util",
"io-std",
] }
serde = { version = "1", features = ["derive"] }
# The tenth name, and the first added since 0.3.0. `io exec --json` writes each
# `io_harness::RunEvent` as one line of JSON, and the event type derives
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ the limits that capability actually has.
| [Pictures and documents](docs/guide/media.md) | Attaching an image, drawing it where the terminal can, and the twelve document tools |
| [Reading it without seeing it](docs/guide/accessibility.md) | `--plain`, and what a screen reader, a braille display or a log gets |
| [Headless](docs/guide/headless.md) | `io exec`, the exit codes, the JSON, resuming without a terminal, and managing the configuration from a shell |
| [From an editor](docs/guide/editors.md) | `io acp`, pointing Zed or a JetBrains IDE at it, what the editor is sent, and the two things it is not |
| [Configuration](docs/guide/configuration.md) | Every section of `io.toml`, changing it without leaving the session, and where io keeps your things |
| [What the store is holding](docs/guide/store.md) | Reading the store, putting work back, and taking the work out |
| [What this release is not](docs/guide/limits.md) | The limits, stated plainly, and the ones that are decisions rather than gaps |
Expand Down
1 change: 1 addition & 0 deletions docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ the limits that capability actually has.
| [Pictures and documents](guide/media.md) | Attaching an image, drawing the real thing only where the terminal can, and the twelve document tools of which six write |
| [Reading it without seeing it](guide/accessibility.md) | `--plain`: nothing turns, nothing moves, and every state change is committed as text |
| [Headless](guide/headless.md) | `io exec` and `io resume`, the seven exit codes, `--json`, and managing MCP servers, bundles, skills and configuration from a shell |
| [From an editor](guide/editors.md) | `io acp` serving the Agent Client Protocol on stdio, the session updates an editor is sent, the three permission options, and the client-side filesystem io deliberately does not use |
| [Configuration](guide/configuration.md) | One `io.toml` over io-harness's scopes, every section that bounds a turn, changing it without leaving the session, and where io keeps your things |
| [What the store is holding](guide/store.md) | Reading the run store, undo at three grains, and exporting a conversation or a canonical trace |
| [What this release is not](guide/limits.md) | The limits, stated plainly — including the ones that are decisions rather than gaps |
Expand Down
23 changes: 23 additions & 0 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ With no subcommand, `io` opens an interactive session.
| `io plugin …` | Manage capability bundles and marketplaces |
| `io config …` | Read and write configuration keys |
| `io skill …` | Add, list and remove skills |
| `io acp` | Serves the Agent Client Protocol on stdin and stdout, for an editor |

`--plain` reaches an interactive session and stops there. `io exec` builds no theme, draws
nothing and animates nothing already, so there is no second thing for the flag to switch off.
Expand All @@ -39,6 +40,13 @@ nothing and animates nothing already, so there is no second thing for the flag t
`--policy <workspace|read-only>` and `--provider <openrouter|anthropic|openai>`.
**`--policy ask-writes` is refused**, because nothing headless can answer an approval.

`io acp` takes no arguments of its own — the client speaks the protocol, not the command line —
and the global flags reach it, which is how an editor points io at the workspace it has open.
It is not run by hand: an ACP client spawns it and speaks newline-delimited JSON-RPC 2.0 at its
stdin. **stdout is the protocol**, so nothing else is written there and diagnostics go to stderr.
It always exits `0` when the client closes the pipe, because the protocol reports a run's outcome
in its own `stopReason` and a process exit code would be a second, disagreeing answer.

## Exit codes

These are the contract a CI job depends on. **A code never changes meaning under a dependency
Expand All @@ -64,6 +72,21 @@ produce that pause today at all** — it declines every approval rather than def
session and headless alike — so the carve-out is what would be printed if it ever did, and is
written down because the code that would print it exists and is reached by nothing.

**That sentence is still true of every door 0.36.0 ships, `io acp` included.** An ACP session is
the first surface with a person behind it that is not a terminal, so it is the first that *could*
raise an approval and be answered — but 0.36.0 does not raise one. An approval in an ACP session
is refused, and the client is told so by a `session/update` naming the tool call, its act and its
target, with `status: "failed"`.

It is a notification and **not** a `session/request_permission` request. Raising a request means
waiting for the answer, and routing that answer back into the run is not wired; a request whose
reply is ignored would render a prompt in the editor whose outcome does nothing, which is worse
than not asking. The model is told the interface could not route the approval — not that the
operator denied it, because the operator was never asked.

`io exec` is unchanged and declines every approval for its own reason: an unattended run has
nobody to ask, and an approver that blocked there would hang forever.

**Every boundary refusal reaches `2`, from either headless door.** A run whose provider endpoint
lands in the `ask` tier is refused by io-harness before the run's first step, and `io resume`
re-authorizes that same endpoint before it carries anything on, so the refusal reaches both doors.
Expand Down
107 changes: 107 additions & 0 deletions docs/guide/editors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# From an editor

`io acp` serves the [Agent Client Protocol](https://agentclientprotocol.com) on stdin and stdout,
so an editor that speaks ACP runs io against your own configuration, your own provider chain and
your own bundles — without a terminal.

It is not a command you run. An ACP client spawns `io acp` as a child process and speaks
newline-delimited JSON-RPC 2.0 at it.

## Pointing an editor at it

The client needs the path to the `io` binary and the argument `acp`. In Zed, that is an entry in
`settings.json`:

```json
{
"agent_servers": {
"io": {
"command": "io",
"args": ["acp"]
}
}
}
```

Global flags reach the subcommand, so `["acp", "-C", "/path/to/repo"]` points a session at a
workspace other than the one the editor launched io from, and `["acp", "-m", "<model>"]` picks the
model for that session.

Configuration is read exactly as every other door reads it: `$IO_CONFIG`, else
`$IO_CONFIG_HOME/io.toml`, else `~/.config/io/io.toml`. There is no editor-specific settings file
and no second way to be configured — the adapter adds a protocol, not a second product.

## What the editor sees

| ACP update | What io sends |
| --- | --- |
| `agent_message_chunk` | The answer, streamed as the provider returns it |
| `agent_thought_chunk` | Reasoning, where the provider returns it separately, so your editor can fold it |
| `tool_call` | A tool starting, with its ACP kind — a read, an edit, a search, an execution |
| `tool_call_update` | That call finishing, failing, or being refused by the policy |
| `plan` | The agent's plan changed |

A turn answers with ACP's own `stopReason`. A run that finished is `end_turn`, one you cancelled
is `cancelled`, one the policy refused is `refusal`, and a budget or step ceiling is
`max_turn_requests` or `max_tokens`.

**A run that stopped to ask you something also answers `end_turn`, not `refusal`.** It has
refused nothing — it stopped with work outstanding. `io resume` carries it on from the terminal;
see [When a run stops for you](resume.md).

`cancelled` is reachable only where a cancel arrived before the turn began — see
[Cancelling](#cancelling).

## Permissions

**In 0.36.0 an editor session refuses every action the policy puts in the grey tier, and does not
ask you about it.** The tool call's update says the action needed an approval this agent could not
route, and the agent is told the same — not that you denied it, because you were never asked.

io does not send `session/request_permission`. Raising that request means waiting for your answer,
and routing the answer back into the running turn is the piece 0.36.0 does not have; a prompt in
your editor whose outcome is ignored would be worse than no prompt.

So configure the posture you want rather than relying on being asked — see
[Configuration](configuration.md). A `workspace` posture lets the agent write inside the workspace
without an approval; `read-only` refuses writes outright. Nothing is granted that you did not
grant, and nothing happens silently.

When the request does land, it will carry three options — allow once, allow for this session, deny
— and not ACP's four. There is no *reject always*, because io-harness records a remembered
**approval** and has nowhere to record a remembered refusal: a later matching action would ask
again, and an option that quietly means something narrower than its name is worse than one that is
absent.

## Cancelling

`session/cancel` is served, and in 0.36.0 it takes effect **between** turns rather than during
one: a turn in flight runs to its own end. Stopping a turn mid-flight is what the terminal's
`Ctrl+C` does; see [While it works](the-session.md).

## What is not carried

**Your editor's unsaved buffers.** ACP lets an agent read and write files *through* the client,
which is how it sees edits you have not saved. io does not: io-harness owns the filesystem inside
its own sandbox and publishes no seam to route a read through somebody else. io reads what is on
disk. Save before you ask about a file.

**A terminal the client owns.** Same reason — io runs programs inside its own sandbox, and handing
a spawn to the editor would put the run outside the boundary io exists to show you.

**More than one session per process.** A second `session/new` is refused with a sentence. Run a
second `io acp` for a second conversation.

**Loading an earlier conversation.** `session/load` is not served and `loadSession` is declared
unsupported, so a conforming client will not offer it. Runs are still in the store and `io resume`
reaches them.

## When something looks wrong

Diagnostics go to **stderr**, which most clients surface as a log for the agent process. stdout
carries the protocol and nothing else — a stray byte there is a frame boundary in the wrong place,
and every message after it is read at the wrong offset.

If the session goes silent, check the agent log for the sentence io printed. A missing provider
credential, a configuration file the harness refused, and a workspace io was not pointed at all
report there.
Loading