@vanillagreen/pi-claude-bridge 1.2.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 +25 -2
- package/bundle/index.js +7304 -6333
- package/package.json +38 -3
- package/src/config.ts +67 -1
- package/src/convert.ts +77 -20
- package/src/index.ts +590 -56
- package/src/models.ts +1 -1
- package/src/query-state.ts +187 -3
- package/src/tool-pairing-audit.ts +61 -0
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthro
|
|
|
6
6
|
import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
|
|
7
7
|
import { spawn as spawnProcess } from "child_process";
|
|
8
8
|
import { createHash } from "crypto";
|
|
9
|
-
import { accessSync, appendFileSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
|
|
9
|
+
import { accessSync, appendFileSync, chmodSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
|
|
10
10
|
import { resolve as pathResolve } from "path";
|
|
11
11
|
import { homedir } from "os";
|
|
12
12
|
import { delimiter, dirname, join } from "path";
|
|
@@ -16,7 +16,8 @@ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.j
|
|
|
16
16
|
import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
|
|
17
17
|
import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
|
|
18
18
|
import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
|
|
19
|
-
import {
|
|
19
|
+
import { findUnpairedToolUses, summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
|
|
20
|
+
import { loadConfig, normalizeEffortLevel, type Config } from "./config.js";
|
|
20
21
|
import { extractAgentsAppend } from "./agents-md.js";
|
|
21
22
|
import { buildPromptContextAppend } from "./prompt-context.js";
|
|
22
23
|
import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
|
|
@@ -33,13 +34,17 @@ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
|
|
|
33
34
|
|
|
34
35
|
const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
|
|
35
36
|
const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(homedir(), ".pi", "agent", "claude-bridge.log");
|
|
36
|
-
const
|
|
37
|
+
const DEFAULT_DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
|
|
38
|
+
|
|
39
|
+
function diagLogPath(): string {
|
|
40
|
+
return process.env.CLAUDE_BRIDGE_DIAG_PATH || DEFAULT_DIAG_LOG_PATH;
|
|
41
|
+
}
|
|
37
42
|
|
|
38
43
|
// Ensure log directories exist when debug is enabled
|
|
39
44
|
if (DEBUG) {
|
|
40
45
|
try {
|
|
41
46
|
mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
|
|
42
|
-
mkdirSync(dirname(
|
|
47
|
+
mkdirSync(dirname(diagLogPath()), { recursive: true, mode: 0o700 });
|
|
43
48
|
} catch {
|
|
44
49
|
// If directory creation fails, debug functions will throw on first use
|
|
45
50
|
}
|
|
@@ -57,7 +62,7 @@ function debug(...args: unknown[]) {
|
|
|
57
62
|
return JSON.stringify(a);
|
|
58
63
|
};
|
|
59
64
|
const msg = args.map(fmt).join(" ");
|
|
60
|
-
appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`);
|
|
65
|
+
try { appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`); } catch { /* debug is best effort */ }
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
function executableFromPath(name: string): string | undefined {
|
|
@@ -342,10 +347,109 @@ function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string
|
|
|
342
347
|
|
|
343
348
|
/** Unconditional diagnostic dump — for "should never happen" paths */
|
|
344
349
|
function diagDump(label: string, data: Record<string, unknown>) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
350
|
+
try {
|
|
351
|
+
const ts = new Date().toISOString();
|
|
352
|
+
const entry = { ts, moduleInstanceId, label, ...data };
|
|
353
|
+
const path = diagLogPath();
|
|
354
|
+
try { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); } catch { /* best effort */ }
|
|
355
|
+
appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
356
|
+
try { chmodSync(path, 0o600); } catch { /* best effort */ }
|
|
357
|
+
debug(`DIAG: ${label} (see ${path})`);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
debug(`DIAG FAILED: ${label}`, error);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function safeNotify(message: string, level: "info" | "warning" | "error" = "warning"): void {
|
|
364
|
+
try { piUI?.notify(message, level); }
|
|
365
|
+
catch (error) { debug("notify failed:", error); }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function argKeys(args: Record<string, unknown> | undefined): string[] {
|
|
369
|
+
return Object.keys(args ?? {}).sort();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function safeToolCallSummary(calls: Array<{ id: string; toolName: string; arguments?: Record<string, unknown> }>): Array<{ id: string; toolName: string; argKeys: string[] }> {
|
|
373
|
+
return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
|
|
377
|
+
const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
|
|
378
|
+
if (names.length > limit) shown.push(`+${names.length - limit} more`);
|
|
379
|
+
return shown;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function reportSyntheticToolResultRepair(missing: MissingToolResult[], context: Record<string, unknown>): void {
|
|
383
|
+
try {
|
|
384
|
+
if (missing.length === 0) return;
|
|
385
|
+
const toolNames = summarizeMissingToolNames(missing);
|
|
386
|
+
const toolNameSummary = compactToolNameSummary(toolNames);
|
|
387
|
+
const sampledToolCallIds = missing.slice(0, 50).map((item) => item.id);
|
|
388
|
+
diagDump("repair_tool_pairing_synthetic_results", {
|
|
389
|
+
count: missing.length,
|
|
390
|
+
toolNames,
|
|
391
|
+
sampledToolCallIds,
|
|
392
|
+
missing: missing.slice(0, 50),
|
|
393
|
+
...context,
|
|
394
|
+
});
|
|
395
|
+
safeNotify(
|
|
396
|
+
`Claude bridge: ${missing.length} missing tool result(s) repaired with "[no tool result recorded]"` +
|
|
397
|
+
`${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
|
|
398
|
+
`Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
|
|
399
|
+
"error",
|
|
400
|
+
);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
debug("reportSyntheticToolResultRepair failed:", error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
|
|
407
|
+
try {
|
|
408
|
+
if (queryCtx.reportedToolResultMismatch) return false;
|
|
409
|
+
const progress = queryCtx.toolResultProgress();
|
|
410
|
+
const hasMismatch = progress.expectedCount > 0
|
|
411
|
+
? progress.unresolvedIds.length > 0 || progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0
|
|
412
|
+
: progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
|
|
413
|
+
if (!hasMismatch) return false;
|
|
414
|
+
queryCtx.reportedToolResultMismatch = true;
|
|
415
|
+
if (sharedSession) {
|
|
416
|
+
sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
|
|
417
|
+
}
|
|
418
|
+
const toolNameSummary = compactToolNameSummary(progress.toolNames);
|
|
419
|
+
diagDump("tool_result_delivery_mismatch", {
|
|
420
|
+
reason,
|
|
421
|
+
cwd,
|
|
422
|
+
progress,
|
|
423
|
+
activeQueryExists: queryCtx.activeQuery !== null,
|
|
424
|
+
sharedSession: sharedSession ? {
|
|
425
|
+
sessionId: sharedSession.sessionId.slice(0, 8),
|
|
426
|
+
cursor: sharedSession.cursor,
|
|
427
|
+
needsRebuild: sharedSession.needsRebuild === true,
|
|
428
|
+
forceRotate: sharedSession.forceRotate === true,
|
|
429
|
+
} : null,
|
|
430
|
+
});
|
|
431
|
+
safeNotify(
|
|
432
|
+
`Claude bridge: tool result delivery interrupted during ${reason}; ` +
|
|
433
|
+
`delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
|
|
434
|
+
`waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
|
|
435
|
+
`${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
|
|
436
|
+
`Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
|
|
437
|
+
"error",
|
|
438
|
+
);
|
|
439
|
+
return true;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
debug("reportToolResultMismatch failed:", error);
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
|
|
447
|
+
if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
|
|
448
|
+
if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
|
|
452
|
+
return { sharedSession };
|
|
349
453
|
}
|
|
350
454
|
|
|
351
455
|
// --- Constants ---
|
|
@@ -426,6 +530,140 @@ let extensionApi: ExtensionAPI | undefined;
|
|
|
426
530
|
let piUI: ExtensionUIContext | undefined;
|
|
427
531
|
let extraUsageHelperInFlight: Promise<string> | null = null;
|
|
428
532
|
|
|
533
|
+
const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
|
|
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
|
+
}
|
|
666
|
+
|
|
429
667
|
export function isExtraUsageRequiredMessage(value: unknown): boolean {
|
|
430
668
|
let text: string;
|
|
431
669
|
if (typeof value === "string") text = value;
|
|
@@ -437,6 +675,64 @@ export function isExtraUsageRequiredMessage(value: unknown): boolean {
|
|
|
437
675
|
return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
|
|
438
676
|
}
|
|
439
677
|
|
|
678
|
+
export function uniqueNonEmptyLines(values: unknown[]): string[] {
|
|
679
|
+
const seen = new Set<string>();
|
|
680
|
+
const out: string[] = [];
|
|
681
|
+
for (const value of values) {
|
|
682
|
+
const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
|
|
683
|
+
if (!text || seen.has(text)) continue;
|
|
684
|
+
seen.add(text);
|
|
685
|
+
out.push(text);
|
|
686
|
+
}
|
|
687
|
+
return out;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function formatResetTimestamp(value: unknown): string {
|
|
691
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
|
|
692
|
+
if (!Number.isFinite(parsed)) return "unknown";
|
|
693
|
+
return new Date(parsed).toLocaleString(undefined, {
|
|
694
|
+
day: "numeric",
|
|
695
|
+
hour: "numeric",
|
|
696
|
+
minute: "2-digit",
|
|
697
|
+
month: "short",
|
|
698
|
+
second: "2-digit",
|
|
699
|
+
timeZoneName: "short",
|
|
700
|
+
year: "numeric",
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export const ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD = 80;
|
|
705
|
+
|
|
706
|
+
export function normalizeRateLimitUtilization(value: unknown): number | undefined {
|
|
707
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
|
|
708
|
+
if (value === 0) return 0;
|
|
709
|
+
// Claude SDK payloads have appeared as both fractions and percentages.
|
|
710
|
+
// Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
|
|
711
|
+
if (value > 0 && value < 1) return value * 100;
|
|
712
|
+
if (value > 1 && value <= 100) return value;
|
|
713
|
+
return undefined;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function rateLimitTypeLabel(value: unknown): string {
|
|
717
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
718
|
+
return text || "unknown";
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function formatAllowedRateLimitWarning(info: { status?: unknown; utilization?: unknown; rateLimitType?: unknown } | null | undefined): string | undefined {
|
|
722
|
+
if (info?.status !== "allowed_warning") return undefined;
|
|
723
|
+
const utilization = normalizeRateLimitUtilization(info.utilization);
|
|
724
|
+
if (utilization === undefined || utilization < ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD) return undefined;
|
|
725
|
+
return `Claude rate limit warning: nearing ${rateLimitTypeLabel(info.rateLimitType)} limit; check Claude Code /usage for exact utilization.`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function emitRateLimitEvent(payload: Record<string, unknown>): void {
|
|
729
|
+
try {
|
|
730
|
+
extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
|
|
731
|
+
} catch {
|
|
732
|
+
// Cross-extension broker is best-effort only.
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
440
736
|
function extraUsageAllowed(config: Config): boolean {
|
|
441
737
|
return config.provider?.allowExtraUsage === true;
|
|
442
738
|
}
|
|
@@ -641,6 +937,7 @@ function convertAndImportMessages(
|
|
|
641
937
|
session: ReturnType<typeof createSession>,
|
|
642
938
|
messages: Context["messages"],
|
|
643
939
|
customToolNameToSdk?: Map<string, string>,
|
|
940
|
+
cwd?: string,
|
|
644
941
|
): void {
|
|
645
942
|
const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
|
|
646
943
|
|
|
@@ -656,7 +953,17 @@ function convertAndImportMessages(
|
|
|
656
953
|
[...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
|
|
657
954
|
}
|
|
658
955
|
// Pre-repair for debug logging; importMessages also repairs internally (idempotent).
|
|
956
|
+
const missingToolResults = findUnpairedToolUses(anthropicMessages);
|
|
659
957
|
const repaired = repairToolPairing(anthropicMessages);
|
|
958
|
+
if (missingToolResults.length > 0) {
|
|
959
|
+
reportSyntheticToolResultRepair(missingToolResults, {
|
|
960
|
+
cwd,
|
|
961
|
+
messageCount: messages.length,
|
|
962
|
+
anthropicMessageCount: anthropicMessages.length,
|
|
963
|
+
sessionId: session.sessionId,
|
|
964
|
+
jsonlPath: session.jsonlPath,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
660
967
|
if (repaired.length !== anthropicMessages.length) {
|
|
661
968
|
debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
|
|
662
969
|
}
|
|
@@ -854,7 +1161,7 @@ function syncSharedSession(
|
|
|
854
1161
|
...(preserveId ? { sessionId: previousSessionId } : {}),
|
|
855
1162
|
...(modelId ? { model: modelId } : {}),
|
|
856
1163
|
});
|
|
857
|
-
convertAndImportMessages(session, priorMessages, customToolNameToSdk);
|
|
1164
|
+
convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
|
|
858
1165
|
session.save();
|
|
859
1166
|
verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
|
|
860
1167
|
sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
|
|
@@ -954,28 +1261,50 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
|
954
1261
|
|
|
955
1262
|
// Creates an MCP server that bridges pi tools to the SDK. Each tool handler
|
|
956
1263
|
// blocks on a Promise until pi delivers the tool result via streamSimple.
|
|
957
|
-
// Handlers
|
|
958
|
-
//
|
|
959
|
-
// Handlers close over the captured `queryCtx`, ensuring they
|
|
960
|
-
// correct query's state even across pushContext/popContext calls.
|
|
1264
|
+
// Handlers claim their tool_call id by matching the actual MCP call
|
|
1265
|
+
// (tool name + arguments) against the recorded tool_use blocks, then results
|
|
1266
|
+
// are matched by ID. Handlers close over the captured `queryCtx`, ensuring they
|
|
1267
|
+
// operate on the correct query's state even across pushContext/popContext calls.
|
|
961
1268
|
function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createSdkMcpServer>> | undefined {
|
|
962
1269
|
if (!tools.length) return undefined;
|
|
963
1270
|
const mcpTools = tools.map((tool) => ({
|
|
964
1271
|
name: tool.name,
|
|
965
1272
|
description: tool.description,
|
|
966
1273
|
inputSchema: jsonSchemaToZodShape(tool.parameters),
|
|
967
|
-
handler: async () => {
|
|
968
|
-
const
|
|
969
|
-
|
|
1274
|
+
handler: async (args?: Record<string, unknown>) => {
|
|
1275
|
+
const mappedArgs = mapToolArgs(tool.name, args);
|
|
1276
|
+
const claim = queryCtx.claimToolCall(tool.name, mappedArgs);
|
|
1277
|
+
const toolCallId = claim.toolCallId;
|
|
1278
|
+
if (!toolCallId) {
|
|
1279
|
+
debug(`WARNING: mcp handler ${tool.name} has no toolCallId (available=${claim.available})`);
|
|
1280
|
+
diagDump("tool_handler_unmatched", {
|
|
1281
|
+
toolName: tool.name,
|
|
1282
|
+
argKeys: argKeys(mappedArgs),
|
|
1283
|
+
available: claim.available,
|
|
1284
|
+
turnToolCallIds: queryCtx.turnToolCallIds,
|
|
1285
|
+
turnToolCalls: safeToolCallSummary(queryCtx.turnToolCalls),
|
|
1286
|
+
});
|
|
1287
|
+
return { content: [{ type: "text", text: `Claude bridge internal error: no matching tool_call id for ${tool.name}` }], isError: true } satisfies McpResult;
|
|
1288
|
+
}
|
|
1289
|
+
if (claim.match !== "tool-args" || claim.ambiguous) {
|
|
1290
|
+
debug(`mcp handler: ${tool.name} [${toolCallId}] claimed by ${claim.match}${claim.ambiguous ? " (ambiguous)" : ""}`);
|
|
1291
|
+
}
|
|
970
1292
|
if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
|
|
971
1293
|
const result = queryCtx.pendingResults.get(toolCallId)!;
|
|
972
1294
|
queryCtx.pendingResults.delete(toolCallId);
|
|
1295
|
+
queryCtx.markToolResultResolved(toolCallId);
|
|
973
1296
|
debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
|
|
974
1297
|
return result;
|
|
975
1298
|
}
|
|
976
1299
|
debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
|
|
977
1300
|
return new Promise<McpResult>((resolve) => {
|
|
978
|
-
queryCtx.pendingToolCalls.set(toolCallId, {
|
|
1301
|
+
queryCtx.pendingToolCalls.set(toolCallId, {
|
|
1302
|
+
toolName: tool.name,
|
|
1303
|
+
resolve: (result) => {
|
|
1304
|
+
queryCtx.markToolResultResolved(toolCallId);
|
|
1305
|
+
resolve(result);
|
|
1306
|
+
},
|
|
1307
|
+
});
|
|
979
1308
|
});
|
|
980
1309
|
},
|
|
981
1310
|
}));
|
|
@@ -1004,6 +1333,26 @@ const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
|
|
|
1004
1333
|
minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
|
|
1005
1334
|
};
|
|
1006
1335
|
|
|
1336
|
+
function normalizeEffortOverrideModelKey(value: string): string {
|
|
1337
|
+
const key = value.trim().toLowerCase();
|
|
1338
|
+
return key.startsWith(`${PROVIDER_ID}/`) ? key.slice(PROVIDER_ID.length + 1) : key;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
export function resolveConfiguredEffort(
|
|
1342
|
+
modelId: string,
|
|
1343
|
+
reasoningEffort: EffortLevel | undefined,
|
|
1344
|
+
providerConfig?: Config["provider"],
|
|
1345
|
+
): EffortLevel | undefined {
|
|
1346
|
+
const target = normalizeEffortOverrideModelKey(modelId);
|
|
1347
|
+
for (const [key, rawEffort] of Object.entries(providerConfig?.modelEffortOverrides ?? {})) {
|
|
1348
|
+
const normalizedKey = normalizeEffortOverrideModelKey(key);
|
|
1349
|
+
if (normalizedKey !== "*" && normalizedKey !== target) continue;
|
|
1350
|
+
const effort = normalizeEffortLevel(rawEffort) as EffortLevel | undefined;
|
|
1351
|
+
if (effort) return effort;
|
|
1352
|
+
}
|
|
1353
|
+
return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1007
1356
|
// --- Provider helpers: misc ---
|
|
1008
1357
|
|
|
1009
1358
|
function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
|
|
@@ -1054,24 +1403,28 @@ function finalizeCurrentStream(stopReason?: string): void {
|
|
|
1054
1403
|
|
|
1055
1404
|
/** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
|
|
1056
1405
|
* On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
|
|
1057
|
-
function processStreamEvent(
|
|
1406
|
+
export function processStreamEvent(
|
|
1058
1407
|
message: SDKMessage,
|
|
1059
1408
|
customToolNameToPi: Map<string, string>,
|
|
1060
1409
|
model: Model<any>,
|
|
1061
1410
|
): void {
|
|
1062
1411
|
const c = ctx();
|
|
1063
1412
|
if (!c.currentPiStream || !c.turnOutput) return;
|
|
1064
|
-
c.turnSawStreamEvent = true;
|
|
1065
1413
|
const event = (message as SDKMessage & { event: any }).event;
|
|
1414
|
+
if (event?.type === "ping") return;
|
|
1415
|
+
if (event?.type === "message_stop" && !c.turnSawToolCall) {
|
|
1416
|
+
debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1066
1419
|
|
|
1067
1420
|
if (event?.type === "message_start") {
|
|
1068
|
-
c.
|
|
1069
|
-
c.nextHandlerIdx = 0;
|
|
1421
|
+
c.resetToolTracking();
|
|
1070
1422
|
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
|
|
1071
1423
|
return;
|
|
1072
1424
|
}
|
|
1073
1425
|
|
|
1074
1426
|
if (event?.type === "content_block_start") {
|
|
1427
|
+
c.turnSawStreamEvent = true;
|
|
1075
1428
|
ensureTurnStarted();
|
|
1076
1429
|
if (event.content_block?.type === "text") {
|
|
1077
1430
|
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
@@ -1081,10 +1434,11 @@ function processStreamEvent(
|
|
|
1081
1434
|
c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
1082
1435
|
} else if (event.content_block?.type === "tool_use") {
|
|
1083
1436
|
c.turnSawToolCall = true;
|
|
1084
|
-
|
|
1437
|
+
const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
|
|
1438
|
+
c.recordToolCall(event.content_block.id, mappedName, {});
|
|
1085
1439
|
c.turnBlocks.push({
|
|
1086
1440
|
type: "toolCall", id: event.content_block.id,
|
|
1087
|
-
name:
|
|
1441
|
+
name: mappedName,
|
|
1088
1442
|
arguments: (event.content_block.input as Record<string, unknown>) ?? {},
|
|
1089
1443
|
partialJson: "", index: event.index,
|
|
1090
1444
|
});
|
|
@@ -1098,7 +1452,11 @@ function processStreamEvent(
|
|
|
1098
1452
|
if (event?.type === "content_block_delta") {
|
|
1099
1453
|
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
1100
1454
|
const block = c.turnBlocks[index];
|
|
1101
|
-
if (!block)
|
|
1455
|
+
if (!block) {
|
|
1456
|
+
debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
c.turnSawStreamEvent = true;
|
|
1102
1460
|
if (event.delta?.type === "text_delta" && block.type === "text") {
|
|
1103
1461
|
block.text += event.delta.text;
|
|
1104
1462
|
c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
|
|
@@ -1120,7 +1478,11 @@ function processStreamEvent(
|
|
|
1120
1478
|
if (event?.type === "content_block_stop") {
|
|
1121
1479
|
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
1122
1480
|
const block = c.turnBlocks[index];
|
|
1123
|
-
if (!block)
|
|
1481
|
+
if (!block) {
|
|
1482
|
+
debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
c.turnSawStreamEvent = true;
|
|
1124
1486
|
delete block.index;
|
|
1125
1487
|
if (block.type === "text") {
|
|
1126
1488
|
c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
|
|
@@ -1131,6 +1493,7 @@ function processStreamEvent(
|
|
|
1131
1493
|
block.arguments = mapToolArgs(
|
|
1132
1494
|
block.name, parsePartialJson(block.partialJson, block.arguments),
|
|
1133
1495
|
);
|
|
1496
|
+
c.updateToolCallArgs(block.id, block.arguments);
|
|
1134
1497
|
delete block.partialJson;
|
|
1135
1498
|
c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
|
|
1136
1499
|
}
|
|
@@ -1169,13 +1532,73 @@ function processStreamEvent(
|
|
|
1169
1532
|
// arrives before any stream_events, this is the primary content path. Must maintain
|
|
1170
1533
|
// the same stream lifecycle as processStreamEvent — including ending the stream on
|
|
1171
1534
|
// tool_use to prevent deadlock with the MCP handler.
|
|
1172
|
-
function
|
|
1535
|
+
function appendMissingToolUsesFromAssistant(
|
|
1536
|
+
assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
|
|
1537
|
+
model: Model<any>,
|
|
1538
|
+
customToolNameToPi: Map<string, string>,
|
|
1539
|
+
): boolean {
|
|
1540
|
+
const c = ctx();
|
|
1541
|
+
if (!assistantMsg?.content) return false;
|
|
1542
|
+
let sawToolUse = false;
|
|
1543
|
+
for (const block of assistantMsg.content) {
|
|
1544
|
+
if (block.type !== "tool_use") continue;
|
|
1545
|
+
sawToolUse = true;
|
|
1546
|
+
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
1547
|
+
const name = mapToolName(block.name, customToolNameToPi);
|
|
1548
|
+
const mappedArgs = mapToolArgs(name, block.input);
|
|
1549
|
+
c.recordToolCall(block.id, name, mappedArgs);
|
|
1550
|
+
if (existingIdx >= 0) {
|
|
1551
|
+
const existing = c.turnBlocks[existingIdx] as any;
|
|
1552
|
+
existing.name = name;
|
|
1553
|
+
existing.arguments = mappedArgs;
|
|
1554
|
+
c.updateToolCallArgs(block.id, mappedArgs);
|
|
1555
|
+
if ("partialJson" in existing) {
|
|
1556
|
+
delete existing.partialJson;
|
|
1557
|
+
delete existing.index;
|
|
1558
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
|
|
1559
|
+
}
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
ensureTurnStarted();
|
|
1564
|
+
c.turnBlocks.push({
|
|
1565
|
+
type: "toolCall", id: block.id,
|
|
1566
|
+
name,
|
|
1567
|
+
arguments: mappedArgs,
|
|
1568
|
+
});
|
|
1569
|
+
const idx = c.turnBlocks.length - 1;
|
|
1570
|
+
const toolBlock = c.turnBlocks[idx];
|
|
1571
|
+
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
1572
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
1573
|
+
}
|
|
1574
|
+
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
1575
|
+
return sawToolUse;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
|
|
1173
1579
|
const c = ctx();
|
|
1174
|
-
if (c.turnSawStreamEvent) return;
|
|
1175
1580
|
const assistantMsg = (message as any).message;
|
|
1176
1581
|
if (!assistantMsg?.content) return;
|
|
1177
|
-
c.
|
|
1178
|
-
|
|
1582
|
+
if (c.turnSawStreamEvent) {
|
|
1583
|
+
// Claude Agent SDK can yield the completed assistant message before (or
|
|
1584
|
+
// instead of) a stream_event message_stop for a tool-use turn. Treat that
|
|
1585
|
+
// assistant message as a hard turn boundary so Pi executes the tool calls
|
|
1586
|
+
// and the MCP handlers stay blocked until real tool results are delivered.
|
|
1587
|
+
// Without this fallback, Claude Code can continue internally with empty MCP
|
|
1588
|
+
// results and Pi only sees the real outputs one render cycle later.
|
|
1589
|
+
if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
|
|
1590
|
+
c.turnSawToolCall = true;
|
|
1591
|
+
if (c.currentPiStream && c.turnOutput) {
|
|
1592
|
+
c.turnOutput.stopReason = "toolUse";
|
|
1593
|
+
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
1594
|
+
c.currentPiStream.end();
|
|
1595
|
+
c.currentPiStream = null;
|
|
1596
|
+
debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
c.resetToolTracking();
|
|
1179
1602
|
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
|
|
1180
1603
|
for (const block of assistantMsg.content) {
|
|
1181
1604
|
if (block.type === "text" && block.text) {
|
|
@@ -1195,11 +1618,12 @@ function processAssistantMessage(message: SDKMessage, model: Model<any>, customT
|
|
|
1195
1618
|
} else if (block.type === "tool_use") {
|
|
1196
1619
|
ensureTurnStarted();
|
|
1197
1620
|
c.turnSawToolCall = true;
|
|
1198
|
-
|
|
1199
|
-
const mappedArgs = mapToolArgs(
|
|
1621
|
+
const mappedName = mapToolName(block.name, customToolNameToPi);
|
|
1622
|
+
const mappedArgs = mapToolArgs(mappedName, block.input);
|
|
1623
|
+
c.recordToolCall(block.id, mappedName, mappedArgs);
|
|
1200
1624
|
c.turnBlocks.push({
|
|
1201
1625
|
type: "toolCall", id: block.id,
|
|
1202
|
-
name:
|
|
1626
|
+
name: mappedName,
|
|
1203
1627
|
arguments: mappedArgs,
|
|
1204
1628
|
});
|
|
1205
1629
|
const idx = c.turnBlocks.length - 1;
|
|
@@ -1238,7 +1662,10 @@ async function consumeQuery(
|
|
|
1238
1662
|
|
|
1239
1663
|
for await (const message of sdkQuery) {
|
|
1240
1664
|
if (wasAborted()) break;
|
|
1241
|
-
|
|
1665
|
+
const queryCtx = ctx();
|
|
1666
|
+
activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
|
|
1667
|
+
if (!queryCtx.turnOutput) continue;
|
|
1668
|
+
if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
|
|
1242
1669
|
|
|
1243
1670
|
switch (message.type) {
|
|
1244
1671
|
case "stream_event":
|
|
@@ -1257,8 +1684,10 @@ async function consumeQuery(
|
|
|
1257
1684
|
ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
|
|
1258
1685
|
ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
|
|
1259
1686
|
} else if (message.subtype !== "success" && isExtraUsageRequiredMessage(message)) {
|
|
1260
|
-
const
|
|
1687
|
+
const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
|
|
1688
|
+
const errors = errorLines.length > 0 ? errorLines.join("\n") : String(message.subtype ?? "Claude Code rate limit");
|
|
1261
1689
|
const openedExtraUsage = launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
|
|
1690
|
+
ctx().handledTerminalError = true;
|
|
1262
1691
|
ctx().turnOutput.stopReason = "error";
|
|
1263
1692
|
ctx().turnOutput.errorMessage = `${errors}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : "\n\nRun /claude-bridge:extra, or enable Allow extra usage helper in settings."}`;
|
|
1264
1693
|
ctx().currentPiStream?.push({ type: "error", reason: "error", error: ctx().turnOutput });
|
|
@@ -1277,12 +1706,25 @@ async function consumeQuery(
|
|
|
1277
1706
|
const info = (message as any).rate_limit_info;
|
|
1278
1707
|
debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
1279
1708
|
if (info?.status === "rejected") {
|
|
1280
|
-
const resetsAt =
|
|
1709
|
+
const resetsAt = formatResetTimestamp(info.resetsAt);
|
|
1710
|
+
const resetAtMs = typeof info.resetsAt === "string" ? Date.parse(info.resetsAt) : undefined;
|
|
1281
1711
|
const reason = `${info.rateLimitType ?? "unknown"} rate limit`;
|
|
1282
1712
|
const launchedExtraUsage = isExtraUsageRequiredMessage(info) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, reason);
|
|
1283
|
-
|
|
1713
|
+
emitRateLimitEvent({
|
|
1714
|
+
model: model.id,
|
|
1715
|
+
provider: PROVIDER_ID,
|
|
1716
|
+
rateLimitType: info.rateLimitType,
|
|
1717
|
+
reason,
|
|
1718
|
+
resetAt: info.resetsAt,
|
|
1719
|
+
...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
|
|
1720
|
+
source: "claude-bridge",
|
|
1721
|
+
status: "rejected",
|
|
1722
|
+
});
|
|
1723
|
+
piUI?.notify(`${RATE_LIMIT_TOKEN} Claude ${reason} hit — resets ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
|
|
1284
1724
|
} else if (info?.status === "allowed_warning") {
|
|
1285
|
-
|
|
1725
|
+
const warning = formatAllowedRateLimitWarning(info);
|
|
1726
|
+
if (warning) piUI?.notify(warning, "warning");
|
|
1727
|
+
else debug("consumeQuery: suppressed low/ambiguous allowed_warning rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
1286
1728
|
}
|
|
1287
1729
|
break;
|
|
1288
1730
|
}
|
|
@@ -1305,6 +1747,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1305
1747
|
|
|
1306
1748
|
// DEBUG: trace followUp message triggering
|
|
1307
1749
|
const lastMsgRole = context.messages[context.messages.length - 1]?.role;
|
|
1750
|
+
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
1308
1751
|
debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
|
|
1309
1752
|
|
|
1310
1753
|
// --- Tool result delivery ---
|
|
@@ -1312,30 +1755,49 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1312
1755
|
// (everything after the last assistant message) and match against waiting MCP
|
|
1313
1756
|
// handlers. Results that arrive before their handler get queued in pendingResults.
|
|
1314
1757
|
if (ctx().activeQuery) {
|
|
1315
|
-
ctx()
|
|
1316
|
-
|
|
1758
|
+
const queryCtx = ctx();
|
|
1759
|
+
queryCtx.currentPiStream = stream;
|
|
1760
|
+
queryCtx.resetTurnState(model);
|
|
1761
|
+
activeStreamIdleWatchdogs.get(queryCtx)?.refresh();
|
|
1317
1762
|
const allResults = extractAllToolResults(context);
|
|
1318
|
-
debug(`provider: tool results, ${allResults.length} results, ${
|
|
1763
|
+
debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
|
|
1764
|
+
const unmatchedResultIds: string[] = [];
|
|
1319
1765
|
for (const result of allResults) {
|
|
1320
1766
|
const id = result.toolCallId;
|
|
1321
|
-
if (id &&
|
|
1322
|
-
|
|
1323
|
-
|
|
1767
|
+
if (id && !queryCtx.hasRecordedToolCall(id)) {
|
|
1768
|
+
queryCtx.markToolResultUnmatched(id);
|
|
1769
|
+
unmatchedResultIds.push(id);
|
|
1770
|
+
debug(`ERROR: tool result [${id}] has no registered tool_call id; refusing to queue or deliver`);
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
queryCtx.markToolResultDelivered(id);
|
|
1774
|
+
if (id && queryCtx.pendingToolCalls.has(id)) {
|
|
1775
|
+
const pending = queryCtx.pendingToolCalls.get(id)!;
|
|
1776
|
+
queryCtx.pendingToolCalls.delete(id);
|
|
1324
1777
|
debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""}`, JSON.stringify(result.content).slice(0, 200));
|
|
1325
1778
|
pending.resolve(result);
|
|
1326
1779
|
} else if (id) {
|
|
1327
|
-
|
|
1328
|
-
debug(`provider: queued result [${id}] (${
|
|
1780
|
+
queryCtx.pendingResults.set(id, result);
|
|
1781
|
+
debug(`provider: queued result [${id}] (${queryCtx.pendingResults.size} pending)`);
|
|
1329
1782
|
} else {
|
|
1330
1783
|
debug(`WARNING: tool result without toolCallId, cannot match`);
|
|
1331
1784
|
}
|
|
1332
|
-
if (
|
|
1333
|
-
debug(`BUG: both maps non-empty! handlers=${
|
|
1785
|
+
if (queryCtx.pendingToolCalls.size > 0 && queryCtx.pendingResults.size > 0) {
|
|
1786
|
+
debug(`BUG: both maps non-empty! handlers=${queryCtx.pendingToolCalls.size} results=${queryCtx.pendingResults.size}`);
|
|
1334
1787
|
}
|
|
1335
1788
|
}
|
|
1336
|
-
if (
|
|
1337
|
-
|
|
1338
|
-
|
|
1789
|
+
if (unmatchedResultIds.length > 0) {
|
|
1790
|
+
const errorResult: McpResult = {
|
|
1791
|
+
content: [{ type: "text", text: `Claude bridge internal error: ${unmatchedResultIds.length} tool result(s) did not match any registered tool_call id. The turn was stopped to avoid delivering tool output to the wrong call. Unmatched ids: ${unmatchedResultIds.slice(0, 8).join(", ")}${unmatchedResultIds.length > 8 ? ", ..." : ""}` }],
|
|
1792
|
+
isError: true,
|
|
1793
|
+
};
|
|
1794
|
+
for (const pending of queryCtx.pendingToolCalls.values()) pending.resolve(errorResult);
|
|
1795
|
+
queryCtx.pendingToolCalls.clear();
|
|
1796
|
+
reportToolResultMismatch(queryCtx, "unmatched tool result", cwd);
|
|
1797
|
+
}
|
|
1798
|
+
if (queryCtx.pendingToolCalls.size > 0) {
|
|
1799
|
+
debug(`WARNING: ${queryCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
|
|
1800
|
+
piUI?.notify(`Claude bridge: ${queryCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
|
|
1339
1801
|
}
|
|
1340
1802
|
|
|
1341
1803
|
// Detect user messages (steer/followUp) that pi injected into context
|
|
@@ -1355,7 +1817,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1355
1817
|
}
|
|
1356
1818
|
|
|
1357
1819
|
if (sharedSession) sharedSession.cursor = context.messages.length;
|
|
1358
|
-
|
|
1820
|
+
queryCtx.latestCursor = Math.max(queryCtx.latestCursor, context.messages.length);
|
|
1359
1821
|
return stream;
|
|
1360
1822
|
}
|
|
1361
1823
|
|
|
@@ -1389,10 +1851,10 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1389
1851
|
ctx().pendingResults.clear();
|
|
1390
1852
|
ctx().deferredUserMessages = [];
|
|
1391
1853
|
ctx().resetTurnState(model);
|
|
1854
|
+
ctx().resetToolTracking();
|
|
1392
1855
|
ctx().latestCursor = 0;
|
|
1393
1856
|
|
|
1394
1857
|
const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
|
|
1395
|
-
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
1396
1858
|
const promptBlocks = extractUserPromptBlocks(context.messages);
|
|
1397
1859
|
let promptText = extractUserPrompt(context.messages) ?? "";
|
|
1398
1860
|
|
|
@@ -1440,10 +1902,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1440
1902
|
// Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
|
|
1441
1903
|
// per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
|
|
1442
1904
|
// Fall back to our generic table for older pi-ai or unmapped levels.
|
|
1443
|
-
const
|
|
1905
|
+
const requestedEffort = options?.reasoning
|
|
1444
1906
|
? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
|
|
1445
1907
|
?? REASONING_TO_EFFORT[options.reasoning]
|
|
1446
1908
|
: undefined;
|
|
1909
|
+
const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
|
|
1447
1910
|
|
|
1448
1911
|
const extraArgs: Record<string, string | null> = { model: model.id };
|
|
1449
1912
|
if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
|
|
@@ -1468,6 +1931,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1468
1931
|
...CLAUDE_BRIDGE_TOOL_ISOLATION,
|
|
1469
1932
|
permissionMode: "bypassPermissions",
|
|
1470
1933
|
includePartialMessages: true,
|
|
1934
|
+
...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
|
|
1471
1935
|
systemPrompt: {
|
|
1472
1936
|
type: "preset", preset: "claude_code",
|
|
1473
1937
|
append: systemPromptAppend ? systemPromptAppend : undefined,
|
|
@@ -1485,12 +1949,13 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1485
1949
|
debug("provider: fresh query",
|
|
1486
1950
|
`model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
|
|
1487
1951
|
`resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
|
|
1488
|
-
`appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled}`,
|
|
1952
|
+
`appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
|
|
1489
1953
|
`claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
|
|
1490
1954
|
`prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
|
|
1491
1955
|
|
|
1492
1956
|
// 3. Start SDK query and claim it for this context
|
|
1493
1957
|
let wasAborted = false;
|
|
1958
|
+
let streamIdleTimedOut = false;
|
|
1494
1959
|
const sdkQuery = query({ prompt, options: queryOptions });
|
|
1495
1960
|
ctx().activeQuery = sdkQuery;
|
|
1496
1961
|
|
|
@@ -1503,10 +1968,62 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1503
1968
|
void sdkQuery.interrupt().catch(() => {});
|
|
1504
1969
|
try { sdkQuery.close(); } catch {}
|
|
1505
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
|
+
}
|
|
1506
2022
|
const onAbort = () => {
|
|
1507
2023
|
wasAborted = true;
|
|
1508
2024
|
// Prevent stale deferred messages from being replayed by parent on pop
|
|
1509
2025
|
abortCtx.deferredUserMessages = [];
|
|
2026
|
+
reportToolResultMismatch(abortCtx, "abort", cwd, { forceRotate: true });
|
|
1510
2027
|
for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
|
|
1511
2028
|
abortCtx.pendingToolCalls.clear();
|
|
1512
2029
|
abortCtx.pendingResults.clear();
|
|
@@ -1521,6 +2038,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1521
2038
|
consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
|
|
1522
2039
|
.then(async ({ capturedSessionId }) => {
|
|
1523
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
|
+
}
|
|
1524
2046
|
|
|
1525
2047
|
// --- Abort detection in normal completion path ---
|
|
1526
2048
|
if (wasAborted || options?.signal?.aborted) {
|
|
@@ -1553,6 +2075,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1553
2075
|
const steerPrompt = ctx().deferredUserMessages.shift()!;
|
|
1554
2076
|
debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
|
|
1555
2077
|
ctx().resetTurnState(model);
|
|
2078
|
+
ctx().resetToolTracking();
|
|
1556
2079
|
|
|
1557
2080
|
const resumeId = sharedSession?.sessionId;
|
|
1558
2081
|
if (!resumeId) {
|
|
@@ -1588,13 +2111,18 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1588
2111
|
})
|
|
1589
2112
|
.catch((error) => {
|
|
1590
2113
|
debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
|
|
1591
|
-
const
|
|
2114
|
+
const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
|
|
2115
|
+
const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
|
|
1592
2116
|
if ((wasAborted || options?.signal?.aborted) && sharedSession) {
|
|
1593
2117
|
sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
1594
2118
|
} else {
|
|
1595
2119
|
sharedSession = null;
|
|
1596
2120
|
}
|
|
1597
2121
|
ctx().deferredUserMessages = [];
|
|
2122
|
+
if (suppressDuplicateError) {
|
|
2123
|
+
debug("provider: suppressing duplicate query error after terminal error was already emitted");
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
1598
2126
|
if (ctx().turnOutput) {
|
|
1599
2127
|
ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
1600
2128
|
ctx().turnOutput.errorMessage = `${error instanceof Error ? error.message : String(error)}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : ""}`;
|
|
@@ -1604,8 +2132,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1604
2132
|
ctx().currentPiStream = null;
|
|
1605
2133
|
})
|
|
1606
2134
|
.finally(() => {
|
|
2135
|
+
streamIdleWatchdog?.dispose();
|
|
2136
|
+
activeStreamIdleWatchdogs.delete(abortCtx);
|
|
1607
2137
|
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
|
1608
2138
|
if (ctx().activeQuery === sdkQuery) {
|
|
2139
|
+
reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted || streamIdleTimedOut });
|
|
1609
2140
|
// Drain pending handlers for this query
|
|
1610
2141
|
for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
|
|
1611
2142
|
ctx().pendingToolCalls.clear();
|
|
@@ -1741,6 +2272,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1741
2272
|
// triggers CC's autocompact-thrashing guard (issue #8). Force the next
|
|
1742
2273
|
// call down the REBUILD path so CC sees the current history.
|
|
1743
2274
|
const markRebuild = (event: string) => {
|
|
2275
|
+
if (ctx().activeQuery) {
|
|
2276
|
+
reportToolResultMismatch(ctx(), event, sharedSession?.cwd ?? process.cwd());
|
|
2277
|
+
}
|
|
1744
2278
|
if (sharedSession) {
|
|
1745
2279
|
debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
|
|
1746
2280
|
sharedSession = { ...sharedSession, needsRebuild: true };
|