* feat(shared): steer capability gates and live steered signal schemas - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which agents can deliver queued messages into the active turn (pi, codex, cursor ACP; legacy stream-json cursor excluded) - AgentState.steeringActive, DecryptedMessage.steered and messages-consumed live signal (never persisted by the hub) * feat(cli): queue reservations and steered messages-consumed option - MessageQueue2 gains takeByLocalId/restoreReservation/ beginReservationDispatch/commitReservation so an async steer can reserve a queued row without racing the main loop's turn/start drain - emitMessagesConsumed accepts steered: true to mark mid-turn delivery * feat(codex): mid-turn steer via app-server turn/steer (#888) - CodexAppServerClient.steerTurn + TurnSteerParams/Response types - CodexRemoteLauncher registers the steer-queued-message RPC handler: reserves the queued row, validates it against the active turn (no control commands, matching mode hash), injects via turn/steer with an epoch guard that invalidates in-flight steers on abort/cleanup - steeringActive agent state tracks the active-turn window - hub syncEngine gate opens to codex; messages-consumed relays steered * feat(web): Steered badge and steer gating for codex sessions - HappyUserMessage shows a ↳ Steered badge fed by the live messages-consumed steered signal, preserved across server echoes and refetches (mergeMessages carries the optimistic marker) - SessionChat gates canSteer via isSteeringSupportedForSession instead of the pi-only check - clearStaleQueuedStatus normalizes a queued status on an invoked message - fix(web): drop duplicate showSessionSummaryInChat in markdown test (upstream typecheck breakage) * fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise codex and pi only; cursor joins when its soft-steer handler lands (#1609) - turn/steer now splits dispatch (stdin accepted) from completion (turn finished): the hub RPC acks once dispatch succeeds — never on the concurrent turn's completion, which can exceed the 30s RPC window - queue row commits only after the turn settles; a rejected/aborted steer restores the row so the message still delivers via turn/start, and a dispatched steer is never restored (no duplicate delivery) - steer carries clientUserMessageId (echoed as userMessage.clientId) so ambiguous transport failures can reconcile the thread later - client tests cover dispatch/complete split and stdin-write failure * fix(codex): reconcile dispatched steers before restoring; align error copy - A dispatched turn/steer whose completion fails (disconnect / protocol error) is now reconciled via thread/read by clientUserMessageId before the queued row is restored — the instruction is only re-delivered by turn/start when the thread never received it - Reconcile targets the pinned steer thread, not whichever turn is current when completion fails - syncEngine unsupported-flavor error now matches the capability gate (Pi and Codex only until the cursor handler lands) - launcher tests cover steer success (ack on dispatch), reconcile-accepted and reconcile-rejected outcomes * fix(codex): consume the row at dispatch; drop background reconcile - The hub RPC acks and the queue row is consumed as soon as stdin accepts turn/steer; completion is background-only logging. A dispatched steer is never restored, so the same localId cannot be re-delivered via turn/start after the caller was told the steer succeeded - Dispatch failure (stdin write error) still restores the row and reports failure - steer.completed rejection is always handled (no unhandled rejection on the dispatch-failure path) - tests updated: completion failure after dispatch keeps the row consumed; dispatch failure restores it * fix(codex): distinguish definite rejection from indeterminate completion - Transport-level failures (timeout, abort, disconnect, spawn, protocol) carry an indeterminate marker; explicit JSON-RPC error responses do not - After a dispatched steer, turn completion resolves → commit + consumed; a definite app-server rejection restores the row (instruction was never accepted, so turn/start cannot duplicate it); an indeterminate outcome leaves the row reserved so it can never be delivered twice - Completion handling registers before awaiting dispatch so the dispatch-failure path cannot leak an unhandled rejection - client/launcher tests cover explicit rejection (restore), indeterminate outcome (row stays reserved) and dispatch failure * fix(codex): reconcile indeterminate steers instead of a permanent reservation - After an indeterminate completion (disconnect/protocol), reconcile the thread by clientUserMessageId immediately: accepted → commit + consumed, provably rejected → restore, still unreadable → keep the reservation and retry from the main-loop top on later passes (post-reconnect) - A row never sits in dispatching forever: the hub cannot stamp it invoked while the instruction may never have been accepted - tests: indeterminate keeps reserved while thread unreadable; accepted reconciliation consumes; rejected path restores * fix(codex): accept all thread item shapes; retry reconcile; ack through abort - Reconcile matcher accepts userMessage/user_message with clientId/ client_id, matching the shapes the thread parser supports — an accepted steer can no longer be misclassified as rejected - A pending reconciliation schedules a wakeLoop retry, so a temporary app-server outage cannot strand the reservation behind waitForTurnOrRecovery - The success-path ACK no longer checks the steer epoch: the hub already reported steered on dispatch, so commit + messages-consumed must reach it even when an abort resets the queue in between * fix(codex): reinit reconnected app-server; keep reconcile retries alive - thread/read after a disconnect auto-connects a fresh app-server, which must be initialized before any request — reconcile now ensures connect + initialize (isConnected getter added to the client) - every still-unknown loop-top reconciliation schedules the next retry, so recovery without external traffic is eventually observed - launcher mock gains isConnected * fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK - Reconciliation runs on a self-rescheduling 1s timer independent of the main loop (wakes it too), so idle loops and waitForTurnOrRecovery still observe app-server recovery; abort clears nothing implicitly — the ACK path commits and consumes even when the reservation was cancelled - Absence of a durable client id is ambiguous: unmatched reads stay 'unknown' and keep retrying instead of restoring the row - CodexAppServerClient tracks initialized state (reset on disconnect/exit) so ensureAppServerInitialized re-initializes a fresh process before thread/read; initialize failures leave the flag false for the next retry - tests: accepted reconciliation via scheduled timer, indeterminate keeps reserved, explicit rejection restores * fix(codex): bind reconciliation to the launcher lifecycle - runSteerReconciliation clears any armed retry timer on entry and never installs a second one, so loop-top and timer-driven passes cannot multiply - shuttingDown is set when the main loop ends: timers are cleared and the pending map is dropped, so an unresolved steer can never respawn an app-server after cleanup (remote-to-local switch included) * fix(codex): report steered only after app-server acceptance - The handler now awaits steer.completed (the inject-acceptance response): an explicit JSON-RPC rejection surfaces as failed and restores the row for the normal turn/start path instead of a false steered - Transport failure after dispatch reports 'Steer outcome is being reconciled' and keeps the row reserved while the timer-driven thread reconciliation runs - dispatch-failure path also swallows the paired completion rejection * fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait - MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching steer reservation: the hub neither deletes the row nor stamps invoked_at (new CancelMessageResponse 'busy' status; web restores the optimistic row); pushIsolateAndClear and reset/close share cancelReservations so /clear-style commands cannot have a rejected steer resurrect a discarded prompt - turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a lost response is indeterminate and funnels into thread reconciliation instead of stranding the reservation - tests updated for the tri-state cancel contract * fix(codex,web): busy-aware edit flow; bound reconciliation reads - QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it never prefills the composer when the row is inside an async steer, so a second client cannot send a duplicate - reconcileSteerByClientId bounds thread/read with a 5s timeout so a connected-but-silent app-server cannot hold the reservation in-flight indefinitely * fix(steer): inFlight-dominated cancel acks; bounded reconciliation - hub cancel-queued-message acks check inFlight before removed: a stale duplicate socket reporting removed can no longer delete the durable row while another socket is dispatching the steer - reconciliation entries expire after 60s and mark delivered: after the rejection window, a dispatched steer that the app-server never proved (client ids dropped on restart) is committed instead of polling thread/read forever - pre-dispatch failures (abort before write included) never enter reconciliation — they restore the row and report failure * fix(steer): persist indeterminate outcomes without replay * fix(steer): make ambiguous delivery restart-safe * fix(steer): recover crash-held rows and preserve retry dedup * fix(steer): ack retries and bound stdin dispatch * fix(steer): reconcile indeterminate dispatches and serialize retries * fix(codex): classify stdin callback failures as indeterminate * fix(steer): recheck indeterminate cancels after ACK * fix(steer): close retry and abort races * fix(steer): serialize live retries and abort admission * fix(steer): distinguish live dispatching from unknown * fix(steer): keep ACK failures held and reconcile busy cancel * fix(steer): distinguish held cancel from removal * fix(store): combine schema v24 migrations * fix(store): reserve schema v25 for steer delivery state * fix(steer): keep held cancel state and notify requeue * fix(steer): release explicitly cancelled unknown reservations * fix(codex): reject cancelled reservations before native steer * fix(codex): make reservation restore atomic with state * fix(codex): terminate abandoned transport writes * fix(steer): own abandoned app-server lifecycle and consume races * fix(codex): confirm dispatch and recover abandoned turns * test(codex): mock abandoned transport callback * fix(codex): clear visible turn state on transport loss * fix(steer): claim retries and cover native delivery state * fix(native): preserve indeterminate state on Android hydration * fix(steer): make retry claims single-winner * fix(steer): serialize concurrent retry claims * fix(socket): tolerate missing steer-state ACK callbacks * fix(native): serialize retry operations * docs(web): document unknown steer delivery and retry controls * fix(steer): handle retry failures and abort-before-connect * fix(steer): reinitialize after transport loss and finish iOS retry errors * fix(steer): preserve indeterminate rows across reconnect gaps * test(web): mock indeterminate queued recovery state * fix(steer): recover consumed ACK tombstones * fix(steer): expose consumed cancel tombstones
16 KiB
Message decode tree
Audience: Implementers of native HAPI clients (iOS / Android). This page specifies how to decode DecryptedMessage.content into renderable chat structure. This is the largest porting surface — the reference pipeline is web/src/chat/ (~4600 lines); this page is its wire-level contract. Companion pages: pagination (how messages arrive), sse (live delivery).
Source of truth: shared/src/schemas.ts (DecryptedMessageSchema), shared/src/messages.ts (envelope helpers), web/src/chat/normalize.ts, web/src/chat/normalizeUser.ts, web/src/chat/normalizeAgent.ts, web/src/chat/types.ts, hub/src/store/contentCodec.ts.
Wire shape
type DecryptedMessage = {
id: string // server uuid (optimistic rows: == localId until echoed)
seq: number | null // per-session insert counter
localId: string | null // client-generated id for optimistic reconciliation
content: unknown // the role-wrapped envelope — everything below
createdAt: number // hub receive time (epoch ms)
invokedAt?: number | null // when the agent consumed it (null = still queued)
scheduledAt?: number | null // future-scheduled sends
deliveryState?: 'indeterminate' // steer dispatched, outcome unproven; explicit retry/cancel required
}
content is deliberately unknown on the wire. Decoding must be total: malformed content degrades to a stringified fallback — a client must never drop or crash on a message it does not recognize (with the two precise exceptions listed in Fallback rules).
Envelope
content is a role-wrapped envelope:
{ role: 'user' | 'agent', content: <payload>, meta?: unknown }
Unwrap algorithm (unwrapRoleWrappedRecordEnvelope, shared/src/messages.ts) — a record qualifies when it has a string role and a content key; if content itself is not one, also probe, in order:
content.messagecontent.data.messagecontent.payload.message
No envelope found ⇒ render the whole content as stringified agent text. role other than user/agent ⇒ stringify record.content as agent text.
meta
Opaque record; carry it through. Known keys:
| Key | Values | Meaning |
|---|---|---|
sentFrom |
'webapp', 'telegram-bot', 'cli', … |
Origin of a user message. 'cli' marks CLI-echo traffic: user/assistant text containing <command-name>, <command-args>, <command-message>, or <local-command-stdout> tags renders as a monospace cli-output block instead of a chat bubble, and a <command-name> block merges with its <local-command-stdout> follow-up (web/src/chat/reducerCliOutput.ts). |
deliveryMode |
'queue' | 'steer' |
Durable delivery intent of a user send (see pagination). Absent = queue. |
Decode tree
DecryptedMessage.content
└─ unwrap envelope → { role, content: payload, meta? }
├─ role: 'user'
│ ├─ payload is string → user text
│ ├─ payload {type:'text', text, attachments?} → user text (+ attachments)
│ └─ anything else → user text (stringified payload)
└─ role: 'agent' — dispatch on payload.type
├─ 'codex' → generic agent family (payload.data.type dispatch)
├─ 'output' → Claude SDK passthrough + agy (payload.data.type dispatch)
├─ 'event' → AgentEvent union (payload.data)
└─ unknown → agent text (stringified payload)
role: 'user' payloads
Reference: web/src/chat/normalizeUser.ts.
| Payload | Result |
|---|---|
bare string |
user text |
{type:'text', text: string, attachments?: AttachmentMetadata[]} |
user text with attachments. Each attachment is accepted only when id, filename, mimeType (strings), size (number), path (string) are all present; optional previewUrl. Invalid entries are skipped, an empty result means "no attachments". |
| anything else | user text = stringified payload (never drop) |
role: 'agent' — family 'codex'
payload.type === 'codex' (AGENT_MESSAGE_PAYLOAD_TYPE, shared/src/modes.ts:8) is the generic agent envelope used by the non-Claude-SDK flavors (codex, gemini, cursor, copilot, grok, opencode, pi, kimi; agy uses the 'output' family below). Dispatch on payload.data.type (web/src/chat/normalizeAgent.ts):
data.type |
Payload fields | Renders as |
|---|---|---|
message |
message: string, id? (stream id), streamSnapshot? |
Agent text. If the text is a bare JSON object with review markers (findings / overall_correctness / overall_explanation), parse it as a codex review block instead — unless it is a Pi stream snapshot (streamSnapshot: true or id matching /^pi-.+-turn-\d+-message-\d+-text-\d+$/), which is always plain text. |
reasoning |
message: string, id? |
Reasoning (thinking) block. |
error |
message: string |
Error event row. |
tool-call |
callId, name?, input?, description?, nativeTitle?/title?, nativeKind?/kind?, progress? |
Open a tool card keyed by callId. |
tool-call-result |
callId, output, is_error? |
Complete the tool card with the same callId. |
generated-image |
imageId/image_id, fileName/file_name, mimeType/mime_type, id?, source? |
Inline generated image (fetch via the images REST endpoint). Missing imageId ⇒ drop. |
context_compacted |
trigger?, preTokens/pre_tokens |
compact event row. |
compact-summary |
summary, tokensBefore?, estimatedTokensAfter? |
compact-summary event row. |
token_count |
info: {last | total | …}, thread_id?, scope? |
Usage sample (event). Prefer info.last* over info.total*; context_tokens falls back to input_tokens; modelContextWindow → context_window. Unparseable usage ⇒ drop. |
thread_goal_updated |
goal {threadId, objective, status, tokenBudget?, tokensUsed?, timeUsedSeconds?, createdAt?, updatedAt?}, threadId?, turnId? |
thread-goal-updated event. status ∈ active|paused|budgetLimited|usageLimited|blocked|complete; invalid goal ⇒ drop. |
thread_goal_cleared |
threadId? |
thread-goal-cleared event. |
plan |
entries/items/steps: list of steps |
Synthetic completed update_plan tool pair (cursor flavor). Steps accept step|content|text|title|description + status|state (normalized to pending|in_progress|completed). Empty plan ⇒ drop. |
plan_update |
plan/update/items/steps |
Same, codex flavor. |
agent-run-start / agent-run-update / agent-run-trace |
run payload | Background agent-run event rows (windowed separately, see pagination). |
| anything else | — | Drop silently (normalize.ts: unknown codex content returns null, not a stringified bubble). |
Snake_case/camelCase field pairs above are both accepted — always probe both.
role: 'agent' — family 'output' (Claude SDK passthrough)
payload.data is a Claude Code SDK log entry, forwarded verbatim. Envelope-level fields on data: uuid, parentUuid, isSidechain?, parentToolUseId?, timestamp? (ISO-8601 execution-machine clock — parse to epoch ms, fall back to createdAt), and the flags below.
Skip filters — evaluate first (isSkippableAgentContent / isClaudeChatVisibleMessage):
data.isMetaordata.isCompactSummarytruthy ⇒ hidden.data.type === 'rate_limit_event'or'tool_progress'⇒ hidden.data.type === 'system'withsubtypenot in{api_error, turn_duration, microcompact_boundary, compact_boundary, away_summary}⇒ hidden.- Empty
away_summary/ emptyagy_message⇒ hidden.
Then dispatch on data.type:
assistant
data.message = {model?, content, usage?}. content is a string (⇒ one text block) or an array of blocks:
| Block | Fields | Renders as |
|---|---|---|
text |
text |
agent text |
thinking |
thinking |
reasoning |
tool_use |
id, name, input |
tool card open (description convention: input.description when present) |
Other block types are ignored. message.usage carries input_tokens, output_tokens, cache_creation_input_tokens?, cache_read_input_tokens?, service_tier?, context_window?.
user
Despite the name, these arrive through the agent path (tool results and system-injected turns). data.message.content cases:
| Case | Renders as |
|---|---|
array with tool_result blocks {tool_use_id, content, is_error?, permissions?} |
tool card completion. Prefer entry-level data.toolUseResult over the block's content when present. permissions = {date, result:'approved'|'denied', mode?, allowedTools?, decision?} — merge into the tool card's permission state. |
| string content (any), or sidechain array-of-text | sidechain marker {prompt} — subagent prompts and system-injected turns; group under the parent Task tool card via parentToolUseId (fallback: exact prompt match). |
non-sidechain array that is entirely text blocks |
a real user message the CLI wrapped as output ⇒ render in the user lane. |
text blocks mixed with tool results |
agent text blocks. |
system subtypes → event rows
subtype |
Fields | Event |
|---|---|---|
api_error |
retryAttempt, maxRetries, error |
api-error |
turn_duration |
durationMs, messageId? |
turn-duration {durationMs, targetMessageId?} |
microcompact_boundary |
microcompactMetadata {trigger, preTokens, tokensSaved} |
microcompact |
compact_boundary |
compactMetadata {trigger, preTokens} |
compact |
away_summary |
content: string |
recap {text} |
summary
data.summary: string ⇒ conversation-summary content block.
agy (Antigravity) — also in the 'output' family
data.type |
Renders as |
|---|---|
agy_message |
Agent text (data.content, per-turn data.model?). Empty ⇒ skip. Text starting Inside the task-NNN log ⇒ compact AgyTaskLog chip (synthetic completed tool pair). Echoed raw task results ([Message] timestamp=… trailer) are stripped from the prose. |
agy_tool_action |
Synthetic completed tool pair (tool-call + tool-result, same id — prefer data.toolUseId, falling back to the message id). name === 'SYSTEM_MESSAGE' ⇒ AgyAsyncTask background-task card; name === 'ERROR_MESSAGE' ⇒ AgyError card (is_error: true); otherwise map agy tool names to canonical ones (run_command→Bash, view_file→Read, write_to_file→Write, replace_file_content→Edit, grep_search→Grep, list_dir→LS) and translate arg keys (CommandLine→command, TargetFile→file_path, …), stripping agy's result preambles/trailers. See normalizeAgent.ts for the exact strip rules. |
Unknown 'output' types
A visible data.type not matched above ⇒ stringified agent text fallback (unlike the codex family, which drops).
role: 'agent' — family 'event'
payload.data is one AgentEvent (web/src/chat/types.ts lines 17–36). The union is open-ended — the last member is {type: string} & Record<string, unknown>; tolerate unknown types (render generically or ignore, never crash).
type |
Fields |
|---|---|
switch |
mode: 'local' | 'remote' |
message |
message: string |
error |
message: string |
title-changed |
title: string |
limit-reached |
endsAt: number, limitType: string |
limit-warning |
utilization: number (0–1), endsAt, limitType |
ready |
— |
api-error |
retryAttempt, maxRetries, error: unknown |
turn-duration |
durationMs, targetMessageId? |
microcompact |
trigger, preTokens, tokensSaved |
compact |
trigger, preTokens |
compact-summary |
summary, tokensBefore?, estimatedTokensAfter? |
recap |
text |
thread-goal-updated |
goal: ThreadGoal, threadId?, turnId? |
thread-goal-cleared |
threadId? |
abort-restore |
text |
| (catch-all) | {type: string, …} |
Several event rows are also synthesized by the other two families (system subtypes, context_compacted, token-count, agent-run-*) — the renderer should treat them uniformly. Note: event-family message rows whose text is a Goal … status line are filtered by the hub at ingest and from REST pages (isRedundantGoalStatusEventContent, shared/src/messages.ts); clients need no special handling.
Fallback rules
| Situation | Behavior |
|---|---|
| No unwrappable envelope | stringify whole content as agent text |
role not user/agent |
stringify record.content as agent text |
| user payload unrecognized | stringify as user text |
'codex' family, unknown data.type |
drop (return nothing) |
'output' family, hidden by skip filters |
drop |
'output' family, visible but unknown data.type |
stringify as agent text |
'event' family, data lacks a string type |
stringify as agent text |
"Stringify" = a stable JSON serialization (web: safeStringify) rendered as plain text. These are the only two legitimate drop paths; everything else must render something.
Truncation marker
At ingest the hub head+tail-truncates any string longer than 64 KiB found anywhere inside agent-role content (hub/src/store/contentCodec.ts): the stored value becomes first 48 KiB + \n…[hapi: truncated N chars]…\n + last 12 KiB. User-role content is never truncated (it is delivered verbatim to the CLI). The operation is idempotent and applied deep (arrays/objects).
Clients must render truncated strings as-is (recognizing the …[hapi: truncated N chars]… marker is optional polish), must not assume tool results are complete, and must never choke on the marker.
Permission requests are NOT messages
Pending tool approvals never appear in the message stream. They live on the session object (shared/src/schemas.ts:167-203):
session.agentState = {
requests?: Record<requestId, { tool: string, arguments: unknown, createdAt?: number | null }>
completedRequests?: Record<requestId, {
tool, arguments, createdAt?, completedAt?,
status: 'canceled' | 'denied' | 'approved',
reason?, mode?, allowTools?: string[],
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort',
answers?: Record<string, string[]> // flat (AskUserQuestion)
| Record<string, { answers: string[] }> // nested (request_user_input)
}>
}
agentState updates arrive as a versioned SSE patch — apply it under the version gate described in sse.md. Render pending requests as approval cards interleaved with the chat (the web reducer keys them to the matching tool_use when one exists); on resolution the entry moves to completedRequests, whose status/answers back-fill the tool card's permission state. Decide via POST /api/sessions/:id/permissions/:requestId/approve ({mode?, allowTools?, decision?, answers?}) or …/deny ({decision?}) — see rest.md. Session-list badges come precomputed on SessionSummary.pendingRequestsCount / pendingRequests (≤ 5 entries).
Golden fixtures
The golden fixtures in shared/fixtures/chat/ are the executable form of this section: input DecryptedMessage samples paired with the canonical decoded projection, generated from the web pipeline. A native decode implementation is correct when it reproduces them exactly — when the fixtures and this page disagree, the fixtures win.