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
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,29 @@ jobs:
irm https://raw.githubusercontent.com/initorigin/io-cli/main/install.ps1 | iex
```

Or with a package manager:

```sh
brew tap initorigin/io-cli https://github.com/initorigin/io-cli
brew install initorigin/io-cli/io
```

```powershell
scoop bucket add io-cli https://github.com/initorigin/io-cli
scoop install io
```

The tap and the bucket are updated from this Release's own
`SHA256SUMS` shortly after it is published, so they may serve the
previous version for a few minutes.

Both scripts verify the artifact against `SHA256SUMS` before unpacking
it and install into a per-user directory with no administrator rights.
The checksum defends against a truncated download and a tampered asset;
`curl | sh` is a trust-the-publisher model regardless.

`io upgrade` prints the command that updates whichever of these
installed the binary you are running.
EOF
cat notes.md

Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ the only dev-dependency. Adding one is a deliberate, argued act — read the com
- **No agent loop.** The sweep strips comments first, so a doc comment containing "while" is not
a finding; `src/provider.rs` is exempted by path and held to four properties of its own.

**Module map (`src/`, 79 files).**
**Module map (`src/`, 82 files).**

- Session surface — `app.rs` (state), `term.rs` (an inline viewport, never the alternate
screen), `events.rs` + `triage.rs` (what each io-harness event does; a kind with no
Expand All @@ -82,7 +82,8 @@ the only dev-dependency. Adding one is a deliberate, argued act — read the com
field for field, asserted by Debug equality.
- Configuration — `edit.rs` (a format-preserving writer that replaces one value's bytes),
`settings.rs`, `configure.rs` (`/config`), `servers.rs` (`/mcp`), `providers.rs`,
`home.rs` (`~/.io-cli`), `reload.rs`, `memory.rs`, `recall.rs`.
`home.rs` (`~/.io-cli`), `reload.rs`, `memory.rs`, `recall.rs`, `upgrade.rs` (which of the
three installers placed the running binary, read from its own path and never from a network).
- Work — `exec.rs` (headless, and the exit-code mapping), `resume.rs`, `lock.rs`, `queue.rs`,
`compact.rs`, `context.rs`, `fleet.rs`, `plan.rs`, `intent.rs`, `gates.rs`, `routing.rs`,
`provider.rs`.
Expand Down
66 changes: 65 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,69 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

## [0.38.0] - 2026-09-04

The release that lets you install io the way you install everything else. Distribution was a
script piped from a URL and nothing else — a checksum-verified, no-sudo, no-lifecycle-script
path, and also the path a security-conscious developer's employer has told them not to take. A
Homebrew formula and a scoop manifest now point at the same four artifacts the installers
already fetch, so nothing about what is distributed changes; what changes is how many ways there
are to reach it. Both live in this repository rather than in two of their own, which costs one
longer `brew tap` line and saves two public repositories and a cross-repository push credential.

`io upgrade` prints the command that updates the binary you are running — `brew upgrade io`,
`scoop update io`, or the installer line — decided from the running binary's own path. It prints
and does not run, and it asks nothing about what the newest version is. There is still no
version check: this crate has no HTTP client and `tests/dependencies.rs` fails the build if it
grows one.

And three things this product published as limitations stop being true. `io acp` asks the editor
for permission instead of denying every approval. `/cost`'s grouped rows say when a cache-write
count is a floor rather than a fact. And declining a bundle re-install leaves the installed
adapter alone, which the guide has claimed in writing since 0.35.0 while the code did the
opposite.

### Added

- A Homebrew formula (`Formula/io.rb`) and a scoop manifest (`bucket/io.json`), served from this
repository as its own tap and bucket. `scripts/update-tap.sh` regenerates both from a published
Release's own `SHA256SUMS`, so no digest is ever hand-copied.
- `io upgrade`, the ninth argv variant. It classifies the running binary's path as a Homebrew,
scoop or script install and names the matching command; a path none of the three would have
produced is told so rather than given advice that might replace a binary something else placed.
- `session/request_permission` in `io acp`, with the three options `io_harness::Decision` can
express. The answer decides the call, and `allow-session` remembers exactly that act on exactly
that target — the same rule the terminal's overlay writes, from one shared function.
- A cache-write floor on `/cost`'s per-model and per-day rows, in the words the totals above them
have used since 0.36.0.

### Changed

- **`RunOutcome::SchemaUnsatisfied` has an exit code, a description and an ACP stop reason.** The
variant arrived in io-harness 0.77.0 and that crate's changelog never declares it; the enum is
`#[non_exhaustive]`, so a run that failed its output schema exited `5` where its sibling
`VerificationFailed` exits `6`, and reported `end_turn` to an editor. It now exits `6`.
- The bundle adapter is swapped **after** consent rather than before it, so a declined re-install
leaves the installed adapter byte-identical.
- `io acp`'s reader runs on its own task. It awaited the turn inline, which meant the answer to a
question that turn asked could not be read until the turn returned — a deadlock, and the reason
the permission round trip was never wired.

### Fixed

- Forty-one io-harness source citations across `src/` and `tests/` re-anchored to the pinned
tree. They were **three generations deep** — some naming io-harness 0.69-era line numbers, some
0.74-era — and had been asserting false `file:line` facts for seven or more releases.
`tests/docs.rs`'s `f19` never saw them: it checks that a citation naming a version names the
*pinned* version, and it does not check the line at all, so an unversioned citation is invisible
to it.

### Dependencies

- io-harness **0.76.0 → 0.78.0**. `EventKind::ToolCall` gained `origin`, which this release takes
and deliberately does not consume; `EventKind` is 53 variants in both, so no mapping table grew
a row.

## [0.37.0] - 2026-09-04

The release that says where your context went. `/context` has read the real outgoing request
Expand Down Expand Up @@ -3397,7 +3460,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.37.0...HEAD
[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.38.0...HEAD
[0.38.0]: https://github.com/initorigin/io-cli/compare/v0.37.0...v0.38.0
[0.37.0]: https://github.com/initorigin/io-cli/compare/v0.36.0...v0.37.0
[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
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "io-cli"
version = "0.37.0"
version = "0.38.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.76", features = ["media", "browser", "documents"] }
io-harness = { version = "0.78", 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
68 changes: 68 additions & 0 deletions Formula/io.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# The Homebrew formula for io. The repository is the tap, so it is added by URL:
#
# brew tap initorigin/io-cli https://github.com/initorigin/io-cli
# brew install initorigin/io-cli/io
#
# The formula ships the Release's own prebuilt artifacts and has no build step.
# io links io-harness, which carries rusqlite with a bundled C SQLite: building
# from source here would put a C toolchain and several minutes between
# `brew install` and a working binary, and it would hand the user bytes nobody
# published a checksum for. The three archives named below are the ones the
# release workflow built, and every sha256 is copied out of that Release's
# SHA256SUMS by `scripts/update-tap.sh`. Never by hand — a hand-copied digest is
# how a formula comes to name an artifact that does not exist.
#
# The version the URLs name is the newest RELEASED one, which during a release is
# one behind the version being built: an artifact that has not been uploaded yet
# has no checksum to name. The generator runs after the Release is cut and its
# change goes in through an ordinary pull request, because the `pull_request`
# ruleset on `main` and `develop` bypasses for `OrganizationAdmin` alone and the
# release workflow is not one.
#
# **There is deliberately no `version` stanza.** Homebrew scans the version out
# of the url, `brew audit --strict` calls an explicit one redundant, and it is
# worse than redundant: it is a second declaration of a fact the urls already
# carry, and the two can disagree. A formula whose `version` said 0.37.0 over
# urls naming 0.36.0 would install 0.36.0 and report the other number from its
# own `test` block.
class Io < Formula
desc "Terminal agent that shows what it may do, what it spends and what it refused"
homepage "https://github.com/initorigin/io-cli"
license "Apache-2.0"

on_macos do
if Hardware::CPU.arm?
url "https://github.com/initorigin/io-cli/releases/download/v0.37.0/io-0.37.0-aarch64-apple-darwin.tar.gz"
sha256 "93fa6ea5a85535457056207cf8ddde44c3a2b1f58dd644e78b26853f4169069f"
end

if Hardware::CPU.intel?
url "https://github.com/initorigin/io-cli/releases/download/v0.37.0/io-0.37.0-x86_64-apple-darwin.tar.gz"
sha256 "f070a8d2afed9498a8a08bbf49683744b5741dda90319291430b99bcb98e08f8"
end
end

# Linux is musl and x86_64 only, exactly as the release matrix is. There is no
# Linux arm64 artifact to point at, so that machine gets Homebrew's own refusal
# rather than a URL that 404s halfway through an install.
on_linux do
if Hardware::CPU.intel?
url "https://github.com/initorigin/io-cli/releases/download/v0.37.0/io-0.37.0-x86_64-unknown-linux-musl.tar.gz"
sha256 "1196630b787c343e96953a4091e78908d812c39a10876655c4afe6c3dbc2faf3"
end
end

# Each archive holds one versioned directory — `io-<version>-<target>/` with the
# binary beside LICENSE, NOTICE, README.md and CHANGELOG.md — and Homebrew
# descends into that single directory before this runs. Only the binary is
# installed; the licence and the notice travel with the Release itself.
def install
bin.install "io"
end

# The binary's own words, not the formula's: proof that what was unpacked runs
# here and is the version this file claims to have installed.
test do
assert_match version.to_s, shell_output("#{bin}/io --version")
end
end
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,37 @@ Windows, in PowerShell:
irm https://raw.githubusercontent.com/initorigin/io-cli/main/install.ps1 | iex
```

Or with a package manager you already have. macOS and Linux:

```sh
brew tap initorigin/io-cli https://github.com/initorigin/io-cli
brew install initorigin/io-cli/io
```

Windows:

```powershell
scoop bucket add io-cli https://github.com/initorigin/io-cli
scoop install io
```

The tap and the bucket live in this repository rather than in two of their own,
which is why `brew tap` and `scoop bucket add` name a URL. They install the same
checksum-verified artifacts the scripts do.

Both scripts pick the right build for your machine, **verify it against the
published `SHA256SUMS` before unpacking it**, and install into a directory you
own — `~/.local/bin`, or `%LOCALAPPDATA%\io\bin` on Windows. Neither needs
administrator rights and neither edits your shell profile or your `PATH`: if
the directory is not on your `PATH`, the script prints the line to add.

Re-running the script is how you update. There is no auto-update and no version
check: a terminal tool that contacts a server you did not ask it to contact is
one of this product's stated non-goals.
`io upgrade` prints the command that updates the binary you are running:
`brew upgrade io` from a Homebrew install, `scoop update io` from a scoop one,
and the installer line above from a script install. It prints and does not run,
and it asks nothing about what the newest version is. There is still no
auto-update and no version check: a terminal tool that contacts a server you did
not ask it to contact is one of this product's stated non-goals, and `io upgrade`
reads the path of the running binary rather than a network.

Set `IO_VERSION` to install a specific version, and `IO_INSTALL_DIR` to install
somewhere else.
Expand Down Expand Up @@ -155,7 +177,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 |
| [From an editor](docs/guide/editors.md) | `io acp`, pointing Zed or a JetBrains IDE at it, what the editor is sent, how it is asked for permission, and what it is still 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
28 changes: 28 additions & 0 deletions bucket/io.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"version": "0.37.0",
"description": "Terminal agent that shows what it may do, what it spends and what it refused",
"homepage": "https://github.com/initorigin/io-cli",
"license": "Apache-2.0",
"architecture": {
"64bit": {
"url": "https://github.com/initorigin/io-cli/releases/download/v0.37.0/io-0.37.0-x86_64-pc-windows-msvc.zip",
"hash": "a6011df7775bdcc4d3a8b5e026690c0b010ee42c7d018c595196cb323d21ce57",
"extract_dir": "io-0.37.0-x86_64-pc-windows-msvc"
}
},
"bin": "io.exe",
"checkver": {
"github": "https://github.com/initorigin/io-cli"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/initorigin/io-cli/releases/download/v$version/io-$version-x86_64-pc-windows-msvc.zip",
"extract_dir": "io-$version-x86_64-pc-windows-msvc",
"hash": {
"url": "https://github.com/initorigin/io-cli/releases/download/v$version/SHA256SUMS"
}
}
}
}
}
32 changes: 21 additions & 11 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ With no subcommand, `io` opens an interactive session.
| `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 |
| `io upgrade` | Prints the command that updates this binary, for the way it was installed |

`--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 Down Expand Up @@ -72,17 +73,26 @@ 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.
**That sentence is true of `io exec`. It stopped being true of `io acp` in 0.38.0.** An ACP
session is the first surface with a person behind it that is not a terminal, so it is the first
that can raise an approval and be answered — and it does. An approval becomes a
`session/request_permission` request naming the tool call, its act and its target, offering three
options: allow once, allow for this session, and deny.

The answer decides the call. `allow-session` remembers exactly that act on exactly that target
for the rest of the run, which is the same rule the terminal's own approval overlay writes —
both surfaces build it in one place, so they cannot come to mean different things by the same
word. An option id that was never offered, a cancellation, and a JSON-RPC error are all
denials: there is one safe direction to be wrong in.

**A client that disconnects mid-question denies rather than hangs, and there is no timeout.**
When the reader stops, every outstanding request is abandoned and each waiter becomes a denial.
A timeout would have been a number invented here — a minute is too short for someone reading a
diff, and an hour is indistinguishable from a hang. The connection ending is the real event.

Where nobody answered, the model is told the interface could not route the approval, never that
the operator denied it: the operator was not asked. Where the operator *was* asked and said no,
the model is told they refused, because that is what happened.

`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.
Expand Down
Loading