@oh-my-pi/pi-ai 18.3.0 → 18.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/THIRD-PARTY-NOTICES.txt +2 -2
  3. package/dist/types/auth/policy.d.ts +8 -1
  4. package/dist/types/auth/pool.d.ts +8 -0
  5. package/dist/types/auth/types.d.ts +17 -7
  6. package/dist/types/auth/usage.d.ts +2 -0
  7. package/dist/types/auth-broker/discover.d.ts +22 -1
  8. package/dist/types/auth-storage.d.ts +35 -11
  9. package/dist/types/error/rate-limit.d.ts +3 -2
  10. package/dist/types/index.d.ts +1 -0
  11. package/dist/types/providers/anthropic-slow-mode.d.ts +109 -0
  12. package/dist/types/providers/anthropic-wire.d.ts +4 -0
  13. package/dist/types/providers/mock.d.ts +3 -1
  14. package/dist/types/providers/openai-codex/live-steering.d.ts +77 -0
  15. package/dist/types/providers/transform-messages.d.ts +9 -1
  16. package/dist/types/types.d.ts +56 -0
  17. package/dist/types/usage/xai-oauth.d.ts +6 -1
  18. package/dist/types/utils/http-inspector.d.ts +6 -0
  19. package/package.json +6 -6
  20. package/src/auth/policy.ts +27 -6
  21. package/src/auth/pool.ts +35 -2
  22. package/src/auth/refresh.ts +2 -2
  23. package/src/auth/types.ts +17 -7
  24. package/src/auth/usage.ts +5 -0
  25. package/src/auth-broker/discover.ts +57 -27
  26. package/src/auth-storage.ts +121 -35
  27. package/src/error/flags.ts +2 -1
  28. package/src/error/rate-limit.ts +8 -4
  29. package/src/index.ts +1 -0
  30. package/src/providers/anthropic-slow-mode.ts +232 -0
  31. package/src/providers/anthropic-wire.ts +4 -0
  32. package/src/providers/anthropic.ts +322 -22
  33. package/src/providers/cowork-fetch.ts +11 -4
  34. package/src/providers/google-shared.ts +30 -7
  35. package/src/providers/inference-headers.ts +7 -1
  36. package/src/providers/mock.ts +4 -0
  37. package/src/providers/openai-codex/live-steering.ts +237 -0
  38. package/src/providers/openai-codex-responses.ts +372 -73
  39. package/src/providers/transform-messages.ts +27 -7
  40. package/src/stream.ts +32 -2
  41. package/src/types.ts +59 -0
  42. package/src/usage/registry.ts +2 -1
  43. package/src/usage/xai-oauth.ts +31 -1
  44. package/src/utils/http-inspector.ts +21 -2
  45. package/src/utils/openrouter-headers.ts +3 -3
@@ -82,6 +82,13 @@ import {
82
82
  transformRequestBody,
83
83
  } from "./openai-codex/request-transformer";
84
84
  import { applyCodexAccessPrograms, dropRejectedCodexAccessPrograms } from "./openai-codex/access-programs";
85
+ import {
86
+ type CodexAcceptedSteer,
87
+ type CodexSteerAck,
88
+ CodexSteerPump,
89
+ planSteeredRequest,
90
+ toSteerInputItems,
91
+ } from "./openai-codex/live-steering";
85
92
  import { CodexApiError } from "./openai-codex/response-handler";
86
93
  import { getCodexAttestationHeader } from "./openai-codex-attestation";
87
94
  export { createOpenAICodexCompactionRequestContext } from "./openai-codex-compaction";
@@ -423,6 +430,12 @@ type CodexWebSocketSessionState = {
423
430
  canAppend: boolean;
424
431
  modelsEtag?: string;
425
432
  connection?: CodexWebSocketConnection;
433
+ /**
434
+ * Steering the server accepted on `connection` for `lastResponseId`. The
435
+ * next request reads the server's automatic successor or sends only the
436
+ * pending tool output; see {@link planSteeredRequest}.
437
+ */
438
+ acceptedSteering?: { connection: CodexWebSocketConnection; steers: CodexAcceptedSteer[] };
426
439
  lastTransport?: CodexTransport;
427
440
  fallbackCount: number;
428
441
  lastFallbackAt?: number;
@@ -1636,6 +1649,8 @@ async function openInitialCodexEventStream(
1636
1649
  break;
1637
1650
  }
1638
1651
  }
1652
+ } else if (websocketState) {
1653
+ releaseIdleCodexWebSocket(websocketState);
1639
1654
  }
1640
1655
  try {
1641
1656
  return await openCodexSseTransport(model, requestContext, requestSetup, options, websocketState, transformedBody);
@@ -1795,8 +1810,78 @@ async function openCodexWebSocketTransport(
1795
1810
  requestBodyForState: RequestBody;
1796
1811
  transport: CodexTransport;
1797
1812
  }> {
1813
+ const steered = websocketState.acceptedSteering;
1814
+ websocketState.acceptedSteering = undefined;
1798
1815
  const canAppendBeforeRequest = websocketState.canAppend === true;
1799
- const chainedBody = buildCodexChainedRequestBody(requestContext.transformedBody, websocketState);
1816
+ let chainedBody = buildCodexChainedRequestBody(requestContext.transformedBody, websocketState);
1817
+ const websocketHeaders = createCodexHeaders(
1818
+ requestContext.requestHeaders,
1819
+ requestContext.accountId,
1820
+ requestContext.apiKey,
1821
+ requestContext.codexClientVersion,
1822
+ requestContext.transportSessionId,
1823
+ "websocket",
1824
+ websocketState,
1825
+ requestContext.turnState,
1826
+ requestContext.responsesLite,
1827
+ requestContext.requestMetadata,
1828
+ await getCodexAttestationHeader(requestContext.accountId),
1829
+ requestContext.transformedBody,
1830
+ );
1831
+ let websocketConnection = await getOrCreateCodexWebSocketConnection(
1832
+ websocketState,
1833
+ requestContext.turnState,
1834
+ toWebSocketUrl(requestContext.url),
1835
+ websocketHeaders,
1836
+ model.provider,
1837
+ requestSetup.requestSignal,
1838
+ );
1839
+ const timeouts: CodexWebSocketRequestTimeouts = {
1840
+ idleTimeoutMs: requestSetup.websocketIdleTimeoutMs,
1841
+ firstEventTimeoutMs: requestSetup.websocketFirstEventTimeoutMs,
1842
+ };
1843
+ // Steering accepted for the previous response lives only on the socket that
1844
+ // accepted it; a replaced socket lost it, so the input is plain history.
1845
+ const steers = steered?.connection === websocketConnection ? steered.steers : [];
1846
+ const liveSteers = steers.filter(steer => !websocketConnection.hasFailedSteer([steer.id]));
1847
+ if (liveSteers.length > 0) {
1848
+ const plan = planSteeredRequest(
1849
+ chainedBody.previous_response_id ? chainedBody.input : undefined,
1850
+ liveSteers.flatMap(steer => steer.items),
1851
+ );
1852
+ if (plan.kind === "attach") {
1853
+ recordCodexTurnRequestDiagnostics(websocketState, chainedBody, "websocket", canAppendBeforeRequest);
1854
+ requestContext.rawRequestDump.body = chainedBody;
1855
+ return {
1856
+ eventStream: websocketConnection.streamRequest(
1857
+ { attachSteerIds: liveSteers.map(steer => steer.id) },
1858
+ timeouts,
1859
+ requestSetup.requestSignal,
1860
+ onSseEvent,
1861
+ ),
1862
+ requestBodyForState: structuredCloneJSON(requestContext.transformedBody),
1863
+ transport: "websocket",
1864
+ };
1865
+ }
1866
+ if (plan.kind === "create") {
1867
+ chainedBody = { ...chainedBody, input: plan.input };
1868
+ } else {
1869
+ // The server continues from input this request cannot line up with; a
1870
+ // fresh socket drops its queue and the full transcript carries the input.
1871
+ websocketConnection.close("steering-discard");
1872
+ websocketState.connection = undefined;
1873
+ resetCodexWebSocketAppendState(websocketState);
1874
+ chainedBody = requestContext.transformedBody;
1875
+ websocketConnection = await getOrCreateCodexWebSocketConnection(
1876
+ websocketState,
1877
+ requestContext.turnState,
1878
+ toWebSocketUrl(requestContext.url),
1879
+ websocketHeaders,
1880
+ model.provider,
1881
+ requestSetup.requestSignal,
1882
+ );
1883
+ }
1884
+ }
1800
1885
  // WebSocket frames cannot carry per-request HTTP headers. Canonical Codex
1801
1886
  // request identity is already in `client_metadata`; connection-scoped
1802
1887
  // compatibility values that can change after the upgrade ride alongside it
@@ -1818,20 +1903,6 @@ async function openCodexWebSocketTransport(
1818
1903
  websocketRequest = replacementWebsocketRequest as typeof websocketRequest;
1819
1904
  }
1820
1905
  recordCodexTurnRequestDiagnostics(websocketState, websocketRequest, "websocket", canAppendBeforeRequest);
1821
- const websocketHeaders = createCodexHeaders(
1822
- requestContext.requestHeaders,
1823
- requestContext.accountId,
1824
- requestContext.apiKey,
1825
- requestContext.codexClientVersion,
1826
- requestContext.transportSessionId,
1827
- "websocket",
1828
- websocketState,
1829
- requestContext.turnState,
1830
- requestContext.responsesLite,
1831
- requestContext.requestMetadata,
1832
- await getCodexAttestationHeader(requestContext.accountId),
1833
- requestContext.transformedBody,
1834
- );
1835
1906
  const requestBodyForState = structuredCloneJSON(requestContext.transformedBody);
1836
1907
  // `onPayload` may rewrite the outgoing frame (e.g. drop `stream_options`);
1837
1908
  // recorded state must reflect what was actually sent — the sequential-cutoff
@@ -1854,20 +1925,9 @@ async function openCodexWebSocketTransport(
1854
1925
  retry,
1855
1926
  retryBudget: CODEX_WEBSOCKET_RETRY_BUDGET,
1856
1927
  });
1857
- const websocketConnection = await getOrCreateCodexWebSocketConnection(
1858
- websocketState,
1859
- requestContext.turnState,
1860
- toWebSocketUrl(requestContext.url),
1861
- websocketHeaders,
1862
- model.provider,
1863
- requestSetup.requestSignal,
1864
- );
1865
1928
  const eventStream = websocketConnection.streamRequest(
1866
- websocketRequest,
1867
- {
1868
- idleTimeoutMs: requestSetup.websocketIdleTimeoutMs,
1869
- firstEventTimeoutMs: requestSetup.websocketFirstEventTimeoutMs,
1870
- },
1929
+ { send: websocketRequest },
1930
+ timeouts,
1871
1931
  requestSetup.requestSignal,
1872
1932
  onSseEvent,
1873
1933
  );
@@ -2097,6 +2157,12 @@ class CodexStreamProcessor {
2097
2157
  requestContext: CodexRequestContext;
2098
2158
  startTime: number;
2099
2159
  firstTokenTime?: number;
2160
+ /** Submits caller steering to the response currently streaming. */
2161
+ #steerPump?: CodexSteerPump;
2162
+ /** Socket {@link #steerPump} submits through. */
2163
+ #steerConnection?: CodexWebSocketConnection;
2164
+ /** A replaced attempt accepted steering its socket may still act on. */
2165
+ #steerTainted = false;
2100
2166
 
2101
2167
  constructor(init: {
2102
2168
  runtime: CodexStreamRuntime;
@@ -2150,10 +2216,15 @@ class CodexStreamProcessor {
2150
2216
  });
2151
2217
  throw new CodexProviderStreamError("Codex stream ended before terminal completion event", true);
2152
2218
  }
2219
+ await this.#settleSteering(true);
2153
2220
  return { firstTokenTime };
2154
2221
  } catch (error) {
2222
+ // The failed attempt's response no longer takes input; a retry
2223
+ // steers its own response.
2224
+ await this.#retireSteering();
2155
2225
  const recovered = await this.#recoverStreamError(error);
2156
2226
  if (!recovered) {
2227
+ await this.#settleSteering(false);
2157
2228
  throw error;
2158
2229
  }
2159
2230
  stream.push({ type: "start", partial: output });
@@ -2166,6 +2237,11 @@ class CodexStreamProcessor {
2166
2237
  const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
2167
2238
  if (!eventType) return firstTokenTime;
2168
2239
 
2240
+ if (eventType === "response.created") {
2241
+ this.#startSteering(rawEvent);
2242
+ return firstTokenTime;
2243
+ }
2244
+
2169
2245
  if (eventType === "response.output_item.added") {
2170
2246
  this.runtime.whitespaceToolCallArgumentsDelta = undefined;
2171
2247
  if (!firstTokenTime) firstTokenTime = performance.now();
@@ -2376,6 +2452,67 @@ class CodexStreamProcessor {
2376
2452
  return firstTokenTime;
2377
2453
  }
2378
2454
 
2455
+ /** Start delivering caller steering into the response `response.created` announced. */
2456
+ #startSteering(rawEvent: Record<string, unknown>): void {
2457
+ const source = this.options?.liveSteering;
2458
+ const connection = this.runtime.websocketState?.connection;
2459
+ const responseId = asRecord(rawEvent.response)?.id;
2460
+ if (
2461
+ !source ||
2462
+ !connection ||
2463
+ this.#steerPump ||
2464
+ typeof responseId !== "string" ||
2465
+ this.runtime.transport !== "websocket" ||
2466
+ !this.model.compat.supportsSteering
2467
+ ) {
2468
+ return;
2469
+ }
2470
+ this.#steerConnection = connection;
2471
+ this.#steerPump = new CodexSteerPump(source, connection, messages =>
2472
+ toSteerInputItems(convertMessages(this.model, { messages: [...messages] })),
2473
+ );
2474
+ this.#steerPump.start(responseId);
2475
+ }
2476
+
2477
+ /** Stop steering an attempt that is being replaced; anything it delivered taints its socket. */
2478
+ async #retireSteering(): Promise<void> {
2479
+ const pump = this.#steerPump;
2480
+ if (!pump) return;
2481
+ this.#steerPump = undefined;
2482
+ const outcome = await pump.finish();
2483
+ if (outcome.accepted.length > 0 || outcome.uncertain) this.#steerTainted = true;
2484
+ }
2485
+
2486
+ /**
2487
+ * Settle steering once the stream is over. Steering accepted for the final
2488
+ * response is handed to the next request; anything the session cannot line
2489
+ * up with the server's queue drops the socket so no stray successor streams
2490
+ * into a later request.
2491
+ */
2492
+ async #settleSteering(completed: boolean): Promise<void> {
2493
+ const pump = this.#steerPump;
2494
+ this.#steerPump = undefined;
2495
+ const outcome = pump ? await pump.finish() : undefined;
2496
+ const accepted = outcome?.accepted ?? [];
2497
+ const tainted = this.#steerTainted || outcome?.uncertain === true;
2498
+ const state = this.runtime.websocketState;
2499
+ const connection = this.#steerConnection;
2500
+ if (!state || !connection || (accepted.length === 0 && !tainted)) return;
2501
+ if (
2502
+ completed &&
2503
+ !tainted &&
2504
+ state.canAppend &&
2505
+ state.connection === connection &&
2506
+ state.lastResponseId === outcome?.responseId
2507
+ ) {
2508
+ state.acceptedSteering = { connection, steers: accepted };
2509
+ return;
2510
+ }
2511
+ connection.close("steering-discard");
2512
+ if (state.connection === connection) state.connection = undefined;
2513
+ resetCodexWebSocketAppendState(state);
2514
+ }
2515
+
2379
2516
  #flushSummaryDeltas(entry: CodexOpenItem | null): void {
2380
2517
  if (entry?.block?.type !== "thinking") return;
2381
2518
  for (const delta of this.runtime.takeSummaryDeltas(entry)) {
@@ -2529,6 +2666,21 @@ class CodexStreamProcessor {
2529
2666
  output.responseId = responseId;
2530
2667
  }
2531
2668
 
2669
+ const incompleteDetails =
2670
+ response &&
2671
+ "incomplete_details" in response &&
2672
+ response.incomplete_details &&
2673
+ typeof response.incomplete_details === "object"
2674
+ ? response.incomplete_details
2675
+ : undefined;
2676
+ const incompleteReason =
2677
+ incompleteDetails && "reason" in incompleteDetails ? incompleteDetails.reason : undefined;
2678
+ // Steering stopped the response at an output boundary: every item is
2679
+ // complete and the server continues from it, so it chains and ends like a
2680
+ // completed response.
2681
+ const steered = status === "incomplete" && incompleteReason === "steered";
2682
+ if (steered) logger.debug("Codex response stopped for steering", { responseId });
2683
+
2532
2684
  const state = runtime.websocketState;
2533
2685
  if (state) {
2534
2686
  if (runtime.transport !== "websocket") {
@@ -2545,7 +2697,7 @@ class CodexStreamProcessor {
2545
2697
  if (responseId && replayableResponseItems && replayableResponseItems.length === nativeOutputItems.length) {
2546
2698
  state.lastResponseId = responseId;
2547
2699
  state.lastResponseItems = replayableResponseItems;
2548
- state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed";
2700
+ state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed" || steered;
2549
2701
  } else {
2550
2702
  // No response id, or replay sanitization dropped an item the server
2551
2703
  // still holds. Sanitization is 1:1-or-fewer, so either case makes the
@@ -2555,24 +2707,15 @@ class CodexStreamProcessor {
2555
2707
  }
2556
2708
  }
2557
2709
 
2558
- const incompleteDetails =
2559
- response &&
2560
- "incomplete_details" in response &&
2561
- response.incomplete_details &&
2562
- typeof response.incomplete_details === "object"
2563
- ? response.incomplete_details
2564
- : undefined;
2565
2710
  const shouldPromoteIncompleteToolUse =
2566
2711
  status === "incomplete" &&
2567
- incompleteDetails !== undefined &&
2568
- "reason" in incompleteDetails &&
2569
- incompleteDetails.reason === "max_output_tokens" &&
2712
+ incompleteReason === "max_output_tokens" &&
2570
2713
  hasExecutableIncompleteResponsesToolCalls(output);
2571
2714
  finalizePendingResponsesToolCalls(output);
2572
2715
 
2573
2716
  calculateCost(model, output.usage, output.timestamp);
2574
2717
  applyCodexServiceTierPricing(model, output.usage, serviceTier, runtime.requestBodyForState.service_tier);
2575
- output.stopReason = mapOpenAIResponsesStopReason(status);
2718
+ output.stopReason = mapOpenAIResponsesStopReason(steered ? "completed" : status);
2576
2719
  promoteResponsesToolUseStopReason(
2577
2720
  output,
2578
2721
  endTurn === true ? true : endTurn === false ? false : undefined,
@@ -2581,6 +2724,17 @@ class CodexStreamProcessor {
2581
2724
  }
2582
2725
 
2583
2726
  async #recoverStreamError(error: unknown): Promise<boolean> {
2727
+ if (
2728
+ error instanceof CodexSteerCommitError &&
2729
+ this.runtime.websocketState &&
2730
+ this.output.content.length === 0 &&
2731
+ !this.options?.signal?.aborted
2732
+ ) {
2733
+ // No successor is coming: send this request explicitly, steering
2734
+ // input included as ordinary history.
2735
+ await this.#reopenWebSocketStream(this.runtime.websocketState);
2736
+ return true;
2737
+ }
2584
2738
  if (await this.#tryDropRejectedAccessPrograms(error)) {
2585
2739
  return true;
2586
2740
  }
@@ -3241,6 +3395,19 @@ function recordCodexWebSocketFailure(state: CodexWebSocketSessionState, activate
3241
3395
  }
3242
3396
  }
3243
3397
 
3398
+ /**
3399
+ * WebSocket not selected for this request (e.g. the preference was switched off
3400
+ * mid-session): close an idle cached socket so it stops heartbeating and is
3401
+ * never reused against a transcript advanced over SSE.
3402
+ */
3403
+ function releaseIdleCodexWebSocket(state: CodexWebSocketSessionState): void {
3404
+ const connection = state.connection;
3405
+ if (!connection || connection.isBusy()) return;
3406
+ connection.close("transport_not_selected");
3407
+ state.connection = undefined;
3408
+ resetCodexWebSocketAppendState(state);
3409
+ }
3410
+
3244
3411
  function shouldUseCodexWebSocket(
3245
3412
  model: Model<"openai-codex-responses">,
3246
3413
  state: CodexWebSocketSessionState | undefined,
@@ -3635,6 +3802,18 @@ interface CodexWebSocketRequestTimeouts {
3635
3802
  firstEventTimeoutMs?: number;
3636
3803
  }
3637
3804
 
3805
+ /** What {@link CodexWebSocketConnection.streamRequest} puts on the wire before reading a response. */
3806
+ type CodexWebSocketExchange =
3807
+ | { send: Record<string, unknown> }
3808
+ /** Read the successor the server creates from accepted steering; nothing is sent. */
3809
+ | { attachSteerIds: readonly string[] };
3810
+
3811
+ interface CodexSteerWaiter {
3812
+ previousResponseId: string;
3813
+ resolve: (ack: CodexSteerAck) => void;
3814
+ reject: (error: Error) => void;
3815
+ }
3816
+
3638
3817
  interface CodexWebSocketConnectionOptions {
3639
3818
  onHandshakeHeaders?: (headers: Headers) => void;
3640
3819
  proxy?: string;
@@ -3670,6 +3849,14 @@ class CodexWebSocketConnection {
3670
3849
  * previous (cleanly-completed) response that outlived the queue drain.
3671
3850
  */
3672
3851
  #lastSeenResponseId?: string;
3852
+ /** `response.steer` submissions awaiting acknowledgement, oldest first. */
3853
+ #steerWaiters: CodexSteerWaiter[] = [];
3854
+ /** Steering ids the server accepted on this socket. */
3855
+ #acceptedSteers = new Set<string>();
3856
+ /** Accepted steering the server later failed to commit to a successor. */
3857
+ #failedSteers = new Set<string>();
3858
+ /** Steering whose automatic successor the active attach request is reading. */
3859
+ #attachSteerIds?: ReadonlySet<string>;
3673
3860
 
3674
3861
  constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
3675
3862
  this.#url = url;
@@ -3687,6 +3874,11 @@ class CodexWebSocketConnection {
3687
3874
  return this.#connectPromise !== undefined;
3688
3875
  }
3689
3876
 
3877
+ /** True while a handshake or a request stream is in flight on this socket. */
3878
+ isBusy(): boolean {
3879
+ return this.#activeRequest || this.#connectPromise !== undefined;
3880
+ }
3881
+
3690
3882
  /**
3691
3883
  * Stricter variant of {@link isOpen} for the connection-pool reuse gate.
3692
3884
  * Refuses sockets that have been silent past {@link CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS}.
@@ -3718,6 +3910,7 @@ class CodexWebSocketConnection {
3718
3910
  const socket = this.#socket;
3719
3911
  this.#socket = null;
3720
3912
  this.#stopHeartbeat();
3913
+ this.#rejectSteerWaiters(`websocket closed (${reason})`);
3721
3914
  if (!socket || (socket.readyState !== WebSocket.OPEN && socket.readyState !== WebSocket.CONNECTING)) return;
3722
3915
  try {
3723
3916
  socket.close(1000, reason);
@@ -3808,6 +4001,7 @@ class CodexWebSocketConnection {
3808
4001
  socket.onclose = event => {
3809
4002
  this.#socket = null;
3810
4003
  this.#stopHeartbeat();
4004
+ this.#rejectSteerWaiters(`websocket closed (${event.code})`);
3811
4005
  if (!settled) {
3812
4006
  settled = true;
3813
4007
  clearPending();
@@ -3837,6 +4031,12 @@ class CodexWebSocketConnection {
3837
4031
  }
3838
4032
  }
3839
4033
  notifyCodexWebSocketInbound(this.#streamObserver, parsed, text);
4034
+ // Steering acknowledgements belong to the submitter, not to the
4035
+ // response stream they interleave with.
4036
+ if (typeof parsed.type === "string" && parsed.type.startsWith("response.steer.")) {
4037
+ this.#handleSteerEvent(parsed);
4038
+ return;
4039
+ }
3840
4040
  this.#push(parsed);
3841
4041
  } catch (error) {
3842
4042
  notifyCodexWebSocketMalformed(this.#streamObserver, event.data, error);
@@ -3853,7 +4053,7 @@ class CodexWebSocketConnection {
3853
4053
  }
3854
4054
 
3855
4055
  async *streamRequest(
3856
- request: Record<string, unknown>,
4056
+ exchange: CodexWebSocketExchange,
3857
4057
  timeouts: CodexWebSocketRequestTimeouts,
3858
4058
  signal?: AbortSignal,
3859
4059
  onSseEvent?: (event: RawSseEvent) => void,
@@ -3870,6 +4070,8 @@ class CodexWebSocketConnection {
3870
4070
  this.#activeRequest = true;
3871
4071
  this.#streamObserver = onSseEvent;
3872
4072
  // Drain any non-error frames left over from a prior request before sending.
4073
+ // An attach reads exactly those frames: the successor the server started
4074
+ // from accepted steering while no request was reading.
3873
4075
  // `CodexStreamProcessor.process` breaks its `for-await` on the terminal event,
3874
4076
  // which interrupts our generator at `yield next` (the post-yield `break`
3875
4077
  // never runs). Any frame that landed between the consumer's break and the
@@ -3879,7 +4081,11 @@ class CodexWebSocketConnection {
3879
4081
  // flip `sawFirstEvent` and silently downgrade the first-event timeout to
3880
4082
  // the longer idle timeout. Transport errors are preserved so we surface
3881
4083
  // the death signal instead of writing into a dead socket.
3882
- this.#dropStaleFrames();
4084
+ if ("send" in exchange) {
4085
+ this.#dropStaleFrames();
4086
+ } else {
4087
+ this.#attachSteerIds = new Set(exchange.attachSteerIds);
4088
+ }
3883
4089
  const onAbort = () => {
3884
4090
  this.#push(new CodexWebSocketTransportError(`request was aborted`, { cause: signal?.reason }));
3885
4091
  // Closing can synchronously enqueue a generic onclose error.
@@ -3888,39 +4094,44 @@ class CodexWebSocketConnection {
3888
4094
  if (signal) signal.addEventListener("abort", onAbort, { once: true });
3889
4095
 
3890
4096
  try {
3891
- const debugSession = isRequestDebugEnabled()
3892
- ? await createRequestDebugSession({
3893
- protocol: "websocket",
3894
- method: "POST",
3895
- url: this.#url,
3896
- headers: this.#headers,
3897
- body: request,
3898
- })
3899
- : undefined;
3900
- this.#debugResponseLog = debugSession
3901
- ? await debugSession.openResponseLog("WebSocket 101 Switching Protocols", this.#handshakeHeaders)
3902
- : undefined;
4097
+ if ("send" in exchange) {
4098
+ const request = exchange.send;
4099
+ const debugSession = isRequestDebugEnabled()
4100
+ ? await createRequestDebugSession({
4101
+ protocol: "websocket",
4102
+ method: "POST",
4103
+ url: this.#url,
4104
+ headers: this.#headers,
4105
+ body: request,
4106
+ })
4107
+ : undefined;
4108
+ this.#debugResponseLog = debugSession
4109
+ ? await debugSession.openResponseLog("WebSocket 101 Switching Protocols", this.#handshakeHeaders)
4110
+ : undefined;
3903
4111
 
3904
- const requestPayload = JSON.stringify(request);
3905
- notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
3906
- // Re-check liveness: the debug-session await above can outlive the socket.
3907
- // Preserve the abort cause: onAbort already queued a caused error, but this
3908
- // throw would otherwise mask it before #nextMessage() drains the queue.
3909
- const socket = this.#socket;
3910
- if (!socket || socket.readyState !== WebSocket.OPEN) {
3911
- if (signal?.aborted) {
3912
- throw new CodexWebSocketTransportError(`websocket connection is unavailable`, {
3913
- cause: signal.reason,
3914
- });
4112
+ const requestPayload = JSON.stringify(request);
4113
+ notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
4114
+ // Re-check liveness: the debug-session await above can outlive the socket.
4115
+ // Preserve the abort cause: onAbort already queued a caused error, but this
4116
+ // throw would otherwise mask it before #nextMessage() drains the queue.
4117
+ const socket = this.#socket;
4118
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
4119
+ if (signal?.aborted) {
4120
+ throw new CodexWebSocketTransportError(`websocket connection is unavailable`, {
4121
+ cause: signal.reason,
4122
+ });
4123
+ }
4124
+ throw new CodexWebSocketTransportError(`websocket connection is unavailable`);
3915
4125
  }
3916
- throw new CodexWebSocketTransportError(`websocket connection is unavailable`);
3917
- }
3918
- try {
3919
- socket.send(requestPayload);
3920
- } catch (error) {
3921
- throw new CodexWebSocketTransportError(
3922
- `websocket send failed: ${error instanceof Error ? error.message : String(error)}`,
3923
- );
4126
+ try {
4127
+ socket.send(requestPayload);
4128
+ } catch (error) {
4129
+ throw new CodexWebSocketTransportError(
4130
+ `websocket send failed: ${error instanceof Error ? error.message : String(error)}`,
4131
+ );
4132
+ }
4133
+ } else if (this.hasFailedSteer(exchange.attachSteerIds)) {
4134
+ throw new CodexSteerCommitError("accepted steering was not committed to a successor response");
3924
4135
  }
3925
4136
  let sawFirstEvent = false;
3926
4137
  const { idleTimeoutMs, firstEventTimeoutMs } = timeouts;
@@ -4039,6 +4250,7 @@ class CodexWebSocketConnection {
4039
4250
  } finally {
4040
4251
  this.#activeRequest = false;
4041
4252
  this.#streamObserver = undefined;
4253
+ this.#attachSteerIds = undefined;
4042
4254
  if (signal) {
4043
4255
  signal.removeEventListener("abort", onAbort);
4044
4256
  }
@@ -4048,6 +4260,83 @@ class CodexWebSocketConnection {
4048
4260
  }
4049
4261
  }
4050
4262
 
4263
+ /**
4264
+ * Submit `response.steer` for `previousResponseId`, which must be streaming
4265
+ * on this socket. Resolves on the server's acknowledgement.
4266
+ */
4267
+ steer(previousResponseId: string, input: InputItem[]): Promise<CodexSteerAck> {
4268
+ const socket = this.#socket;
4269
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
4270
+ return Promise.reject(new CodexWebSocketTransportError(`websocket connection is unavailable`));
4271
+ }
4272
+ const event = { type: "response.steer", previous_response_id: previousResponseId, input };
4273
+ const payload = JSON.stringify(event);
4274
+ const { promise, resolve, reject } = Promise.withResolvers<CodexSteerAck>();
4275
+ const waiter: CodexSteerWaiter = { previousResponseId, resolve, reject };
4276
+ this.#steerWaiters.push(waiter);
4277
+ notifyCodexWebSocketOutbound(this.#streamObserver, event, payload);
4278
+ try {
4279
+ socket.send(payload);
4280
+ } catch (error) {
4281
+ this.#steerWaiters.splice(this.#steerWaiters.indexOf(waiter), 1);
4282
+ reject(
4283
+ new CodexWebSocketTransportError(
4284
+ `websocket send failed: ${error instanceof Error ? error.message : String(error)}`,
4285
+ ),
4286
+ );
4287
+ }
4288
+ return promise;
4289
+ }
4290
+
4291
+ /** True when the server failed to commit any of the accepted steering `ids`. */
4292
+ hasFailedSteer(ids: readonly string[]): boolean {
4293
+ return ids.some(id => this.#failedSteers.has(id));
4294
+ }
4295
+
4296
+ #handleSteerEvent(event: Record<string, unknown>): void {
4297
+ const steer = asRecord(event.steer);
4298
+ const id = typeof steer?.id === "string" ? steer.id : undefined;
4299
+ const previousResponseId = typeof steer?.previous_response_id === "string" ? steer.previous_response_id : "";
4300
+ if (event.type === "response.steer.accepted") {
4301
+ if (!id) return;
4302
+ this.#acceptedSteers.add(id);
4303
+ this.#takeSteerWaiter(previousResponseId)?.resolve({ accepted: true, id });
4304
+ return;
4305
+ }
4306
+ if (event.type === "response.steer.failed") {
4307
+ const error = asRecord(event.error);
4308
+ const code = typeof error?.code === "string" ? error.code : undefined;
4309
+ const message = typeof error?.message === "string" ? error.message : undefined;
4310
+ if (id && this.#acceptedSteers.has(id)) {
4311
+ // Accepted input that never reached a successor response.
4312
+ this.#failedSteers.add(id);
4313
+ if (this.#attachSteerIds?.has(id)) {
4314
+ this.#push(
4315
+ new CodexSteerCommitError(`steering ${id} was not committed: ${code ?? message ?? "unknown"}`),
4316
+ );
4317
+ }
4318
+ return;
4319
+ }
4320
+ this.#takeSteerWaiter(previousResponseId)?.resolve({ accepted: false, code, message });
4321
+ return;
4322
+ }
4323
+ // `response.steer.pending` restates that accepted input awaits tool output;
4324
+ // the request planner already derives that from the transcript.
4325
+ CODEX_DEBUG && logger.debug("[codex] codex steering event", { type: event.type, id, previousResponseId });
4326
+ }
4327
+
4328
+ #takeSteerWaiter(previousResponseId: string): CodexSteerWaiter | undefined {
4329
+ const index = this.#steerWaiters.findIndex(waiter => waiter.previousResponseId === previousResponseId);
4330
+ return index < 0 ? undefined : this.#steerWaiters.splice(index, 1)[0];
4331
+ }
4332
+
4333
+ #rejectSteerWaiters(reason: string): void {
4334
+ const waiters = this.#steerWaiters;
4335
+ if (waiters.length === 0) return;
4336
+ this.#steerWaiters = [];
4337
+ for (const waiter of waiters) waiter.reject(new CodexWebSocketTransportError(reason));
4338
+ }
4339
+
4051
4340
  #captureHandshakeHeaders(socket: Bun.WebSocket, openEvent?: Event): void {
4052
4341
  const headers = extractCodexWebSocketHandshakeHeaders(socket, openEvent);
4053
4342
  if (!headers) return;
@@ -4806,6 +5095,16 @@ export class CodexWebSocketTransportError extends Error {
4806
5095
  this.name = "CodexWebSocketTransportError";
4807
5096
  }
4808
5097
  }
5098
+ /**
5099
+ * The server failed to commit accepted steering to its automatic successor, so
5100
+ * the request that was reading that successor must be sent explicitly instead.
5101
+ */
5102
+ class CodexSteerCommitError extends Error {
5103
+ constructor(message: string) {
5104
+ super(message);
5105
+ this.name = "CodexSteerCommitError";
5106
+ }
5107
+ }
4809
5108
  class CodexWhitespaceToolCallLoopError extends Error {
4810
5109
  constructor(message: string) {
4811
5110
  super(message);