@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 +2 -0
- package/bundle/index.js +151 -2
- package/package.json +1 -1
- package/src/index.ts +194 -2
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
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();
|