Summary
Today, when the operator sends a message while an agent turn is still running, HAPI queues it and only delivers it after the entire turn terminates (full agentic loop, possibly a dozen tool calls). Both native CLIs we wrap instead deliver such "steering" input at the next step boundary inside the running turn (after the current model output / next tool call), without a hard interrupt.
This issue proposes adding mid-turn steering (opt-in), and documents the per-flavor feasibility I verified. The two flavors are asymmetric, so they need different implementations:
- Codex — the app-server protocol has a first-class, non-interrupting
turn/steer RPC. HAPI just doesn't call it. Clean to implement.
- Claude — there is no non-interrupting steer primitive in
--input-format stream-json. I verified empirically that mid-turn stdin messages are ignored by the running turn. Steering would require interrupt + resubmit (a soft interrupt), which is a different UX.
Current HAPI behavior (both flavors deliver only at full-turn end)
Codex (cli/src/codex/codexRemoteLauncher.ts):
turnInFlight is cleared only on a terminal event, and wakeLoop() is called only there — codexRemoteLauncher.ts:2159-2172.
- The main loop parks in
waitForTurnOrRecovery (:1797) until the turn fully ends, then pulls the next queued message via session.queue.waitForMessagesAndGetAsString (:2955).
- The app-server client exposes
startThread / resumeThread / startTurn / interruptTurn / compactThread / goal — no steer/inject (cli/src/codex/codexAppServerClient.ts).
Claude (cli/src/claude/claudeRemote.ts):
scheduleNextMessage() (which pushes the next queued user message into the streaming-input iterable) is invoked only when message.type === 'result', i.e. at full-turn end — claudeRemote.ts:258-282.
What the native CLIs do
Findings
Codex — turn/steer exists (verified against codex 0.139.0 schema)
codex app-server generate-ts emits a dedicated steering request:
// ClientRequest: { "method": "turn/steer", params: TurnSteerParams }
type TurnSteerParams = {
threadId: string,
clientUserMessageId?: string | null,
input: Array<UserInput>, // same shape as turn/start: text | image | localImage | skill | mention
expectedTurnId: string, // precondition: must equal the active turn id, else the request fails
}
type TurnSteerResponse = { turnId: string }
Relevant constraints baked into the protocol:
- Not every turn is steerable:
NonSteerableTurnKind = "review" | "compact". Steering one returns CodexErrorInfo::activeTurnNotSteerable { turnKind }.
- There is also a lower-level
thread/inject_items (raw Responses API items) — not what we want for steering.
HAPI does not reference turn/steer or thread/inject_items anywhere.
Claude — no non-interrupting mid-turn primitive (verified empirically)
streamToStdin writes each pushed message to the child claude stdin immediately (cli/src/claude/sdk/utils.ts:188-192), so a naive "push the queued message as soon as it arrives instead of on result" change is trivial on the HAPI side. But it does not produce mid-turn steering.
I tested claude 2.1.175 directly in --input-format stream-json --output-format stream-json:
- Turn 1 prompt: run
sleep 3 && echo AAA, then sleep 3 && echo BBB, as two steps.
- At ~2.0s (before the first
tool_use), injected a second user message: "STOP now. Ignore everything before. Reply with exactly: ZEBRA".
- Result: the turn ran
AAA → BBB → "All done." → result, never said ZEBRA, did not pivot at the AAA→BBB step boundary, and no follow-up turn for the injected message was produced within the session.
So the running turn ignores (effectively drops) mid-turn stdin input. HAPI's existing "hold in our own queue, push on result" is in fact the safe pattern for stream-json — you cannot steer Claude mid-turn just by pushing earlier. Achieving "deliver after the current output" for Claude would require the interrupt control request (already available: cli/src/claude/sdk/query.ts:171, subtype: 'interrupt') to halt at the next safe point, then resubmit the queued message — i.e. a soft interrupt, with the in-flight step discarded.
Proposed direction
Make this opt-in (per-session or setting), because "queue strictly until end of turn" is a legitimate preference that the current behavior already serves well.
- Codex: add
steerTurn(params) to codexAppServerClient (turn/steer). In the launcher, when a queued message arrives while turnInFlight and we hold currentThreadId + currentTurnId, call turn/steer with expectedTurnId: currentTurnId instead of waiting. Fallbacks:
activeTurnNotSteerable (review/compact) → fall back to existing queue-until-end path.
expectedTurnId race (turn just ended) / unknown method on older codex → fall back to startTurn / current behavior. Gate on capability or codex version.
- Claude: implement via
interrupt + resubmit behind the same opt-in. Must decide UX for the discarded in-flight step and how to present it. This is closer to a soft-interrupt than to Codex's true steering — set expectations accordingly.
Open questions
- Minimum codex version that ships
turn/steer; best capability-detection (catch "method not found" vs. version check).
- For Claude, whether a soft-interrupt steer is desirable at all, or whether we just document the gap and keep queue-until-end as the only mode.
- Surfacing in the web UI: a "steer now" affordance distinct from plain queue, and distinct from the existing Stop/Abort.
Verification artifacts (Codex protocol dump via codex app-server generate-ts, Claude stream-json probe scripts) available on request.
Summary
Today, when the operator sends a message while an agent turn is still running, HAPI queues it and only delivers it after the entire turn terminates (full agentic loop, possibly a dozen tool calls). Both native CLIs we wrap instead deliver such "steering" input at the next step boundary inside the running turn (after the current model output / next tool call), without a hard interrupt.
This issue proposes adding mid-turn steering (opt-in), and documents the per-flavor feasibility I verified. The two flavors are asymmetric, so they need different implementations:
turn/steerRPC. HAPI just doesn't call it. Clean to implement.--input-format stream-json. I verified empirically that mid-turn stdin messages are ignored by the running turn. Steering would requireinterrupt+ resubmit (a soft interrupt), which is a different UX.Current HAPI behavior (both flavors deliver only at full-turn end)
Codex (
cli/src/codex/codexRemoteLauncher.ts):turnInFlightis cleared only on a terminal event, andwakeLoop()is called only there —codexRemoteLauncher.ts:2159-2172.waitForTurnOrRecovery(:1797) until the turn fully ends, then pulls the next queued message viasession.queue.waitForMessagesAndGetAsString(:2955).startThread / resumeThread / startTurn / interruptTurn / compactThread / goal— no steer/inject (cli/src/codex/codexAppServerClient.ts).Claude (
cli/src/claude/claudeRemote.ts):scheduleNextMessage()(which pushes the next queued user message into the streaming-input iterable) is invoked only whenmessage.type === 'result', i.e. at full-turn end —claudeRemote.ts:258-282.What the native CLIs do
Findings
Codex —
turn/steerexists (verified against codex 0.139.0 schema)codex app-server generate-tsemits a dedicated steering request:Relevant constraints baked into the protocol:
NonSteerableTurnKind = "review" | "compact". Steering one returnsCodexErrorInfo::activeTurnNotSteerable { turnKind }.thread/inject_items(raw Responses API items) — not what we want for steering.HAPI does not reference
turn/steerorthread/inject_itemsanywhere.Claude — no non-interrupting mid-turn primitive (verified empirically)
streamToStdinwrites each pushed message to the childclaudestdin immediately (cli/src/claude/sdk/utils.ts:188-192), so a naive "push the queued message as soon as it arrives instead of onresult" change is trivial on the HAPI side. But it does not produce mid-turn steering.I tested
claude 2.1.175directly in--input-format stream-json --output-format stream-json:sleep 3 && echo AAA, thensleep 3 && echo BBB, as two steps.tool_use), injected a second user message: "STOP now. Ignore everything before. Reply with exactly: ZEBRA".AAA → BBB → "All done." → result, never saidZEBRA, did not pivot at theAAA→BBBstep boundary, and no follow-up turn for the injected message was produced within the session.So the running turn ignores (effectively drops) mid-turn stdin input. HAPI's existing "hold in our own queue, push on
result" is in fact the safe pattern for stream-json — you cannot steer Claude mid-turn just by pushing earlier. Achieving "deliver after the current output" for Claude would require theinterruptcontrol request (already available:cli/src/claude/sdk/query.ts:171,subtype: 'interrupt') to halt at the next safe point, then resubmit the queued message — i.e. a soft interrupt, with the in-flight step discarded.Proposed direction
Make this opt-in (per-session or setting), because "queue strictly until end of turn" is a legitimate preference that the current behavior already serves well.
steerTurn(params)tocodexAppServerClient(turn/steer). In the launcher, when a queued message arrives whileturnInFlightand we holdcurrentThreadId+currentTurnId, callturn/steerwithexpectedTurnId: currentTurnIdinstead of waiting. Fallbacks:activeTurnNotSteerable(review/compact) → fall back to existing queue-until-end path.expectedTurnIdrace (turn just ended) / unknown method on older codex → fall back tostartTurn/ current behavior. Gate on capability or codex version.interrupt+ resubmit behind the same opt-in. Must decide UX for the discarded in-flight step and how to present it. This is closer to a soft-interrupt than to Codex's true steering — set expectations accordingly.Open questions
turn/steer; best capability-detection (catch "method not found" vs. version check).Verification artifacts (Codex protocol dump via
codex app-server generate-ts, Claude stream-json probe scripts) available on request.