v0.2.0: send — one entry, the path chooses itself, headless when the app is closed #2

Merged
claude merged 5 commits from oss-oo/appserver-send-headless into main 2026-10-27 22:11:14 +00:00
Member

v0.2.0 — oo-appserver send

The owner's order (2026-10-27, ~21:55): a line must reach a Codex thread with and without the ChatGPT app, the way ZCode sessions are reached without the ZCode app. Design agreed with Z (steer 36/37): one entry whose path chooses itself, verb-first receipt classes that live only here, heal-when-safe only while the app is closed, send --json as the transport contract for the oo-dispatch appserver backend (lands as v1.0.94 there, claude-session as v1.0.95). The neighbour session's handoff (~/.zcode/tmp/claude/appserver-resume-measure-20261027/HANDOFF.md: the owner's nine statements, the measurements, nine proposals) is folded in.

What ships

  • oo-appserver send <thread> "<line>" (libexec/appserver_send.py): observes the station once, then — app closed → headless (the shared server resumes the thread from its rollout with excludeTurns and a timeout scaled to the rollout size, then turn/start, or turn/steer when a headless turn still runs); app attached → turn/start on an idle thread / turn/steer into a running turn; app in stdio fallback or not attached → refused as held-by-app without any network contact (never a second writer).
  • Heal when safe: a stale (version drift) or closed door is restarted/started before a headless send only while the app is closed, no client holds a connection (checked again right before the restart) and no loaded thread runs a turn; every heal decision is its own receipt row (healed / heal-skipped with the why in skipped_because / heal-failed). A hung unit is restarted, a failed one reset first; oo-appserver heal runs the same decision without sending; a disabled unit is honoured (door-closed).
  • Receipt classes: delivered-headless, delivered-app-start, delivered-app-steer, held-by-app, heal-skipped, plus refused (incl. reason: unloaded-sub-agent), loading, timeout, door-closed, no-active-turn, dropped. Exit 0 only when delivered (1 refused · 2 usage · 3 held-by-app / no-active-turn · 4 door-closed · 5 timeout · 6 dropped · 7 loading). --json prints exactly one receipt line; every send row carries the same keys (unused ones null).
  • Measured defaults (neighbour session's rig with copies of the station's rollouts, 2026-10-27): a full resume of the 719 MB thread answers 143 MB (164 s in the v0.1.0 client), with excludeTurns 5.5 KB; the first load of a thread the history does not know costs 0.011–0.02 s/MB (7.6 s warm / 14.1 s cold for 719 MB), later loads 0.14–0.28 s regardless of size. Resume timeout max(10 s, MB × 0.05) capped at 120 s (the 719 MB thread gets 36 s); past it the sender waits up to 60 s for the thread to appear in thread/loaded/list (the server finishes a load even when the client gives up; every call inside that wait is bounded) and resumes again; only then loading — not an error, retry later, never resend, no throttle consumed.
  • Flags: --from/--tag (sender tag [NAME 📡]), --fyi (the fleet's FYI template), --mode auto|start|steer, --wait, --min-interval (advisory per-thread throttle, marked right after the ack; 0 leaves no mark), --no-heal, --dry-run.
  • doctor treats "ChatGPT not running" as the headless path, prints the headless readiness line and words the stale finding for the closed-app case.
  • Shared read-only observation libexec/appserver_station.py and the stdlib WebSocket client libexec/appserver_ws.py (bytearray buffer, bulk unmask, reply = id without method, deadline enforced inside a frame); appserver_turn.py / appserver_probe.py unchanged (the README marks the old client as slow on big threads).
  • Offline suite tests/test_send.py: 62 legs against a fake app-server (tests/fake_appserver.py: readyz + JSON-RPC, slow first load in the background, slow loaded/list answers, turns in the reply, sub-agents, id collisions, stray completions, cut connections) with a canned station and a stand-in systemctl. Wired into CI; install.sh installs the new modules and compiles them where they land.
  • README: decision table, heal rules and actions, resume/loading, receipts, backend contract, known gaps (two unmeasured edges named), the Claude-session parity target flag.

Live proofs (station, app closed)

  1. 22:28 — oo-appserver send 01a17dae-8f23-7e70-b236-26d45dc8a6e1 … --from claude --tag --fyi --wait 90 (commit f23f2cd): heal restart in 941 ms (running codex-cli 0.164.0-alpha.11.1 → 0.165.0-alpha.1.2; gates: app closed, 0 clients, no active turn), thread/resume 1 457 ms, turn/start acknowledged after 1 487 ms, turn/completed after 7 185 ms with the answer ok. Receipt delivered-headless, heal row healed. The stale shared service noted in the handoff is thereby healed.
  2. 23:03 — same thread, commit 1fcff63: slim resume (excludeTurns) 185 ms, ack 346 ms, completed 6 582 ms, ok, loaded_before: true (the thread stayed loaded since proof 1), no heal due. status counts the loaded thread, doctor reports no findings.

Evidence: ~/.zcode/tmp/claude-adapter-design-20261027/v020-headless-proof/ on the station; the measurements under ~/.zcode/tmp/claude/appserver-resume-measure-20261027/.

Independent reviews

Review 1 (fresh read-only subagent on f23f2cd): 41/41 legs confirmed; should-fix — --min-interval 0 wiped other threads' throttle memory and the temp path raced; replies matched by id only; status counted loaded threads wrongly; refusal rows lacked the contract keys and an unwritable receipt file hid the verdict; door closed with a hung or failed unit was not healed; re-read the client count right before the restart; quadratic read buffer; a throttle test that proved nothing. Folded in 1fcff63 (41 → 55 legs).

Review 2 (second fresh read-only subagent on the delta f23f2cd..2685f62): all nine delta claims verified, 55/55 twice without flakiness, no path restarts with a client or turn present, no double send possible; should-fix — inside the loading wait, thread/loaded/list and the second resume were unbounded by the deadline and could end as timeout instead of loading; nits — throttle directory for nested paths and a temp-file leak, undocumented throttle semantics, receipt key notes, the newest-first fallback applied to a resume reply that carries turns, FYI wording, untested late-client and throttle-error paths, tight timing margins. Folded in 1871c1e (55 → 60 legs). Confirmation of that fold-in by the same reviewer: "delta clean — mergeable as is" (the stalled-loaded/list probe now ends as loading); two nits — a leg that proved nothing because the fake honoured excludeTurns, and the throttle mark landing after the completion wait — folded in dde4b54 (60 → 62 legs: turns in the reply by status, active status without a running turn, early mark).

Review texts: v020-review/ in the evidence folder. CI green on every commit (runs 3–7).

Stage

Code · offline tests green (62/62) · headless path live verified twice · app-attached paths unchanged in protocol terms from v0.1.0 (T7) and covered by the suite.

🤖 Generated with Claude Code

## v0.2.0 — `oo-appserver send` The owner's order (2026-10-27, ~21:55): a line must reach a Codex thread **with and without the ChatGPT app**, the way ZCode sessions are reached without the ZCode app. Design agreed with Z (steer 36/37): one entry whose path chooses itself, verb-first receipt classes that live only here, heal-when-safe only while the app is closed, `send --json` as the transport contract for the oo-dispatch `appserver` backend (lands as v1.0.94 there, `claude-session` as v1.0.95). The neighbour session's handoff (`~/.zcode/tmp/claude/appserver-resume-measure-20261027/HANDOFF.md`: the owner's nine statements, the measurements, nine proposals) is folded in. ### What ships - **`oo-appserver send <thread> "<line>"`** (`libexec/appserver_send.py`): observes the station once, then — app closed → **headless** (the shared server resumes the thread from its rollout with `excludeTurns` and a timeout scaled to the rollout size, then `turn/start`, or `turn/steer` when a headless turn still runs); app attached → `turn/start` on an idle thread / `turn/steer` into a running turn; app in stdio fallback or not attached → refused as **`held-by-app`** without any network contact (never a second writer). - **Heal when safe**: a stale (version drift) or closed door is restarted/started before a headless send only while the app is closed, no client holds a connection (checked again right before the restart) and no loaded thread runs a turn; every heal decision is its own receipt row (`healed` / `heal-skipped` with the why in `skipped_because` / `heal-failed`). A hung unit is restarted, a failed one reset first; `oo-appserver heal` runs the same decision without sending; a disabled unit is honoured (`door-closed`). - **Receipt classes**: `delivered-headless`, `delivered-app-start`, `delivered-app-steer`, `held-by-app`, `heal-skipped`, plus `refused` (incl. `reason: unloaded-sub-agent`), `loading`, `timeout`, `door-closed`, `no-active-turn`, `dropped`. Exit 0 only when delivered (1 refused · 2 usage · 3 held-by-app / no-active-turn · 4 door-closed · 5 timeout · 6 dropped · 7 loading). `--json` prints exactly one receipt line; every send row carries the same keys (unused ones null). - **Measured defaults** (neighbour session's rig with copies of the station's rollouts, 2026-10-27): a full resume of the 719 MB thread answers 143 MB (164 s in the v0.1.0 client), with `excludeTurns` 5.5 KB; the first load of a thread the history does not know costs 0.011–0.02 s/MB (7.6 s warm / 14.1 s cold for 719 MB), later loads 0.14–0.28 s regardless of size. Resume timeout `max(10 s, MB × 0.05)` capped at 120 s (the 719 MB thread gets 36 s); past it the sender waits up to 60 s for the thread to appear in `thread/loaded/list` (the server finishes a load even when the client gives up; every call inside that wait is bounded) and resumes again; only then `loading` — not an error, retry later, never resend, no throttle consumed. - Flags: `--from/--tag` (sender tag `[NAME 📡]`), `--fyi` (the fleet's FYI template), `--mode auto|start|steer`, `--wait`, `--min-interval` (advisory per-thread throttle, marked right after the ack; `0` leaves no mark), `--no-heal`, `--dry-run`. - `doctor` treats "ChatGPT not running" as the headless path, prints the headless readiness line and words the stale finding for the closed-app case. - Shared read-only observation `libexec/appserver_station.py` and the stdlib WebSocket client `libexec/appserver_ws.py` (bytearray buffer, bulk unmask, reply = id without `method`, deadline enforced inside a frame); `appserver_turn.py` / `appserver_probe.py` unchanged (the README marks the old client as slow on big threads). - **Offline suite** `tests/test_send.py`: 62 legs against a fake app-server (`tests/fake_appserver.py`: readyz + JSON-RPC, slow first load in the background, slow loaded/list answers, turns in the reply, sub-agents, id collisions, stray completions, cut connections) with a canned station and a stand-in systemctl. Wired into CI; `install.sh` installs the new modules and compiles them where they land. - README: decision table, heal rules and actions, resume/`loading`, receipts, backend contract, known gaps (two unmeasured edges named), the **Claude-session parity** target flag. ### Live proofs (station, app closed) 1. 22:28 — `oo-appserver send 01a17dae-8f23-7e70-b236-26d45dc8a6e1 … --from claude --tag --fyi --wait 90` (commit f23f2cd): heal `restart` in 941 ms (running codex-cli 0.164.0-alpha.11.1 → 0.165.0-alpha.1.2; gates: app closed, 0 clients, no active turn), `thread/resume` 1 457 ms, `turn/start` acknowledged after 1 487 ms, `turn/completed` after 7 185 ms with the answer `ok`. Receipt `delivered-headless`, heal row `healed`. The stale shared service noted in the handoff is thereby healed. 2. 23:03 — same thread, commit 1fcff63: slim resume (`excludeTurns`) 185 ms, ack 346 ms, completed 6 582 ms, `ok`, `loaded_before: true` (the thread stayed loaded since proof 1), no heal due. `status` counts the loaded thread, `doctor` reports no findings. Evidence: `~/.zcode/tmp/claude-adapter-design-20261027/v020-headless-proof/` on the station; the measurements under `~/.zcode/tmp/claude/appserver-resume-measure-20261027/`. ### Independent reviews **Review 1** (fresh read-only subagent on f23f2cd): 41/41 legs confirmed; should-fix — `--min-interval 0` wiped other threads' throttle memory and the temp path raced; replies matched by id only; `status` counted loaded threads wrongly; refusal rows lacked the contract keys and an unwritable receipt file hid the verdict; door closed with a hung or failed unit was not healed; re-read the client count right before the restart; quadratic read buffer; a throttle test that proved nothing. **Folded in 1fcff63** (41 → 55 legs). **Review 2** (second fresh read-only subagent on the delta f23f2cd..2685f62): all nine delta claims verified, 55/55 twice without flakiness, no path restarts with a client or turn present, no double send possible; should-fix — inside the loading wait, `thread/loaded/list` and the second resume were unbounded by the deadline and could end as `timeout` instead of `loading`; nits — throttle directory for nested paths and a temp-file leak, undocumented throttle semantics, receipt key notes, the newest-first fallback applied to a resume reply that carries turns, FYI wording, untested late-client and throttle-error paths, tight timing margins. **Folded in 1871c1e** (55 → 60 legs). **Confirmation of that fold-in by the same reviewer:** "delta clean — mergeable as is" (the stalled-loaded/list probe now ends as `loading`); two nits — a leg that proved nothing because the fake honoured `excludeTurns`, and the throttle mark landing after the completion wait — **folded in dde4b54** (60 → 62 legs: turns in the reply by status, active status without a running turn, early mark). Review texts: `v020-review/` in the evidence folder. CI green on every commit (runs 3–7). ### Stage Code · offline tests green (62/62) · headless path live verified twice · app-attached paths unchanged in protocol terms from v0.1.0 (T7) and covered by the suite. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude agent added 1 commit 2026-10-27 21:30:13 +00:00
The owner's order (2026-10-27): a line must reach a Codex thread with and without the
ChatGPT app, the way ZCode sessions are reached without the ZCode app.

- `oo-appserver send <thread> "<line>"` observes the station once and takes one path:
  app closed → headless (the shared server resumes the thread from its rollout, timeout
  scaled to the rollout size, then turn/start — or turn/steer when a headless turn still
  runs); app attached → turn/start on an idle thread, turn/steer into a running turn;
  app in stdio fallback or not attached → refused as held-by-app (never a second writer).
- Heal when safe: a stale or closed door is restarted/started before a headless send only
  while the app is closed, no client holds a connection and no loaded thread runs a turn;
  every heal decision is its own receipt row (healed / heal-skipped with the why / heal-failed).
  `oo-appserver heal` runs the same decision without sending.
- Receipt classes, verb first: delivered-headless, delivered-app-start, delivered-app-steer,
  held-by-app, heal-skipped, plus refused, timeout, door-closed, no-active-turn, dropped
  (per-thread throttle). `--json` prints exactly one receipt line — the transport contract
  for the oo-dispatch `appserver` backend; exit 0 only when delivered.
- `--from/--tag` (sender tag), `--fyi` (the fleet's FYI template), `--mode`, `--wait`,
  `--min-interval`, `--no-heal`, `--dry-run`.
- `doctor` treats "ChatGPT not running" as the headless path, reports the headless readiness
  and words the stale finding for the closed-app case; the unit-disabled door is honoured.
- Shared read-only observation (`libexec/appserver_station.py`) and a stdlib WebSocket client
  (`libexec/appserver_ws.py`) behind the helper and the sender; `appserver_turn.py` and
  `appserver_probe.py` stay as they were.
- Offline suite `tests/test_send.py` (41 legs) against a fake app-server with a canned
  station: decision matrix, heal gates, throttle, timeout, refusals, receipts; wired into CI.
  README: decision table, heal rules, resume timeout, receipts, backend contract, the
  Claude-session parity target flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent added 1 commit 2026-10-27 21:50:03 +00:00
Measurements (neighbour session's rig, copies of the station's rollouts, 2026-10-27):
a full `thread/resume` of the 719 MB thread answers 143 MB (164 s in the v0.1.0 client);
with `excludeTurns` 5.5 KB. The first load of a thread the history does not know costs
0.011–0.02 s/MB (7.6 s warm / 14.1 s cold for 719 MB), later loads 0.14–0.28 s regardless of
size; the server finishes a load even when the client gives up; an unloaded multi-agent v2
sub-agent cannot be resumed through its parent.

- resume with `excludeTurns: true`; a running turn is `thread.status.type == "active"`, its id
  comes from `thread/turns/list {limit: 1}`; `loaded_before` in the receipt.
- resume timeout defaults 10 s / 0.05 s per MB / 120 s (the 719 MB thread gets 36 s, 2.5x its
  worst first load); past the deadline the sender waits up to 60 s for the thread to appear in
  `thread/loaded/list` and resumes again; only then the receipt says `loading` (exit 7): not an
  error, retry later, never resend — and it does not consume the throttle window.
- `refused` names `unloaded-sub-agent` with the server's rule as the note.
- WebSocket client: bytearray buffer, bulk unmask, a reply is matched by id AND no `method`
  (server→client requests can never pass as the ack); late replies to timed-out calls are dropped.

Independent read-only review of f23f2cd (all findings folded):
- throttle: `--min-interval 0` no longer wipes other threads' memory (no mark at all), the mark
  re-reads and merges under flock and writes through mkstemp; a failing mark or an unwritable
  receipt file never hides the verdict (`throttle_mark_error`, `receipt_write_error` in the row).
- every send row carries the same contract keys (`SEND_KEYS`, unused ones null), refusals included.
- heal: door closed with a hung (`active`) unit → `restart`, with a `failed` unit →
  `reset-failed` + `start`; the client count is read once more right before the restart
  (`client-connected-late`); the receipt keeps the observed station, not a patched state.
- `oo-appserver status` counts the loaded threads from the probe's "N entries" line.
- `--fyi` matches case-insensitively; docstrings and usage lines corrected.
- suite 41 → 55 legs: id collision, ack timeout, heal-failed → door-closed, hung/failed unit
  actions, loaded-check cut, stray turn/completed, throttle memory with `--min-interval 0`,
  refusal rows' keys, slow first load awaited, `loading`, sub-agent refusal, unwritable receipt
  file; the throttle-window leg now refuses on the same thread; rigs clean up their temp dirs.
- README: measurements, `loading`, heal actions, receipt keys, the unanswered server requests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent added 1 commit 2026-10-27 21:53:30 +00:00
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent added 1 commit 2026-10-27 22:05:04 +00:00
- `resume()`: `thread/loaded/list` during the loading wait and the second `thread/resume` are
  bounded and fall through to the `loading` verdict (never `timeout` for a line that was not
  sent); `loading_waited_s` is the measured wait.
- `active_turn()`: the newest-first fallback applies only to the `thread/turns/list` answer; a
  resume reply that still carries turns is read by status alone.
- throttle memory: plain `makedirs` of the file's directory (nested paths work), the mkstemp
  file is removed when the write fails; the late client re-read has a test seam (`late_clients`).
- fake server: `hang_loaded_list_s` (slow loaded/list answers while the server loads).
- suite 55 → 60 legs: slow loaded/list still ends as `loading`, late client skips the heal,
  throttle mark error never hides the verdict, nested throttle directory, turns oldest-first by
  status; timing margins of the first-load legs widened to two seconds.
- README: `timeout` wording (`method: null` = not sent), case-insensitive FYI, advisory throttle
  and `--min-interval 0` semantics, receipt key notes (`throttle_mark_error`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent added 1 commit 2026-10-27 22:09:18 +00:00
Mark the throttle right after the ack; test the turns-in-reply path for real
All checks were successful
ci / check (pull_request) Successful in 43s
dde4b54f0e
- the throttle memory is written when the server accepts the line, before the optional
  completion wait (the race window shrinks to the resume + ack time); README says so.
- fake server: `ignore_exclude_turns` answers with turns oldest first, `active_without_turn`
  reports an active status without an inProgress turn; two legs prove the by-status pick (no
  thread/turns/list call) and the no-positional-guess case (turn/start), one leg the early mark.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent merged commit b7d1f89311 into main 2026-10-27 22:11:14 +00:00
claude agent deleted branch oss-oo/appserver-send-headless 2026-10-27 22:11:14 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OSS-OO/oo-appserver!2
No description provided.