Skip to content

Commit 747f793

Browse files
committed
Document the human-request contract and record CF-3 in status
docs/human-requests.md covers the broker's exported signatures, its states and refusals, the runner's binding and withdrawal rules, the Codex 0.163.0 and Claude 2.1.302 mappings, the clients, how secrets are kept, the tests, and what is left for CF-6 and CF-7. Part of #276. Coven-Harness: Claude Code / claude-opus-5-5
1 parent 3dd4bb1 commit 747f793

2 files changed

Lines changed: 208 additions & 2 deletions

File tree

‎docs/human-requests.md‎

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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).

‎docs/status.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ and [landing verification](reviews/2026-10-11-landing-clarity.md).
8989
draining without exposing MCP credentials. Live macOS checks cover pending approval, running
9090
command, resume and mid-stream Stop; Windows/Linux escalation remains unproven.
9191
[Evidence and limits](reviews/2026-10-18-claude-lifecycle.md).
92+
- **Approvals and questions, answered once (CF-3 #276).** Each request is bound to its turn,
93+
attempt and the CLI's own request id. The first answer is taken; any other is refused with a
94+
reason, and the phone gets a 409. Codex questions (`item/tool/requestUserInput`, experimental in
95+
codex-cli 0.163.0) are cards with a control for each question, and a secret answer goes to the
96+
CLI only. Stop sends Codex an error, never an empty answer. A request Codex settles itself closes
97+
as withdrawn, and cards a restarted Modex left open close as ended everywhere. Phones show
98+
questions as text to answer on the Mac. Scripted fixtures only. [Human requests](human-requests.md).
9299
- **Old transcripts** read unchanged, with no invented provenance.
93100
- **Acceptance (CF-8 #281, first part).** A fixture Claude CLI drives the real app through a
94101
plan, an approval, a real edit, a check, the answer and Save to Notes, then a restart that
@@ -99,8 +106,8 @@ and [landing verification](reviews/2026-10-11-landing-clarity.md).
99106
- **Fixes those checks found.** Opening a thread for the first time no longer resends and rewrites
100107
every item in it, which cost a 1,400-item transcript about 55 ms. An approval card in a narrow
101108
window wraps its text and buttons instead of pushing Deny out of view.
102-
- **Still open:** the Markdown parser (#283), CF-6's request half (after CF-3), live-provider
103-
acceptance and human VoiceOver checks.
109+
- **Still open:** the Markdown parser (#283), CF-6's request half, CF-7's clarifications,
110+
live-provider acceptance and human VoiceOver checks.
104111

105112
- **Every inline error can be copied, and most can be retried.** A shared `ErrorNotice`
106113
replaces bare error text across the terminal, Changes diff, Files, Canvas, browser and

0 commit comments

Comments
 (0)