Decision record for #38. Evidence is the
app-server schema emitted by codex app-server generate-json-schema for codex-cli 0.154.0 — the release
src/core/codex-bridge.js is pinned to — generated both without and with --experimental, plus a live probe
against a 0.154.0 daemon (below).
| Surface | In 0.154.0 | Notes |
|---|---|---|
turn/start on an idle thread |
stable client request | Starts a new turn; TurnStartResponse.turn.id is the new turn. |
turn/start on an active thread |
stable client request | Steers the running turn. TurnStartParams.turnTrigger is documented as "Ignored when this request steers an already-active turn", so the call does not queue behind the human's turn — it joins it. |
turn/steer |
stable client request | Explicit steer; requires expectedTurnId and fails when it no longer matches the active turn. |
turn/interrupt |
stable client request | Stops the active turn. |
thread/queue/add / list / update / delete / reorder / start |
experimental client requests | Present only in the --experimental schema and only for a client that initialized with capabilities.experimentalApi: true. add takes { threadId, clientUserMessageId, input } and returns a QueuedSubmission { id, clientUserMessageId, input }; list pages with cursor/limit; start runs one queued submission as a turn. |
thread/queue/changed |
server notification | Emitted when a thread's queue changes. |
ThreadStatus |
type | notLoaded, idle, active (with activeFlags: waitingOnApproval, waitingOnUserInput), systemError. |
With experimentalApi: true: thread/queue/list → { data: [], nextCursor: null }; thread/queue/add
returned a QueuedSubmission with the supplied clientUserMessageId; list then showed it first in
insertion order; thread/queue/delete → { deleted: true } and the list was empty again. The target
thread was notLoaded — the queue is server-side state, not a property of a loaded session. Not tested
on this machine: whether queued submissions survive a daemon restart, and whether the daemon starts them
itself when the active turn ends or only on thread/queue/start (the TUI drains its own queue; a
daemon-only thread may need an explicit start). Both stay documented as unknown until measured.
- Default: refuse busy destinations.
sendresumes the thread (thread/resume,excludeTurns: true) and readsthread.status.active→delivery: "rejected", reason: "busy";systemError→reason: "thread-error"; any status this pin does not know →reason: "unknown-status". OnlyidleandnotLoaded(no turn can be running) proceed toturn/start.turn/steerandturn/interruptare never called: they change or stop work a human may be doing in the same thread. - Opt-in: hand busy sends to the daemon's queue.
--when-busy queuerequiresGROK_BOT_CODEX_EXPERIMENTAL=1(the experimental API is gated, so its use is an operator decision, not a default) and initializes the session withexperimentalApi: true. On anactivethreadsendcallsthread/queue/addwith the envelope'smessageIdasclientUserMessageIdand returnsdelivery: "queued"withqueuedSubmissionId. A daemon without the method answers-32601, reported asreason: "unsupported".gbot codex queue <threadId>lists the queue (same gate) so the caller can see whether the submission is still waiting. Idle threads are never queued — they start directly. - No gbot-side queue. A local queue would live in one process on one machine, be lost on restart, and race the human's own submissions. The daemon's queue is the right owner; when it stabilizes the gate goes away.
- Receipts distinguish states.
accepted(turn started;turnId,turnStatus),queued(queuedSubmissionId; not started),rejected(nothing leftgbot;reasonsays why),unknown(the request left but no acknowledgment came back;messageIdis Codex'sclientUserMessageId, so the caller can look for it in the thread or the queue before resending). There is nocompletedstate:sendreturns when the turn starts, and waiting for a result is a separate bounded operation. - Steer and interrupt stay out. Adding either is a new flag plus a schema re-pin, never a default.
Status is read at resume time and turn/start follows on the same connection a few milliseconds later.
A human who starts a turn inside that window turns our turn/start into a steer. The daemon exposes no
compare-and-start request, so the race cannot be closed client-side; it is small, and the receipt's
turnId plus clientUserMessageId make it auditable. The upgrade path is thread/queue/add followed by
thread/queue/start once the queue API leaves experimental.
- idle →
accepted,turn/startcarriesclientUserMessageIdandturnTrigger: "gbot". - active (
waitingOnUserInput) →busy, noturn/start/turn/steer/turn/interruptsent. - active +
--when-busy queue+ gate on →queued,initializecarriedexperimentalApi: true,thread/queue/addcarried the envelope id;gbot codex queuelists and sanitizes; a daemon without the method →unsupported. --when-busy queuewith the gate off →experimental-disabled, nothing sent.- systemError →
thread-error; unknown status →unknown-status. - active writer elsewhere →
external-owner; unknown thread →unknown-thread. - connection dropped after
turn/startleft →delivery: "unknown",reason: "transport", receipt keepsmessageId. - malformed acknowledgment →
delivery: "unknown",reason: "bad-response". - every rejection, including route and allowlist refusals, carries
messageId/correlationId/hop.