The external protocol is a projection
What the IDE sees is Thread / Turn / Item, not kernel EventMsg. A turn/start first returns a response, then pushes the event stream; approval is a reverse request — no reply, and this turn stalls.
turn/start reply only means the request was accepted, and real start is turn/started; the Python SDK and the TypeScript SDK do not walk the same protocol face.
turn/start. Enter plays it.
turn/start response comes back at once — it only means the request was accepted. The spinner waits for the later turn/started notification.You’re writing an editor plugin. The debugger already shows the kernel throwing events: turn_started, exec_command_begin, fields in snake_case. The first packet arrives and doesn’t match. The method is turn/started, slash in the middle. Fields are threadId, startedAt.
When a command starts, the exec_command_begin you waited for never shows up. You get item/started instead, stuffed with an item whose type: “commandExecution”. Approval is weirder: the server sends a request the other way, and you have to reply — or this turn sits there.
If the editor switches on all 81 EventMsg variants, every new internal event is a client upgrade. A deprecated alias would also stop being an in-repo compat issue and become an external contract.
The dispatcher apply_bespoke_event_handling eats one kernel Event and folds it into an external message under four rules.
The snake_case type of EventMsg becomes a resource path like turn/started or item/agentMessage/delta, and fields flip to camelCase.
Deltas and tool lifetimes get folded into a ThreadItem, then stuffed into item/started or item/completed. The IDE paints cards from the item’s type.
ExecCommandBegin, ViewImageToolCall, and the wildcard arm at the end of the match have no live notification. Old events still fan out to rollout.
One ItemStarted(DynamicToolCall) both notifies and sends the item/tool/call ServerRequest, then waits for the client to run it.
Source: codex-rs/app-server/src/bespoke_event_handling.rs lines 159–188; codex-rs/app-server/src/bespoke_event_handling.rs lines 880–918; codex-rs/app-server/src/bespoke_event_handling.rs lines 996–1036
item_event_to_server_notification only covers one-to-one, stateless projections. The name looks like the main door; call sites know it’s a helper. ExecCommandBegin can still become item/started in the helper, but in the dispatcher it walks into a deprecated empty arm. Live command cards come from a later ItemStarted. Trust the dispatcher.
Source: codex-rs/app-server-protocol/src/protocol/event_mapping.rs lines 25–37; codex-rs/app-server-protocol/src/protocol/item_builders.rs lines 1–11
The kernel names what happened; the outside names what the user sees. Internals can still send deprecated events to rollout — the dispatcher drops them with a comment. Rewrite it in another language and the table stays: internal type on the left, keep / rename / drop / split written on the right.
Unknown rows must fail. An empty default is a wildcard arm: new events compile, and the IDE’s stdout shows nothing.
Source: codex-rs/app-server/src/bespoke_event_handling.rs lines 1238–1245
A teammate treats the turn/start response as “the turn has begun.” It comes back at once, with a turn whose items are empty. The model hasn’t spoken. Real start is the later turn/started notification.
Source: codex-rs/app-server/README.md lines 81–81
If approval were an ordinary notification, the client could ignore it. The turn would sit on wait until timeout or interrupt.
Only four objects decode on the wire: a request with an id, a notification without one, a success response, an error response. It looks like JSON-RPC, but the struct has no jsonrpc field. The constant JSONRPC_VERSION is still there; the live key is not.
Source: codex-rs/app-server-protocol/src/rpc.rs lines 1–11; codex-rs/app-server-protocol/src/rpc.rs lines 34–72
External messages come in four sets, and they are asymmetric.
1. ClientRequest: the client asks and waits for a reply. initialize and turn/start are the stable-face spine.
2. ServerNotification: the server pushes and does not wait. turn/started and item/started live here.
3. ServerRequest: the server asks a person. The first stable method is item/commandExecution/requestApproval.
4. ClientNotification: expand it and you only get Initialized.
Source: codex-rs/app-server-protocol/src/protocol/common.rs lines 1663–1670; codex-rs/app-server-protocol/src/protocol/common.rs lines 1954–1956
Requests need a receipt, notifies are broadcasts, reverse requests pull a person into the loop. Mix the three into one and the editor either spins waiting for a start or forgets the approval button. Matching ids also let you fail a request back to the caller under load, so approval doesn’t hang.
Experimental methods carry 57 method-level marks. A second port would make stable clients and adventurous clients connect in two places. If the TUI switched to raw EventMsg just because it shares a process, live notifies and a remote IDE would each write their own item.
The experimental face is one boolean on initialize: experimentalApi, default false. A second initialize gets Already initialized. Hit server/diagnostics with the switch off and the error is -32600, the sentence fixed as server/diagnostics requires experimentalApi capability. The Python SDK flips that default to True — official scripts already stand on the experimental contract.
Source: codex-rs/app-server/src/message_processor.rs lines 891–895; sdk/python/src/openai_codex/client.py lines 209–209
The TUI does not talk to core directly. In-process only swaps the carrier: socket and stdio become an in-memory channel; MessageProcessor stays. Requests are still ClientRequest, responses still walk the same envelope. In-process is transport-local, not protocol-free.
Source: codex-rs/app-server/src/in_process.rs lines 1–24
The TypeScript SDK does not take this road. It assembles exec --experimental-json: event types are dotted, fields are snake_case, the full enum has 8 variants. No initialize, no approval request. The gap is the protocol face, not the language.
Source: sdk/typescript/src/exec.ts lines 89–90; codex-rs/exec/src/exec_events.rs lines 8–37
Remote vs local should land on the network, not on the semantics. A capability is harder than a docs sentence that says “experimental.” One boolean splits the stable face from the experimental face; schema generation emits two copies, and the default copy has no experimental fields.
DeepSeek Harness: kernel types are protocol types
DSH’s five doors share one plugin tree. The headless entry config writes itself as a composition base: it assembles plugins and does not invent a second event type. Across processes, Typert generates stubs from the TypeScript type graph, and @Remote('create') returns an identity, not another display model.
Change one event field and all five faces move together. The win is you never get Python seeing thread/started while TypeScript sees thread.started. Codex flips it: internals can stay deprecated and keep fanning to rollout, while the external contract freezes at the projection. Miss the projection and the client sees nothing — the feature is just gone.
Claude Code: an entry mark, no second protocol face
What the restored source has is an entry check: when CLAUDE_CODE_ENTRYPOINT === 'claude-vscode' it returns claude-vscode. There is no matching external IDE protocol crate. Extensions nest in through MCP and a process entry; a third-party IDE has no schema-backed two-way RPC to pair against.
Codex pays the projection-layer upkeep and gets a VS Code extension, a Python SDK, and a local TUI sharing the same v2.
Checked against source · 2026-08-22 · restored-src/src/main.tsx lines 823–823The reply is here — should the spinner start?
The turn/start response is back, and items is empty. Should the editor spin now, or wait for turn/started? If the kernel adds a new EventMsg variant and the projection doesn’t follow, what shows up on stdout?
Advanced: in the same turn the model wants a command that needs a question. A Python client can pop a dialog and reply. Why can’t TypeScript’s Thread.run()?