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
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,57 @@
All notable changes to this project are documented here. The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `GUEST_UNRESPONSIVE` and `GUEST_SERVER_DOWN`: a guest call that times out is followed by one probe of the machine,
which reads `/proc` and the server's port and never talks to OpenCode. A machine under a tenth of its memory or over
four load per CPU answers `GUEST_UNRESPONSIVE` with the free memory, the load and the three largest processes, and
its one next step is to tell the user; one with nothing on the OpenCode port answers `GUEST_SERVER_DOWN`, which the
next `boxd_job` fixes. A machine that looks well keeps `TIMEOUT`, with the facts, and a probe that does not answer,
or answers with a line that is not JSON, keeps the plain timeout.
- `boxd_job` says what the session last did: `lastText`, its newest assistant text, redacted and cut to 300
characters; `lastActivityAt`; and `pending`, the prompts waiting in its inbox. A run that completed but says its
task did not happen, such as a push refused with 401, no longer reads like a finished one.
- `boxd_submit` answers `queued: true` when the session was still running, so the prompt waits in its inbox for that
run. The read is a report, not a gate: a server that cannot say which sessions run still takes the prompt. A message
of another kind than the one asked for is never read as the assistant's last words or a run's outcome.
- A machine that is down says why. `boxd_job`'s `asleep` answer and each work in status carry `asleepBy`:
`boxd_sleep`, with the `sleptAt` the ledger recorded, or `idle_or_cli` for the idle bound or the boxd CLI. The plugin
clears the record when it wakes or recreates the machine. A wake outside the plugin followed by a later idle
hibernation still reads `boxd_sleep` with the older time: the plugin cannot see that wake.

- The skill covers many works from one Code Mode loop, where a guest's credentials come from and what a session should
do on a 401, and how to look at a slow guest with the boxd CLI. The README and the golden-image page say that a
short-lived token stops every guest once nothing renews it.

- The boxd CLI that runs inside a boxd machine is refused. It takes `--vm <name>` where the laptop CLI takes the machine
as an argument, so the plugin's commands failed one by one and the awake bound ended as `AWAKE_UNBOUND`. Every tool now
reads `boxd --help` once and, when it carries both phrases the in-VM CLI's help uses, answers `BOXD_MISSING`, saying the plugin drives remote machines from the laptop's CLI,
before it changes anything. A CLI that cannot answer `--help` is not refused.

### Fixed

- `boxd_ensure` on an open work rewrote its ledger record from a short list of fields and dropped the rest: the files
moved in and out and the last run's outcome. A drop then refused an output that a harvest had already brought back,
and status lost a failed run. Ensure now keeps every field and clears only the create intent.
- `boxd_job` could report an earlier run's error, or none, for a failed run. The guest's message list is newest first,
and the helper walked it oldest first and stopped at the first idle message. It now asks for the newest idle message
only, so a credential failure after a completed run reports `GUEST_AUTH_FAILED` with its own error.
- A coded error's tool result kept only `error` and `next`, so a Code Mode program or a script never saw what happened.
It is now `{ error, message, next }`. A timeout names its step, such as the guest's `phase` on `oc-<work id>`, and a
`GUEST_FAILED` message carries the helper's error and detail, redacted, or the last line the machine wrote.
- `docs/scripting.md` said a plugin code arrives as the RPC error `failed`. It arrives as the method's output; only the
four RPC codes are errors.
- A machine that rebooted, for example after a resize, lost the guest helper with its `/tmp`, and every tool call failed
with `GUEST_FAILED` until OpenCode restarted. A call that gets no answer now lists `/tmp`, and only when the folder is
clearly gone copies the helper again and runs once more. A crash, or a boxd CLI that failed, keeps its own error and
is never run twice.
- The skill said `directory` moves the guest's folder and that `boxd_job` answers only busy, idle or missing. It now
says `directory` is the laptop folder, names every job state, and says what `proof` `fresh`, `stale` and `missing`
mean.

## [0.0.0] - 2026-11-10
<!-- kit-release: the GitHub release v0.0.0 -->

Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,15 @@ session you cannot get back.
the current project.
2. `boxd_submit` with the task, and optionally `files: [{ from, to }]` to copy data in. Each `from` is a laptop file
inside the project or an allowed root, and each `to` is a path inside the work tree. It returns when the guest has
admitted the prompt, not when the model finishes.
admitted the prompt, not when the model finishes, with `queued: true` when the session was still running, so the
prompt waits in its inbox for that run.
3. `boxd_job` in a later turn: busy, idle, asleep, gone or starting, with the guest session's last outcome
(`completed`, `failed` or `aborted`) when it is idle. It never wakes a machine, because waking starts billing; a
hibernated work reports `asleep` with `boxd_resume` as its next step.
(`completed`, `failed` or `aborted`) when it is idle. A running machine's answer also carries `lastText`, the
session's newest assistant text (redacted, at most 300 characters), `lastActivityAt`, and `pending`, the prompts
waiting in its inbox: a run can complete and still say its task did not happen. It never wakes a machine, because
waking starts billing; a hibernated work reports `asleep` with `boxd_resume` as its next step, and `asleepBy` says
who put it down: `boxd_sleep`, with `sleptAt`, or `idle_or_cli` for the idle bound or the boxd CLI, whose time boxd
does not report.
4. `boxd_harvest`, optionally with `files: ["SUMMARY.md"]` and `to`, copies the files a work produced back to this
laptop. The session tree is exported, imported here parents first, git is recorded, and then the files move.
5. `boxd_sleep` to hibernate, `boxd_resume` to wake or rebuild, `boxd_image` to save a snapshot, `boxd_drop` to destroy.
Expand Down Expand Up @@ -167,6 +172,9 @@ Each member still needs the boxd CLI signed in on their own laptop.

## Troubleshooting

A tool that fails returns `error` (the code), `message` (what happened, such as which step timed out) and `next` (the
one next step).

| Code | What to do |
|---|---|
| `HOST_UNTESTED` | Use OpenCode `>=2.2.3 <2.3.0`, or set `"allowUntestedHost": true` to run anyway. |
Expand All @@ -189,8 +197,10 @@ Each member still needs the boxd CLI signed in on their own laptop.
| `RUN_INTERRUPTED` | The guest's OpenCode stopped during a run, so the run never finished. `boxd_resume` says so once, and `boxd_job` reports the run as aborted. Harvest what exists, then `boxd_submit` again. |
| `GUEST_AUTH_FAILED` | The failed run was a credential failure. Fix the guest's sign-in as [the golden image](https://github.com/joe-broadhead/opencode-boxd/blob/HEAD/docs/golden-image.md) describes, then `boxd_submit` again. |
| `GUEST_FAILED` | Call `boxd_status`, and recreate the machine with `boxd_resume` if the guest keeps failing. |
| `GUEST_UNRESPONSIVE` | The guest's OpenCode did not answer and the machine is short of memory or overloaded; the message names the largest processes. Tell the user, who can free it; waiting a turn does not. |
| `GUEST_SERVER_DOWN` | The guest's OpenCode did not answer and nothing listens on its port, on a machine that is otherwise well. Call `boxd_job` in a later turn: it starts the server again. |
| `BOXD_FAILED` | Read the boxd CLI's own message, and run `boxd_status` before trying again. |
| `BOXD_MISSING` | Install the boxd CLI, sign in to it, and restart OpenCode so it is on the `PATH`. |
| `BOXD_MISSING` | Install the laptop's boxd CLI, sign in to it, and restart OpenCode so it is on the `PATH`. The CLI that runs inside a boxd machine is not the one this plugin drives. |
| `TIMEOUT` | Call `boxd_status` in a later turn instead. Do not retry inside the same call. |
| `NOT_PROVEN` | Call `boxd_harvest` before sleeping, imaging or dropping this work. |
| `DIRTY` | Call `boxd_harvest`, which stores the uncommitted diff with the export, before dropping. |
Expand All @@ -214,6 +224,9 @@ agents without the default rules, the OpenCode version and one next step.
[docs/architecture.md](https://github.com/joe-broadhead/opencode-boxd/blob/HEAD/docs/architecture.md) records each
measurement, and [docs/live-check.md](https://github.com/joe-broadhead/opencode-boxd/blob/HEAD/docs/live-check.md)
lists what to check again after a boxd CLI or API change.
- **The laptop's boxd CLI only.** The plugin drives remote machines from a laptop. The boxd CLI that runs inside a boxd
machine takes `--vm <name>` where the laptop CLI takes the machine as an argument, so it is not supported: every
tool answers `BOXD_MISSING` on it, and no machine is changed.
- **Idle bounds.** boxd gives a new machine a 4-hour idle bound. The plugin sets 30 minutes when it creates or wakes a
machine, and that bound is what limits billing when the laptop is gone.
- **A create can land after boxd stops answering.** If `machine new` times out, the machine may still appear. `boxd_ensure`
Expand All @@ -225,6 +238,9 @@ agents without the default rules, the OpenCode version and one next step.
- **Model keys.** A model provider can refuse a service-account key. The
[golden image page](https://github.com/joe-broadhead/opencode-boxd/blob/HEAD/docs/golden-image.md) says what to use.
- **Sign-in is boxd's.** The plugin runs the boxd CLI you are signed in to, and never runs `boxd auth login` for you.
- **Guest credentials are boxd's too.** A guest reads its model key and any push token from `boxd env` at boot; the
plugin never reads or renews them. A token that expires, such as a one-hour GitHub App token, stops every guest that
uses it unless something renews it, and a guest session cannot finish an interactive sign-in by itself.
- Updates are not automatic for a Git install, and `opencode plugin update` and `opencode plugin check` do not work on one: to move to a newer release, remove the old entry and add the new tag.

## Uninstall
Expand Down
28 changes: 24 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,19 @@ OpenCode (laptop) boxd machine laptop
the status RPC, the tools RPC and the tool transform.
- `src/fleet.ts` — the control plane: ensure, submit, job, harvest, sleep, resume, image, drop. It refuses to sleep,
image or drop work whose laptop import is not fresh, and refuses to drop a machine it does not own.
- `src/cli-flavor.ts` — reads `boxd --help` once per binary and refuses the in-VM CLI (its help carries two phrases, and both are required) with `BOXD_MISSING` before `cli-boxd.ts`
sends a command: the in-VM CLI takes `--vm <name>` for `config set` and `snapshots save`, where the laptop CLI takes the
machine as an argument. A CLI that cannot answer `--help` is not refused; its next command reports its own failure.
- `src/cli-boxd.ts`, `src/cli-guest.ts`, `src/prover.ts` — the three ports, each a thin wrapper over a command. Every
argument is an argv array, never a shell string.
- `guest/guest.py` — the helper copied to the machine. It speaks one JSON object in and one out, and talks only to
`127.0.0.1`.
`127.0.0.1`. It lives in `/tmp/opencode-boxd` on the machine, which a reboot clears: a call that gets no answer lists
`/tmp`, and only when the listing clearly lacks the folder copies the helper again and runs the call once more. Python
never started the action in that case, so nothing runs twice; a boxd CLI that failed keeps the call's own error. Its
`health` action reads `/proc` and the server's port, never OpenCode: after a helper call times out, the plugin runs it
once, for at most 10 s. A machine short of memory or overloaded answers `GUEST_UNRESPONSIVE`, and one with nothing on
the OpenCode port `GUEST_SERVER_DOWN`, with the facts instead of a bare `TIMEOUT`. A process is named by its command
name, never its arguments.
- `src/ledger.ts` — the laptop's store: atomic writes, one lock per work, 0700/0600 modes.
- `src/paths.ts` — the laptop path policy for files in and out: the project and `allowedRoots`, no symlink, an 8 MiB file
and a 32 MiB call, and a temp-then-rename write that never replaces a local edit.
Expand Down Expand Up @@ -59,7 +68,18 @@ first, and only then may the machine sleep, be imaged or be destroyed.
lose each other's fields. `submit` stamps `lastSubmitAt` before the prompt, so a crash fails towards "stale".
- The import runs with `--standalone`, so it never depends on a service being up.
- A session's last run is `info.outcome` (`succeeded`, `failed`, `interrupted`) on `GET /api/session/<id>`, and the
same outcome with the failure's structured error on the last `idle` message from `GET /api/session/<id>/message`. An
same outcome with the failure's structured error on the last `idle` message from `GET /api/session/<id>/message`.
That list is newest first and 50 a page unless `order` and `limit` say otherwise, and `type` filters it before the
page is cut, on 2.2.3 and 2.2.6 alike. The helper asks for `?type=idle&order=desc&limit=1`: one message, the last
run's.
- An assistant message is `{type: "assistant", time: {created, completed?}, content: [...]}`, where `content` holds
`text`, `reasoning` and `tool` parts. `boxd_job` reads the newest one (`?type=assistant&order=desc&limit=1`) and keeps
its `text` parts only, redacted and cut to 300 characters on the laptop. `lastActivityAt` is the latest of the
session's `time.updated`, `busy` and `idle` and that message's times, all epoch milliseconds.
- `POST /api/session/<id>/prompt` admits a prompt for a running session too: it waits in `GET /api/session/<id>/inbox`
(`user`, `synthetic`, `compaction` and `move` items, in enqueue order) until the run takes it, as a steer by default.
`boxd_submit` reads `/api/session/active` first and says `queued`; `boxd_job` counts the inbox's `user` items as
`pending`. An
export carries both, so a harvest reads the outcome from the transcript and `boxd_job` from the live session: no model
call either way. `src/outcome.ts` maps them to `completed`, `failed` and `aborted`.
- `POST /api/session` creates a session, `POST /api/session/<id>/prompt` admits one, and
Expand Down Expand Up @@ -258,8 +278,8 @@ removed afterwards. Each item gives the command and what it answered.
- A killed run used to leave its session busy, so `SESSION_BUSY` never ended. It is now `aborted`, and `boxd_resume`
raises `RUN_INTERRUPTED` for it.
- The first `boxd_submit` after an image answered `GUEST_FAILED`, and `boxd_job` 30 s later answered `MACHINE_STARTING`,
while the server process was still listed. It did not recur, and its cause is not known. The message carries the
helper's error word only, not its detail (`src/cli-guest.ts`), so the cause could not be read from it.
while the server process was still listed. It did not recur, and its cause is not known. A `GUEST_FAILED` message
now carries the helper's error and its detail, redacted, and the tool result keeps it as `message`.

**Concurrency**
- A `boxd_submit` and a `boxd_harvest` on one work, in flight together, both returned. The ledger kept the root session,
Expand Down
18 changes: 15 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ permissions, followed by your top-level rules for boxd tools.
`/boxd-status`, the `/boxd` dialog and the status RPC read the same. No read wakes a machine, because waking starts
billing. The plugin never reads your boxd account or credentials.
- **A read never wakes.** `boxd_job` reports `asleep` for a hibernated machine and names `boxd_resume` as the next step.
`asleepBy` is `boxd_sleep` (with `sleptAt`) when the plugin hibernated it and has not woken it since, and
`idle_or_cli` otherwise: the 30-minute idle bound, or the boxd CLI. boxd does not say when a machine hibernated by
itself. Status shows the same per work. The plugin cannot see a wake it did not make: a machine woken outside it,
by the boxd CLI or an SSH connection, and put down again by the idle bound still reports `boxd_sleep` with the older
`sleptAt`, until the plugin wakes or recreates it.
- **Hibernating destroys nothing.** `boxd_sleep` needs no proof and works when the guest cannot be harvested; it records
a warning that the last prompt is not on the laptop yet, which status shows. `boxd_image` and `boxd_drop` still need a
fresh proof.
Expand All @@ -138,6 +143,9 @@ permissions, followed by your top-level rules for boxd tools.
`GUEST_RUN_FAILED` or `GUEST_AUTH_FAILED` as a warning, and status shows each work's last outcome. The error is `<type>: <first line>`, passed
through redaction; a provider's response body is never read. It comes from the guest or the imported transcript, never
from a model call.
- **A job says what the session last did.** On a running machine `boxd_job` adds `lastText` (the newest assistant text,
redacted and cut to 300 characters), `lastActivityAt` and `pending` (prompts waiting in the session's inbox). Each is
read from the guest's own session with no model call, and left out when the guest does not answer it.
- **Drop only destroys what the laptop can account for.** It refuses a non-git directory, untracked or ignored paths, and
a dirty tree with no stored patch. Tracked changes are stored as `git diff HEAD`; untracked contents are stored beside
it when the guest can read them, which is not the same as a faithful copy, so a drop still refuses.
Expand All @@ -148,7 +156,9 @@ permissions, followed by your top-level rules for boxd tools.

## Errors

Every plugin error reads `CODE: What happened. Next step: What to do.`, with exactly one next step. Upstream and host
Every plugin error reads `CODE: What happened. Next step: What to do.`, with exactly one next step. A tool's result
for it is `{ "error": "<CODE>", "message": "<what happened>", "next": "<what to do>" }`, so a Code Mode program or a
script reads the same three parts the model does. Upstream and host
errors pass through unchanged. No error, log or status line contains a token, a code or a signed URL.

| Code | What it means |
Expand All @@ -169,9 +179,11 @@ errors pass through unchanged. No error, log or status line contains a token, a
| `RUN_INTERRUPTED` | The guest's OpenCode stopped during a run, so the run never finished. The transcript is kept. Call `boxd_harvest`, then `boxd_submit` again. |
| `GUEST_AUTH_FAILED` | The guest session's last run failed on a model credential. The transcript is still imported; fix the guest's sign-in. |
| `GUEST_FAILED` | The guest helper failed an action. |
| `GUEST_UNRESPONSIVE` | A guest call timed out, and a probe of the machine found under a tenth of its memory available, or a load over four per CPU. The message gives the free memory, the load and the three largest processes by name and pid. |
| `GUEST_SERVER_DOWN` | A guest call timed out, and a probe found the machine well but nothing listening on the OpenCode port. The next guest call starts the server again. |
| `BOXD_FAILED` | The boxd CLI refused a command or returned something unusable. |
| `BOXD_MISSING` | The boxd CLI is not on the `PATH`. |
| `TIMEOUT` | A boxd command did not answer in time. |
| `BOXD_MISSING` | The boxd CLI is not on the `PATH`, or the one there is the in-VM CLI, which this plugin does not support. |
| `TIMEOUT` | A boxd command did not answer in time. After a guest call, a probe that found the machine well, server included, adds what it found. |
| `NOT_PROVEN` | No fresh laptop import. Sleep, image and drop refuse. |
| `DIRTY` | The guest tree has uncommitted work and no stored patch. |
| `EXPORT_INVALID` | An export is not in OpenCode's session shape. |
Expand Down
Loading
Loading