- Python 98.1%
- Shell 1.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
ci / check (push) Successful in 1m35s
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 |
||
| .forgejo/workflows | ||
| assets | ||
| docs | ||
| libexec | ||
| systemd | ||
| tests | ||
| .gitignore | ||
| install.sh | ||
| LICENSE | ||
| oo-appserver | ||
| README.md | ||
| VERSION | ||
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:
- 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,
- no client holds a connection to port 8937 (
ss), and the count is known, - no loaded thread is busy:
thread/loaded/list, thenthread/readper thread (itsstatus.type; it also knows the app's ephemeral threads — new empty chats — which answer neitherthread/resumenorthread/turns/list; one of them keptreleaserefusing on 2026-10-28;thread/resume {excludeTurns}only on a server withoutthread/read; measured on idle ephemeral threads — an active one is expected to reportactivelike 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.servicelistens on the stable socket$XDG_RUNTIME_DIR/oo-appserver-bridge/ app-tools.sock(0600). The shared server gets that path asCODEX_APP_TOOLS_PIPE_PATHand 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 aChatGPTprocess (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 toolsprints 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/doctorshow 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-bytetools/listrequest, about 69.9 KB of catalogue back), while the tool server of an active thread keeps one connection for its calls (tools/callwith 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_threadfrom Luna's chat, and the Ping-Pong Luna 1 ↔ Luna 2 viasend_message_to_threadin 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 thatoo-appserver sendstarted on a second Luna thread ran asdelivered-app-start, andread_threadinside that turn answered (one measurement, 2026-10-28 03:49, with the owner's yes). Whether every tool behaves the same there — theprompt-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 ENOENTat once; a peer that closes without an answer →pipe closedat 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) andread_threadcorrectly — 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
_metafrom 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_threadfrom 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 inALL_TOOLSthere as well); the approval modes of.mcp.json(promptfor 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, andOO_APPSERVER=0should 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
- Login. The user service starts with the session (
WantedBy=default.target) — on the station a moment BEFORE the X login importsDISPLAY,XAUTHORITYandDBUS_SESSION_BUS_ADDRESSinto the user manager (/etc/X11/Xsession.d/20dbus_xdg-runtime, then95dbus_update-activation-envwith the whole block;SSH_AUTH_SOCKis there from the start). Measured 2026-10-28: service 11:53:05.3,default.target11:53:06.5, the import after that. Cinnamon never activatesgraphical-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, untilDISPLAYorWAYLAND_DISPLAYis 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 byoo-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 doctorwarns and names the gate record. The bound: a drop-in withEnvironment=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). - 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 statusshows "attached to the shared server"). - 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. - App closed.
oo-appserver sendstill 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. - 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.shfrom a newer tag) the installer says when the unit file changed; restart then as well.oo-appserver doctorwarns 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 andREFERENCE_ENV_KEYSinlibexec/appserver_station.py). - 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-appanddoctorwarns. With the app closed, the next send starts the unit itself (healdoor-closed); the family's oo-dispatch backend reportssocket-missinguntil then.oo-appserver startopens the door by hand; the app reconnects by itself (observed: within one second after a service restart). - Rollback.
oo-appserver disable(stops and disables the unit); the next launch runs the app with its own app-server and headless sends answerdoor-closedinstead 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_appMCP: read_thread, send_message_to_thread…) runs in the shared server since v0.3.0 through the bridge (below); the per-launch pipeCODEX_APP_TOOLS_PIPE_PATHused 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 anOriginheader (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_threadalso 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 inno-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/ChatGPTin its command line (it scans/proc); watchers must spell the path split ('/usr/lib/chatgpt/Chat''GPT'). held-by-appis 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, itsturn/startanswers-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/unsubscribedoes 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 whileoo-appserver.serviceruns (e.g. after a launch withOO_APPSERVER=0), and the other way round a send to a thread the app's fallback server holds is refused asheld-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.doctornames the held threads while the app runs in fallback mode. The launcher wrapper could release before a fallback launch and must drop an inheritedCODEX_APP_SERVER_WS_URLon opt-out (the owner's dotfiles — proposals, not done here). - A second
thread/resumeissued 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 pollsthread/loaded/listinstead of resuming again; to queue indefinitely like Z's steer, raiseOO_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'sTimeoutStartSecof 90 s; non-finite, negative or non-numeric values fall back to 15 s, clamp and fallback are noted in the journal; onesystemctlcall gets 1–10 s, so the wall time is at most the bound plus 1 s): set it in a drop-in ofoo-appserver.service(Environment=…), the gate runs inside the unit (v0.5.1). Keep it under the 20 s the launcher wrapper andoo-appserver sendwait 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) andOO_APPSERVER_LOADING_POLL_S(2) — the extra wait for a thread the server is still loading.OO_APPSERVER_ACK_S(20) — how long aturn/startorturn/steermay take to be acknowledged.OO_APPSERVER_WAIT_S(60) andOO_APPSERVER_MIN_INTERVAL_S(60) — defaults for--waitand--min-interval.OO_APPSERVER_HEAL_WAIT_S(20) — how long a heal waits forreadyz;OO_APPSERVER_UNIT_YOUNG_S(30) — a unit active for less than this withoutreadyzis waited for, not restarted.OO_APPSERVER_WAIT_ACTIVE_S(120) — how longappserver_turn.py … steerwaits for an active turn (v0.1.0 client).PROBE_WS_PATH(default/rpc),PROBE_PARAMS(JSON) andPROBE_MAXforappserver_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 forsystemctl --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'sclass,delivered,method,turnId,ms_to_ack,reason/errorandhealinto its own receipt fields — the five classes live here, in one implementation. held-by-appanddoor-closedare named non-deliveries (the door was tried, the line did not reach a writer);refusedandtimeoutname the step that failed before anything was sent;loadingmeans "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-unknownmeans "the request was sent, the outcome is unknown" — count it as a kiss, do not resend blindly, look for the line withoo-appserver read <thread> --turns 2first;droppedis 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 plusturns[]), exit 0 only when read. It never loads a thread, so a backend may call it freely before and after a send — to verify anack-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/.