Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/codex-contract-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

Make `gbot codex` dependable for automation and safe for agent relays: `codex status --json` reports `socketState`, a stable failure `mode` (`socket-absent`, `permission-denied`, `not-a-socket`, `connect-failed`, `handshake-failed`, `bad-response`, `windows-unsupported`), `schema.compatibility` separate from reachability, a bounded `codex --version` probe (`cliVersionProbe`), and `desktopAttached: "unknown"`; `codex list-threads` adds `--cursor`, bounds `--limit` to 1–200, rejects unknown arguments, validates the response shape, and strips terminal controls from every text field in JSON too. `codex send` and `send` accept `--correlation-id`, `--reply-to`, `--hop`, and `--envelope`, return receipts with `messageId` (sent as Codex's `clientUserMessageId`), `correlationId`, `replyTo`, `hop`, and `maxHops`, refuse relays at `GROK_BOT_MAX_HOPS` (default 4) with `reason: "hop-limit"`, honor the operator allowlist `GROK_BOT_CODEX_THREADS`, refuse `active` threads with `reason: "busy"` instead of steering a running turn (or, with `--when-busy queue` and `GROK_BOT_CODEX_EXPERIMENTAL=1`, hand them to Codex's experimental `thread/queue/add` and report `delivery: "queued"`; `codex queue <threadId>` lists that queue), and emit `reason`/`mode` in every `--json` send failure. Fixes #37, #38, #39.
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,31 @@ gbot codex send <threadId> "Grok here: the build is green, please continue."

**ChatGPT Desktop limitation.** Desktop runs its own private stdio app-server and does not publish the shared control socket, so external clients cannot reach live Desktop tasks. When the socket is absent, `gbot codex status` exits 1 and says so, naming the upstream issues: [openai/codex#41014](https://github.com/openai/codex/issues/41014) and [openai/codex#41112](https://github.com/openai/codex/issues/41112). `gbot` never reads Desktop's temporary `CODEX_APP_TOOLS_PIPE_PATH` sockets under `/tmp/codex-browser-use/`; that channel is private to Desktop.

**Failure modes.**
**Status contract (`gbot codex status --json`).** `reachable` is endpoint reachability only. `socketState` is `socket`, `absent`, `permission-denied`, or `not-a-socket`; `mode` is `daemon` for a usable daemon, otherwise the failure: `socket-absent`, `permission-denied` (the file or the connect refused this user), `not-a-socket`, `connect-failed` (socket present, nothing completed the WebSocket upgrade), `handshake-failed` (upgrade or `initialize` failed), `windows-unsupported`, or `bad-response` (reachable, but `initialize` returned something off-schema — `reachable` stays `true`). `schema.compatibility` is `exact` when the daemon reports the pinned version, `unverified` when it differs (methods usually survive upgrades, but the shapes are not re-checked), or `unknown`. `cliVersionProbe` reports whether `codex --version` answered (`ok`, `missing`, `timeout` after 3 s, `error`). Whether ChatGPT Desktop owns a thread is not observable from the socket, so `desktopAttached` is always `"unknown"`. Every failure exits 1; automation reads `mode`/`reason`, not the exit code.

- Socket absent: no daemon, or Desktop-private mode. Start the daemon or wait for the upstream fixes.
- Unknown thread: `send` fails with "Unknown Codex thread"; use `list-threads`.
- Thread open elsewhere: a thread with an active writer (VS Code, TUI) fails with "open in another client"; close it there first.
- Approvals: `gbot` never approves commands or file changes on your behalf. If Codex asks while `gbot` is still connected, `send` refuses the request, exits 1, and tells you the turn id. `send` disconnects as soon as the turn starts, so later approval requests stay with the daemon for a Codex client to answer; for unattended sends set `approval_policy = "never"` in the daemon's `config.toml`.
**Thread discovery.** `list-threads --limit N` (1–200) pages with the opaque `--cursor` from the previous `nextCursor`; JSON keeps the cursor verbatim, text output prints a sanitized `more: --cursor …` hint. Text fields are stripped of terminal control sequences in both outputs (single-line fields also lose line breaks; `preview` keeps its newlines; a structured `source` such as `{ "custom": … }` passes through unchanged), `status` is one of `notLoaded | idle | active | systemError | unknown`, and non-numeric `updatedAt` becomes `null`. Unknown arguments are rejected before the socket is touched; a response that does not match the pinned schema (including an entry without a string `id`) fails with `reason: "bad-response"`.

**Routes, attribution, and loops.** `gbot codex send` runs on the machine that owns `CODEX_HOME`, as the user who owns the socket, with that user's Codex credentials; the socket path comes only from `CODEX_HOME`, never from the message or an agent-supplied argument. A cloud-hosted Grok Bot cannot reach a desktop socket directly — run `gbot` locally (for example from a Codex skill or an agent on that machine). `GROK_BOT_CODEX_THREADS=id,id` lets the operator pin `send` to approved threads (`reason: "route-not-allowed"` otherwise). Every send gets a delivery envelope: `messageId` (also sent as Codex's native `clientUserMessageId`), `correlationId` (defaults to the message id), optional `replyTo`, and `hop`. A reply passes the original correlation id and `hop` + 1:

```sh
gbot codex send <threadId> "Grok here: build is green" # receipt: messageId M, correlationId M, hop 0
gbot codex send --correlation-id M --reply-to M --hop 1 <threadId> "ack" # the answer, one hop later
```

Sends at `hop >= GROK_BOT_MAX_HOPS` (default 4) are refused with `reason: "hop-limit"` before anything reaches the daemon, so two agents cannot acknowledge each other forever; `gbot` never auto-acknowledges. `--envelope` (implied by any envelope flag) prepends a one-line `[gbot msg=… corr=… reply-to=… hop=… from=user@host]` header so the receiving agent can quote the ids back. That header is caller-authored provenance for the reader, not authentication: the daemon authenticates the local user through the socket, nothing else. The same flags work for `gbot send` to Grok bots and groups (the receipt carries `envelopeId`, `correlationId`, `hop`; Grok's own `messageId` remains the delivery receipt). Private ChatGPT Desktop pipes and arbitrary ChatGPT chats stay out of scope; only Codex threads on a reachable app-server daemon are routes.

**Busy threads.** `send` reads the thread status on resume. Only `idle` and `notLoaded` threads start a turn. An `active` thread (a turn in progress, or waiting on approval / user input) is refused with `reason: "busy"`: in app-server 0.154.0 a `turn/start` on an active thread steers that turn rather than queueing behind it, and `gbot` never steers or interrupts work a human may be doing. Either wait for `list-threads` to show `idle` and resend, or pass `--when-busy queue` to hand the message to the daemon's own queue through Codex's experimental `thread/queue/add` — that needs `GROK_BOT_CODEX_EXPERIMENTAL=1`, returns `delivery: "queued"` with `queuedSubmissionId`, and `gbot codex queue <threadId>` shows what is still waiting. `systemError` threads are refused with `reason: "thread-error"`, statuses this version does not know with `reason: "unknown-status"`. Receipts distinguish `delivery: "accepted"` (turn started; `turnId`, `turnStatus`), `"queued"`, `"rejected"` (nothing was sent; see `reason`), and `"unknown"` (the request left but no acknowledgment came back — look for `messageId` in the thread or queue before resending). The decision record, with the schema evidence and a live probe of the queue API, is in [`docs/codex-busy-threads.md`](docs/codex-busy-threads.md).

**Failure modes.** Every `send` / `codex send` failure under `--json` is `{ error, delivery, reason, messageId, correlationId, hop, … }` on stderr with exit 1; argument mistakes are `{ error, reason: "usage" }`; `codex status` never fails this way — it prints the status document on stdout and exits 1 when `mode` is anything but `daemon`. `reason` values are stable:

- `socket-absent` / `permission-denied` / `not-a-socket` / `connect-failed` / `handshake-failed` / `windows-unsupported`: the route is unavailable. Start the daemon, fix the socket, or wait for the upstream Desktop fixes.
- `unknown-thread`: use `list-threads`.
- `external-owner`: a thread with an active writer (VS Code, TUI) is open in another client; close it there first.
- `busy` / `thread-error` / `unknown-status`: see above.
- `route-not-allowed` / `hop-limit` / `experimental-disabled`: refused by operator policy, the relay bound, or the experimental-API gate.
- `unsupported`: the daemon does not offer the (experimental) method `--when-busy queue` needs.
- `approval-refused` (`delivery: "accepted"`): `gbot` never approves commands or file changes on your behalf. If Codex asks while `gbot` is still connected, `send` refuses the request, exits 1, and tells you the turn id. `send` disconnects as soon as the turn starts, so later approval requests stay with the daemon for a Codex client to answer; for unattended sends set `approval_policy = "never"` in the daemon's `config.toml`.
- `transport` / `bad-response` (`delivery: "unknown"`): the connection dropped or the daemon answered off-schema after the request left.

## Talking to Grok Bot from Codex

Expand Down
74 changes: 74 additions & 0 deletions docs/codex-busy-threads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Busy-thread delivery for `gbot codex send`

Decision record for [#38](https://github.com/ScriptedAlchemy/grok-bot-cli/issues/38). Evidence is the
app-server schema emitted by `codex app-server generate-json-schema` for codex-cli 0.154.0 — the release
`src/codex-bridge.js` is pinned to — generated both without and with `--experimental`, plus a live probe
against a 0.154.0 daemon (below).

## What the protocol offers

| 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`. |

### Live probe (0.154.0 daemon, 2026-09-15)

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.

## Decision

1. **Default: refuse busy destinations.** `send` resumes the thread (`thread/resume`, `excludeTurns: true`)
and reads `thread.status`. `active` → `delivery: "rejected", reason: "busy"`; `systemError` →
`reason: "thread-error"`; any status this pin does not know → `reason: "unknown-status"`. Only `idle`
and `notLoaded` (no turn can be running) proceed to `turn/start`. `turn/steer` and `turn/interrupt`
are never called: they change or stop work a human may be doing in the same thread.
2. **Opt-in: hand busy sends to the daemon's queue.** `--when-busy queue` requires
`GROK_BOT_CODEX_EXPERIMENTAL=1` (the experimental API is gated, so its use is an operator decision, not
a default) and initializes the session with `experimentalApi: true`. On an `active` thread `send` calls
`thread/queue/add` with the envelope's `messageId` as `clientUserMessageId` and returns
`delivery: "queued"` with `queuedSubmissionId`. A daemon without the method answers `-32601`, reported
as `reason: "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.
3. **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.
4. **Receipts distinguish states.** `accepted` (turn started; `turnId`, `turnStatus`), `queued`
(`queuedSubmissionId`; not started), `rejected` (nothing left `gbot`; `reason` says why), `unknown`
(the request left but no acknowledgment came back; `messageId` is Codex's `clientUserMessageId`, so
the caller can look for it in the thread or the queue before resending). There is no `completed`
state: `send` returns when the turn starts, and waiting for a result is a separate bounded operation.
5. **Steer and interrupt stay out.** Adding either is a new flag plus a schema re-pin, never a default.

## Known ceiling

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.

## Test matrix (`test/codex-bridge.test.js`)

- idle → `accepted`, `turn/start` carries `clientUserMessageId` and `turnTrigger: "gbot"`.
- active (`waitingOnUserInput`) → `busy`, no `turn/start` / `turn/steer` / `turn/interrupt` sent.
- active + `--when-busy queue` + gate on → `queued`, `initialize` carried `experimentalApi: true`,
`thread/queue/add` carried the envelope id; `gbot codex queue` lists and sanitizes; a daemon without the
method → `unsupported`.
- `--when-busy queue` with 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/start` left → `delivery: "unknown"`, `reason: "transport"`, receipt keeps `messageId`.
- malformed acknowledgment → `delivery: "unknown"`, `reason: "bad-response"`.
- every rejection, including route and allowlist refusals, carries `messageId` / `correlationId` / `hop`.
Loading