|
| 1 | +# Human requests |
| 2 | + |
| 3 | +How a CLI's approvals and questions reach a person and how their answers go back, exactly once. This |
| 4 | +is the contract from [#276](https://github.com/TypeSafeAI/modex/issues/276) (CF-3 of tracker |
| 5 | +[#273](https://github.com/TypeSafeAI/modex/issues/273)). It builds on the conversation identity in |
| 6 | +[conversation-events.md](conversation-events.md) and on CF-2's withdrawn cards |
| 7 | +([#275](https://github.com/TypeSafeAI/modex/issues/275)). |
| 8 | + |
| 9 | +The pure contract is `apps/desktop/src/shared/human-requests.ts`. The runner |
| 10 | +(`src/main/engine/runner.ts`) owns the authoritative state. The backends map native requests onto it. |
| 11 | +Nothing here executes anything or talks to a provider. Coding turns still run only through |
| 12 | +`codex app-server` and `claude -p`. |
| 13 | + |
| 14 | +## What changed |
| 15 | + |
| 16 | +Before, an approval card resolved a promise the first time anyone answered it, and every other answer |
| 17 | +was silently ignored. A client could not tell "accepted" from "too late". Cards left open by a process |
| 18 | +that died stayed actionable after a restart, though nothing was listening. Codex's |
| 19 | +`item/tool/requestUserInput` questions were declined with `-32601`. |
| 20 | + |
| 21 | +Now: |
| 22 | + |
| 23 | +- Every request is registered with a binding (who asked) and a digest (what exactly it asks). |
| 24 | +- Exactly one answer is accepted. Every other answer gets an explicit refusal. |
| 25 | +- Codex questions are cards with a control for each question. Answers are checked against the questions |
| 26 | + before anything reaches the CLI. |
| 27 | +- A secret answer reaches the CLI and nowhere else. |
| 28 | +- Cards nobody can answer any more are closed everywhere, including after a restart. |
| 29 | + |
| 30 | +## Contract |
| 31 | + |
| 32 | +```ts |
| 33 | +interface RequestBinding { |
| 34 | + thread: string; turn: string; attempt: string; // the Modex turn and attempt (TurnRef) |
| 35 | + backend: BackendId; account?: string; // opaque account binding, never a credential |
| 36 | + runtime: string; // the CLI process generation whose ids these are |
| 37 | + nativeRequestId: string; // Codex JSON-RPC id; Claude control request_id |
| 38 | + item?: string; // the native item it is about (Codex itemId, Claude tool_use_id) |
| 39 | +} |
| 40 | + |
| 41 | +interface Question { |
| 42 | + id: string; header: string; text: string; |
| 43 | + options: { label: string; description: string }[] | null; // null: free text |
| 44 | + allowOther: boolean; // free text besides the options |
| 45 | + secret: boolean; // sent to the CLI; never shown, saved or exported |
| 46 | +} |
| 47 | + |
| 48 | +type HumanRequest = |
| 49 | + | { kind: "approval"; id; binding; action: unknown; digest: string; canAlways: boolean } |
| 50 | + | { kind: "question"; id; binding; questions: Question[]; blocking: boolean; digest: string }; |
| 51 | + |
| 52 | +type Resolution = |
| 53 | + | { kind: "approval"; answer: "yes" | "no" | "always" } |
| 54 | + | { kind: "question"; answers: Record<string, string[]> }; |
| 55 | + |
| 56 | +type RequestState = "pending" | "resolving" | "resolved" | "withdrawn" | "uncertain"; |
| 57 | +type Refusal = "unknown" | "already-resolved" | "withdrawn" | "wrong-identity" | "changed-action" | "invalid"; |
| 58 | +``` |
| 59 | + |
| 60 | +The broker functions are pure. Each one takes `RequestsState` and returns a new one: |
| 61 | + |
| 62 | +| Function | What it does | |
| 63 | +| --- | --- | |
| 64 | +| `register(state, request)` | Adds a `pending` request. Registering an id twice changes nothing. | |
| 65 | +| `resolve(state, id, resolution, { binding, digest, by })` | Accepts one answer for a `pending` request and moves it to `resolving`. Otherwise it refuses with a reason. | |
| 66 | +| `settle(state, id, "sent" \| "uncertain")` | Records the send. Only a `resolving` request changes. | |
| 67 | +| `withdraw(state, { id } \| { thread, turn?, attempt? })` | Moves `pending` requests to `withdrawn`, and `resolving` ones to `uncertain`. | |
| 68 | +| `actionDigest(action)` | Gives a stable digest of the exact action. A changed action is a new request. | |
| 69 | +| `validateAnswers(questions, answers)` | Requires every question answered, choices from the options unless other text is allowed, and no unknown ids. | |
| 70 | +| `publicAnswers(questions, answers)` | Returns the answers as they may be shown, saved or exported. A secret answer becomes `"(hidden)"`. | |
| 71 | +| `codexQuestions(params)` | Maps Codex `requestUserInput` params to questions, or to `null` when they are malformed. | |
| 72 | +| `codexAnswerResponse(answers)` | Builds Codex's `{ answers: { [id]: { answers } } }`. | |
| 73 | + |
| 74 | +Refusals, in the order `resolve` checks them: |
| 75 | + |
| 76 | +| Reason | When | |
| 77 | +| --- | --- | |
| 78 | +| `unknown` | There is no request or card with this id. | |
| 79 | +| `withdrawn` | The request went away before any answer: Stop, the turn ended, or the CLI withdrew it. | |
| 80 | +| `already-resolved` | Another answer was accepted first. | |
| 81 | +| `wrong-identity` | The claim names a different thread, turn, attempt, backend, account, runtime or native request. | |
| 82 | +| `changed-action` | The digest differs from the request's. | |
| 83 | +| `invalid` | The answer is the wrong kind, or the answers fail `validateAnswers`. | |
| 84 | + |
| 85 | +`uncertain` means an answer was accepted but the request went away before the send was recorded, so |
| 86 | +whether the CLI received the answer is unknown. It never reads as resolved. Today's backends hand an |
| 87 | +accepted answer to the CLI's stdin in the same tick, so the runner records `sent` at once. The state |
| 88 | +exists for transports where the hand-off can fail after acceptance. |
| 89 | + |
| 90 | +## Runner |
| 91 | + |
| 92 | +`ThreadRunner` keeps the running turn's requests in `Live.requests` and the waiting promises in |
| 93 | +`Live.pending`. The pending map is typed: an approval settles with an answer, and a question settles |
| 94 | +with answers or a withdrawal. Requests live only while their turn runs. They are reset when the next turn |
| 95 | +starts and when one ends, so a secret answer is gone from memory with its turn. Afterwards, the card's own |
| 96 | +fields say how it closed. |
| 97 | + |
| 98 | +```ts |
| 99 | +answer(threadId, itemId, answer: ApprovalAnswer, by = "desktop"): AnswerResult |
| 100 | +answerQuestion(threadId, itemId, answers: Record<string, unknown>, by = "desktop"): AnswerResult |
| 101 | +type AnswerResult = { ok: true } | { ok: false; reason: Refusal; error?: string }; |
| 102 | +``` |
| 103 | + |
| 104 | +- **Binding.** The sink builds each request's binding from the attempt's `TurnRef` and the backend's |
| 105 | + `NativeRequest` (`{ runtime, request, item? }`). Clients name a card by its item id. Modex mints |
| 106 | + that id for each request, so it stands for the binding. The runner supplies the binding and the |
| 107 | + digest when it resolves. |
| 108 | +- **Questions.** `TurnSink.question(req, withdrawn?)` adds a `question` card. A blocking question |
| 109 | + sets the thread to `waiting`; a non-blocking one lets the turn run on. `answerQuestion` validates |
| 110 | + the answers and gives `error` text for an invalid one (for example "Token needs an answer."). |
| 111 | + Only then does anything reach the CLI. The card saves `publicAnswers`. |
| 112 | +- **Withdrawal.** `withdraw(threadId, l, itemId, "native" | "stopped" | "ended")` closes a card |
| 113 | + once. An approval closes as `answer: "no"` plus `withdrawn`, so older clients show it closed. A |
| 114 | + question closes with `withdrawn` alone, and its sink settles with `{ withdrawn }`, never with empty |
| 115 | + answers. |
| 116 | +- **Restart.** When a thread first loads in a new process, any approval without an answer and any question |
| 117 | + without answers or a withdrawal is closed as `withdrawn: "ended"`. The change is saved, and emitted as |
| 118 | + `item_update`, so the journal and paired clients see it too. |
| 119 | +- **Routines.** An unattended routine has nobody to ask. Approvals are refused with a receipt, as |
| 120 | + before. Questions are declined with a warning notice. |
| 121 | + |
| 122 | +## Native mappings |
| 123 | + |
| 124 | +### Codex (codex-cli 0.163.0, `app-server`) |
| 125 | + |
| 126 | +| Native | Modex | |
| 127 | +| --- | --- | |
| 128 | +| `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval` | Approval cards, as before, now carrying `native: { runtime: generation, request: id, item: itemId }`. | |
| 129 | +| `item/tool/requestUserInput` (experimental) | A question card through `codexQuestions`. A malformed request is declined whole with `-32602`, and a warning notice. | |
| 130 | +| Answers | Sent as `{ answers: { [questionId]: { answers: string[] } } }`. | |
| 131 | +| `serverRequest/resolved` for a request still open | Codex settled the request itself. The card closes as `withdrawn: "native"`, and Modex sends no answer. | |
| 132 | +| Stop, or the turn ending, while a question waits | The CLI gets error `-32000` ("withdrawn by modex: …"), never an empty answer. | |
| 133 | +| A sink with no `question` method | Declined with `-32601`, as before. | |
| 134 | + |
| 135 | +### Claude Code (2.1.302, `claude -p` stream-json) |
| 136 | + |
| 137 | +| Native | Modex | |
| 138 | +| --- | --- | |
| 139 | +| `control_request` `can_use_tool` | An approval card with `native: { runtime, request: request_id, item: tool_use_id }`. | |
| 140 | +| `control_cancel_request` | The card closes as `withdrawn: "native"` (CF-2), and no answer is sent. | |
| 141 | +| Other control request subtypes | Answered with an error, as before. Claude sends Modex no structured questions. | |
| 142 | + |
| 143 | +## Clients |
| 144 | + |
| 145 | +- **Desktop.** `thread:answer` now returns `AnswerResult`. `thread:answer-question |
| 146 | + { threadId, itemId, answers }` answers a question card. The card (`transcript/QuestionCard.tsx`) |
| 147 | + gives each question its own control: |
| 148 | + - options are radio buttons, with Other when the question takes free text; |
| 149 | + - a free-text question gets a text field; |
| 150 | + - a secret question gets a password field. |
| 151 | + |
| 152 | + A refusal is shown on the card. An answered card lists the answers, with a secret one shown as |
| 153 | + "(hidden)". A closed card says why, in the approval card's words. While a question waits, the turn |
| 154 | + header reads "Waiting for your answer" instead of "Waiting for approval". An answered or closed card |
| 155 | + folds into the turn's work like an answered approval; an open one never does. |
| 156 | +- **Phone.** The companion still answers approvals only. It refuses an answer to anything that is not an |
| 157 | + open approval, and a runner refusal becomes a 409. A question reaches the phone as an `info` notice |
| 158 | + that carries the question text and its state ("Answer it in Modex on your Mac.", "Answered on your |
| 159 | + Mac.", "Not answered."), never the answers. |
| 160 | +- **Store demo.** `thread:answer` returns `{ ok: true }`. The demo asks no questions, so |
| 161 | + `thread:answer-question` refuses with `unknown`. |
| 162 | + |
| 163 | +## Secrets |
| 164 | + |
| 165 | +A secret answer is held only in the turn's memory and written only to the CLI's stdin. It is never in a |
| 166 | +saved item, an `item`/`item_update` event, the event journal, a snapshot, the phone's view or an |
| 167 | +export. Tests check the saved store and the emitted events (`test/human-requests-runner.test.ts`), and |
| 168 | +every file under `MODEX_HOME/app` (`e2e/human-requests.spec.ts`). |
| 169 | + |
| 170 | +## Tests |
| 171 | + |
| 172 | +- `test/human-requests.test.ts` covers the pure broker: |
| 173 | + - two clients answering one request; |
| 174 | + - a wrong identity, a changed action, or an unknown id; |
| 175 | + - Stop while an answer is being sent, which makes the request `uncertain`; |
| 176 | + - the Codex mapping, answer validation and secret redaction. |
| 177 | +- `test/human-requests-runner.test.ts` covers the runner and the Codex backend: |
| 178 | + - a second answer is refused as `already-resolved`, and the CLI hears one answer; |
| 179 | + - a late answer after Stop is refused as `withdrawn`; |
| 180 | + - a Codex question is answered by its schema, and the secret stays out of saved items and events; |
| 181 | + - Stop sends an error reply; |
| 182 | + - `serverRequest/resolved` withdraws a question with no reply sent; |
| 183 | + - after a restart, open cards are inert, saved and emitted. |
| 184 | +- `test/companion.test.ts` covers the phone: the question fallback text, and refusals as 409. |
| 185 | +- `test/turn-layout.test.ts` checks that an open question never folds away. |
| 186 | +- `e2e/human-requests.spec.ts` runs a fixture Codex app-server through the real UI: |
| 187 | + - the turn header while a question waits; |
| 188 | + - an incomplete answer is refused in place; |
| 189 | + - the password field; |
| 190 | + - one reply carrying the answers; |
| 191 | + - a refused second answer; |
| 192 | + - Stop gives an error reply; |
| 193 | + - no secret on disk. |
| 194 | + |
| 195 | +## Not done here |
| 196 | + |
| 197 | +- Answering questions from the phone. |
| 198 | +- Request lifecycle events in the event journal (the second half of CF-6). |
| 199 | +- Clarifying a task plan through a question card (the second half of CF-7). |
0 commit comments