The station's shared Codex app-server: the ChatGPT desktop app and the family attach to one writer — user service, launcher hook, helper CLI, WebSocket clients (owner's door, 2026-10-27).
  • Python 98.1%
  • Shell 1.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
claude 488bcaa163
All checks were successful
ci / check (push) Successful in 1m35s
v0.5.1 (SESSION-ENV): the unit waits for the login's session environment — an ExecStartPre gate instead of a start before the import (#11)
At the 2026-10-28 reboot oo-appserver.service (WantedBy=default.target) started at 11:53:05.3; the X login imported DISPLAY, XAUTHORITY and DBUS_SESSION_BUS_ADDRESS into the user manager only after default.target (11:53:06.5; Xsession.d/20dbus_xdg-runtime, then 95dbus_update-activation-env), so the shared server ran without them until the launcher's restart. Cinnamon never activates graphical-session.target, so ordering cannot fix it.

- systemd/oo-appserver.service: `ExecStartPre=-python3 -I libexec/appserver_session_env.py` — polls `systemctl --user show-environment` until DISPLAY or WAYLAND_DISPLAY is present (default 15 s under the callers' 20 s budgets, ceiling 60 s under TimeoutStartSec 90 s, one systemctl call 1–10 s), records one line in $RUNTIME_DIRECTORY/session-env; advisory (`-`): the door opens whatever the gate does. Verified hermetically on systemd 255: ExecStart inherits what was imported while ExecStartPre waited.
- oo-appserver status: `session` line (record / no record yet / activating / not running); doctor names the gate record in the session-keys warning.
- station: session_env_record(); install.sh installs the gate; CI runs tests/test_session_env.py (11 legs); README Parts, "The regular flow" step 1 with the measured times, Knobs; VERSION 0.5.1.

Review: independent read-only review (two rounds) — must-fix ExecStartPre=-, finite/capped bound, activating wording, accurate key names, deterministic per-poll window; all folded. CI green on 047e933, beb1ede, dcccbc4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 11:57:44 +00:00
.forgejo/workflows v0.5.1 (SESSION-ENV): the unit waits for the login's session environment — an ExecStartPre gate instead of a start before the import (#11) 2026-10-28 11:57:44 +00:00
assets Fold in the independent review before v0.1.0 (#1) 2026-10-27 16:55:25 +00:00
docs docs: design C carries Z's word on the reworked decisions (steer 41) (#8) 2026-10-28 01:06:54 +00:00
libexec v0.5.1 (SESSION-ENV): the unit waits for the login's session environment — an ExecStartPre gate instead of a start before the import (#11) 2026-10-28 11:57:44 +00:00
systemd v0.5.1 (SESSION-ENV): the unit waits for the login's session environment — an ExecStartPre gate instead of a start before the import (#11) 2026-10-28 11:57:44 +00:00
tests v0.5.1 (SESSION-ENV): the unit waits for the login's session environment — an ExecStartPre gate instead of a start before the import (#11) 2026-10-28 11:57:44 +00:00
.gitignore oo-appserver v0.1.0: the station's shared Codex app-server — user service, launcher hook contract, helper CLI, WebSocket clients, regular flow 2026-10-27 17:30:06 +01:00
install.sh v0.5.1 (SESSION-ENV): the unit waits for the login's session environment — an ExecStartPre gate instead of a start before the import (#11) 2026-10-28 11:57:44 +00:00
LICENSE oo-appserver v0.1.0: the station's shared Codex app-server — user service, launcher hook contract, helper CLI, WebSocket clients, regular flow 2026-10-27 17:30:06 +01:00
oo-appserver v0.5.1 (SESSION-ENV): the unit waits for the login's session environment — an ExecStartPre gate instead of a start before the import (#11) 2026-10-28 11:57:44 +00:00
README.md v0.5.1 (SESSION-ENV): the unit waits for the login's session environment — an ExecStartPre gate instead of a start before the import (#11) 2026-10-28 11:57:44 +00:00
VERSION v0.5.1 (SESSION-ENV): the unit waits for the login's session environment — an ExecStartPre gate instead of a start before the import (#11) 2026-10-28 11:57:44 +00:00

oo-appserver — the station's shared Codex app-server

One Codex app-server, one writer, many clients: the ChatGPT desktop app attaches to it instead of spawning its own stdio app-server, and the family (the oo-dispatch / zcode-dispatch kiss backend appserver, the clients in libexec/) talks into the owner's open sessions through the same door — a new turn when a thread is idle, a steer into the running turn otherwise. Proven on 2026-10-27 (T7): the steer lands inside the running turn, interrupts a running tool step, is read within about two seconds and does not clutter the chat (the owner's "Königsweg").

Since v0.2.0 the same door works with the app closed: oo-appserver send resumes the thread in the shared server from its rollout and runs the turn there — the way ZCode sessions are reached without the ZCode app (the owner's order of 2026-10-27: "sollte auch ohne gestartete chatgpt funktionieren, sowie mit"). One entry, the path chooses itself.

Why not the daemon: the ChatGPT app build (codex 0.164.0-alpha.11.1) never takes the CODEX_APP_SERVER_USE_LOCAL_DAEMON branch for local hosts (its config overrides are never empty); the designed door is CODEX_APP_SERVER_WS_URL, which makes the app connect over WebSocket to the app-server named in the URL.

Parts

Part Where What
systemd/oo-appserver.service ~/.config/systemd/user/ the shared server: bundled binary /usr/lib/chatgpt/resources/codex … app-server --listen ws://127.0.0.1:8937, parity environment, records the bundle version in $XDG_RUNTIME_DIR/oo-appserver/version; since v0.3.0 with the bundled app tools switched on and CODEX_APP_TOOLS_PIPE_PATH pointing at the bridge
systemd/oo-appserver-bridge.service + libexec/app_tools_bridge.py ~/.config/systemd/user/, ~/tools/oo-appserver/libexec/ the app-tools bridge (v0.3.0): a stable Unix socket $XDG_RUNTIME_DIR/oo-appserver-bridge/app-tools.sock; every connection the shared server's tool server opens there is forwarded to the tools socket of the ChatGPT app running right now; receipts ~/.local/state/oo-appserver/bridge.jsonl
oo-appserver ~/tools/oo-appserver/, linked from ~/.local/bin/ helper: status, doctor, send, read, heal, release, tools, start, stop, restart, enable, disable, version, threads, logs
libexec/appserver_send.py ~/tools/oo-appserver/libexec/ the sender behind oo-appserver send and oo-appserver heal: observes the station, picks the path, heals when safe, delivers one line, writes the receipt
systemd/oo-appserver-release.service + .timer ~/.config/systemd/user/ the nightly gated release (04:30 + up to 20 min, catches up after a night off): release --only-if-loaded — a restart only when a thread is loaded and the gate allows it; a skipped night is not a failure (v0.5.0)
libexec/appserver_read.py ~/tools/oo-appserver/libexec/ the reader behind oo-appserver read: thread/read, thread/turns/list, thread/items/list — read-only, never loads a thread (v0.4.0)
libexec/appserver_session_env.py ~/tools/oo-appserver/libexec/ the ExecStartPre gate of the service (v0.5.1): waits, bounded by 15 s (ceiling 60 s), until the login has imported the session environment (DISPLAY or WAYLAND_DISPLAY) into the user manager, so the server is spawned WITH DISPLAY/XAUTHORITY; records the result in $XDG_RUNTIME_DIR/oo-appserver/session-env (oo-appserver status); run as ExecStartPre=-, so the unit never fails because of it
libexec/appserver_station.py ~/tools/oo-appserver/libexec/ the read-only observation shared by the helper and the sender (app state, door, versions, clients, rollout sizes)
libexec/appserver_ws.py ~/tools/oo-appserver/libexec/ the stdlib WebSocket JSON-RPC client the sender uses
libexec/appserver_probe.py ~/tools/oo-appserver/libexec/ read-only WebSocket client (initialize, thread/loaded/list, thread/list, thread/turns/list, thread/items/list)
libexec/appserver_turn.py ~/tools/oo-appserver/libexec/ the v0.1.0 low-level client: exactly one turn/start or turn/steer, no station awareness, full resume (slow on big threads: 164 s for the 719 MB thread) — superseded by send for callers, kept for the proving-round scripts
tests/ repository only the offline suites (tests/test_send.py, tests/test_read.py, tests/test_bridge.py) against a fake app-server (tests/fake_appserver.py) and a fake app socket
launcher hook ~/.local/bin/chatgpt-neon (owner's wrapper, dotfiles) before launching ChatGPT: service enabled and healthy → export CODEX_APP_SERVER_WS_URL=ws://127.0.0.1:8937/rpc; bundle newer than the running service, or service older than this login's session environment → restart it first (20 s cap); otherwise stdio fallback with a hint; opt out per launch with OO_APPSERVER=0

Install (fleet law: from the checked-out tag)

git clone https://oss-oo.io/OSS-OO/oo-appserver.git && cd oo-appserver && git checkout v0.3.0
./install.sh --target ~/tools/oo-appserver
oo-appserver doctor

The launcher hook is part of the owner's dotfiles (~/.local/bin/chatgpt-neon); without it the app still runs, just with its own app-server. The hook is the constants OO_APPSERVER_UNIT, OO_APPSERVER_PORT, OO_APPSERVER_URL, OO_CODEX_BUNDLE, OO_SESSION_ENV_KEYS and the functions oo_appserver_ready, oo_appserver_unit_state, oo_appserver_version_stale, oo_appserver_env_stale, ensure_oo_appserver (called once before the launch arguments are built).

The installer writes into files the dotfiles repository tracks (the unit in ~/.config/systemd/user/, the ~/.local/bin/oo-appserver symlink) — the station's convention, same as ~/tools/zcode-dispatch; commit the dotfiles after an install. --target is free, but the unit's Documentation= line points at ~/tools/oo-appserver/README.md.

Sending a line: oo-appserver send

oo-appserver send <thread-id> "<line>" [--from NAME] [--tag] [--fyi] [--mode auto|start|steer]
                  [--wait S] [--min-interval S] [--no-heal] [--json] [--dry-run]
oo-appserver heal            # heal the shared server when due and safe, nothing else
oo-appserver release         # restart when safe even if nothing is stale: frees every writer lock
oo-appserver release --only-if-loaded   # the same, but nothing happens when no thread is loaded (nightly timer, launcher)

release is the cure for the lock-out (below): it restarts the shared server even when nothing is stale — while the app is closed OR runs its own app-server (fallback) OR an orphaned stdio server exists, as long as no client holds a connection to the shared server and no loaded thread is busy; it never takes the server away from an attached app (heal-skipped, app-attached). --dry-run plans only. --only-if-loaded (v0.5.0) turns a release into a no-op (not-needed, reason: nothing-loaded) when the server holds no thread, and never opens a closed door (door-closed-nothing-loaded) — the shape the nightly timer oo-appserver-release.timer runs (04:30 plus up to 20 minutes, Persistent so a night the machine was off is caught up; exit 1 of a skipped release counts as success for the oneshot, a restart that left the door shut exits 5 and shows as a failed unit). When the loaded list cannot be read — an error reply, a reply of the wrong shape, or a door closed while the unit is still active (a hung server keeps its loaded threads) — the normal gate decides, exactly as a plain release would. and the shape the owner's launcher runs before a fallback launch (OO_APPSERVER=0), so the app's own app-server can write the chats the shared server held. oo-appserver status shows the timer's next and last run; every run leaves a receipt row (kind: heal, from: nightly-release).

The sender observes the station (appserver_station.observe) when it starts, again under the heal lock, and once more right before the line goes out, and takes exactly one path:

Station Path What happens Receipt class
ChatGPT not running headless heal when due and safe (below), then thread/resume (excludeTurns) in the shared server with a timeout scaled to the rollout size, then turn/start — or turn/steer when a headless turn is still running there delivered-headless (mode start or steer)
ChatGPT attached to the shared server app thread/resume; idle thread → turn/start (visible bubble) delivered-app-start
ChatGPT attached, the thread runs a turn app turn/steer with the active turn id (invisible, read at the model's next step) delivered-app-steer
ChatGPT runs its own stdio app-server (fallback) refuse nothing is sent: the app's child is the writer of its threads, a second writer is never opened held-by-app (exit 3)
ChatGPT running, no attachment seen (starting up, ss unavailable) refuse nothing is sent held-by-app (exit 3)
ChatGPT attached but the door is closed (the service died under the app) refuse nothing is sent; the app falls back to its own app-server by itself held-by-app (exit 3)
app closed, but a stdio app-server of the bundle runs outside the shared service (an orphaned child after an app crash, or the app starting) refuse nothing is sent; --listen servers (the shared service, rigs on other ports) do not count held-by-app (exit 3)
the app or such a server appears while the thread is loading refuse the station is looked at once more right before turn/start; nothing is sent held-by-app (exit 3)
the thread store holds the thread for another writer (resume answers thread-store conflict … already has an active writer, or turn/start answers thread not found right after a successful resume) refuse the server's own verdict, measured on rigs; nothing is sent held-by-app (exit 3)
app closed, door closed, unit disabled by the owner refuse nothing is sent (oo-appserver enable reopens the door) door-closed (exit 4)

Other classes: refused (a JSON-RPC error — an unknown thread, reason: unloaded-sub-agent for a multi-agent v2 sub-agent thread whose parent must be resumed first —, or a transport failure such as a closed connection; exit 1), loading (the server is still loading a big thread — not an error, retry later, never resend the line; exit 7), timeout (a step BEFORE the line was sent did not answer in time; exit 5), ack-unknown (the turn/start or turn/steer request was on the wire but no acknowledgement came, or the connection died before it — the line MAY have landed: delivered: null, the throttle stays marked, do not resend blindly, look for the line with oo-appserver read <thread> --turns 2 first; exit 8), no-active-turn (--mode steer without a running turn id; exit 3), dropped (per-thread throttle, reason: throttled, note: next eligible in Ns; exit 6). Exit 0 means delivered; exit 2 is a usage error. Once the server has acknowledged a line the receipt is never downgraded: a connection lost during the completion wait leaves delivered: true and sets completed: false plus wait_error. Classes are verb-first and consistent with the kiss vocabulary of oo-dispatch (delivered / steered / refused). Heal rows know healed, waited, heal-skipped, heal-failed and, from oo-appserver heal, not-needed.

Heal when safe

A headless send first looks at the door. The running service is stale when the version it recorded at start differs from /usr/lib/chatgpt/resources/codex --version (the app was updated); the door is closed when readyz does not answer (the probe goes to the loopback directly, never through an http_proxy from the environment). Both are healed before the send — systemctl --user restart or start — under a lock (heal.lock next to the throttle memory, so two senders never heal at once; the second one observes the station again under the lock and finds the door already healed), and only when all of this holds:

  1. the ChatGPT app is not running (while the app runs, the launcher wrapper owns the restart — no second authority), and no stdio app-server of the bundle runs outside the service,
  2. no client holds a connection to port 8937 (ss), and the count is known,
  3. no loaded thread is busy: thread/loaded/list, then thread/read per thread (its status.type; it also knows the app's ephemeral threads — new empty chats — which answer neither thread/resume nor thread/turns/list; one of them kept release refusing on 2026-10-28; thread/resume {excludeTurns} only on a server without thread/read; measured on idle ephemeral threads — an active one is expected to report active like every loaded thread, and a read error still counts as busy; the metadata read takes a few ms whatever the rollout size: 4 ms for the 719 MB thread, 2026-10-28) — status.type == "active" counts as busy even when no running turn id can be read, and a thread whose state cannot be read counts as busy too.

Otherwise the heal is skipped and the send still goes through the running server. Every heal decision is its own receipt row (kind: heal): healed (with to_version and ms), waited (a starting unit answered readyz within the wait, nothing restarted), heal-skipped (the WHY in skipped_because: client-connected:N, client-connected-late:N — a client appeared while the loaded threads were checked —, active-turn:<thread>:<turn|active|error>, client-count-unknown, loaded-check-failed:…, lock-unavailable:…, disabled-by-flag, app-running, app-attached, already-healed / door-open-now — another sender healed it meanwhile —, or door-closed / held-by-app when the station changed under the lock, incl. a unit that is disabled or failed) or heal-failed (door still closed after OO_APPSERVER_HEAL_WAIT_S; the send then answers door-closed). Under the lock the station is observed again and the plan re-derived, so a heal never acts on a stale picture; when the app appeared meanwhile the send answers held-by-app. The send receipt carries a summary of it in heal. The heal is announced on stderr before it runs. oo-appserver heal runs the same decision without sending (exit 0 when healed, waited, not needed or already healed by another sender; 1 when skipped for a gate, 5 when the restart failed).

The action follows the unit's state: a closed door with an inactive unit → start; with a unit that is activating, or became active less than OO_APPSERVER_UNIT_YOUNG_S (30) seconds ago → wait for readyz (another sender or the login just started it; Type=simple reports active before the server listens — receipt class waited, nothing restarted; still silent after the wait → restart); with a unit active for longer without readyz (hung) → restart; a stale running service → restart. A failed unit (start limit) is not reset by the sender — a crashing service deserves a look (oo-appserver logs), then systemctl --user reset-failed oo-appserver.service by hand; the send answers door-closed. The client count is read once more right before the restart, so the window between the loaded-thread check and the restart is milliseconds (the wrapper's app-launch restart has the same kind of window).

Resume: small replies, size-aware patience, loading

The sender resumes with thread/resume {threadId, excludeTurns: true}: the reply is a few KB regardless of the thread's size (a full resume of the station's largest thread answers 143 MB and took the v0.1.0 client 164 s to read) and carries "turns": [] — the key is always present, so a running turn shows ONLY as thread.status.type == "active"; its id then comes from thread/turns/list {threadId, limit: 1} (newest first, picked by its inProgress status, never by position). v0.2.0 looked for a missing key and therefore never saw a running turn — fixed in v0.2.1 and proven live (a headless turn started with --wait 0, a second send steered into it).

What costs time is the server's FIRST load of a thread its history does not know yet — measured on a rig on 2026-10-27 with copies of real rollouts (bundle 0.165.0-alpha.1.2): 3.5 MB 0.25 s · 158 MB 1.2 s warm / 2.1 s cold · 719 MB 7.6 s warm / 14.1 s cold cache, about 0.02 s/MB; threads the history already knows load in 0.14–0.28 s whatever their size, and a thread stays loaded in the shared server until the service restarts. Hence the sender grants thread/resume max(OO_APPSERVER_RESUME_MIN_S, ceil(MB × OO_APPSERVER_RESUME_S_PER_MB)) seconds, capped by OO_APPSERVER_RESUME_MAX_S — defaults 10 s, 0.05 s/MB, 120 s, so the 719 MB thread gets 36 s, 2.5× its worst measurement. The rollout is looked up under $CODEX_HOME/sessions (default ~/.codex) by thread id; rollout_mb, resume_timeout_s and loaded_before land in the receipt, --dry-run shows the first two without sending. Calibrate the per-MB default against the station's largest rollout when it changes.

When the deadline passes the server keeps loading (verified: a client that disconnects after one second still ends with the thread loaded). The sender therefore waits up to OO_APPSERVER_LOADING_WAIT_S (60) more, polling thread/loaded/list every OO_APPSERVER_LOADING_POLL_S (2) seconds, and resumes again (fast now) once the thread appears — the receipt notes loading_waited_s. Only when that wait ends too does the receipt say loading (exit 7) with rollout_mb and resume_timeout_s: not an error, the load continues in the server, the next attempt is quick — retry later and never send the same line twice. A loading attempt does not consume the throttle window. There is no built-in list of threads to pre-load: every deliverer must know that the first delivery to a big, never-loaded thread may take a while (the owner's guidance).

The line

The line is sent as given. --from NAME names the sender in the receipt; with --tag it is also prefixed as [NAME 📡] unless the line already starts with [. --fyi appends the fleet's FYI template (… — FYI-Meilenstein der Flotte, keine Aktion nötig, bitte nur mit »ok« antworten.) unless the line already carries fyi in any case — lines to Codex threads always carry it (the kiss law: a line that reads like a task makes a reasoning model work and touch the station).

--wait S (default 60) reads notifications after the ack and records completed and up to three agent_items of the thread's answer; --wait 0 fires and forgets (the turn runs on in the server). --min-interval S (default 60) is the per-thread throttle, remembered in ~/.local/state/oo-appserver/last-send.json. It is a reservation taken under a lock before the resume (check and mark in one step, so parallel senders on one thread pass exactly once); when the line is not sent (refusals, loading, door-closed) the reservation is given back, a delivered or ack-unknown line keeps it. A send with --min-interval 0 leaves no mark at all (so it does not hold back a later throttled sender). --mode steer insists on steering (refuses on an idle thread), --mode start insists on turn/start (the server steers anyway when a turn runs — the receipt notes it).

Receipts

Every attempt appends one JSON line to ~/.local/state/oo-appserver/receipts.jsonl (0600; path via OO_APPSERVER_RECEIPTS). Every send row (kind: send) carries the same keys, unused ones as null: class, delivered, path, method, mode, thread, line, from, turnId, turnStatus, thread_status, active_turn, loaded_before, ms_resume and ms_to_ack (both measured from the connect, so the ack time includes the resume), ms_total, completed, wait_error (set when the connection died during the completion wait), agent_items (an empty list until an answer is recorded), waited_s, rollout_mb, resume_timeout_s, loading_waited_s (the measured wait), min_interval_s (filled on dropped rows), heal, station (the observed state before any heal, incl. foreign_servers and unit_age_s), reason, error, note, client_version; ms_total is filled on delivered rows. When the receipt file cannot be written the row is still printed, with receipt_write_error; a throttle memory that cannot be written is noted on stderr and never blocks a delivery. --json prints exactly that row on stdout (one line; human notes go to stderr) — the contract for callers such as oo-dispatch.

Messages between sessions: oo-appserver send as the family way

The owner's word (2026-10-28): the way through the shared server is an equal family way for the houses — Mama, Omi, Luna, Z's fleet, Claude — not a fallback ("ggf. mögen sie diesen Weg sogar präferiert"), and the quick substitute while the bundled app tools find their way into the shared server. For a Codex session the recipe is:

oo-appserver send <thread-id> "<line>" --from <name> --tag --fyi [--wait 60] [--json]
oo-appserver threads          # which threads the shared server holds
oo-appserver status | doctor  # door, app, headless readiness, app tools bridge

With the app closed the line goes headless, with the app attached it starts a turn or steers into the running one; --wait brings the answer back as agent_items. The kiss law applies — --fyi appends the fleet's FYI template, a line without it reads like a task and makes a reasoning model work. The receipt (one JSON line with --json, always in ~/.local/state/oo-appserver/receipts.jsonl) says what happened: delivered-headless, delivered-app-start, delivered-app-steer, held-by-app (another writer holds the thread — nothing sent), dropped (throttle, 60 s per thread), loading (retry later, never resend), ack-unknown (do not resend blindly), refused, door-closed, timeout. A thread's id is the id in its rollout file name (~/.codex/sessions/…/rollout-*-<id>.jsonl). Proven from inside a Codex session on 2026-10-28: Luna found the command in her own app chat, her shell reached 127.0.0.1:8937, and her line to a second Luna thread came back delivered-app-start with the answer "ok"; the second try 58 s later was held by the throttle. The same recipe is documented for the Codex house in the station skill ~/.codex/skills/zcode-usage/SKILL.md and will be in oo-dispatch's own documentation with the appserver backend. The read counterpart oo-appserver read <thread> (next section) shows whether a line landed and what the thread answered — the first building block of design C.

Reading a thread: oo-appserver read (v0.4.0)

oo-appserver read <thread-id> [--turns N] [--items] [--full] [--max-chars N] [--timeout S] [--json]

The read counterpart of send and the first building block of design C (docs/FAMILY-TOOLS-DESIGN.md, Z's yes of 2026-10-28): thread/read gives the thread's metadata and status (notLoaded, idle, active, systemError), thread/turns/list the newest N turns (default 3, printed oldest first) with their messages, and --items fetches every item of each turn via thread/items/list — commands, tool calls and web searches as a one-line gist, a reasoning item only as its size (--full adds the raw items; pages are followed up to 1000 items per turn — beyond that, after a page that adds nothing or a cursor the server repeats, items_more says so and the read stops). --max-chars clips long messages (2000; 0 = no clip). An ephemeral thread (the app's new empty chat) is read from its metadata alone — the server stores no turns for it. A reply of the wrong shape is read as empty (a non-list data is "no turns"); anything that still raises ends in a classed receipt (refused, reason: bad-reply), never in a traceback. Measured on the station before the build (2026-10-28, read-measure): none of the three methods loads the thread — a read never takes the thread store's writer lock, never locks the app out and never heals or starts anything; it only needs the door open (door-closed, exit 4, with the hint oo-appserver start — a read does not open it). An unknown thread is refused (reason: unknown-thread, exit 1); a call without an answer within --timeout (20 s, OO_APPSERVER_READ_S) is a timeout (exit 5). Every read appends one row kind: read to the receipts file (class, thread, status, turn and item counts, ms — without the turns); --json prints that row plus turns[] (id, status, startedAt, completedAt, durationMs, item_source, items_more, items[] with type and text) and the row's fields (class, thread, status, turn_count, item_count, more, items_more, ms, reason, error; the receipt row itself carries the counts as turns and items), the contract for callers: a backend that got ack-unknown from send looks here before it would ever resend, and Luna's "ok" to an FYI line is read from her thread instead of from the app. Reads answered in about 50 ms on the station for an unloaded 227 KB thread and for Luna's loaded app chat alike (2026-10-28 01:10).

The app tools bridge (v0.3.0)

The bundled plugin "Codex app tools" (codex_app MCP: send_message_to_thread, read_thread, create_thread, fork_thread, handoff_thread, automation_update, move_thread_to_project) is how the owner's chats talk to each other from inside the app — Mama and Omi coordinate through it. The plugin's tool server reaches the ChatGPT app over a Unix socket the app creates per launch (/tmp/codex-app-tools/<uuid>.sock) and hands to its OWN app-server child as CODEX_APP_TOOLS_PIPE_PATH; the shared server never saw that path, so v0.1–v0.2 switched the plugin off. Measured on 2026-10-28: the app opens that socket even while it is attached to the shared server — only the bridge was missing.

The bridge (design "A", Z's word of 2026-10-28, measured on a rig by the neighbour session):

  • oo-appserver-bridge.service listens on the stable socket $XDG_RUNTIME_DIR/oo-appserver-bridge/ app-tools.sock (0600). The shared server gets that path as CODEX_APP_TOOLS_PIPE_PATH and runs the plugin again (…codex_app.enabled=true).
  • Every connection the tool server opens is forwarded, byte for byte, to the tools socket of the ChatGPT app that is running right now — resolved per connection: a listening socket under /tmp/codex-app-tools/ owned by a ChatGPT process (ss -xlp), else the newest connectable socket file there (stale files of earlier launches are skipped). An app restart means a new socket; the next connection finds it. When the app's side ends, the bridge ends the tool server's connection too.
  • Without a running app the bridge closes the connection at once (receipt no-app): the bundled tools need the app — that is their nature. The headless way for the same six thread tools is design "C", a family-own MCP over the shared server itself, the next step.
  • Receipts: ~/.local/state/oo-appserver/bridge.jsonl, one row per connection (target, bytes_to_app, bytes_to_tools, ms, outcome: closed / no-app / app-unreachable).
  • oo-appserver tools prints the bridge's state (unit, stable socket, the app's socket, whether the plugin is on in the unit file and in the RUNNING server process — they differ between an install and the gated restart); status/doctor show the same line and warn when the unit offers the tools but the bridge is down, or when the attached app owns no tools socket.
  • Knob: OO_APPSERVER_TOOLS_SOCK (the stable path, default as above), OO_APPSERVER_BRIDGE_RECEIPTS. A row is written when a connection ends: the per-turn catalogue fetches are short connections (an 85-byte tools/list request, about 69.9 KB of catalogue back), while the tool server of an active thread keeps one connection for its calls (tools/call with the caller's thread and turn ids from the MCP _meta) and its row appears when that process ends — measured 2026-10-28 with the first real calls in the shared server: read_thread from Luna's chat, and the Ping-Pong Luna 1 ↔ Luna 2 via send_message_to_thread in both directions (the owner approved each call in the app). Catalogue and calls travel over the pipe, never over the WebSocket, so without the running app the tools cannot work. With the app attached they were also reachable from a thread the app had not opened: a turn that oo-appserver send started on a second Luna thread ran as delivered-app-start, and read_thread inside that turn answered (one measurement, 2026-10-28 03:49, with the owner's yes). Whether every tool behaves the same there — the prompt-gated ones included, and who answers their approval for a thread the app has not opened — is not measured.

What the rig and the owner's app taught (neighbour session, 2026-10-28, read-only; one read_thread on the real app socket with the owner's explicit yes):

  • the tool server is a thin proxy: it sends length-prefixed JSON-RPC frames over a pipe connection it reuses while it lives and reopens after the app closes it or it errors (the pipe path itself is read once per tool-server process — which is why it must be the bridge's stable path) — on the rig the app closed the pipe after each answer, so every request looked like a new connection; receipts count connections, not requests. A missing socket → -32603 connect ENOENT at once; a peer that closes without an answer → pipe closed at once; a peer that accepts and never answers → the tool server waits without a timeout. Hence the bridge never accepts-and-hangs: no live app socket → closed immediately, the upstream connect has a timeout, and the app's end is propagated.
  • the shared server starts one tool-server instance per loaded thread (seen in its cgroup) — with per-request connections, the per-connection resolver covers app restarts without any restart of the tool servers.
  • the ChatGPT app accepts connections from a foreign process on its tools socket while attached to the shared server and answers tools/list (57 tools) and read_thread correctly — so every thread in the shared server sees the same catalogue the app's own server used to show.
  • the tool server needs the calling thread's _meta from the codex core (as the app's own server provides it); that the shared server passes it too is verified at the first real call after the unit switch (read_thread from a chat, with the owner's yes).
  • both directions are real: Mama → Omi and Omi → Mama use the tools, mostly in code mode as tools.mcp__codex_app__* (the catalogue must appear in ALL_TOOLS there as well); the approval modes of .mcp.json (prompt for sending, creating, forking, handing off) keep applying — with the app attached the prompt appears in the app, headless nobody answers it: another reason for design C. The owner's word: clean before fast, and OO_APPSERVER=0 should not be needed.

After installing v0.3.0 the running shared server still carries the old unit (plugin off): the installer says so; oo-appserver release (gated) or oo-appserver restart when the owner's chats are idle loads the new unit, then relaunch ChatGPT from the menu.

The regular flow

  1. Login. The user service starts with the session (WantedBy=default.target) — on the station a moment BEFORE the X login imports DISPLAY, XAUTHORITY and DBUS_SESSION_BUS_ADDRESS into the user manager (/etc/X11/Xsession.d/20dbus_xdg-runtime, then 95dbus_update-activation-env with the whole block; SSH_AUTH_SOCK is there from the start). Measured 2026-10-28: service 11:53:05.3, default.target 11:53:06.5, the import after that. Cinnamon never activates graphical-session.target, so the unit cannot be ordered behind it. Since v0.5.1 the unit's ExecStartPre gate (libexec/appserver_session_env.py) waits, bounded by 15 s, until DISPLAY or WAYLAND_DISPLAY is in the manager's environment; the server is spawned afterwards and inherits the imported block (verified on systemd 255 with a private key: every Exec* gets the manager environment of its own spawn). The result is one line in $XDG_RUNTIME_DIR/oo-appserver/session-env, shown by oo-appserver status (session : imported after 1.2s · keys DISPLAY,XAUTHORITY,SSH_AUTH_SOCK,DBUS_SESSION_BUS_ADDRESS · 2026-10-28T11:53:06+0100 — the login's session environment, waited for by the unit's ExecStartPre gate (v0.5.1)). A console or SSH login has no graphical session: the gate passes after the bound (not imported within 15s) and the door opens anyway — the gate is advisory (ExecStartPre=-), so a missing file, a broken interpreter or an exception never fails the unit. Second net, unchanged: the launcher notices a service without the keys (oo_appserver_env_stale) and restarts it once before the app connects; oo-appserver doctor warns and names the gate record. The bound: a drop-in with Environment=OO_APPSERVER_SESSION_ENV_TIMEOUT_S=<s> (ceiling 60 s; keep it under the 20 s the launcher and the sender wait for an activating unit).
  2. Launch ChatGPT from the menu. The wrapper checks readyz, compares the recorded bundle version with /usr/lib/chatgpt/resources/codex --version, restarts the service when the app was updated, then exports the WebSocket URL. The app comes up attached: no stdio child, one TCP connection to port 8937 (oo-appserver status shows "attached to the shared server").
  3. Open a chat. The thread is loaded in the shared server (oo-appserver threads). The family can now reach it: oo-appserver send → idle → turn/start (visible as a bubble), running → turn/steer (invisible, read at the next step). The kiss law stays: FYI template, throttle, minimal answers from Mama.
  4. App closed. oo-appserver send still reaches the thread: the shared server resumes it from the rollout and runs the turn headless; a stale or closed door is healed first when safe. The owner sees the exchange the next time the app loads that chat.
  5. After a ChatGPT app update. Quit the app, launch it from the menu again: the wrapper sees the version drift and restarts the service before the app connects. Manual form: oo-appserver restart, then relaunch the app. With the app closed, oo-appserver heal (or the next headless send) restarts it when nothing holds it. After a package update (install.sh from a newer tag) the installer says when the unit file changed; restart then as well. oo-appserver doctor warns while the running service is older than the bundle, and when the app's own child (fallback mode) carries environment keys the service does not know (env drift — update the unit and REFERENCE_ENV_KEYS in libexec/appserver_station.py).
  6. If the server is down. The app falls back to its own stdio app-server; while the app runs that way, sends are refused as held-by-app and doctor warns. With the app closed, the next send starts the unit itself (heal door-closed); the family's oo-dispatch backend reports socket-missing until then. oo-appserver start opens the door by hand; the app reconnects by itself (observed: within one second after a service restart).
  7. Rollback. oo-appserver disable (stops and disables the unit); the next launch runs the app with its own app-server and headless sends answer door-closed instead of reopening the door behind the owner's back. One launch without the door: OO_APPSERVER=0 chatgpt-neon.

Known gaps

  • The bundled plugin "Codex app tools" (codex_app MCP: read_thread, send_message_to_thread…) runs in the shared server since v0.3.0 through the bridge (below); the per-launch pipe CODEX_APP_TOOLS_PIPE_PATH used to reach only the app's own child. Both the catalogue and the calls travel over that pipe, so the tools need the running app.
  • The server listens on loopback without authentication: any local process (of any user) can talk to it. Single-user workstation by design; the family's conduct is governed by the fleet constitution (~/AGENTS.md). Browser pages cannot reach it: the server answers 403 to every WebSocket handshake that carries an Origin header (verified 2026-10-27).
  • A steered line is lost when the turn is aborted before the model's next step.
  • Loaded threads keep their memory until the service restarts (about 100 MiB for the 719 MB thread; the server's baseline is ~85 MiB). Since v0.5.0 the nightly gated release (oo-appserver-release.timer, owner's word 2026-10-28) returns the memory and the writer locks once a night when a thread is loaded and the gate allows it; a night with the app attached is skipped.
  • The bundled "Codex app tools" need the running app even with the bridge (v0.3.0) — but not an open chat: read_thread also answered inside a turn on a thread the app had not opened (one measurement, 2026-10-28). With the app closed every tool call ends in no-app. The family-own tools over the shared server (design "C", the same six thread tools, with and without the app) are the next step; their first building block, oo-appserver read, exists since v0.4.0. Whether the tool server reconnects after an app restart or must be respawned by the shared server is being measured on a rig; the bridge resolves the app's socket per connection either way.
  • The sender answers no server→client requests (command approvals, elicitations): a headless turn that asks for an approval waits until the sender disconnects and then follows the server's own rule. FYI lines never trigger one — another reason for the template.
  • The launcher wrapper refuses to start while any process on the station carries /usr/lib/chatgpt/ChatGPT in its command line (it scans /proc); watchers must spell the path split ('/usr/lib/chatgpt/Chat''GPT').
  • held-by-app is decided from the station, not from the thread: while the app runs its own app-server, even a thread the app never opened is refused (the sender cannot ask the app's child which threads it holds). Launch the app attached, or close it, and the send goes through.
  • Threads resumed headless stay loaded in the shared server until it restarts (about 100 MiB of server memory for the 719 MB thread). After a resume the server also warms its model connection (an outbound connection even without a turn — observed on a rig without login as a 401).
  • The thread store has a writer lock (measured on rigs 2026-10-27, v7/v8, two servers on one home): a thread one server has loaded cannot be written by another — the second server logs thread-store conflict: … already has an active writer, its turn/start answers -32600 thread not found, reading (thread/turns/list) still works, the rollout stays byte-identical. The lock is held until the server PROCESS ends; thread/unsubscribe does not release it, and an idle unload was not observed within 20 s (longer idle times not measured). So a "stale pre-loaded thread" cannot arise — the real edge is a lock-out: once the shared server has loaded a thread (a headless send, or the attached app), the app's own stdio app-server cannot write that chat while oo-appserver.service runs (e.g. after a launch with OO_APPSERVER=0), and the other way round a send to a thread the app's fallback server holds is refused as held-by-app (the server's verdict is mapped to that class). The app handles its side gently (measured on the owner's real app, 2026-10-28): the locked chat shows "This is open in another app / Close it there to continue here" with Retry and Fork instead of a prompt, other chats work. oo-appserver release (a gated restart: no client, no busy thread, allowed while the app runs its own app-server but never while it is attached) frees every lock in well under a second; Retry in the app then brings the prompt back. doctor names the held threads while the app runs in fallback mode. The launcher wrapper could release before a fallback launch and must drop an inherited CODEX_APP_SERVER_WS_URL on opt-out (the owner's dotfiles — proposals, not done here).
  • A second thread/resume issued while the first load of the same thread still runs joins that load (measured on a rig 2026-10-27: the server read the rollout once, both callers returned together after 16.7 s and 15.7 s for 719 MB — the worst first load seen so far, 0.023 s/MB, still under the 0.05 s/MB default). The sender polls thread/loaded/list instead of resuming again; to queue indefinitely like Z's steer, raise OO_APPSERVER_LOADING_WAIT_S.

Knobs

  • OO_APPSERVER=0 — one launch without the door (wrapper).
  • OO_APPSERVER_SESSION_ENV_TIMEOUT_S — the bound of the unit's ExecStartPre gate (15 s; ceiling 60 s, below the unit's TimeoutStartSec of 90 s; non-finite, negative or non-numeric values fall back to 15 s, clamp and fallback are noted in the journal; one systemctl call gets 1–10 s, so the wall time is at most the bound plus 1 s): set it in a drop-in of oo-appserver.service (Environment=…), the gate runs inside the unit (v0.5.1). Keep it under the 20 s the launcher wrapper and oo-appserver send wait for an activating unit.
  • OO_APPSERVER_RECEIPTS — receipt file (default ~/.local/state/oo-appserver/receipts.jsonl, mode 0600); OO_APPSERVER_THROTTLE_FILE — the per-thread throttle memory next to it.
  • OO_APPSERVER_RESUME_MIN_S / OO_APPSERVER_RESUME_S_PER_MB / OO_APPSERVER_RESUME_MAX_S — the resume timeout (10 / 0.05 / 120); OO_APPSERVER_LOADING_WAIT_S (60) and OO_APPSERVER_LOADING_POLL_S (2) — the extra wait for a thread the server is still loading.
  • OO_APPSERVER_ACK_S (20) — how long a turn/start or turn/steer may take to be acknowledged.
  • OO_APPSERVER_WAIT_S (60) and OO_APPSERVER_MIN_INTERVAL_S (60) — defaults for --wait and --min-interval.
  • OO_APPSERVER_HEAL_WAIT_S (20) — how long a heal waits for readyz; OO_APPSERVER_UNIT_YOUNG_S (30) — a unit active for less than this without readyz is waited for, not restarted.
  • OO_APPSERVER_WAIT_ACTIVE_S (120) — how long appserver_turn.py … steer waits for an active turn (v0.1.0 client).
  • PROBE_WS_PATH (default /rpc), PROBE_PARAMS (JSON) and PROBE_MAX for appserver_probe.py.
  • Test seams (never set on the station): OO_APPSERVER_PORT, OO_APPSERVER_STATE_JSON (a canned observation), OO_APPSERVER_STATE_JSON_LATE (the station as seen right before the send), OO_APPSERVER_STATE_JSON_LOCKED (the station as seen under the heal lock), OO_APPSERVER_SYSTEMCTL (a stand-in for systemctl --user), OO_APPSERVER_HEAL_LOCK, CODEX_HOME.

Backend contract (for appserver in oo-dispatch)

  • The transport is oo-appserver send <thread> "<line>" --json [--from NAME] [--wait S] [--min-interval 0]: one receipt JSON line on stdout, exit 0 only when delivered. oo-dispatch keeps its own throttle and template, so it passes the templated line and --min-interval 0, and maps the receipt's class, delivered, method, turnId, ms_to_ack, reason/error and heal into its own receipt fields — the five classes live here, in one implementation.
  • held-by-app and door-closed are named non-deliveries (the door was tried, the line did not reach a writer); refused and timeout name the step that failed before anything was sent; loading means "retry later, the server is still loading this thread" — a kiss backend must not count it as a sent kiss (or its own throttle, 900 s for Mama, blocks the retry) and must never resend the same line twice; ack-unknown means "the request was sent, the outcome is unknown" — count it as a kiss, do not resend blindly, look for the line with oo-appserver read <thread> --turns 2 first; dropped is the sender's own throttle (not used by oo-dispatch, which throttles itself).
  • The read side is oo-appserver read <thread> --turns N [--items] --json: one JSON object (the receipt row plus turns[]), exit 0 only when read. It never loads a thread, so a backend may call it freely before and after a send — to verify an ack-unknown, or to read the answer to a line.
  • Lines to Codex threads always carry the FYI template; receipts never lie.

Target flag: Claude-session parity

The same one-entry contract — one line, the path chooses itself, verb-first receipt classes, heal only when safe, a receipt per attempt — is the goal for Claude Code sessions too: a running Claude session is reached by name or session id through its own door, and a closed one through its postbox. That lives in oo-dispatch as the backend claude-session (planned as v1.0.95, Z's house; this repository stays the Codex side) and shares the vocabulary above.

Evidence

Receipts, rollouts and the watch log of the proving rounds live under ~/.zcode/tmp/claude-adapter-design-20261027/ on the station (T6, T7-0b, stages 1 and 2, the three-poem test, the comparison run, the v0.2.0 headless proofs); the resume measurements (rig scripts, outputs and the neighbour session's handoff HANDOFF.md with the owner's words and the nine proposals) under ~/.zcode/tmp/claude/appserver-resume-measure-20261027/.