zc2 0.0.26

P2P compute broker with credit-based billing, WAL, and broker mesh support
# zc agent local API (v1)

`zc agent` serves this API to the Zakuro menu-bar app and widget, and to the
`zc` CLI's hand-off. It is loopback-only and never reachable from the network.

- **Base URL:** `http://127.0.0.1:<port>`. Read `port` and `token` from
  `~/.zakuro/agent/agent.json` (`$ZAKURO_HOME/agent/agent.json` when set).
  4720 (or `ZAKURO_AGENT_PORT`) is the *preferred* port, tried first; the
  agent never runs two instances for one zc dir, so a port already held by
  another running zc agent refuses to start, but a port held by anything
  else falls back to an OS-assigned one -- always read the real port from
  `agent.json`, never assume 4720.
- **Auth:** every route needs `Authorization: Bearer <token>` → 401 otherwise.
- **Browser guards:** any `Origin` header → 403. A `Host` other than
  `127.0.0.1:<port>` / `localhost:<port>` → 403. No CORS headers are sent.
- **Errors:** `{"error":{"code":"…","message":"…"}}`. `message` is for people;
  branch on `code`. The guards run before routing, so an unknown route or a
  wrong method without a valid token is 401 (or 403), not 404 or 405.

| Status | `code` | When |
|---|---|---|
| 400 | `invalid_input` | the body isn't JSON the route accepts (a wrong type, an unknown field in a price body, a missing `price_per_hour`), a `scope` other than `device` or `default`, or a worker count or price out of range |
| 401 | `unauthorized` | the bearer token is missing or wrong |
| 403 | `forbidden` | an `Origin` header, or an unexpected `Host` |
| 404 | `not_found` | no such route |
| 405 | `method_not_allowed` | the route exists, but not with this method |
| 409 | `conflict` | sharing on while a broker the agent didn't start is running (`zc down` stops it), or a device price while the hub has no worker rows for this Mac yet |
| 500 | `internal` | the agent couldn't save its own state file (`state.json`) |
| 502 | `hub_error` | a hub write failed, or the hub never answered; `message` carries the hub's reason (see `hub.error`) |
| 503 | `prerequisite_missing` | a prerequisite is missing: this Mac isn't signed in (`zc login`) |

| Route | Body | Effect |
|---|---|---|
| `GET /v1/summary` || cached merged summary |
| `POST /v1/refresh` || re-poll the hub now, then return the summary. The broker and workers are re-observed on the agent's own tick (every 2 s, 1 s while a worker drains); refresh wakes that tick but doesn't wait for it |
| `PUT /v1/sharing` | `{"on": bool}` | persist, then reconcile (start, or drain and stop) |
| `PUT /v1/workers` | `{"count": 0..max}` | persist, then reconcile; out of range → 400 |
| `PUT /v1/price` | `{"scope":"device"\|"default","price_per_hour": number\|null}` | hub write (below) |

Every write returns the fresh summary.

### `PUT /v1/price`

- The body is strict. `scope` and `price_per_hour` are both required, and any
  other field is a 400.
- `"price_per_hour": null` clears the price: `device` puts this Mac's workers
  back on the account default, `default` removes the account default. A
  missing `price_per_hour` is a 400, never a clear.
- A number must be finite and within `prices.bounds`.
- `device` writes every one of this Mac's worker rows on the hub at once,
  retrying each failed row once.

## Summary v1

[`agent-api/summary.v1.json`](agent-api/summary.v1.json) is the contract.
`zc`'s test suite serializes to it and the macOS app's `swift test` decodes it;
any incompatible change bumps `v`. Field rules:

- `env`: `production` | `staging` | `custom`, from the hub URL zc uses.
- `account`, `earnings`, `devices`: `null` until the first successful hub
  poll, then the last good values, kept while the hub is unreachable.
- `devices`: `[]` means the hub answered and lists no devices; `null` means it
  hasn't answered yet.
- Timestamps (`as_of`, `hub.last_ok`, `devices[].last_seen_at`): RFC 3339 in
  UTC, whole seconds, with a `Z`, e.g. `2026-09-13T01:41:30Z`, whatever form
  the hub sent. A hub timestamp the agent can't parse becomes `null`.
- `hub.reachable`: `false` while the hub can't be reached (or rejects this
  Mac's key). A hub that answers but predates the summary route stays
  `reachable: true`, alongside the `hub_too_old` problem.
- `hub.error`: the last hub failure, or `null`. For an HTTP error it carries
  FastAPI's `detail` string, otherwise at most about 200 characters of the
  body, never a whole HTML page.
- `prices.this_mac.state`: one of four values.
  - `set`: every worker of this Mac has the same explicit price.
  - `inherited`: every one is `null` (all inherit the account default).
  - `mixed`: anything else; `per_hour` is `null` and `effective_per_hour` is
    the minimum across this Mac's workers.
  - `disabled`: every one of this Mac's workers is disabled on the hub;
    `per_hour` and `effective_per_hour` are both `null`.
- `prices.default_per_hour`: `null` when the account has no default price set
  (a negative account default arrives on the wire as `null`, never as a
  negative number).
- `this_mac.broker`: `running` | `starting` | `stopping` | `stopped` | `unmanaged` | `error`.
- `this_mac.workers.max`: `ZAKURO_AGENT_MAX_WORKERS`, else logical CPUs / 2, at least 1.
- `devices_total` = number of devices; `devices_online` = devices whose
  `freshness` is `fresh`; `devices[].link` uses the first online worker, or `null`.
- `problems[].code`:

| Code | Meaning | Hint |
|---|---|---|
| `not_logged_in` | no credentials, or the hub answered 401 | Run `zc login` |
| `hub_unreachable` | the last hub poll failed | Showing the last known values |
| `hub_too_old` | the summary route returned 404 | Local status only until the hub is updated |
| `uv_missing` | `uv` isn't installed | `brew install uv` |
| `zakuro_dir_missing` | the zakuro worker directory can't be found; `detail` names the setting that pointed at an invalid path, or `null` when nothing was set | Clone zak-zakuro into `~/.zakuro/zak-zakuro`, or set `ZAKURO_WORKER_DIR` |
| `unmanaged_broker` | a broker the agent didn't start is running; `zc down` pauses the agent and stops it | Run `zc down` so the agent can manage sharing |
| `port_in_use` | no free port was found for the broker: 9000, the 9001-9010 fallback range, and an OS-assigned port were all unavailable | No free port for the broker (9000–9010 and a random port were all unavailable) |
| `worker_crashloop` | a worker keeps crashing; `detail` holds the last 20 log lines | See `~/.zakuro/agent/workers/` |
| `drain_timeout` | a drain took longer than allowed | Check the worker's log for a stuck request |
| `broker_crashloop` | the broker keeps crashing | Check `~/.zakuro/agent/agent.log` |

## Settings read when the agent starts

`zc agent run` reads these once, when it starts. A running agent keeps the
values it started with, so restart it after changing any of them (`zc agent
install` rewrites the LaunchAgent and restarts it).

- `ZAKURO_HOME`, `ZAKURO_AGENT_PORT`, `ZAKURO_WORKER_DIR`,
  `ZAKURO_AGENT_MAX_WORKERS` and `ZAKURO_AGENT_WORKER_CMD`: `zc agent install`
  copies each one set in its shell into the LaunchAgent.
- The settings the agent spawns its broker and workers with: `ZAKURO_P2P`
  (whether the broker joins the P2P mesh; otherwise it does when this Mac is on
  the mesh) and the worker command (`ZAKURO_AGENT_WORKER_CMD`).

The zakuro worker directory itself is **not** fixed at startup like the
settings above: only the *process-environment* value of `ZAKURO_WORKER_DIR`
is fixed for the agent's lifetime (carried into the LaunchAgent as listed
above, same as the others). Which directory that setting -- together with
everything else in the lookup -- resolves to is re-checked fresh on every
reconcile tick (a cheap file check), in order: that `ZAKURO_WORKER_DIR`, then
the same key read live from `<zc dir>/env`, then `~/.zakuro/zak-zakuro`
(`$ZAKURO_HOME/zak-zakuro`), then the legacy candidates (`../zak-zakuro` next
to the cwd, `/opt/code/ZAK/zak-zakuro`, `$HOME/zak-zakuro`). A fresh clone or
an edited `<zc dir>/env` therefore clears `zakuro_dir_missing`, and a newly
spawned worker picks up the new directory as its cwd, without restarting the
agent.

## The broker's port

The broker prefers 9000. If that is held by a zakuro broker the agent didn't
start, sharing is blocked with `unmanaged_broker` and the port is left alone.
If it is held by anything else, the agent falls back to the first free port
in 9001-9010 (the same range zc clients scan for a local broker -- see
`zc://localhost`'s resolution), and finally to an OS-assigned port if the
whole range is taken. The Summary v1 shape carries no broker port (`this_mac.broker`
is only the state label `running`/`starting`/.../`unmanaged`/`error`); the
chosen port is recorded in `children.json` instead, so a restarted agent finds
and re-adopts the same broker. An OS-assigned fallback port is outside the
9000-9010 scan, so a zc client looking for a local broker on its own won't
find it there -- only the agent itself, via `children.json`, knows where it
is. Only when nothing at all is free does the agent report `port_in_use`.