@vanillagreen/pi-claude-bridge 1.3.0 → 1.4.1

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
@@ -21976,7 +21976,7 @@ function readSession(jsonlPath, projectPath) {
21976
21976
  // src/index.ts
21977
21977
  import { spawn as spawnProcess } from "child_process";
21978
21978
  import { createHash } from "crypto";
21979
- import { accessSync, appendFileSync as appendFileSync3, chmodSync, constants as fsConstants, mkdirSync as mkdirSync3, readFileSync as readFileSync7, realpathSync as realpathSync3, statSync as statSync3 } from "fs";
21979
+ import { accessSync, appendFileSync as appendFileSync3, chmodSync, constants as fsConstants, mkdirSync as mkdirSync3, readFileSync as readFileSync6, realpathSync as realpathSync3, statSync as statSync3 } from "fs";
21980
21980
  import { resolve as pathResolve } from "path";
21981
21981
  import { homedir as homedir5 } from "os";
21982
21982
  import { delimiter, dirname as dirname5, join as join5 } from "path";
@@ -22272,7 +22272,46 @@ function rewriteSkillsBlock(skillsBlock) {
22272
22272
  }
22273
22273
 
22274
22274
  // src/session-verify.ts
22275
- import { statSync as statSync2, readFileSync as readFileSync3 } from "fs";
22275
+ import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
22276
+ import { StringDecoder } from "node:string_decoder";
22277
+ function forEachJsonlLine(path, onLine) {
22278
+ const fd = openSync2(path, "r");
22279
+ const buffer = Buffer.allocUnsafe(64 * 1024);
22280
+ const decoder = new StringDecoder("utf8");
22281
+ let pending = "";
22282
+ try {
22283
+ for (; ; ) {
22284
+ const bytesRead = readSync2(fd, buffer, 0, buffer.length, null);
22285
+ if (bytesRead === 0) break;
22286
+ pending += decoder.write(buffer.subarray(0, bytesRead));
22287
+ let start = 0;
22288
+ for (; ; ) {
22289
+ const newline = pending.indexOf("\n", start);
22290
+ if (newline < 0) {
22291
+ pending = pending.slice(start);
22292
+ break;
22293
+ }
22294
+ const line = pending.slice(start, newline);
22295
+ onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
22296
+ start = newline + 1;
22297
+ }
22298
+ }
22299
+ pending += decoder.end();
22300
+ if (pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
22301
+ } finally {
22302
+ closeSync2(fd);
22303
+ }
22304
+ }
22305
+ function summarizeJsonl(path) {
22306
+ const summary = { count: 0 };
22307
+ forEachJsonlLine(path, (line) => {
22308
+ if (!line.trim()) return;
22309
+ summary.count += 1;
22310
+ if (summary.firstLine === void 0) summary.firstLine = line;
22311
+ summary.lastLine = line;
22312
+ });
22313
+ return summary;
22314
+ }
22276
22315
  function verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount) {
22277
22316
  const warnings = [];
22278
22317
  let st;
@@ -22282,21 +22321,20 @@ function verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount)
22282
22321
  warnings.push(`file missing after save \u2014 path=${jsonlPath} err=${e2.message}`);
22283
22322
  return warnings;
22284
22323
  }
22285
- let content;
22324
+ let summary;
22286
22325
  try {
22287
- content = readFileSync3(jsonlPath, "utf8");
22326
+ summary = summarizeJsonl(jsonlPath);
22288
22327
  } catch (e2) {
22289
22328
  warnings.push(`file unreadable \u2014 path=${jsonlPath} size=${st.size} err=${e2.message}`);
22290
22329
  return warnings;
22291
22330
  }
22292
- const lines = content.split("\n").filter((l2) => l2.trim().length > 0);
22293
- if (lines.length !== expectedRecordCount) {
22294
- warnings.push(`record count mismatch \u2014 expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
22331
+ if (summary.count !== expectedRecordCount) {
22332
+ warnings.push(`record count mismatch \u2014 expected=${expectedRecordCount} actual=${summary.count} path=${jsonlPath} bytes=${st.size}`);
22295
22333
  return warnings;
22296
22334
  }
22297
22335
  try {
22298
- const firstRec = JSON.parse(lines[0]);
22299
- const lastRec = JSON.parse(lines[lines.length - 1]);
22336
+ const firstRec = JSON.parse(summary.firstLine ?? "");
22337
+ const lastRec = JSON.parse(summary.lastLine ?? "");
22300
22338
  if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
22301
22339
  warnings.push(`sessionId drift \u2014 expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
22302
22340
  }
@@ -22564,7 +22602,7 @@ function summarizeMissingToolNames(missing) {
22564
22602
  }
22565
22603
 
22566
22604
  // src/config.ts
22567
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
22605
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
22568
22606
  import { homedir as homedir2 } from "os";
22569
22607
  import { dirname as dirname2, join as join2, resolve } from "path";
22570
22608
  var PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
@@ -22606,7 +22644,7 @@ function settingsPaths(cwd) {
22606
22644
  function tryParseJson(path) {
22607
22645
  if (!existsSync3(path)) return {};
22608
22646
  try {
22609
- return JSON.parse(readFileSync4(path, "utf-8"));
22647
+ return JSON.parse(readFileSync3(path, "utf-8"));
22610
22648
  } catch {
22611
22649
  return {};
22612
22650
  }
@@ -22616,7 +22654,7 @@ function readManagerConfig(cwd) {
22616
22654
  for (const path of settingsPaths(cwd)) {
22617
22655
  if (!existsSync3(path)) continue;
22618
22656
  try {
22619
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
22657
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
22620
22658
  const configRoot = asRecord(asRecord(asRecord(parsed?.vstack)?.extensionManager)?.config);
22621
22659
  const config2 = asRecord(configRoot?.[PACKAGE_ID]);
22622
22660
  if (config2) mergeDeep(merged, config2);
@@ -22720,7 +22758,7 @@ function loadConfig(cwd) {
22720
22758
  }
22721
22759
 
22722
22760
  // src/agents-md.ts
22723
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
22761
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
22724
22762
  import { homedir as homedir3 } from "os";
22725
22763
  import { dirname as dirname3, join as join3, resolve as resolve2 } from "path";
22726
22764
  var GLOBAL_AGENTS_PATH = join3(homedir3(), ".pi", "agent", "AGENTS.md");
@@ -22745,7 +22783,7 @@ function extractAgentsAppend() {
22745
22783
  const agentsPath = resolveAgentsMdPath();
22746
22784
  if (!agentsPath) return void 0;
22747
22785
  try {
22748
- const content = readFileSync5(agentsPath, "utf-8").trim();
22786
+ const content = readFileSync4(agentsPath, "utf-8").trim();
22749
22787
  if (!content) return void 0;
22750
22788
  const sanitized = sanitizeAgentsContent(content);
22751
22789
  return sanitized.length > 0 ? `# CLAUDE.md
@@ -22765,7 +22803,7 @@ function sanitizeAgentsContent(content) {
22765
22803
  }
22766
22804
 
22767
22805
  // src/prompt-context.ts
22768
- import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
22806
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
22769
22807
  import { homedir as homedir4 } from "os";
22770
22808
  import { dirname as dirname4, join as join4, resolve as resolve3 } from "path";
22771
22809
  function piUserDir2() {
@@ -22776,7 +22814,7 @@ function piUserDir2() {
22776
22814
  function readTrimmed(path) {
22777
22815
  try {
22778
22816
  if (!existsSync5(path)) return void 0;
22779
- const content = readFileSync6(path, "utf8").trim();
22817
+ const content = readFileSync5(path, "utf8").trim();
22780
22818
  return content.length > 0 ? content : void 0;
22781
22819
  } catch {
22782
22820
  return void 0;
@@ -37607,7 +37645,7 @@ function preflightClaudeExecutable(path, cwd) {
37607
37645
  }
37608
37646
  let fileType;
37609
37647
  try {
37610
- fileType = classifyClaudeExecutableBytes(readFileSync7(realPath).subarray(0, 16));
37648
+ fileType = classifyClaudeExecutableBytes(readFileSync6(realPath).subarray(0, 16));
37611
37649
  } catch (err) {
37612
37650
  throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
37613
37651
  code: codeValue(err, "EACCES"),
@@ -37873,6 +37911,90 @@ var piUI;
37873
37911
  var extraUsageHelperInFlight = null;
37874
37912
  var RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
37875
37913
  var RATE_LIMIT_TOKEN = "\x1B[31m[rate-limit]\x1B[39m";
37914
+ var DEFAULT_STREAM_IDLE_TIMEOUT_MS = 9e4;
37915
+ var STREAM_IDLE_BACKOFF_HINT_MS = 6e4;
37916
+ var STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
37917
+ var activeStreamIdleWatchdogs = /* @__PURE__ */ new WeakMap();
37918
+ function parseDurationLiteralMs(value, defaultUnit = "s") {
37919
+ const text = value.trim().toLowerCase();
37920
+ if (!text) return void 0;
37921
+ if (["off", "false", "disabled", "disable"].includes(text)) return 0;
37922
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
37923
+ if (!match) return void 0;
37924
+ const amount = Number(match[1]);
37925
+ if (!Number.isFinite(amount) || amount < 0) return void 0;
37926
+ const unit = (match[2] ?? defaultUnit).toLowerCase();
37927
+ 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;
37928
+ if (multiplier === void 0) return void 0;
37929
+ const ms = Math.round(amount * multiplier);
37930
+ return Number.isFinite(ms) ? ms : void 0;
37931
+ }
37932
+ function streamIdleTimeoutMsFromEnv(env = process.env) {
37933
+ const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
37934
+ if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
37935
+ return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
37936
+ }
37937
+ function formatDurationShort(ms) {
37938
+ if (ms < 18e4 && ms % 1e3 === 0) return `${ms / 1e3}s`;
37939
+ if (ms % 6e4 === 0) return `${ms / 6e4}m`;
37940
+ if (ms % 1e3 === 0) return `${ms / 1e3}s`;
37941
+ return `${ms}ms`;
37942
+ }
37943
+ function buildStreamIdleTimeoutErrorMessage(timeoutMs) {
37944
+ 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)}.`;
37945
+ }
37946
+ function createStreamIdleWatchdog({
37947
+ clearTimer = (timer) => clearTimeout(timer),
37948
+ getState,
37949
+ now = () => Date.now(),
37950
+ onTimeout,
37951
+ setTimer = (fn, delayMs) => setTimeout(fn, delayMs),
37952
+ timeoutMs
37953
+ }) {
37954
+ let disposed = false;
37955
+ let lastChunkAt = now();
37956
+ let timer = null;
37957
+ let didTimeout = false;
37958
+ const clear = () => {
37959
+ if (!timer) return;
37960
+ try {
37961
+ clearTimer(timer);
37962
+ } catch {
37963
+ }
37964
+ timer = null;
37965
+ };
37966
+ const shouldMonitor = (state) => Boolean(
37967
+ timeoutMs > 0 && state.activeQuery && state.currentPiStream && state.turnOutput && !state.turnStarted && !state.turnSawStreamEvent
37968
+ );
37969
+ const schedule = () => {
37970
+ clear();
37971
+ if (disposed || didTimeout || timeoutMs <= 0) return;
37972
+ const state = getState();
37973
+ if (!shouldMonitor(state)) return;
37974
+ const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
37975
+ const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
37976
+ const idleMs = Math.max(0, now() - idleStartedAt);
37977
+ if (idleMs >= timeoutMs) {
37978
+ didTimeout = true;
37979
+ onTimeout({ idleMs, timeoutMs });
37980
+ return;
37981
+ }
37982
+ timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
37983
+ timer.unref?.();
37984
+ };
37985
+ return {
37986
+ dispose: () => {
37987
+ disposed = true;
37988
+ clear();
37989
+ },
37990
+ noteChunk: () => {
37991
+ lastChunkAt = now();
37992
+ schedule();
37993
+ },
37994
+ refresh: schedule,
37995
+ timedOut: () => didTimeout
37996
+ };
37997
+ }
37876
37998
  function isExtraUsageRequiredMessage(value) {
37877
37999
  let text;
37878
38000
  if (typeof value === "string") text = value;
@@ -38639,6 +38761,7 @@ async function consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConf
38639
38761
  for await (const message of sdkQuery) {
38640
38762
  if (wasAborted()) break;
38641
38763
  const queryCtx = ctx();
38764
+ activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
38642
38765
  if (!queryCtx.turnOutput) continue;
38643
38766
  if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
38644
38767
  switch (message.type) {
@@ -38720,6 +38843,7 @@ function streamClaudeAgentSdk(model, context, options) {
38720
38843
  const queryCtx = ctx();
38721
38844
  queryCtx.currentPiStream = stream;
38722
38845
  queryCtx.resetTurnState(model);
38846
+ activeStreamIdleWatchdogs.get(queryCtx)?.refresh();
38723
38847
  const allResults = extractAllToolResults2(context);
38724
38848
  debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
38725
38849
  const unmatchedResultIds = [];
@@ -38859,6 +38983,7 @@ function streamClaudeAgentSdk(model, context, options) {
38859
38983
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`
38860
38984
  );
38861
38985
  let wasAborted = false;
38986
+ let streamIdleTimedOut = false;
38862
38987
  const sdkQuery = jA$({ prompt, options: queryOptions });
38863
38988
  ctx().activeQuery = sdkQuery;
38864
38989
  const abortCtx = ctx();
@@ -38870,6 +38995,55 @@ function streamClaudeAgentSdk(model, context, options) {
38870
38995
  } catch {
38871
38996
  }
38872
38997
  };
38998
+ const streamIdleTimeoutMs = streamIdleTimeoutMsFromEnv();
38999
+ const streamIdleWatchdog = streamIdleTimeoutMs > 0 ? createStreamIdleWatchdog({
39000
+ getState: () => ({
39001
+ activeQuery: abortCtx.activeQuery,
39002
+ currentPiStream: abortCtx.currentPiStream,
39003
+ turnOutput: abortCtx.turnOutput,
39004
+ turnSawStreamEvent: abortCtx.turnSawStreamEvent,
39005
+ turnStarted: abortCtx.turnStarted
39006
+ }),
39007
+ onTimeout: ({ idleMs, timeoutMs }) => {
39008
+ if (streamIdleTimedOut || wasAborted || options?.signal?.aborted || abortCtx.activeQuery !== sdkQuery) return;
39009
+ streamIdleTimedOut = true;
39010
+ abortCtx.deferredUserMessages = [];
39011
+ abortCtx.handledTerminalError = true;
39012
+ if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
39013
+ const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
39014
+ debug("provider: stream idle timeout", `model=${model.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
39015
+ emitRateLimitEvent({
39016
+ idleMs,
39017
+ model: model.id,
39018
+ provider: PROVIDER_ID,
39019
+ rateLimitType: "stream_idle",
39020
+ reason: "Claude Code stream idle timeout",
39021
+ retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
39022
+ source: "claude-bridge",
39023
+ status: "rejected",
39024
+ timeoutMs
39025
+ });
39026
+ piUI?.notify(`${RATE_LIMIT_TOKEN} Claude stream idle timeout after ${formatDurationShort(timeoutMs)} \u2014 retrying via rate-limit backoff`, "warning");
39027
+ if (abortCtx.turnOutput) {
39028
+ abortCtx.turnOutput.stopReason = "error";
39029
+ abortCtx.turnOutput.errorMessage = errorMessage;
39030
+ Object.assign(abortCtx.turnOutput, {
39031
+ rateLimitType: "stream_idle",
39032
+ retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
39033
+ streamIdleTimeoutMs: timeoutMs
39034
+ });
39035
+ }
39036
+ abortCtx.currentPiStream?.push({ type: "error", reason: "error", error: abortCtx.turnOutput });
39037
+ abortCtx.currentPiStream?.end();
39038
+ abortCtx.currentPiStream = null;
39039
+ requestAbort();
39040
+ },
39041
+ timeoutMs: streamIdleTimeoutMs
39042
+ }) : null;
39043
+ if (streamIdleWatchdog) {
39044
+ activeStreamIdleWatchdogs.set(abortCtx, streamIdleWatchdog);
39045
+ streamIdleWatchdog.refresh();
39046
+ }
38873
39047
  const onAbort = () => {
38874
39048
  wasAborted = true;
38875
39049
  abortCtx.deferredUserMessages = [];
@@ -38887,6 +39061,11 @@ function streamClaudeAgentSdk(model, context, options) {
38887
39061
  }
38888
39062
  consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted).then(async ({ capturedSessionId }) => {
38889
39063
  debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
39064
+ if (streamIdleTimedOut) {
39065
+ abortCtx.deferredUserMessages = [];
39066
+ debug("provider: stream idle timeout already surfaced; skipping normal completion");
39067
+ return;
39068
+ }
38890
39069
  if (wasAborted || options?.signal?.aborted) {
38891
39070
  if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
38892
39071
  ctx().deferredUserMessages = [];
@@ -38940,7 +39119,7 @@ function streamClaudeAgentSdk(model, context, options) {
38940
39119
  finalizeCurrentStream(ctx().turnOutput?.stopReason);
38941
39120
  }).catch((error51) => {
38942
39121
  debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error51);
38943
- const suppressDuplicateError = ctx().handledTerminalError;
39122
+ const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
38944
39123
  const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error51) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
38945
39124
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
38946
39125
  sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
@@ -38960,9 +39139,11 @@ function streamClaudeAgentSdk(model, context, options) {
38960
39139
  ctx().currentPiStream?.end();
38961
39140
  ctx().currentPiStream = null;
38962
39141
  }).finally(() => {
39142
+ streamIdleWatchdog?.dispose();
39143
+ activeStreamIdleWatchdogs.delete(abortCtx);
38963
39144
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
38964
39145
  if (ctx().activeQuery === sdkQuery) {
38965
- reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted });
39146
+ reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted || streamIdleTimedOut });
38966
39147
  for (const pending of ctx().pendingToolCalls.values()) {
38967
39148
  pending.resolve({ content: [{ type: "text", text: "Query ended" }] });
38968
39149
  }
@@ -39097,10 +39278,15 @@ function index_default(pi) {
39097
39278
  export {
39098
39279
  ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD,
39099
39280
  CLAUDE_BRIDGE_TOOL_ISOLATION,
39281
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
39100
39282
  DISALLOWED_BUILTIN_TOOLS,
39283
+ STREAM_IDLE_BACKOFF_HINT_MS,
39284
+ STREAM_IDLE_TIMEOUT_ENV,
39101
39285
  __testGetBridgeIntegrityState,
39102
39286
  __testSetBridgeIntegrityState,
39287
+ buildStreamIdleTimeoutErrorMessage,
39103
39288
  classifyClaudeExecutableBytes,
39289
+ createStreamIdleWatchdog,
39104
39290
  index_default as default,
39105
39291
  formatAllowedRateLimitWarning,
39106
39292
  formatResetTimestamp,
@@ -39115,6 +39301,7 @@ export {
39115
39301
  restoreSharedSessionFromPi,
39116
39302
  shouldRestorePersistedBridgeEntry,
39117
39303
  spawnClaudeCodeWithDiagnostics,
39304
+ streamIdleTimeoutMsFromEnv,
39118
39305
  uniqueNonEmptyLines,
39119
39306
  wrapClaudeSpawnErrorForSdk
39120
39307
  };
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.1",
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();
@@ -2,7 +2,54 @@
2
2
  // callers decide how to surface them (debug log, piUI, diagDump, etc.).
3
3
  // Extracted from index.ts so tests can import without activating the extension.
4
4
 
5
- import { statSync, readFileSync } from "fs";
5
+ import { closeSync, openSync, readSync, statSync } from "fs";
6
+ import { StringDecoder } from "node:string_decoder";
7
+
8
+ interface JsonlSummary {
9
+ count: number;
10
+ firstLine?: string;
11
+ lastLine?: string;
12
+ }
13
+
14
+ function forEachJsonlLine(path: string, onLine: (line: string) => void): void {
15
+ const fd = openSync(path, "r");
16
+ const buffer = Buffer.allocUnsafe(64 * 1024);
17
+ const decoder = new StringDecoder("utf8");
18
+ let pending = "";
19
+ try {
20
+ for (;;) {
21
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
22
+ if (bytesRead === 0) break;
23
+ pending += decoder.write(buffer.subarray(0, bytesRead));
24
+ let start = 0;
25
+ for (;;) {
26
+ const newline = pending.indexOf("\n", start);
27
+ if (newline < 0) {
28
+ pending = pending.slice(start);
29
+ break;
30
+ }
31
+ const line = pending.slice(start, newline);
32
+ onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
33
+ start = newline + 1;
34
+ }
35
+ }
36
+ pending += decoder.end();
37
+ if (pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
38
+ } finally {
39
+ closeSync(fd);
40
+ }
41
+ }
42
+
43
+ function summarizeJsonl(path: string): JsonlSummary {
44
+ const summary: JsonlSummary = { count: 0 };
45
+ forEachJsonlLine(path, (line) => {
46
+ if (!line.trim()) return;
47
+ summary.count += 1;
48
+ if (summary.firstLine === undefined) summary.firstLine = line;
49
+ summary.lastLine = line;
50
+ });
51
+ return summary;
52
+ }
6
53
 
7
54
  export function verifyWrittenSession(jsonlPath: string, expectedSessionId: string, expectedRecordCount: number): string[] {
8
55
  const warnings = [];
@@ -13,21 +60,20 @@ export function verifyWrittenSession(jsonlPath: string, expectedSessionId: strin
13
60
  warnings.push(`file missing after save — path=${jsonlPath} err=${e.message}`);
14
61
  return warnings;
15
62
  }
16
- let content;
63
+ let summary;
17
64
  try {
18
- content = readFileSync(jsonlPath, "utf8");
65
+ summary = summarizeJsonl(jsonlPath);
19
66
  } catch (e) {
20
67
  warnings.push(`file unreadable — path=${jsonlPath} size=${st.size} err=${e.message}`);
21
68
  return warnings;
22
69
  }
23
- const lines = content.split("\n").filter((l) => l.trim().length > 0);
24
- if (lines.length !== expectedRecordCount) {
25
- warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
70
+ if (summary.count !== expectedRecordCount) {
71
+ warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${summary.count} path=${jsonlPath} bytes=${st.size}`);
26
72
  return warnings;
27
73
  }
28
74
  try {
29
- const firstRec = JSON.parse(lines[0]);
30
- const lastRec = JSON.parse(lines[lines.length - 1]);
75
+ const firstRec = JSON.parse(summary.firstLine ?? "");
76
+ const lastRec = JSON.parse(summary.lastLine ?? "");
31
77
  if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
32
78
  warnings.push(`sessionId drift — expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
33
79
  }