@oh-my-pi/pi-ai 16.3.12 → 16.3.13

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.13] - 2026-07-09
6
+
7
+ ### Changed
8
+
9
+ - Changed the xAI Grok OAuth (`xai-oauth`) provider to use manual code-paste login by default. `/login` now accepts a pasted authorization code or full `http://127.0.0.1:56121/callback?code=...` redirect URL without starting a local callback listener ([#3277](https://github.com/can1357/oh-my-pi/pull/3277) by [@Jaaneek](https://github.com/Jaaneek)).
10
+ - Renamed the xAI Grok OAuth provider in login and credential prompts to "xAI Grok OAuth (SuperGrok or X Premium+)" ([#3277](https://github.com/can1357/oh-my-pi/pull/3277) by [@Jaaneek](https://github.com/Jaaneek)).
11
+
12
+ ### Fixed
13
+
14
+ - Fixed the generic lazy-stream idle watchdog aborting healthy `cursor-agent` streams with "Provider stream stalled while waiting for the next event" while a Cursor exec-channel local tool (shell/read/grep/write/MCP/…) legitimately ran longer than the idle budget. Provider streams now advertise consumer-side local work in flight and the watchdog slides its deadline instead of aborting; genuinely silent streams still time out. ([#4593](https://github.com/can1357/oh-my-pi/issues/4593))
15
+ - Fixed OpenAI Codex/Responses reasoning streams so streamed thinking content is preserved when the final `output_item.done` reconstructs to an empty summary ([#4918](https://github.com/can1357/oh-my-pi/issues/4918)).
16
+ - Fixed Anthropic streams hanging forever when generation wedges mid-stream (notably long `write` tool calls on Opus 4.8 high/xhigh) while the server keeps sending `ping` keepalives: pings now extend the idle watchdog only within a bounded window (3x the idle timeout) since the last real stream event, so a stalled tool-call stream times out and recovers instead of hanging with no retry path ([#4900](https://github.com/can1357/oh-my-pi/issues/4900)).
17
+
5
18
  ## [16.3.12] - 2026-07-08
6
19
 
7
20
  ### Added
@@ -19,7 +19,7 @@ export declare function getAuthBrokerTokenFilePath(): string;
19
19
  * Resolve broker connection configuration using the same precedence as the TUI:
20
20
  *
21
21
  * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
22
- * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
22
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or `<agentDir>/config.yaml`.
23
23
  * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
24
24
  *
25
25
  * Returns `null` when no broker URL is configured — callers should fall back to
@@ -1,4 +1,7 @@
1
+ import http2 from "node:http2";
1
2
  import { type JsonValue } from "@bufbuild/protobuf";
3
+ import type { McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
4
+ import { type AgentServerMessage, type ConversationStateStructure } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
2
5
  import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, StreamFunction, StreamOptions, TextContent, ThinkingContent, ToolCall, ToolResultMessage } from "../types.js";
3
6
  import { kCursorExecResolved, kStreamingBlockIndex, kStreamingBlockKind, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols.js";
4
7
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
@@ -39,6 +42,8 @@ export interface BlockState {
39
42
  export interface UsageState {
40
43
  sawTokenDelta: boolean;
41
44
  }
45
+ /** Exported for tests: drives one Cursor server message through the stream (exec waits mark the stream busy). */
46
+ export declare function handleServerMessage(msg: AgentServerMessage, output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, blobStore: Map<string, Uint8Array>, h2Request: http2.ClientHttp2Stream, execHandlers: CursorExecHandlers | undefined, onToolResult: CursorToolResultHandler | undefined, usageState: UsageState, requestContextTools: McpToolDefinition[], onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void): Promise<void>;
42
47
  /** Exported for tests: verifies handler is invoked with correct `this` when passed as bound. */
43
48
  export declare function resolveExecHandler<TArgs, TResult>(args: TArgs, handler: ((args: TArgs) => Promise<CursorExecHandlerResult<TResult>>) | undefined, onToolResult: CursorToolResultHandler | undefined, buildFromToolResult: (toolResult: ToolResultMessage) => TResult, buildRejected: (reason: string) => TResult, buildError: (error: string) => TResult): Promise<{
44
49
  execResult: TResult;
@@ -382,6 +382,8 @@ type ResponsesToolCallBlock = ToolCall & {
382
382
  [kStreamingLastParseLen]?: number;
383
383
  };
384
384
  export declare function appendReasoningSummaryPart(item: ResponseReasoningItem, part: ResponseReasoningItem["summary"][number]): void;
385
+ /** Chooses the final reasoning text without discarding content already streamed into the block. */
386
+ export declare function finalizeReasoningThinking(item: ResponseReasoningItem, streamedThinking: string): string;
385
387
  export declare function appendReasoningSummaryTextDelta(item: ResponseReasoningItem, block: ThinkingContent, delta: string, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
386
388
  export declare function appendReasoningSummaryPartDone(item: ResponseReasoningItem, block: ThinkingContent, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
387
389
  export declare function appendMessageContentPart(item: ResponseOutputMessage, part: ResponseContentPartAddedEvent["part"] | undefined): void;
@@ -13,10 +13,15 @@
13
13
  import type { AssistantMessageEventStream, Context, Model, OptionsForApi } from "../types.js";
14
14
  import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream.js";
15
15
  import type { BedrockOptions } from "./amazon-bedrock.js";
16
+ import type { CursorOptions } from "./cursor.js";
17
+ interface CursorProviderModule {
18
+ streamCursor: (model: Model<"cursor-agent">, context: Context, options: CursorOptions) => AssistantMessageEventStream;
19
+ }
16
20
  interface BedrockProviderModule {
17
21
  streamBedrock: (model: Model<"bedrock-converse-stream">, context: Context, options: BedrockOptions) => AssistantMessageEventStream;
18
22
  }
19
23
  export declare function setBedrockProviderModule(module: BedrockProviderModule): void;
24
+ export declare function setCursorProviderModule(module: CursorProviderModule): void;
20
25
  export declare const streamAnthropic: (model: Model<"anthropic-messages">, context: Context, options: OptionsForApi<"anthropic-messages">) => EventStreamImpl;
21
26
  export declare const streamAzureOpenAIResponses: (model: Model<"azure-openai-responses">, context: Context, options: OptionsForApi<"azure-openai-responses">) => EventStreamImpl;
22
27
  export declare const streamGoogle: (model: Model<"google-generative-ai">, context: Context, options: OptionsForApi<"google-generative-ai">) => EventStreamImpl;
@@ -23,6 +23,8 @@ export interface OAuthCallbackFlowOptions {
23
23
  * an actionable message before opening the browser.
24
24
  */
25
25
  allowPortFallback?: boolean;
26
+ /** Skip the local callback server entirely; the user pastes the code or redirect URL back. */
27
+ manualInputOnly?: boolean;
26
28
  }
27
29
  /**
28
30
  * Abstract base class for OAuth flows with local callback servers.
@@ -24,10 +24,7 @@ export declare function validateXAIEndpoint(url: string, field: string): string;
24
24
  */
25
25
  export declare function isXAIAccessTokenExpiring(jwt: string, skewSeconds?: number): boolean;
26
26
  /**
27
- * xAI Grok OAuth loopback flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
28
- *
29
- * Uses a fixed redirect URI so the callback server fails fast instead of
30
- * falling back to a random port that xAI's redirect_uri allowlist rejects.
27
+ * xAI Grok OAuth code flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
31
28
  */
32
29
  export declare class XAIOAuthFlow extends OAuthCallbackFlow {
33
30
  #private;
@@ -38,9 +35,6 @@ export declare class XAIOAuthFlow extends OAuthCallbackFlow {
38
35
  }>;
39
36
  exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials>;
40
37
  }
41
- /**
42
- * Login with xAI Grok OAuth (SuperGrok Subscription).
43
- */
44
38
  export declare function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials>;
45
39
  /**
46
40
  * Refresh an xAI OAuth access token using a stored refresh_token.
@@ -265,9 +265,10 @@ declare const ALL: ({
265
265
  readonly name: "xAI";
266
266
  } | {
267
267
  readonly id: "xai-oauth";
268
- readonly name: "xAI Grok OAuth (SuperGrok Subscription)";
268
+ readonly name: "xAI Grok OAuth (SuperGrok or X Premium+)";
269
269
  readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<import("./oauth/index.js").OAuthCredentials>;
270
270
  readonly refreshToken: (credentials: import("./oauth/index.js").OAuthCredentials) => Promise<import("./oauth/index.js").OAuthCredentials>;
271
+ readonly pasteCodeFlow: true;
271
272
  } | {
272
273
  readonly id: "xiaomi";
273
274
  readonly name: "Xiaomi MiMo";
@@ -1,7 +1,8 @@
1
1
  import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types.js";
2
2
  export declare const xaiOauthProvider: {
3
3
  readonly id: "xai-oauth";
4
- readonly name: "xAI Grok OAuth (SuperGrok Subscription)";
4
+ readonly name: "xAI Grok OAuth (SuperGrok or X Premium+)";
5
5
  readonly login: (cb: OAuthLoginCallbacks) => Promise<OAuthCredentials>;
6
6
  readonly refreshToken: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
7
+ readonly pasteCodeFlow: true;
7
8
  };
@@ -22,6 +22,13 @@ export declare class EventStream<T, R = T> implements AsyncIterable<T> {
22
22
  fail(err: unknown): void;
23
23
  [Symbol.asyncIterator](): AsyncIterator<T>;
24
24
  result(): Promise<R>;
25
+ /** True while local work tracked via {@link trackLocalWork} is pending. */
26
+ get hasPendingLocalWork(): boolean;
27
+ /**
28
+ * Track a local-work promise so idle watchdogs on this stream do not treat
29
+ * the event silence while it is pending as a provider stall.
30
+ */
31
+ trackLocalWork<TWork>(work: Promise<TWork>): Promise<TWork>;
25
32
  }
26
33
  export declare class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
27
34
  constructor();
@@ -83,6 +83,16 @@ export interface IdleTimeoutIteratorOptions {
83
83
  * keepalive/no-op events from keeping a stalled tool call alive forever.
84
84
  */
85
85
  isProgressItem?: (item: unknown) => boolean;
86
+ /**
87
+ * Reports consumer-side local work in flight for the stream: the provider
88
+ * transport is waiting on a server-requested local tool bridge (e.g. the
89
+ * Cursor exec channel) before anything can flow upstream again. While it
90
+ * returns true, an expired idle / first-item deadline slides forward
91
+ * instead of aborting — the silence is ours, not a provider stall. The
92
+ * watchdog re-arms with a full budget once the local work completes, so a
93
+ * provider that stalls afterwards is still caught.
94
+ */
95
+ hasPendingLocalWork?: () => boolean;
86
96
  /**
87
97
  * Cancel iteration as soon as this signal aborts. Required for caller-driven
88
98
  * cancellation (ESC) when the underlying transport does not surface signal
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.3.12",
4
+ "version": "16.3.13",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.3.12",
42
- "@oh-my-pi/pi-utils": "16.3.12",
43
- "@oh-my-pi/pi-wire": "16.3.12",
41
+ "@oh-my-pi/pi-catalog": "16.3.13",
42
+ "@oh-my-pi/pi-utils": "16.3.13",
43
+ "@oh-my-pi/pi-wire": "16.3.13",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Broker-aware auth-storage discovery used by both the coding-agent runtime and
3
- * the catalog model generator. Keeps the precedence logic (env → config.yml →
3
+ * the catalog model generator. Keeps the precedence logic (env → config.yml/config.yaml
4
4
  * token file → local SQLite) in one place so build-time tooling sees the same
5
5
  * credentials as the TUI.
6
6
  */
@@ -12,6 +12,7 @@ import {
12
12
  getConfigRootDir,
13
13
  isEnoent,
14
14
  logger,
15
+ MAIN_CONFIG_FILENAMES,
15
16
  } from "@oh-my-pi/pi-utils";
16
17
  import { YAML } from "bun";
17
18
  import { AuthStorage } from "../auth-storage";
@@ -72,21 +73,24 @@ interface ConfigSnapshot {
72
73
  }
73
74
 
74
75
  async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
75
- const configPath = path.join(agentDir, "config.yml");
76
- try {
77
- const raw = await Bun.file(configPath).text();
78
- const parsed = YAML.parse(raw);
79
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
80
- const record = parsed as Record<string, unknown>;
81
- const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
82
- const token =
83
- typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
84
- return { url, token };
85
- } catch (err) {
86
- if (isEnoent(err)) return {};
87
- logger.warn("auth-broker config.yml unreadable", { error: String(err) });
88
- return {};
76
+ for (const filename of MAIN_CONFIG_FILENAMES) {
77
+ const configPath = path.join(agentDir, filename);
78
+ try {
79
+ const raw = await Bun.file(configPath).text();
80
+ const parsed = YAML.parse(raw);
81
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
82
+ const record = parsed as Record<string, unknown>;
83
+ const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
84
+ const token =
85
+ typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
86
+ return { url, token };
87
+ } catch (err) {
88
+ if (isEnoent(err)) continue;
89
+ logger.warn("auth-broker config unreadable", { path: configPath, error: String(err) });
90
+ return {};
91
+ }
89
92
  }
93
+ return {};
90
94
  }
91
95
 
92
96
  function resolveSnapshotTtlMs(): number {
@@ -104,7 +108,7 @@ function resolveSnapshotTtlMs(): number {
104
108
  * Resolve broker connection configuration using the same precedence as the TUI:
105
109
  *
106
110
  * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
107
- * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
111
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml` or `<agentDir>/config.yaml`.
108
112
  * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
109
113
  *
110
114
  * Returns `null` when no broker URL is configured — callers should fall back to
@@ -1462,6 +1462,16 @@ async function* observeDecodedAnthropicSdkEvents(
1462
1462
 
1463
1463
  const PROVIDER_MAX_RETRIES = 10;
1464
1464
 
1465
+ /**
1466
+ * How long `ping` keepalives may keep extending the idle deadline without any
1467
+ * semantic stream progress, as a multiple of the idle timeout. Anthropic pings
1468
+ * across legitimate generation gaps, so pings count as liveness — but a wedged
1469
+ * upstream that pings forever while producing no events must eventually trip
1470
+ * the idle watchdog instead of hanging an active tool-call stream without a
1471
+ * recovery path (#4900).
1472
+ */
1473
+ const PING_PROGRESS_MAX_IDLE_MULTIPLIER = 3;
1474
+
1465
1475
  /**
1466
1476
  * Log a malformed-stream-envelope anomaly without aborting the turn. The strict
1467
1477
  * parser would `throw new AnthropicStreamEnvelopeError(...)` here; we instead
@@ -2007,11 +2017,20 @@ const streamAnthropicOnce = (
2007
2017
  }
2008
2018
  >();
2009
2019
 
2010
- // Pings keep the idle deadline alive once content is flowing, but a
2011
- // ping before message_start must not consume the first-event watchdog:
2012
- // it would flip the (retryable) pre-content stall classification into
2013
- // a terminal mid-stream idle timeout.
2020
+ // Pings keep the idle deadline alive once content is flowing (Anthropic
2021
+ // bridges legitimate generation gaps with keepalives), but only within a
2022
+ // bounded window: a wedged upstream that pings forever while the model
2023
+ // produces nothing must still trip the idle watchdog, otherwise an
2024
+ // active tool-call stream hangs unrecoverably with no retry (#4900).
2025
+ // A ping before message_start must not consume the first-event watchdog
2026
+ // either: it would flip the (retryable) pre-content stall classification
2027
+ // into a terminal mid-stream idle timeout.
2014
2028
  let sawNonPingEvent = false;
2029
+ let lastNonPingProgressAtMs = 0;
2030
+ const pingProgressCapMs =
2031
+ idleTimeoutMs !== undefined && idleTimeoutMs > 0
2032
+ ? idleTimeoutMs * PING_PROGRESS_MAX_IDLE_MULTIPLIER
2033
+ : undefined;
2015
2034
  const timedAnthropicStream = iterateWithIdleTimeout(anthropicStream, {
2016
2035
  idleTimeoutMs,
2017
2036
  firstItemTimeoutMs: firstEventTimeoutMs,
@@ -2021,8 +2040,13 @@ const streamAnthropicOnce = (
2021
2040
  onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
2022
2041
  abortSignal: options?.signal,
2023
2042
  isProgressItem: item => {
2024
- if ((item as AnthropicStreamEvent).type === "ping") return sawNonPingEvent;
2043
+ if ((item as AnthropicStreamEvent).type === "ping") {
2044
+ if (!sawNonPingEvent) return false;
2045
+ if (pingProgressCapMs === undefined) return true;
2046
+ return Date.now() - lastNonPingProgressAtMs < pingProgressCapMs;
2047
+ }
2025
2048
  sawNonPingEvent = true;
2049
+ lastNonPingProgressAtMs = Date.now();
2026
2050
  return true;
2027
2051
  },
2028
2052
  });
@@ -653,7 +653,8 @@ export interface UsageState {
653
653
  sawTokenDelta: boolean;
654
654
  }
655
655
 
656
- async function handleServerMessage(
656
+ /** Exported for tests: drives one Cursor server message through the stream (exec waits mark the stream busy). */
657
+ export async function handleServerMessage(
657
658
  msg: AgentServerMessage,
658
659
  output: AssistantMessage,
659
660
  stream: AssistantMessageEventStream,
@@ -675,15 +676,21 @@ async function handleServerMessage(
675
676
  } else if (msgCase === "kvServerMessage") {
676
677
  handleKvServerMessage(msg.message.value as KvServerMessage, blobStore, h2Request);
677
678
  } else if (msgCase === "execServerMessage") {
678
- await handleExecServerMessage(
679
- msg.message.value as ExecServerMessage,
680
- h2Request,
681
- execHandlers,
682
- onToolResult,
683
- requestContextTools,
684
- output,
685
- stream,
686
- state,
679
+ // The server is waiting on OUR local tool result during this window — no
680
+ // AssistantMessageEvent flows until the handler finishes. Mark the wait
681
+ // as local work so the lazy stream idle watchdog attributes the silence
682
+ // to the tool run instead of aborting a healthy stream (issue #4593).
683
+ await stream.trackLocalWork(
684
+ handleExecServerMessage(
685
+ msg.message.value as ExecServerMessage,
686
+ h2Request,
687
+ execHandlers,
688
+ onToolResult,
689
+ requestContextTools,
690
+ output,
691
+ stream,
692
+ state,
693
+ ),
687
694
  );
688
695
  } else if (msgCase === "conversationCheckpointUpdate") {
689
696
  handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
@@ -95,6 +95,7 @@ import {
95
95
  encodeTextSignatureV1,
96
96
  finalizeCustomToolCallInputDone,
97
97
  finalizePendingResponsesToolCalls,
98
+ finalizeReasoningThinking,
98
99
  finalizeToolCallArgumentsDone,
99
100
  isOpenAIResponsesProgressEvent,
100
101
  mapOpenAIResponsesStopReason,
@@ -1407,6 +1408,21 @@ class CodexStreamProcessor {
1407
1408
  return firstTokenTime;
1408
1409
  }
1409
1410
 
1411
+ if (eventType === "response.reasoning_text.delta") {
1412
+ const entry = this.runtime.openItemForEvent(rawEvent);
1413
+ const delta = typeof rawEvent.delta === "string" ? rawEvent.delta : "";
1414
+ if (entry?.item.type === "reasoning" && entry.block?.type === "thinking") {
1415
+ entry.block.thinking += delta;
1416
+ stream.push({
1417
+ type: "thinking_delta",
1418
+ contentIndex: entry.contentIndex,
1419
+ delta,
1420
+ partial: output,
1421
+ });
1422
+ }
1423
+ return firstTokenTime;
1424
+ }
1425
+
1410
1426
  if (eventType === "response.reasoning_summary_part.done") {
1411
1427
  if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
1412
1428
  appendReasoningSummaryPartDone(
@@ -1522,13 +1538,13 @@ class CodexStreamProcessor {
1522
1538
  // most-recently-added block may belong to a sibling (#2619). Some Codex
1523
1539
  // function/custom tool items omit `id`; in that case `output_index` still
1524
1540
  // routes `output_item.done` to the block that received `output_item.added`.
1525
- const itemId = typeof (item as { id?: string }).id === "string" ? (item as { id: string }).id : "";
1541
+ const itemId = "id" in item && typeof item.id === "string" ? item.id : "";
1526
1542
  const entry = (itemId ? runtime.openItems.get(itemId) : null) ?? runtime.openItemForEvent(rawEvent);
1527
1543
  const block = entry?.block ?? null;
1528
1544
  const contentIndex = entry?.contentIndex ?? output.content.length - 1;
1529
1545
 
1530
1546
  if (item.type === "reasoning" && block?.type === "thinking") {
1531
- block.thinking = item.summary?.map(summary => summary.text).join("\n\n") || "";
1547
+ block.thinking = finalizeReasoningThinking(item, block.thinking);
1532
1548
  block.thinkingSignature = JSON.stringify(item);
1533
1549
  stream.push({
1534
1550
  type: "thinking_end",
@@ -1684,6 +1684,14 @@ export function appendReasoningSummaryPart(
1684
1684
  item.summary.push(part);
1685
1685
  }
1686
1686
 
1687
+ /** Chooses the final reasoning text without discarding content already streamed into the block. */
1688
+ export function finalizeReasoningThinking(item: ResponseReasoningItem, streamedThinking: string): string {
1689
+ const summaryThinking = item.summary?.map(part => part.text).join("\n\n") ?? "";
1690
+ if (summaryThinking) return summaryThinking;
1691
+ const contentThinking = item.content?.[0]?.type === "reasoning_text" ? (item.content[0].text ?? "") : "";
1692
+ return contentThinking || streamedThinking || "";
1693
+ }
1694
+
1687
1695
  export function appendReasoningSummaryTextDelta(
1688
1696
  item: ResponseReasoningItem,
1689
1697
  block: ThinkingContent,
@@ -2208,12 +2216,6 @@ export async function processResponsesStream<TApi extends Api>(
2208
2216
  ? lookupOpenItem({ output_index: event.output_index, item_id: item.id ?? item.call_id })
2209
2217
  : lookupOpenItem({ output_index: event.output_index, item_id: item.id });
2210
2218
  if (item.type === "reasoning") {
2211
- const thinking =
2212
- item.summary?.length > 0
2213
- ? item.summary.map(part => part.text).join("\n\n")
2214
- : item.content?.[0]?.type === "reasoning_text"
2215
- ? (item.content[0].text ?? "")
2216
- : "";
2217
2219
  // Prefer the routed entry; the bare itemId find misroutes when ids are
2218
2220
  // absent (`undefined === undefined` matches the FIRST thinking block) and
2219
2221
  // misses entirely when the done-event id drifts from the added-event id.
@@ -2224,12 +2226,12 @@ export async function processResponsesStream<TApi extends Api>(
2224
2226
  | ThinkingContent
2225
2227
  | undefined);
2226
2228
  if (reasoningBlock) {
2227
- reasoningBlock.thinking = thinking;
2229
+ reasoningBlock.thinking = finalizeReasoningThinking(item, reasoningBlock.thinking);
2228
2230
  reasoningBlock.thinkingSignature = JSON.stringify(item);
2229
2231
  stream.push({
2230
2232
  type: "thinking_end",
2231
2233
  contentIndex: contentIndexOf(reasoningBlock),
2232
- content: thinking,
2234
+ content: reasoningBlock.thinking,
2233
2235
  partial: output,
2234
2236
  });
2235
2237
  }
@@ -157,6 +157,7 @@ let openAICompletionsProviderModulePromise: Promise<LazyProviderModule<"openai-c
157
157
  let openAIResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-responses">> | undefined;
158
158
  let ollamaProviderModulePromise: Promise<LazyProviderModule<"ollama-chat">> | undefined;
159
159
  let cursorProviderModulePromise: Promise<LazyProviderModule<"cursor-agent">> | undefined;
160
+ let cursorProviderModuleOverride: LazyProviderModule<"cursor-agent"> | undefined;
160
161
  let devinProviderModulePromise: Promise<LazyProviderModule<"devin-agent">> | undefined;
161
162
  let bedrockProviderModuleOverride: LazyProviderModule<"bedrock-converse-stream"> | undefined;
162
163
  let bedrockProviderModulePromise: Promise<LazyProviderModule<"bedrock-converse-stream">> | undefined;
@@ -167,6 +168,12 @@ export function setBedrockProviderModule(module: BedrockProviderModule): void {
167
168
  };
168
169
  }
169
170
 
171
+ export function setCursorProviderModule(module: CursorProviderModule): void {
172
+ cursorProviderModuleOverride = {
173
+ stream: module.streamCursor,
174
+ };
175
+ }
176
+
170
177
  // ---------------------------------------------------------------------------
171
178
  // Stream forwarding / error helpers
172
179
  // ---------------------------------------------------------------------------
@@ -245,6 +252,10 @@ function forwardStream<TApi extends Api>(
245
252
  (limits?.openAIIdleEnvFloorsFirstEvent
246
253
  ? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs, limits.defaultFirstEventTimeoutMs)
247
254
  : getStreamFirstEventTimeoutMs(idleTimeoutMs, limits?.defaultFirstEventTimeoutMs)));
255
+ // Providers with a server-driven local tool bridge (e.g. the Cursor
256
+ // exec channel) mark their stream busy while a local tool runs; the
257
+ // watchdog must not read that silence as a provider stall (#4593).
258
+ const localWorkSource = source instanceof EventStreamImpl ? source : undefined;
248
259
  const watchedSource = iterateWithIdleTimeout(source, {
249
260
  idleTimeoutMs,
250
261
  firstItemTimeoutMs,
@@ -260,6 +271,7 @@ function forwardStream<TApi extends Api>(
260
271
  // `idleTimeoutMs` while we're still legitimately waiting on the model's
261
272
  // first response (slow first-token from reasoning models, cold proxies, etc.).
262
273
  isProgressItem: event => (event as AssistantMessageEvent).type !== "start",
274
+ hasPendingLocalWork: localWorkSource ? () => localWorkSource.hasPendingLocalWork : undefined,
263
275
  });
264
276
 
265
277
  for await (const event of watchedSource) {
@@ -411,6 +423,9 @@ function loadOllamaProviderModule(): Promise<LazyProviderModule<"ollama-chat">>
411
423
  }
412
424
 
413
425
  function loadCursorProviderModule(): Promise<LazyProviderModule<"cursor-agent">> {
426
+ if (cursorProviderModuleOverride) {
427
+ return Promise.resolve(cursorProviderModuleOverride);
428
+ }
414
429
  cursorProviderModulePromise ||= import("./cursor").then(module => {
415
430
  const provider = module as CursorProviderModule;
416
431
  return { stream: provider.streamCursor };
@@ -69,6 +69,61 @@ describe("XAIOAuthFlow", () => {
69
69
 
70
70
  expect(flow.redirectUri).toBe("http://127.0.0.1:56121/callback");
71
71
  });
72
+
73
+ it("uses pasted-code login without starting a callback server", async () => {
74
+ const serveSpy = vi.spyOn(Bun, "serve").mockImplementation(() => {
75
+ throw new Error("callback server should not start");
76
+ });
77
+ let authUrl = "";
78
+ let tokenRequestBody = "";
79
+ const progress: string[] = [];
80
+ const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
81
+ const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
82
+ if (url.includes("/.well-known/openid-configuration")) {
83
+ return new Response(
84
+ JSON.stringify({
85
+ authorization_endpoint: "https://auth.x.ai/oauth/authorize",
86
+ token_endpoint: "https://auth.x.ai/oauth/token",
87
+ }),
88
+ { status: 200, headers: { "Content-Type": "application/json" } },
89
+ );
90
+ }
91
+ tokenRequestBody = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? "");
92
+ return new Response(
93
+ JSON.stringify({
94
+ access_token: "access-token",
95
+ refresh_token: "refresh-token",
96
+ expires_in: 3600,
97
+ }),
98
+ { status: 200, headers: { "Content-Type": "application/json" } },
99
+ );
100
+ });
101
+
102
+ const flow = new XAIOAuthFlow({
103
+ fetch: fetchMock as unknown as typeof fetch,
104
+ onAuth: info => {
105
+ authUrl = info.url;
106
+ },
107
+ onManualCodeInput: async () => {
108
+ const parsed = new URL(authUrl);
109
+ const redirectUri = parsed.searchParams.get("redirect_uri") ?? "";
110
+ const state = parsed.searchParams.get("state") ?? "";
111
+ return `${redirectUri}?code=code-xyz&state=${encodeURIComponent(state)}`;
112
+ },
113
+ onProgress: message => progress.push(message),
114
+ });
115
+
116
+ const credentials = await flow.login();
117
+ const authorizeUrl = new URL(authUrl);
118
+ const tokenParams = new URLSearchParams(tokenRequestBody);
119
+
120
+ expect(serveSpy).not.toHaveBeenCalled();
121
+ expect(authorizeUrl.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback");
122
+ expect(progress).toContain("Waiting for pasted authorization code...");
123
+ expect(tokenParams.get("code")).toBe("code-xyz");
124
+ expect(credentials.access).toBe("access-token");
125
+ expect(credentials.refresh).toBe("refresh-token");
126
+ });
72
127
  });
73
128
 
74
129
  describe("XAIOAuthFlow.exchangeToken", () => {
@@ -48,6 +48,8 @@ export interface OAuthCallbackFlowOptions {
48
48
  * an actionable message before opening the browser.
49
49
  */
50
50
  allowPortFallback?: boolean;
51
+ /** Skip the local callback server entirely; the user pastes the code or redirect URL back. */
52
+ manualInputOnly?: boolean;
51
53
  }
52
54
 
53
55
  /**
@@ -60,6 +62,7 @@ export abstract class OAuthCallbackFlow {
60
62
  callbackHostname: string;
61
63
  redirectUri?: string;
62
64
  allowPortFallback: boolean;
65
+ #manualInputOnly: boolean;
63
66
  #callbackResolve?: (result: CallbackResult) => void;
64
67
  #callbackReject?: (error: string) => void;
65
68
  /**
@@ -82,6 +85,7 @@ export abstract class OAuthCallbackFlow {
82
85
  this.callbackPath = callbackPath;
83
86
  this.callbackHostname = DEFAULT_HOSTNAME;
84
87
  this.allowPortFallback = true;
88
+ this.#manualInputOnly = false;
85
89
  return;
86
90
  }
87
91
 
@@ -90,6 +94,7 @@ export abstract class OAuthCallbackFlow {
90
94
  this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
91
95
  this.redirectUri = preferredPortOrOptions.redirectUri;
92
96
  this.allowPortFallback = preferredPortOrOptions.allowPortFallback ?? true;
97
+ this.#manualInputOnly = preferredPortOrOptions.manualInputOnly ?? false;
93
98
  }
94
99
 
95
100
  /**
@@ -135,8 +140,12 @@ export abstract class OAuthCallbackFlow {
135
140
  const state = this.generateState();
136
141
  this.#throwIfCancelled();
137
142
 
138
- // Start callback server first to get actual redirect URI
139
- const { server, redirectUri, launchUrl } = await this.#startCallbackServer(state);
143
+ // Start callback server first to get actual redirect URI. Manual-only
144
+ // flows never bind a server the advertised redirect URI is fixed and
145
+ // the user pastes the code/redirect URL back instead.
146
+ const { server, redirectUri, launchUrl } = this.#manualInputOnly
147
+ ? { server: undefined, redirectUri: this.#buildRedirectUri(), launchUrl: undefined }
148
+ : await this.#startCallbackServer(state);
140
149
 
141
150
  try {
142
151
  this.#throwIfCancelled();
@@ -152,9 +161,12 @@ export abstract class OAuthCallbackFlow {
152
161
 
153
162
  // Notify controller that auth is ready
154
163
  this.ctrl.onAuth?.({ url: authUrl, launchUrl, instructions });
155
- this.ctrl.onProgress?.("Waiting for browser authentication...");
164
+ this.ctrl.onProgress?.(
165
+ this.#manualInputOnly
166
+ ? "Waiting for pasted authorization code..."
167
+ : "Waiting for browser authentication...",
168
+ );
156
169
 
157
- // Wait for callback or manual input
158
170
  const { code } = await this.#waitForCallback(state);
159
171
  this.#throwIfCancelled();
160
172
 
@@ -163,10 +175,14 @@ export abstract class OAuthCallbackFlow {
163
175
  return await this.exchangeToken(code, state, redirectUri);
164
176
  } finally {
165
177
  this.#pendingAuthUrl = undefined;
166
- server.stop();
178
+ server?.stop();
167
179
  }
168
180
  }
169
181
 
182
+ #buildRedirectUri(): string {
183
+ return this.redirectUri ?? `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
184
+ }
185
+
170
186
  /**
171
187
  * Start callback server, trying preferred port first, falling back to random.
172
188
  * `launchUrl` is `undefined` when the caller configured `callbackPath` to
@@ -1,9 +1,10 @@
1
1
  // Ported from NousResearch/hermes-agent (MIT) — hermes_cli/auth.py xAI sections (L93-111, L2979-3160, L5286-5469).
2
2
 
3
3
  /**
4
- * xAI Grok (SuperGrok Subscription) OAuth flow.
4
+ * xAI Grok (SuperGrok or X Premium+) OAuth flow.
5
5
  *
6
- * Loopback PKCE flow on `127.0.0.1:56121/callback`. One token unlocks Grok-4.x
6
+ * Manual-code PKCE flow using `127.0.0.1:56121/callback` as the allowlisted
7
+ * redirect URI. One token unlocks Grok-4.x
7
8
  * chat, Grok Imagine image generation, and Grok Voice TTS via subsequent
8
9
  * commits. Endpoint discovery is hardened against MITM via
9
10
  * {@link validateXAIEndpoint}: any non-HTTPS or non-`x.ai`/`*.x.ai` host is
@@ -196,10 +197,7 @@ function buildXAIAuthorizeUrl(opts: BuildXAIAuthorizeUrlOptions): string {
196
197
  }
197
198
 
198
199
  /**
199
- * xAI Grok OAuth loopback flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
200
- *
201
- * Uses a fixed redirect URI so the callback server fails fast instead of
202
- * falling back to a random port that xAI's redirect_uri allowlist rejects.
200
+ * xAI Grok OAuth code flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
203
201
  */
204
202
  export class XAIOAuthFlow extends OAuthCallbackFlow {
205
203
  #verifier: string = "";
@@ -211,6 +209,7 @@ export class XAIOAuthFlow extends OAuthCallbackFlow {
211
209
  callbackPath: XAI_OAUTH_REDIRECT_PATH,
212
210
  callbackHostname: XAI_OAUTH_REDIRECT_HOST,
213
211
  redirectUri: `http://${XAI_OAUTH_REDIRECT_HOST}:${XAI_OAUTH_REDIRECT_PORT}${XAI_OAUTH_REDIRECT_PATH}`,
212
+ manualInputOnly: true,
214
213
  } satisfies OAuthCallbackFlowOptions);
215
214
  this.#fetch = ctrl.fetch ?? fetch;
216
215
  }
@@ -231,7 +230,7 @@ export class XAIOAuthFlow extends OAuthCallbackFlow {
231
230
 
232
231
  return {
233
232
  url,
234
- instructions: `Complete login in your browser for xAI Grok (SuperGrok). Docs: ${XAI_OAUTH_DOCS_URL}`,
233
+ instructions: `Complete login in your browser for xAI Grok (SuperGrok or X Premium+). Docs: ${XAI_OAUTH_DOCS_URL}`,
235
234
  };
236
235
  }
237
236
 
@@ -308,9 +307,6 @@ export class XAIOAuthFlow extends OAuthCallbackFlow {
308
307
  }
309
308
  }
310
309
 
311
- /**
312
- * Login with xAI Grok OAuth (SuperGrok Subscription).
313
- */
314
310
  export async function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials> {
315
311
  return new XAIOAuthFlow(ctrl).login();
316
312
  }
@@ -3,7 +3,7 @@ import type { ProviderDefinition } from "./types";
3
3
 
4
4
  export const xaiOauthProvider = {
5
5
  id: "xai-oauth",
6
- name: "xAI Grok OAuth (SuperGrok Subscription)",
6
+ name: "xAI Grok OAuth (SuperGrok or X Premium+)",
7
7
  login: async (cb: OAuthLoginCallbacks) => {
8
8
  // Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
9
9
  const { loginXAIOAuth } = await import("./oauth/xai-oauth");
@@ -14,4 +14,5 @@ export const xaiOauthProvider = {
14
14
  const { refreshXAIOAuthToken } = await import("./oauth/xai-oauth");
15
15
  return refreshXAIOAuthToken(credentials.refresh);
16
16
  },
17
+ pasteCodeFlow: true,
17
18
  } as const satisfies ProviderDefinition;
@@ -10,6 +10,14 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
10
10
  resultSettled = false;
11
11
  #failed = false;
12
12
  #error: unknown = undefined;
13
+ /**
14
+ * Consumer-side local operations currently in flight for this stream — a
15
+ * provider transport waiting on a server-requested local tool bridge
16
+ * (e.g. the Cursor exec channel) before it can send the result upstream.
17
+ * While non-zero, event silence is attributable to our own pending work,
18
+ * not a provider stall; idle watchdogs consult {@link hasPendingLocalWork}.
19
+ */
20
+ #pendingLocalWork = 0;
13
21
  finalResultPromise: Promise<R>;
14
22
  resolveFinalResult!: (result: R) => void;
15
23
  rejectFinalResult!: (err: unknown) => void;
@@ -116,6 +124,24 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
116
124
  result(): Promise<R> {
117
125
  return this.finalResultPromise;
118
126
  }
127
+
128
+ /** True while local work tracked via {@link trackLocalWork} is pending. */
129
+ get hasPendingLocalWork(): boolean {
130
+ return this.#pendingLocalWork > 0;
131
+ }
132
+
133
+ /**
134
+ * Track a local-work promise so idle watchdogs on this stream do not treat
135
+ * the event silence while it is pending as a provider stall.
136
+ */
137
+ async trackLocalWork<TWork>(work: Promise<TWork>): Promise<TWork> {
138
+ this.#pendingLocalWork++;
139
+ try {
140
+ return await work;
141
+ } finally {
142
+ this.#pendingLocalWork--;
143
+ }
144
+ }
119
145
  }
120
146
 
121
147
  export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -135,6 +135,16 @@ export interface IdleTimeoutIteratorOptions {
135
135
  * keepalive/no-op events from keeping a stalled tool call alive forever.
136
136
  */
137
137
  isProgressItem?: (item: unknown) => boolean;
138
+ /**
139
+ * Reports consumer-side local work in flight for the stream: the provider
140
+ * transport is waiting on a server-requested local tool bridge (e.g. the
141
+ * Cursor exec channel) before anything can flow upstream again. While it
142
+ * returns true, an expired idle / first-item deadline slides forward
143
+ * instead of aborting — the silence is ours, not a provider stall. The
144
+ * watchdog re-arms with a full budget once the local work completes, so a
145
+ * provider that stalls afterwards is still caught.
146
+ */
147
+ hasPendingLocalWork?: () => boolean;
138
148
  /**
139
149
  * Cancel iteration as soon as this signal aborts. Required for caller-driven
140
150
  * cancellation (ESC) when the underlying transport does not surface signal
@@ -157,7 +167,7 @@ export async function* iterateWithIdleTimeout<T>(
157
167
  options: IdleTimeoutIteratorOptions,
158
168
  ): AsyncGenerator<T> {
159
169
  const firstItemTimeoutMs = options.firstItemTimeoutMs ?? options.idleTimeoutMs;
160
- const firstItemDeadlineMs =
170
+ let firstItemDeadlineMs =
161
171
  firstItemTimeoutMs !== undefined && firstItemTimeoutMs > 0 ? Date.now() + firstItemTimeoutMs : undefined;
162
172
  const abortSignal = options.abortSignal;
163
173
  const iterator = iterable[Symbol.asyncIterator]();
@@ -197,6 +207,28 @@ export async function* iterateWithIdleTimeout<T>(
197
207
  };
198
208
  let lastProgressAt = Date.now();
199
209
 
210
+ const hasPendingLocalWork = (): boolean => {
211
+ if (!options.hasPendingLocalWork) return false;
212
+ try {
213
+ return options.hasPendingLocalWork();
214
+ } catch {
215
+ return false;
216
+ }
217
+ };
218
+ // Local work means the current gap is attributable to the consumer side,
219
+ // not the provider: slide the active deadline a full budget past now
220
+ // instead of aborting. Once the work completes the watchdog resumes from
221
+ // the last extension, so a provider that stalls afterwards is still caught.
222
+ const extendDeadlineForLocalWork = (): void => {
223
+ if (awaitingFirstItem) {
224
+ if (firstItemDeadlineMs !== undefined && firstItemTimeoutMs !== undefined) {
225
+ firstItemDeadlineMs = Date.now() + firstItemTimeoutMs;
226
+ }
227
+ } else {
228
+ lastProgressAt = Date.now();
229
+ }
230
+ };
231
+
200
232
  const noTimeoutEnforced =
201
233
  (firstItemTimeoutMs === undefined || firstItemTimeoutMs <= 0) &&
202
234
  (options.idleTimeoutMs === undefined || options.idleTimeoutMs <= 0);
@@ -271,6 +303,12 @@ export async function* iterateWithIdleTimeout<T>(
271
303
  timer = setTimeout(onTimerFire, Math.max(0, deadlineMs - Date.now()));
272
304
  };
273
305
 
306
+ // The in-flight iterator.next() promise, persisted across loop iterations:
307
+ // a deadline extension for pending local work loops without consuming it,
308
+ // and issuing a second next() while one is outstanding would drop an item.
309
+ let pendingNext:
310
+ | Promise<{ kind: "next"; result: IteratorResult<T> } | { kind: "error"; error: unknown }>
311
+ | undefined;
274
312
  try {
275
313
  let raceCount = 0;
276
314
  while (true) {
@@ -291,21 +329,29 @@ export async function* iterateWithIdleTimeout<T>(
291
329
  if (firstItemDeadlineMs !== undefined) {
292
330
  activeTimeoutMs = firstItemDeadlineMs - Date.now();
293
331
  if (activeTimeoutMs <= 0) {
294
- options.onFirstItemTimeout?.();
295
- closeIterator();
296
- throw new AIError.StreamTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
332
+ if (!hasPendingLocalWork()) {
333
+ options.onFirstItemTimeout?.();
334
+ closeIterator();
335
+ throw new AIError.StreamTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
336
+ }
337
+ extendDeadlineForLocalWork();
338
+ activeTimeoutMs = firstItemDeadlineMs! - Date.now();
297
339
  }
298
340
  }
299
341
  } else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
300
342
  activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
301
343
  if (activeTimeoutMs <= 0) {
302
- options.onIdle?.();
303
- closeIterator();
304
- throw new AIError.StreamTimeoutError(options.errorMessage);
344
+ if (!hasPendingLocalWork()) {
345
+ options.onIdle?.();
346
+ closeIterator();
347
+ throw new AIError.StreamTimeoutError(options.errorMessage);
348
+ }
349
+ extendDeadlineForLocalWork();
350
+ activeTimeoutMs = options.idleTimeoutMs;
305
351
  }
306
352
  }
307
353
 
308
- const nextResultPromise = withRacy(iterator.next());
354
+ pendingNext ??= withRacy(iterator.next());
309
355
 
310
356
  const racers: Array<
311
357
  Promise<
@@ -314,7 +360,7 @@ export async function* iterateWithIdleTimeout<T>(
314
360
  | { kind: "timeout" }
315
361
  | { kind: "abort" }
316
362
  >
317
- > = [nextResultPromise];
363
+ > = [pendingNext];
318
364
 
319
365
  const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs > 0;
320
366
  if (enforceTimeout) {
@@ -333,11 +379,21 @@ export async function* iterateWithIdleTimeout<T>(
333
379
  let continuing = false;
334
380
  try {
335
381
  const outcome = await Promise.race(racers);
382
+ if (outcome.kind === "next" || outcome.kind === "error") {
383
+ pendingNext = undefined;
384
+ }
336
385
  if (outcome.kind === "abort") {
337
386
  closeIterator();
338
387
  throw abortReason(abortSignal!);
339
388
  }
340
389
  if (outcome.kind === "timeout") {
390
+ if (hasPendingLocalWork()) {
391
+ // A local tool is still running; the provider cannot make
392
+ // progress until we hand its result back. Keep waiting.
393
+ extendDeadlineForLocalWork();
394
+ continuing = true;
395
+ continue;
396
+ }
341
397
  if (!awaitingFirstItem) {
342
398
  options.onIdle?.();
343
399
  } else {