@vanillagreen/pi-claude-bridge 1.3.0 → 1.4.0

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/README.md CHANGED
@@ -98,6 +98,8 @@ When Claude Code emits rate-limit reset metadata, the bridge shows one red ASCII
98
98
 
99
99
  Allowed-warning rate-limit events are filtered before user notification. The bridge normalizes unambiguous numeric utilization (`0 < value < 1` as fractional, `1 < value <= 100` as percent), suppresses low or unit-ambiguous values such as exact `1`, and only shows a neutral warning at 80%+ instead of claiming an unverified `% used` value. Check Claude Code `/usage` for exact allowed-warning utilization.
100
100
 
101
+ If Claude Code accepts a turn but produces no assistant/tool output, the bridge treats that stream-idle stall as a retryable overload/rate-limit failure: it closes the stalled Claude Code subprocess, emits a normal assistant error with a backoff hint, and lets Flightdeck or `pi-agents-tmux` reuse their existing rate-limit retry ladder. Tune the first-output timeout with `CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT` (bare numbers are seconds; suffixes `ms`, `s`, and `m` are accepted). Default: `90s`; set `0` to disable.
102
+
101
103
  ## Debugging
102
104
 
103
105
  Set `CLAUDE_BRIDGE_DEBUG=1` to write bridge logs to `~/.pi/agent/claude-bridge.log` and per-query Claude Code CLI logs under `~/.pi/agent/cc-cli-logs/`.
package/bundle/index.js CHANGED
@@ -37873,6 +37873,90 @@ var piUI;
37873
37873
  var extraUsageHelperInFlight = null;
37874
37874
  var RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
37875
37875
  var RATE_LIMIT_TOKEN = "\x1B[31m[rate-limit]\x1B[39m";
37876
+ var DEFAULT_STREAM_IDLE_TIMEOUT_MS = 9e4;
37877
+ var STREAM_IDLE_BACKOFF_HINT_MS = 6e4;
37878
+ var STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
37879
+ var activeStreamIdleWatchdogs = /* @__PURE__ */ new WeakMap();
37880
+ function parseDurationLiteralMs(value, defaultUnit = "s") {
37881
+ const text = value.trim().toLowerCase();
37882
+ if (!text) return void 0;
37883
+ if (["off", "false", "disabled", "disable"].includes(text)) return 0;
37884
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
37885
+ if (!match) return void 0;
37886
+ const amount = Number(match[1]);
37887
+ if (!Number.isFinite(amount) || amount < 0) return void 0;
37888
+ const unit = (match[2] ?? defaultUnit).toLowerCase();
37889
+ const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(unit) ? 1 : ["s", "sec", "secs", "second", "seconds"].includes(unit) ? 1e3 : ["m", "min", "mins", "minute", "minutes"].includes(unit) ? 6e4 : void 0;
37890
+ if (multiplier === void 0) return void 0;
37891
+ const ms = Math.round(amount * multiplier);
37892
+ return Number.isFinite(ms) ? ms : void 0;
37893
+ }
37894
+ function streamIdleTimeoutMsFromEnv(env = process.env) {
37895
+ const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
37896
+ if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
37897
+ return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
37898
+ }
37899
+ function formatDurationShort(ms) {
37900
+ if (ms < 18e4 && ms % 1e3 === 0) return `${ms / 1e3}s`;
37901
+ if (ms % 6e4 === 0) return `${ms / 6e4}m`;
37902
+ if (ms % 1e3 === 0) return `${ms / 1e3}s`;
37903
+ return `${ms}ms`;
37904
+ }
37905
+ function buildStreamIdleTimeoutErrorMessage(timeoutMs) {
37906
+ return `Claude Code stream idle timeout after ${formatDurationShort(timeoutMs)} with no assistant/tool output; treating stalled stream as retryable 529 overloaded/rate limit condition. Retry after ${formatDurationShort(STREAM_IDLE_BACKOFF_HINT_MS)}.`;
37907
+ }
37908
+ function createStreamIdleWatchdog({
37909
+ clearTimer = (timer) => clearTimeout(timer),
37910
+ getState,
37911
+ now = () => Date.now(),
37912
+ onTimeout,
37913
+ setTimer = (fn, delayMs) => setTimeout(fn, delayMs),
37914
+ timeoutMs
37915
+ }) {
37916
+ let disposed = false;
37917
+ let lastChunkAt = now();
37918
+ let timer = null;
37919
+ let didTimeout = false;
37920
+ const clear = () => {
37921
+ if (!timer) return;
37922
+ try {
37923
+ clearTimer(timer);
37924
+ } catch {
37925
+ }
37926
+ timer = null;
37927
+ };
37928
+ const shouldMonitor = (state) => Boolean(
37929
+ timeoutMs > 0 && state.activeQuery && state.currentPiStream && state.turnOutput && !state.turnStarted && !state.turnSawStreamEvent
37930
+ );
37931
+ const schedule = () => {
37932
+ clear();
37933
+ if (disposed || didTimeout || timeoutMs <= 0) return;
37934
+ const state = getState();
37935
+ if (!shouldMonitor(state)) return;
37936
+ const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
37937
+ const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
37938
+ const idleMs = Math.max(0, now() - idleStartedAt);
37939
+ if (idleMs >= timeoutMs) {
37940
+ didTimeout = true;
37941
+ onTimeout({ idleMs, timeoutMs });
37942
+ return;
37943
+ }
37944
+ timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
37945
+ timer.unref?.();
37946
+ };
37947
+ return {
37948
+ dispose: () => {
37949
+ disposed = true;
37950
+ clear();
37951
+ },
37952
+ noteChunk: () => {
37953
+ lastChunkAt = now();
37954
+ schedule();
37955
+ },
37956
+ refresh: schedule,
37957
+ timedOut: () => didTimeout
37958
+ };
37959
+ }
37876
37960
  function isExtraUsageRequiredMessage(value) {
37877
37961
  let text;
37878
37962
  if (typeof value === "string") text = value;
@@ -38639,6 +38723,7 @@ async function consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConf
38639
38723
  for await (const message of sdkQuery) {
38640
38724
  if (wasAborted()) break;
38641
38725
  const queryCtx = ctx();
38726
+ activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
38642
38727
  if (!queryCtx.turnOutput) continue;
38643
38728
  if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
38644
38729
  switch (message.type) {
@@ -38720,6 +38805,7 @@ function streamClaudeAgentSdk(model, context, options) {
38720
38805
  const queryCtx = ctx();
38721
38806
  queryCtx.currentPiStream = stream;
38722
38807
  queryCtx.resetTurnState(model);
38808
+ activeStreamIdleWatchdogs.get(queryCtx)?.refresh();
38723
38809
  const allResults = extractAllToolResults2(context);
38724
38810
  debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
38725
38811
  const unmatchedResultIds = [];
@@ -38859,6 +38945,7 @@ function streamClaudeAgentSdk(model, context, options) {
38859
38945
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`
38860
38946
  );
38861
38947
  let wasAborted = false;
38948
+ let streamIdleTimedOut = false;
38862
38949
  const sdkQuery = jA$({ prompt, options: queryOptions });
38863
38950
  ctx().activeQuery = sdkQuery;
38864
38951
  const abortCtx = ctx();
@@ -38870,6 +38957,55 @@ function streamClaudeAgentSdk(model, context, options) {
38870
38957
  } catch {
38871
38958
  }
38872
38959
  };
38960
+ const streamIdleTimeoutMs = streamIdleTimeoutMsFromEnv();
38961
+ const streamIdleWatchdog = streamIdleTimeoutMs > 0 ? createStreamIdleWatchdog({
38962
+ getState: () => ({
38963
+ activeQuery: abortCtx.activeQuery,
38964
+ currentPiStream: abortCtx.currentPiStream,
38965
+ turnOutput: abortCtx.turnOutput,
38966
+ turnSawStreamEvent: abortCtx.turnSawStreamEvent,
38967
+ turnStarted: abortCtx.turnStarted
38968
+ }),
38969
+ onTimeout: ({ idleMs, timeoutMs }) => {
38970
+ if (streamIdleTimedOut || wasAborted || options?.signal?.aborted || abortCtx.activeQuery !== sdkQuery) return;
38971
+ streamIdleTimedOut = true;
38972
+ abortCtx.deferredUserMessages = [];
38973
+ abortCtx.handledTerminalError = true;
38974
+ if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
38975
+ const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
38976
+ debug("provider: stream idle timeout", `model=${model.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
38977
+ emitRateLimitEvent({
38978
+ idleMs,
38979
+ model: model.id,
38980
+ provider: PROVIDER_ID,
38981
+ rateLimitType: "stream_idle",
38982
+ reason: "Claude Code stream idle timeout",
38983
+ retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
38984
+ source: "claude-bridge",
38985
+ status: "rejected",
38986
+ timeoutMs
38987
+ });
38988
+ piUI?.notify(`${RATE_LIMIT_TOKEN} Claude stream idle timeout after ${formatDurationShort(timeoutMs)} \u2014 retrying via rate-limit backoff`, "warning");
38989
+ if (abortCtx.turnOutput) {
38990
+ abortCtx.turnOutput.stopReason = "error";
38991
+ abortCtx.turnOutput.errorMessage = errorMessage;
38992
+ Object.assign(abortCtx.turnOutput, {
38993
+ rateLimitType: "stream_idle",
38994
+ retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
38995
+ streamIdleTimeoutMs: timeoutMs
38996
+ });
38997
+ }
38998
+ abortCtx.currentPiStream?.push({ type: "error", reason: "error", error: abortCtx.turnOutput });
38999
+ abortCtx.currentPiStream?.end();
39000
+ abortCtx.currentPiStream = null;
39001
+ requestAbort();
39002
+ },
39003
+ timeoutMs: streamIdleTimeoutMs
39004
+ }) : null;
39005
+ if (streamIdleWatchdog) {
39006
+ activeStreamIdleWatchdogs.set(abortCtx, streamIdleWatchdog);
39007
+ streamIdleWatchdog.refresh();
39008
+ }
38873
39009
  const onAbort = () => {
38874
39010
  wasAborted = true;
38875
39011
  abortCtx.deferredUserMessages = [];
@@ -38887,6 +39023,11 @@ function streamClaudeAgentSdk(model, context, options) {
38887
39023
  }
38888
39024
  consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted).then(async ({ capturedSessionId }) => {
38889
39025
  debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
39026
+ if (streamIdleTimedOut) {
39027
+ abortCtx.deferredUserMessages = [];
39028
+ debug("provider: stream idle timeout already surfaced; skipping normal completion");
39029
+ return;
39030
+ }
38890
39031
  if (wasAborted || options?.signal?.aborted) {
38891
39032
  if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
38892
39033
  ctx().deferredUserMessages = [];
@@ -38940,7 +39081,7 @@ function streamClaudeAgentSdk(model, context, options) {
38940
39081
  finalizeCurrentStream(ctx().turnOutput?.stopReason);
38941
39082
  }).catch((error51) => {
38942
39083
  debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error51);
38943
- const suppressDuplicateError = ctx().handledTerminalError;
39084
+ const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
38944
39085
  const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error51) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
38945
39086
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
38946
39087
  sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
@@ -38960,9 +39101,11 @@ function streamClaudeAgentSdk(model, context, options) {
38960
39101
  ctx().currentPiStream?.end();
38961
39102
  ctx().currentPiStream = null;
38962
39103
  }).finally(() => {
39104
+ streamIdleWatchdog?.dispose();
39105
+ activeStreamIdleWatchdogs.delete(abortCtx);
38963
39106
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
38964
39107
  if (ctx().activeQuery === sdkQuery) {
38965
- reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted });
39108
+ reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted || streamIdleTimedOut });
38966
39109
  for (const pending of ctx().pendingToolCalls.values()) {
38967
39110
  pending.resolve({ content: [{ type: "text", text: "Query ended" }] });
38968
39111
  }
@@ -39097,10 +39240,15 @@ function index_default(pi) {
39097
39240
  export {
39098
39241
  ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD,
39099
39242
  CLAUDE_BRIDGE_TOOL_ISOLATION,
39243
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
39100
39244
  DISALLOWED_BUILTIN_TOOLS,
39245
+ STREAM_IDLE_BACKOFF_HINT_MS,
39246
+ STREAM_IDLE_TIMEOUT_ENV,
39101
39247
  __testGetBridgeIntegrityState,
39102
39248
  __testSetBridgeIntegrityState,
39249
+ buildStreamIdleTimeoutErrorMessage,
39103
39250
  classifyClaudeExecutableBytes,
39251
+ createStreamIdleWatchdog,
39104
39252
  index_default as default,
39105
39253
  formatAllowedRateLimitWarning,
39106
39254
  formatResetTimestamp,
@@ -39115,6 +39263,7 @@ export {
39115
39263
  restoreSharedSessionFromPi,
39116
39264
  shouldRestorePersistedBridgeEntry,
39117
39265
  spawnClaudeCodeWithDiagnostics,
39266
+ streamIdleTimeoutMsFromEnv,
39118
39267
  uniqueNonEmptyLines,
39119
39268
  wrapClaudeSpawnErrorForSdk
39120
39269
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -532,6 +532,137 @@ let extraUsageHelperInFlight: Promise<string> | null = null;
532
532
 
533
533
  const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
534
534
  const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
535
+ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
536
+ export const STREAM_IDLE_BACKOFF_HINT_MS = 60_000;
537
+ export const STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
538
+
539
+ type TimerHandle = ReturnType<typeof setTimeout>;
540
+
541
+ export interface StreamIdleWatchdogState {
542
+ activeQuery: unknown | null;
543
+ currentPiStream: AssistantMessageEventStream | null;
544
+ turnOutput: AssistantMessage | null;
545
+ turnSawStreamEvent: boolean;
546
+ turnStarted: boolean;
547
+ }
548
+
549
+ export interface StreamIdleTimeoutInfo {
550
+ idleMs: number;
551
+ timeoutMs: number;
552
+ }
553
+
554
+ export interface StreamIdleWatchdog {
555
+ dispose: () => void;
556
+ noteChunk: () => void;
557
+ refresh: () => void;
558
+ timedOut: () => boolean;
559
+ }
560
+
561
+ const activeStreamIdleWatchdogs = new WeakMap<QueryContext, StreamIdleWatchdog>();
562
+
563
+ function parseDurationLiteralMs(value: string, defaultUnit: "ms" | "s" = "s"): number | undefined {
564
+ const text = value.trim().toLowerCase();
565
+ if (!text) return undefined;
566
+ if (["off", "false", "disabled", "disable"].includes(text)) return 0;
567
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
568
+ if (!match) return undefined;
569
+ const amount = Number(match[1]);
570
+ if (!Number.isFinite(amount) || amount < 0) return undefined;
571
+ const unit = (match[2] ?? defaultUnit).toLowerCase();
572
+ const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(unit)
573
+ ? 1
574
+ : ["s", "sec", "secs", "second", "seconds"].includes(unit)
575
+ ? 1000
576
+ : ["m", "min", "mins", "minute", "minutes"].includes(unit)
577
+ ? 60_000
578
+ : undefined;
579
+ if (multiplier === undefined) return undefined;
580
+ const ms = Math.round(amount * multiplier);
581
+ return Number.isFinite(ms) ? ms : undefined;
582
+ }
583
+
584
+ export function streamIdleTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
585
+ const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
586
+ if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
587
+ return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
588
+ }
589
+
590
+ function formatDurationShort(ms: number): string {
591
+ if (ms < 180_000 && ms % 1000 === 0) return `${ms / 1000}s`;
592
+ if (ms % 60_000 === 0) return `${ms / 60_000}m`;
593
+ if (ms % 1000 === 0) return `${ms / 1000}s`;
594
+ return `${ms}ms`;
595
+ }
596
+
597
+ export function buildStreamIdleTimeoutErrorMessage(timeoutMs: number): string {
598
+ return `Claude Code stream idle timeout after ${formatDurationShort(timeoutMs)} with no assistant/tool output; treating stalled stream as retryable 529 overloaded/rate limit condition. Retry after ${formatDurationShort(STREAM_IDLE_BACKOFF_HINT_MS)}.`;
599
+ }
600
+
601
+ export function createStreamIdleWatchdog({
602
+ clearTimer = (timer: TimerHandle) => clearTimeout(timer),
603
+ getState,
604
+ now = () => Date.now(),
605
+ onTimeout,
606
+ setTimer = (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
607
+ timeoutMs,
608
+ }: {
609
+ clearTimer?: (timer: TimerHandle) => void;
610
+ getState: () => StreamIdleWatchdogState;
611
+ now?: () => number;
612
+ onTimeout: (info: StreamIdleTimeoutInfo) => void;
613
+ setTimer?: (fn: () => void, delayMs: number) => TimerHandle;
614
+ timeoutMs: number;
615
+ }): StreamIdleWatchdog {
616
+ let disposed = false;
617
+ let lastChunkAt = now();
618
+ let timer: TimerHandle | null = null;
619
+ let didTimeout = false;
620
+
621
+ const clear = () => {
622
+ if (!timer) return;
623
+ try { clearTimer(timer); } catch { /* best effort */ }
624
+ timer = null;
625
+ };
626
+
627
+ const shouldMonitor = (state: StreamIdleWatchdogState): boolean => Boolean(
628
+ timeoutMs > 0
629
+ && state.activeQuery
630
+ && state.currentPiStream
631
+ && state.turnOutput
632
+ && !state.turnStarted
633
+ && !state.turnSawStreamEvent,
634
+ );
635
+
636
+ const schedule = () => {
637
+ clear();
638
+ if (disposed || didTimeout || timeoutMs <= 0) return;
639
+ const state = getState();
640
+ if (!shouldMonitor(state)) return;
641
+ const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
642
+ const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
643
+ const idleMs = Math.max(0, now() - idleStartedAt);
644
+ if (idleMs >= timeoutMs) {
645
+ didTimeout = true;
646
+ onTimeout({ idleMs, timeoutMs });
647
+ return;
648
+ }
649
+ timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
650
+ (timer as { unref?: () => void }).unref?.();
651
+ };
652
+
653
+ return {
654
+ dispose: () => {
655
+ disposed = true;
656
+ clear();
657
+ },
658
+ noteChunk: () => {
659
+ lastChunkAt = now();
660
+ schedule();
661
+ },
662
+ refresh: schedule,
663
+ timedOut: () => didTimeout,
664
+ };
665
+ }
535
666
 
536
667
  export function isExtraUsageRequiredMessage(value: unknown): boolean {
537
668
  let text: string;
@@ -1532,6 +1663,7 @@ async function consumeQuery(
1532
1663
  for await (const message of sdkQuery) {
1533
1664
  if (wasAborted()) break;
1534
1665
  const queryCtx = ctx();
1666
+ activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
1535
1667
  if (!queryCtx.turnOutput) continue;
1536
1668
  if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
1537
1669
 
@@ -1626,6 +1758,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1626
1758
  const queryCtx = ctx();
1627
1759
  queryCtx.currentPiStream = stream;
1628
1760
  queryCtx.resetTurnState(model);
1761
+ activeStreamIdleWatchdogs.get(queryCtx)?.refresh();
1629
1762
  const allResults = extractAllToolResults(context);
1630
1763
  debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
1631
1764
  const unmatchedResultIds: string[] = [];
@@ -1822,6 +1955,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1822
1955
 
1823
1956
  // 3. Start SDK query and claim it for this context
1824
1957
  let wasAborted = false;
1958
+ let streamIdleTimedOut = false;
1825
1959
  const sdkQuery = query({ prompt, options: queryOptions });
1826
1960
  ctx().activeQuery = sdkQuery;
1827
1961
 
@@ -1834,6 +1968,57 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1834
1968
  void sdkQuery.interrupt().catch(() => {});
1835
1969
  try { sdkQuery.close(); } catch {}
1836
1970
  };
1971
+ const streamIdleTimeoutMs = streamIdleTimeoutMsFromEnv();
1972
+ const streamIdleWatchdog = streamIdleTimeoutMs > 0
1973
+ ? createStreamIdleWatchdog({
1974
+ getState: () => ({
1975
+ activeQuery: abortCtx.activeQuery,
1976
+ currentPiStream: abortCtx.currentPiStream,
1977
+ turnOutput: abortCtx.turnOutput,
1978
+ turnSawStreamEvent: abortCtx.turnSawStreamEvent,
1979
+ turnStarted: abortCtx.turnStarted,
1980
+ }),
1981
+ onTimeout: ({ idleMs, timeoutMs }) => {
1982
+ if (streamIdleTimedOut || wasAborted || options?.signal?.aborted || abortCtx.activeQuery !== sdkQuery) return;
1983
+ streamIdleTimedOut = true;
1984
+ abortCtx.deferredUserMessages = [];
1985
+ abortCtx.handledTerminalError = true;
1986
+ if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1987
+ const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
1988
+ debug("provider: stream idle timeout", `model=${model.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
1989
+ emitRateLimitEvent({
1990
+ idleMs,
1991
+ model: model.id,
1992
+ provider: PROVIDER_ID,
1993
+ rateLimitType: "stream_idle",
1994
+ reason: "Claude Code stream idle timeout",
1995
+ retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
1996
+ source: "claude-bridge",
1997
+ status: "rejected",
1998
+ timeoutMs,
1999
+ });
2000
+ piUI?.notify(`${RATE_LIMIT_TOKEN} Claude stream idle timeout after ${formatDurationShort(timeoutMs)} — retrying via rate-limit backoff`, "warning");
2001
+ if (abortCtx.turnOutput) {
2002
+ abortCtx.turnOutput.stopReason = "error";
2003
+ abortCtx.turnOutput.errorMessage = errorMessage;
2004
+ Object.assign(abortCtx.turnOutput as AssistantMessage & Record<string, unknown>, {
2005
+ rateLimitType: "stream_idle",
2006
+ retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
2007
+ streamIdleTimeoutMs: timeoutMs,
2008
+ });
2009
+ }
2010
+ abortCtx.currentPiStream?.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
2011
+ abortCtx.currentPiStream?.end();
2012
+ abortCtx.currentPiStream = null;
2013
+ requestAbort();
2014
+ },
2015
+ timeoutMs: streamIdleTimeoutMs,
2016
+ })
2017
+ : null;
2018
+ if (streamIdleWatchdog) {
2019
+ activeStreamIdleWatchdogs.set(abortCtx, streamIdleWatchdog);
2020
+ streamIdleWatchdog.refresh();
2021
+ }
1837
2022
  const onAbort = () => {
1838
2023
  wasAborted = true;
1839
2024
  // Prevent stale deferred messages from being replayed by parent on pop
@@ -1853,6 +2038,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1853
2038
  consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
1854
2039
  .then(async ({ capturedSessionId }) => {
1855
2040
  debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
2041
+ if (streamIdleTimedOut) {
2042
+ abortCtx.deferredUserMessages = [];
2043
+ debug("provider: stream idle timeout already surfaced; skipping normal completion");
2044
+ return;
2045
+ }
1856
2046
 
1857
2047
  // --- Abort detection in normal completion path ---
1858
2048
  if (wasAborted || options?.signal?.aborted) {
@@ -1921,7 +2111,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1921
2111
  })
1922
2112
  .catch((error) => {
1923
2113
  debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1924
- const suppressDuplicateError = ctx().handledTerminalError;
2114
+ const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
1925
2115
  const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
1926
2116
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1927
2117
  sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
@@ -1942,9 +2132,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1942
2132
  ctx().currentPiStream = null;
1943
2133
  })
1944
2134
  .finally(() => {
2135
+ streamIdleWatchdog?.dispose();
2136
+ activeStreamIdleWatchdogs.delete(abortCtx);
1945
2137
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1946
2138
  if (ctx().activeQuery === sdkQuery) {
1947
- reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted });
2139
+ reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted || streamIdleTimedOut });
1948
2140
  // Drain pending handlers for this query
1949
2141
  for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1950
2142
  ctx().pendingToolCalls.clear();