@vanillagreen/pi-claude-bridge 1.1.4 → 1.3.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 +27 -1
- package/bundle/index.js +15454 -11932
- package/package.json +47 -3
- package/src/config.ts +70 -1
- package/src/convert.ts +77 -20
- package/src/index.ts +554 -60
- 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 ---
|
|
@@ -365,6 +469,7 @@ function diagDump(label: string, data: Record<string, unknown>) {
|
|
|
365
469
|
// On session_shutdown (including /reload), clearSession() resets this so a fresh
|
|
366
470
|
// registration can occur for the next session.
|
|
367
471
|
const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
|
|
472
|
+
const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
|
|
368
473
|
|
|
369
474
|
const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
|
|
370
475
|
read: "read", write: "write", edit: "edit", bash: "bash",
|
|
@@ -422,6 +527,143 @@ interface SessionState {
|
|
|
422
527
|
|
|
423
528
|
let sharedSession: SessionState | null = null;
|
|
424
529
|
let extensionApi: ExtensionAPI | undefined;
|
|
530
|
+
let piUI: ExtensionUIContext | undefined;
|
|
531
|
+
let extraUsageHelperInFlight: Promise<string> | null = null;
|
|
532
|
+
|
|
533
|
+
const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
|
|
534
|
+
const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
|
|
535
|
+
|
|
536
|
+
export function isExtraUsageRequiredMessage(value: unknown): boolean {
|
|
537
|
+
let text: string;
|
|
538
|
+
if (typeof value === "string") text = value;
|
|
539
|
+
else if (value instanceof Error) text = value.message;
|
|
540
|
+
else {
|
|
541
|
+
try { text = JSON.stringify(value ?? ""); }
|
|
542
|
+
catch { text = String(value); }
|
|
543
|
+
}
|
|
544
|
+
return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function uniqueNonEmptyLines(values: unknown[]): string[] {
|
|
548
|
+
const seen = new Set<string>();
|
|
549
|
+
const out: string[] = [];
|
|
550
|
+
for (const value of values) {
|
|
551
|
+
const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
|
|
552
|
+
if (!text || seen.has(text)) continue;
|
|
553
|
+
seen.add(text);
|
|
554
|
+
out.push(text);
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function formatResetTimestamp(value: unknown): string {
|
|
560
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
|
|
561
|
+
if (!Number.isFinite(parsed)) return "unknown";
|
|
562
|
+
return new Date(parsed).toLocaleString(undefined, {
|
|
563
|
+
day: "numeric",
|
|
564
|
+
hour: "numeric",
|
|
565
|
+
minute: "2-digit",
|
|
566
|
+
month: "short",
|
|
567
|
+
second: "2-digit",
|
|
568
|
+
timeZoneName: "short",
|
|
569
|
+
year: "numeric",
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export const ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD = 80;
|
|
574
|
+
|
|
575
|
+
export function normalizeRateLimitUtilization(value: unknown): number | undefined {
|
|
576
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
|
|
577
|
+
if (value === 0) return 0;
|
|
578
|
+
// Claude SDK payloads have appeared as both fractions and percentages.
|
|
579
|
+
// Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
|
|
580
|
+
if (value > 0 && value < 1) return value * 100;
|
|
581
|
+
if (value > 1 && value <= 100) return value;
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function rateLimitTypeLabel(value: unknown): string {
|
|
586
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
587
|
+
return text || "unknown";
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export function formatAllowedRateLimitWarning(info: { status?: unknown; utilization?: unknown; rateLimitType?: unknown } | null | undefined): string | undefined {
|
|
591
|
+
if (info?.status !== "allowed_warning") return undefined;
|
|
592
|
+
const utilization = normalizeRateLimitUtilization(info.utilization);
|
|
593
|
+
if (utilization === undefined || utilization < ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD) return undefined;
|
|
594
|
+
return `Claude rate limit warning: nearing ${rateLimitTypeLabel(info.rateLimitType)} limit; check Claude Code /usage for exact utilization.`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function emitRateLimitEvent(payload: Record<string, unknown>): void {
|
|
598
|
+
try {
|
|
599
|
+
extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
|
|
600
|
+
} catch {
|
|
601
|
+
// Cross-extension broker is best-effort only.
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function extraUsageAllowed(config: Config): boolean {
|
|
606
|
+
return config.provider?.allowExtraUsage === true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function sdkTextFromMessage(message: SDKMessage): string | undefined {
|
|
610
|
+
if (message.type === "result") return (message as any).result;
|
|
611
|
+
if (message.type === "assistant") {
|
|
612
|
+
const content = (message as any).message?.content;
|
|
613
|
+
if (!Array.isArray(content)) return undefined;
|
|
614
|
+
return content
|
|
615
|
+
.map((block) => block?.type === "text" && typeof block.text === "string" ? block.text : "")
|
|
616
|
+
.filter(Boolean)
|
|
617
|
+
.join("\n");
|
|
618
|
+
}
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function runExtraUsageHelper(cwd: string, config = loadConfig(cwd)): Promise<string> {
|
|
623
|
+
const providerSettings = config.provider ?? {};
|
|
624
|
+
const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
|
|
625
|
+
if (claudeExecutable) preflightClaudeExecutable(claudeExecutable, cwd);
|
|
626
|
+
|
|
627
|
+
const helperQuery = query({
|
|
628
|
+
prompt: "/extra-usage",
|
|
629
|
+
options: {
|
|
630
|
+
cwd,
|
|
631
|
+
env: { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" },
|
|
632
|
+
maxTurns: 1,
|
|
633
|
+
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
|
|
634
|
+
spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
|
|
635
|
+
...makeCliDebugOptions("extra-usage"),
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
const outputs: string[] = [];
|
|
639
|
+
try {
|
|
640
|
+
for await (const message of helperQuery) {
|
|
641
|
+
const text = sdkTextFromMessage(message)?.trim();
|
|
642
|
+
if (text && outputs[outputs.length - 1] !== text) outputs.push(text);
|
|
643
|
+
}
|
|
644
|
+
} finally {
|
|
645
|
+
helperQuery.close();
|
|
646
|
+
}
|
|
647
|
+
return outputs.join("\n").trim() || "Claude Code /extra-usage completed.";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function launchExtraUsageHelperIfAllowed(cwd: string, config: Config, reason: string): boolean {
|
|
651
|
+
if (!extraUsageAllowed(config)) return false;
|
|
652
|
+
if (extraUsageHelperInFlight) return true;
|
|
653
|
+
extraUsageHelperInFlight = runExtraUsageHelper(cwd, config)
|
|
654
|
+
.then((message) => {
|
|
655
|
+
piUI?.notify(`Claude extra usage helper: ${message}`, "info");
|
|
656
|
+
return message;
|
|
657
|
+
})
|
|
658
|
+
.catch((error) => {
|
|
659
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
660
|
+
piUI?.notify(`Claude extra usage helper failed after ${reason}: ${message}`, "error");
|
|
661
|
+
throw error;
|
|
662
|
+
})
|
|
663
|
+
.finally(() => { extraUsageHelperInFlight = null; });
|
|
664
|
+
void extraUsageHelperInFlight.catch(() => {});
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
425
667
|
|
|
426
668
|
const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
|
|
427
669
|
|
|
@@ -564,6 +806,7 @@ function convertAndImportMessages(
|
|
|
564
806
|
session: ReturnType<typeof createSession>,
|
|
565
807
|
messages: Context["messages"],
|
|
566
808
|
customToolNameToSdk?: Map<string, string>,
|
|
809
|
+
cwd?: string,
|
|
567
810
|
): void {
|
|
568
811
|
const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
|
|
569
812
|
|
|
@@ -579,7 +822,17 @@ function convertAndImportMessages(
|
|
|
579
822
|
[...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
|
|
580
823
|
}
|
|
581
824
|
// Pre-repair for debug logging; importMessages also repairs internally (idempotent).
|
|
825
|
+
const missingToolResults = findUnpairedToolUses(anthropicMessages);
|
|
582
826
|
const repaired = repairToolPairing(anthropicMessages);
|
|
827
|
+
if (missingToolResults.length > 0) {
|
|
828
|
+
reportSyntheticToolResultRepair(missingToolResults, {
|
|
829
|
+
cwd,
|
|
830
|
+
messageCount: messages.length,
|
|
831
|
+
anthropicMessageCount: anthropicMessages.length,
|
|
832
|
+
sessionId: session.sessionId,
|
|
833
|
+
jsonlPath: session.jsonlPath,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
583
836
|
if (repaired.length !== anthropicMessages.length) {
|
|
584
837
|
debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
|
|
585
838
|
}
|
|
@@ -777,7 +1030,7 @@ function syncSharedSession(
|
|
|
777
1030
|
...(preserveId ? { sessionId: previousSessionId } : {}),
|
|
778
1031
|
...(modelId ? { model: modelId } : {}),
|
|
779
1032
|
});
|
|
780
|
-
convertAndImportMessages(session, priorMessages, customToolNameToSdk);
|
|
1033
|
+
convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
|
|
781
1034
|
session.save();
|
|
782
1035
|
verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
|
|
783
1036
|
sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
|
|
@@ -851,9 +1104,6 @@ function mapToolArgs(
|
|
|
851
1104
|
// them without activating the extension. `ctx()`, `pushContext()`, `popContext()`
|
|
852
1105
|
// are imported at the top of this file.
|
|
853
1106
|
|
|
854
|
-
// Global (not query state):
|
|
855
|
-
let piUI: ExtensionUIContext | null = null;
|
|
856
|
-
|
|
857
1107
|
function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
858
1108
|
mcpTools: Tool[];
|
|
859
1109
|
customToolNameToSdk: Map<string, string>;
|
|
@@ -880,28 +1130,50 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
|
880
1130
|
|
|
881
1131
|
// Creates an MCP server that bridges pi tools to the SDK. Each tool handler
|
|
882
1132
|
// blocks on a Promise until pi delivers the tool result via streamSimple.
|
|
883
|
-
// Handlers
|
|
884
|
-
//
|
|
885
|
-
// Handlers close over the captured `queryCtx`, ensuring they
|
|
886
|
-
// correct query's state even across pushContext/popContext calls.
|
|
1133
|
+
// Handlers claim their tool_call id by matching the actual MCP call
|
|
1134
|
+
// (tool name + arguments) against the recorded tool_use blocks, then results
|
|
1135
|
+
// are matched by ID. Handlers close over the captured `queryCtx`, ensuring they
|
|
1136
|
+
// operate on the correct query's state even across pushContext/popContext calls.
|
|
887
1137
|
function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createSdkMcpServer>> | undefined {
|
|
888
1138
|
if (!tools.length) return undefined;
|
|
889
1139
|
const mcpTools = tools.map((tool) => ({
|
|
890
1140
|
name: tool.name,
|
|
891
1141
|
description: tool.description,
|
|
892
1142
|
inputSchema: jsonSchemaToZodShape(tool.parameters),
|
|
893
|
-
handler: async () => {
|
|
894
|
-
const
|
|
895
|
-
|
|
1143
|
+
handler: async (args?: Record<string, unknown>) => {
|
|
1144
|
+
const mappedArgs = mapToolArgs(tool.name, args);
|
|
1145
|
+
const claim = queryCtx.claimToolCall(tool.name, mappedArgs);
|
|
1146
|
+
const toolCallId = claim.toolCallId;
|
|
1147
|
+
if (!toolCallId) {
|
|
1148
|
+
debug(`WARNING: mcp handler ${tool.name} has no toolCallId (available=${claim.available})`);
|
|
1149
|
+
diagDump("tool_handler_unmatched", {
|
|
1150
|
+
toolName: tool.name,
|
|
1151
|
+
argKeys: argKeys(mappedArgs),
|
|
1152
|
+
available: claim.available,
|
|
1153
|
+
turnToolCallIds: queryCtx.turnToolCallIds,
|
|
1154
|
+
turnToolCalls: safeToolCallSummary(queryCtx.turnToolCalls),
|
|
1155
|
+
});
|
|
1156
|
+
return { content: [{ type: "text", text: `Claude bridge internal error: no matching tool_call id for ${tool.name}` }], isError: true } satisfies McpResult;
|
|
1157
|
+
}
|
|
1158
|
+
if (claim.match !== "tool-args" || claim.ambiguous) {
|
|
1159
|
+
debug(`mcp handler: ${tool.name} [${toolCallId}] claimed by ${claim.match}${claim.ambiguous ? " (ambiguous)" : ""}`);
|
|
1160
|
+
}
|
|
896
1161
|
if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
|
|
897
1162
|
const result = queryCtx.pendingResults.get(toolCallId)!;
|
|
898
1163
|
queryCtx.pendingResults.delete(toolCallId);
|
|
1164
|
+
queryCtx.markToolResultResolved(toolCallId);
|
|
899
1165
|
debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
|
|
900
1166
|
return result;
|
|
901
1167
|
}
|
|
902
1168
|
debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
|
|
903
1169
|
return new Promise<McpResult>((resolve) => {
|
|
904
|
-
queryCtx.pendingToolCalls.set(toolCallId, {
|
|
1170
|
+
queryCtx.pendingToolCalls.set(toolCallId, {
|
|
1171
|
+
toolName: tool.name,
|
|
1172
|
+
resolve: (result) => {
|
|
1173
|
+
queryCtx.markToolResultResolved(toolCallId);
|
|
1174
|
+
resolve(result);
|
|
1175
|
+
},
|
|
1176
|
+
});
|
|
905
1177
|
});
|
|
906
1178
|
},
|
|
907
1179
|
}));
|
|
@@ -930,6 +1202,26 @@ const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
|
|
|
930
1202
|
minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
|
|
931
1203
|
};
|
|
932
1204
|
|
|
1205
|
+
function normalizeEffortOverrideModelKey(value: string): string {
|
|
1206
|
+
const key = value.trim().toLowerCase();
|
|
1207
|
+
return key.startsWith(`${PROVIDER_ID}/`) ? key.slice(PROVIDER_ID.length + 1) : key;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
export function resolveConfiguredEffort(
|
|
1211
|
+
modelId: string,
|
|
1212
|
+
reasoningEffort: EffortLevel | undefined,
|
|
1213
|
+
providerConfig?: Config["provider"],
|
|
1214
|
+
): EffortLevel | undefined {
|
|
1215
|
+
const target = normalizeEffortOverrideModelKey(modelId);
|
|
1216
|
+
for (const [key, rawEffort] of Object.entries(providerConfig?.modelEffortOverrides ?? {})) {
|
|
1217
|
+
const normalizedKey = normalizeEffortOverrideModelKey(key);
|
|
1218
|
+
if (normalizedKey !== "*" && normalizedKey !== target) continue;
|
|
1219
|
+
const effort = normalizeEffortLevel(rawEffort) as EffortLevel | undefined;
|
|
1220
|
+
if (effort) return effort;
|
|
1221
|
+
}
|
|
1222
|
+
return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
933
1225
|
// --- Provider helpers: misc ---
|
|
934
1226
|
|
|
935
1227
|
function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
|
|
@@ -980,24 +1272,28 @@ function finalizeCurrentStream(stopReason?: string): void {
|
|
|
980
1272
|
|
|
981
1273
|
/** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
|
|
982
1274
|
* On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
|
|
983
|
-
function processStreamEvent(
|
|
1275
|
+
export function processStreamEvent(
|
|
984
1276
|
message: SDKMessage,
|
|
985
1277
|
customToolNameToPi: Map<string, string>,
|
|
986
1278
|
model: Model<any>,
|
|
987
1279
|
): void {
|
|
988
1280
|
const c = ctx();
|
|
989
1281
|
if (!c.currentPiStream || !c.turnOutput) return;
|
|
990
|
-
c.turnSawStreamEvent = true;
|
|
991
1282
|
const event = (message as SDKMessage & { event: any }).event;
|
|
1283
|
+
if (event?.type === "ping") return;
|
|
1284
|
+
if (event?.type === "message_stop" && !c.turnSawToolCall) {
|
|
1285
|
+
debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
992
1288
|
|
|
993
1289
|
if (event?.type === "message_start") {
|
|
994
|
-
c.
|
|
995
|
-
c.nextHandlerIdx = 0;
|
|
1290
|
+
c.resetToolTracking();
|
|
996
1291
|
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
|
|
997
1292
|
return;
|
|
998
1293
|
}
|
|
999
1294
|
|
|
1000
1295
|
if (event?.type === "content_block_start") {
|
|
1296
|
+
c.turnSawStreamEvent = true;
|
|
1001
1297
|
ensureTurnStarted();
|
|
1002
1298
|
if (event.content_block?.type === "text") {
|
|
1003
1299
|
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
@@ -1007,10 +1303,11 @@ function processStreamEvent(
|
|
|
1007
1303
|
c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
1008
1304
|
} else if (event.content_block?.type === "tool_use") {
|
|
1009
1305
|
c.turnSawToolCall = true;
|
|
1010
|
-
|
|
1306
|
+
const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
|
|
1307
|
+
c.recordToolCall(event.content_block.id, mappedName, {});
|
|
1011
1308
|
c.turnBlocks.push({
|
|
1012
1309
|
type: "toolCall", id: event.content_block.id,
|
|
1013
|
-
name:
|
|
1310
|
+
name: mappedName,
|
|
1014
1311
|
arguments: (event.content_block.input as Record<string, unknown>) ?? {},
|
|
1015
1312
|
partialJson: "", index: event.index,
|
|
1016
1313
|
});
|
|
@@ -1024,7 +1321,11 @@ function processStreamEvent(
|
|
|
1024
1321
|
if (event?.type === "content_block_delta") {
|
|
1025
1322
|
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
1026
1323
|
const block = c.turnBlocks[index];
|
|
1027
|
-
if (!block)
|
|
1324
|
+
if (!block) {
|
|
1325
|
+
debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
c.turnSawStreamEvent = true;
|
|
1028
1329
|
if (event.delta?.type === "text_delta" && block.type === "text") {
|
|
1029
1330
|
block.text += event.delta.text;
|
|
1030
1331
|
c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
|
|
@@ -1046,7 +1347,11 @@ function processStreamEvent(
|
|
|
1046
1347
|
if (event?.type === "content_block_stop") {
|
|
1047
1348
|
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
1048
1349
|
const block = c.turnBlocks[index];
|
|
1049
|
-
if (!block)
|
|
1350
|
+
if (!block) {
|
|
1351
|
+
debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
c.turnSawStreamEvent = true;
|
|
1050
1355
|
delete block.index;
|
|
1051
1356
|
if (block.type === "text") {
|
|
1052
1357
|
c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
|
|
@@ -1057,6 +1362,7 @@ function processStreamEvent(
|
|
|
1057
1362
|
block.arguments = mapToolArgs(
|
|
1058
1363
|
block.name, parsePartialJson(block.partialJson, block.arguments),
|
|
1059
1364
|
);
|
|
1365
|
+
c.updateToolCallArgs(block.id, block.arguments);
|
|
1060
1366
|
delete block.partialJson;
|
|
1061
1367
|
c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
|
|
1062
1368
|
}
|
|
@@ -1095,13 +1401,73 @@ function processStreamEvent(
|
|
|
1095
1401
|
// arrives before any stream_events, this is the primary content path. Must maintain
|
|
1096
1402
|
// the same stream lifecycle as processStreamEvent — including ending the stream on
|
|
1097
1403
|
// tool_use to prevent deadlock with the MCP handler.
|
|
1098
|
-
function
|
|
1404
|
+
function appendMissingToolUsesFromAssistant(
|
|
1405
|
+
assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
|
|
1406
|
+
model: Model<any>,
|
|
1407
|
+
customToolNameToPi: Map<string, string>,
|
|
1408
|
+
): boolean {
|
|
1409
|
+
const c = ctx();
|
|
1410
|
+
if (!assistantMsg?.content) return false;
|
|
1411
|
+
let sawToolUse = false;
|
|
1412
|
+
for (const block of assistantMsg.content) {
|
|
1413
|
+
if (block.type !== "tool_use") continue;
|
|
1414
|
+
sawToolUse = true;
|
|
1415
|
+
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
1416
|
+
const name = mapToolName(block.name, customToolNameToPi);
|
|
1417
|
+
const mappedArgs = mapToolArgs(name, block.input);
|
|
1418
|
+
c.recordToolCall(block.id, name, mappedArgs);
|
|
1419
|
+
if (existingIdx >= 0) {
|
|
1420
|
+
const existing = c.turnBlocks[existingIdx] as any;
|
|
1421
|
+
existing.name = name;
|
|
1422
|
+
existing.arguments = mappedArgs;
|
|
1423
|
+
c.updateToolCallArgs(block.id, mappedArgs);
|
|
1424
|
+
if ("partialJson" in existing) {
|
|
1425
|
+
delete existing.partialJson;
|
|
1426
|
+
delete existing.index;
|
|
1427
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
|
|
1428
|
+
}
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
ensureTurnStarted();
|
|
1433
|
+
c.turnBlocks.push({
|
|
1434
|
+
type: "toolCall", id: block.id,
|
|
1435
|
+
name,
|
|
1436
|
+
arguments: mappedArgs,
|
|
1437
|
+
});
|
|
1438
|
+
const idx = c.turnBlocks.length - 1;
|
|
1439
|
+
const toolBlock = c.turnBlocks[idx];
|
|
1440
|
+
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
1441
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
1442
|
+
}
|
|
1443
|
+
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
1444
|
+
return sawToolUse;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
|
|
1099
1448
|
const c = ctx();
|
|
1100
|
-
if (c.turnSawStreamEvent) return;
|
|
1101
1449
|
const assistantMsg = (message as any).message;
|
|
1102
1450
|
if (!assistantMsg?.content) return;
|
|
1103
|
-
c.
|
|
1104
|
-
|
|
1451
|
+
if (c.turnSawStreamEvent) {
|
|
1452
|
+
// Claude Agent SDK can yield the completed assistant message before (or
|
|
1453
|
+
// instead of) a stream_event message_stop for a tool-use turn. Treat that
|
|
1454
|
+
// assistant message as a hard turn boundary so Pi executes the tool calls
|
|
1455
|
+
// and the MCP handlers stay blocked until real tool results are delivered.
|
|
1456
|
+
// Without this fallback, Claude Code can continue internally with empty MCP
|
|
1457
|
+
// results and Pi only sees the real outputs one render cycle later.
|
|
1458
|
+
if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
|
|
1459
|
+
c.turnSawToolCall = true;
|
|
1460
|
+
if (c.currentPiStream && c.turnOutput) {
|
|
1461
|
+
c.turnOutput.stopReason = "toolUse";
|
|
1462
|
+
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
1463
|
+
c.currentPiStream.end();
|
|
1464
|
+
c.currentPiStream = null;
|
|
1465
|
+
debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
c.resetToolTracking();
|
|
1105
1471
|
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
|
|
1106
1472
|
for (const block of assistantMsg.content) {
|
|
1107
1473
|
if (block.type === "text" && block.text) {
|
|
@@ -1121,11 +1487,12 @@ function processAssistantMessage(message: SDKMessage, model: Model<any>, customT
|
|
|
1121
1487
|
} else if (block.type === "tool_use") {
|
|
1122
1488
|
ensureTurnStarted();
|
|
1123
1489
|
c.turnSawToolCall = true;
|
|
1124
|
-
|
|
1125
|
-
const mappedArgs = mapToolArgs(
|
|
1490
|
+
const mappedName = mapToolName(block.name, customToolNameToPi);
|
|
1491
|
+
const mappedArgs = mapToolArgs(mappedName, block.input);
|
|
1492
|
+
c.recordToolCall(block.id, mappedName, mappedArgs);
|
|
1126
1493
|
c.turnBlocks.push({
|
|
1127
1494
|
type: "toolCall", id: block.id,
|
|
1128
|
-
name:
|
|
1495
|
+
name: mappedName,
|
|
1129
1496
|
arguments: mappedArgs,
|
|
1130
1497
|
});
|
|
1131
1498
|
const idx = c.turnBlocks.length - 1;
|
|
@@ -1156,13 +1523,17 @@ async function consumeQuery(
|
|
|
1156
1523
|
sdkQuery: ReturnType<typeof query>,
|
|
1157
1524
|
customToolNameToPi: Map<string, string>,
|
|
1158
1525
|
model: Model<any>,
|
|
1526
|
+
cwd: string,
|
|
1527
|
+
bridgeConfig: Config,
|
|
1159
1528
|
wasAborted: () => boolean,
|
|
1160
1529
|
): Promise<{ capturedSessionId?: string }> {
|
|
1161
1530
|
let capturedSessionId: string | undefined;
|
|
1162
1531
|
|
|
1163
1532
|
for await (const message of sdkQuery) {
|
|
1164
1533
|
if (wasAborted()) break;
|
|
1165
|
-
|
|
1534
|
+
const queryCtx = ctx();
|
|
1535
|
+
if (!queryCtx.turnOutput) continue;
|
|
1536
|
+
if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
|
|
1166
1537
|
|
|
1167
1538
|
switch (message.type) {
|
|
1168
1539
|
case "stream_event":
|
|
@@ -1180,6 +1551,16 @@ async function consumeQuery(
|
|
|
1180
1551
|
ctx().currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: ctx().turnOutput });
|
|
1181
1552
|
ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
|
|
1182
1553
|
ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
|
|
1554
|
+
} else if (message.subtype !== "success" && isExtraUsageRequiredMessage(message)) {
|
|
1555
|
+
const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
|
|
1556
|
+
const errors = errorLines.length > 0 ? errorLines.join("\n") : String(message.subtype ?? "Claude Code rate limit");
|
|
1557
|
+
const openedExtraUsage = launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
|
|
1558
|
+
ctx().handledTerminalError = true;
|
|
1559
|
+
ctx().turnOutput.stopReason = "error";
|
|
1560
|
+
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."}`;
|
|
1561
|
+
ctx().currentPiStream?.push({ type: "error", reason: "error", error: ctx().turnOutput });
|
|
1562
|
+
ctx().currentPiStream?.end();
|
|
1563
|
+
ctx().currentPiStream = null;
|
|
1183
1564
|
}
|
|
1184
1565
|
break;
|
|
1185
1566
|
case "system":
|
|
@@ -1193,10 +1574,25 @@ async function consumeQuery(
|
|
|
1193
1574
|
const info = (message as any).rate_limit_info;
|
|
1194
1575
|
debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
1195
1576
|
if (info?.status === "rejected") {
|
|
1196
|
-
const resetsAt =
|
|
1197
|
-
|
|
1577
|
+
const resetsAt = formatResetTimestamp(info.resetsAt);
|
|
1578
|
+
const resetAtMs = typeof info.resetsAt === "string" ? Date.parse(info.resetsAt) : undefined;
|
|
1579
|
+
const reason = `${info.rateLimitType ?? "unknown"} rate limit`;
|
|
1580
|
+
const launchedExtraUsage = isExtraUsageRequiredMessage(info) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, reason);
|
|
1581
|
+
emitRateLimitEvent({
|
|
1582
|
+
model: model.id,
|
|
1583
|
+
provider: PROVIDER_ID,
|
|
1584
|
+
rateLimitType: info.rateLimitType,
|
|
1585
|
+
reason,
|
|
1586
|
+
resetAt: info.resetsAt,
|
|
1587
|
+
...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
|
|
1588
|
+
source: "claude-bridge",
|
|
1589
|
+
status: "rejected",
|
|
1590
|
+
});
|
|
1591
|
+
piUI?.notify(`${RATE_LIMIT_TOKEN} Claude ${reason} hit — resets ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
|
|
1198
1592
|
} else if (info?.status === "allowed_warning") {
|
|
1199
|
-
|
|
1593
|
+
const warning = formatAllowedRateLimitWarning(info);
|
|
1594
|
+
if (warning) piUI?.notify(warning, "warning");
|
|
1595
|
+
else debug("consumeQuery: suppressed low/ambiguous allowed_warning rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
1200
1596
|
}
|
|
1201
1597
|
break;
|
|
1202
1598
|
}
|
|
@@ -1219,6 +1615,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1219
1615
|
|
|
1220
1616
|
// DEBUG: trace followUp message triggering
|
|
1221
1617
|
const lastMsgRole = context.messages[context.messages.length - 1]?.role;
|
|
1618
|
+
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
1222
1619
|
debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
|
|
1223
1620
|
|
|
1224
1621
|
// --- Tool result delivery ---
|
|
@@ -1226,30 +1623,48 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1226
1623
|
// (everything after the last assistant message) and match against waiting MCP
|
|
1227
1624
|
// handlers. Results that arrive before their handler get queued in pendingResults.
|
|
1228
1625
|
if (ctx().activeQuery) {
|
|
1229
|
-
ctx()
|
|
1230
|
-
|
|
1626
|
+
const queryCtx = ctx();
|
|
1627
|
+
queryCtx.currentPiStream = stream;
|
|
1628
|
+
queryCtx.resetTurnState(model);
|
|
1231
1629
|
const allResults = extractAllToolResults(context);
|
|
1232
|
-
debug(`provider: tool results, ${allResults.length} results, ${
|
|
1630
|
+
debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
|
|
1631
|
+
const unmatchedResultIds: string[] = [];
|
|
1233
1632
|
for (const result of allResults) {
|
|
1234
1633
|
const id = result.toolCallId;
|
|
1235
|
-
if (id &&
|
|
1236
|
-
|
|
1237
|
-
|
|
1634
|
+
if (id && !queryCtx.hasRecordedToolCall(id)) {
|
|
1635
|
+
queryCtx.markToolResultUnmatched(id);
|
|
1636
|
+
unmatchedResultIds.push(id);
|
|
1637
|
+
debug(`ERROR: tool result [${id}] has no registered tool_call id; refusing to queue or deliver`);
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
queryCtx.markToolResultDelivered(id);
|
|
1641
|
+
if (id && queryCtx.pendingToolCalls.has(id)) {
|
|
1642
|
+
const pending = queryCtx.pendingToolCalls.get(id)!;
|
|
1643
|
+
queryCtx.pendingToolCalls.delete(id);
|
|
1238
1644
|
debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""}`, JSON.stringify(result.content).slice(0, 200));
|
|
1239
1645
|
pending.resolve(result);
|
|
1240
1646
|
} else if (id) {
|
|
1241
|
-
|
|
1242
|
-
debug(`provider: queued result [${id}] (${
|
|
1647
|
+
queryCtx.pendingResults.set(id, result);
|
|
1648
|
+
debug(`provider: queued result [${id}] (${queryCtx.pendingResults.size} pending)`);
|
|
1243
1649
|
} else {
|
|
1244
1650
|
debug(`WARNING: tool result without toolCallId, cannot match`);
|
|
1245
1651
|
}
|
|
1246
|
-
if (
|
|
1247
|
-
debug(`BUG: both maps non-empty! handlers=${
|
|
1652
|
+
if (queryCtx.pendingToolCalls.size > 0 && queryCtx.pendingResults.size > 0) {
|
|
1653
|
+
debug(`BUG: both maps non-empty! handlers=${queryCtx.pendingToolCalls.size} results=${queryCtx.pendingResults.size}`);
|
|
1248
1654
|
}
|
|
1249
1655
|
}
|
|
1250
|
-
if (
|
|
1251
|
-
|
|
1252
|
-
|
|
1656
|
+
if (unmatchedResultIds.length > 0) {
|
|
1657
|
+
const errorResult: McpResult = {
|
|
1658
|
+
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 ? ", ..." : ""}` }],
|
|
1659
|
+
isError: true,
|
|
1660
|
+
};
|
|
1661
|
+
for (const pending of queryCtx.pendingToolCalls.values()) pending.resolve(errorResult);
|
|
1662
|
+
queryCtx.pendingToolCalls.clear();
|
|
1663
|
+
reportToolResultMismatch(queryCtx, "unmatched tool result", cwd);
|
|
1664
|
+
}
|
|
1665
|
+
if (queryCtx.pendingToolCalls.size > 0) {
|
|
1666
|
+
debug(`WARNING: ${queryCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
|
|
1667
|
+
piUI?.notify(`Claude bridge: ${queryCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
|
|
1253
1668
|
}
|
|
1254
1669
|
|
|
1255
1670
|
// Detect user messages (steer/followUp) that pi injected into context
|
|
@@ -1269,7 +1684,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1269
1684
|
}
|
|
1270
1685
|
|
|
1271
1686
|
if (sharedSession) sharedSession.cursor = context.messages.length;
|
|
1272
|
-
|
|
1687
|
+
queryCtx.latestCursor = Math.max(queryCtx.latestCursor, context.messages.length);
|
|
1273
1688
|
return stream;
|
|
1274
1689
|
}
|
|
1275
1690
|
|
|
@@ -1303,10 +1718,10 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1303
1718
|
ctx().pendingResults.clear();
|
|
1304
1719
|
ctx().deferredUserMessages = [];
|
|
1305
1720
|
ctx().resetTurnState(model);
|
|
1721
|
+
ctx().resetToolTracking();
|
|
1306
1722
|
ctx().latestCursor = 0;
|
|
1307
1723
|
|
|
1308
1724
|
const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
|
|
1309
|
-
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
1310
1725
|
const promptBlocks = extractUserPromptBlocks(context.messages);
|
|
1311
1726
|
let promptText = extractUserPrompt(context.messages) ?? "";
|
|
1312
1727
|
|
|
@@ -1354,10 +1769,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1354
1769
|
// Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
|
|
1355
1770
|
// per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
|
|
1356
1771
|
// Fall back to our generic table for older pi-ai or unmapped levels.
|
|
1357
|
-
const
|
|
1772
|
+
const requestedEffort = options?.reasoning
|
|
1358
1773
|
? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
|
|
1359
1774
|
?? REASONING_TO_EFFORT[options.reasoning]
|
|
1360
1775
|
: undefined;
|
|
1776
|
+
const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
|
|
1361
1777
|
|
|
1362
1778
|
const extraArgs: Record<string, string | null> = { model: model.id };
|
|
1363
1779
|
if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
|
|
@@ -1382,6 +1798,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1382
1798
|
...CLAUDE_BRIDGE_TOOL_ISOLATION,
|
|
1383
1799
|
permissionMode: "bypassPermissions",
|
|
1384
1800
|
includePartialMessages: true,
|
|
1801
|
+
...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
|
|
1385
1802
|
systemPrompt: {
|
|
1386
1803
|
type: "preset", preset: "claude_code",
|
|
1387
1804
|
append: systemPromptAppend ? systemPromptAppend : undefined,
|
|
@@ -1399,7 +1816,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1399
1816
|
debug("provider: fresh query",
|
|
1400
1817
|
`model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
|
|
1401
1818
|
`resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
|
|
1402
|
-
`appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled}`,
|
|
1819
|
+
`appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
|
|
1403
1820
|
`claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
|
|
1404
1821
|
`prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
|
|
1405
1822
|
|
|
@@ -1421,6 +1838,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1421
1838
|
wasAborted = true;
|
|
1422
1839
|
// Prevent stale deferred messages from being replayed by parent on pop
|
|
1423
1840
|
abortCtx.deferredUserMessages = [];
|
|
1841
|
+
reportToolResultMismatch(abortCtx, "abort", cwd, { forceRotate: true });
|
|
1424
1842
|
for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
|
|
1425
1843
|
abortCtx.pendingToolCalls.clear();
|
|
1426
1844
|
abortCtx.pendingResults.clear();
|
|
@@ -1432,7 +1850,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1432
1850
|
}
|
|
1433
1851
|
|
|
1434
1852
|
// Background consumer — runs until query ends
|
|
1435
|
-
consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted)
|
|
1853
|
+
consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
|
|
1436
1854
|
.then(async ({ capturedSessionId }) => {
|
|
1437
1855
|
debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
|
|
1438
1856
|
|
|
@@ -1467,6 +1885,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1467
1885
|
const steerPrompt = ctx().deferredUserMessages.shift()!;
|
|
1468
1886
|
debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
|
|
1469
1887
|
ctx().resetTurnState(model);
|
|
1888
|
+
ctx().resetToolTracking();
|
|
1470
1889
|
|
|
1471
1890
|
const resumeId = sharedSession?.sessionId;
|
|
1472
1891
|
if (!resumeId) {
|
|
@@ -1481,7 +1900,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1481
1900
|
debug(`provider: continuation query, model=${model.id}, resume=${resumeId.slice(0, 8)}, prompt=${steerPrompt.slice(0, 60)}`);
|
|
1482
1901
|
|
|
1483
1902
|
try {
|
|
1484
|
-
const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted);
|
|
1903
|
+
const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted);
|
|
1485
1904
|
const sid = contSid ?? sharedSession?.sessionId;
|
|
1486
1905
|
if (sid) {
|
|
1487
1906
|
sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
|
|
@@ -1502,15 +1921,21 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1502
1921
|
})
|
|
1503
1922
|
.catch((error) => {
|
|
1504
1923
|
debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
|
|
1924
|
+
const suppressDuplicateError = ctx().handledTerminalError;
|
|
1925
|
+
const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
|
|
1505
1926
|
if ((wasAborted || options?.signal?.aborted) && sharedSession) {
|
|
1506
1927
|
sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
1507
1928
|
} else {
|
|
1508
1929
|
sharedSession = null;
|
|
1509
1930
|
}
|
|
1510
1931
|
ctx().deferredUserMessages = [];
|
|
1932
|
+
if (suppressDuplicateError) {
|
|
1933
|
+
debug("provider: suppressing duplicate query error after terminal error was already emitted");
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1511
1936
|
if (ctx().turnOutput) {
|
|
1512
1937
|
ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
1513
|
-
ctx().turnOutput.errorMessage = error instanceof Error ? error.message : String(error)
|
|
1938
|
+
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." : ""}`;
|
|
1514
1939
|
}
|
|
1515
1940
|
ctx().currentPiStream?.push({ type: "error", reason: (ctx().turnOutput?.stopReason ?? "error") as "aborted" | "error", error: ctx().turnOutput! });
|
|
1516
1941
|
ctx().currentPiStream?.end();
|
|
@@ -1519,6 +1944,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1519
1944
|
.finally(() => {
|
|
1520
1945
|
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
|
1521
1946
|
if (ctx().activeQuery === sdkQuery) {
|
|
1947
|
+
reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted });
|
|
1522
1948
|
// Drain pending handlers for this query
|
|
1523
1949
|
for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
|
|
1524
1950
|
ctx().pendingToolCalls.clear();
|
|
@@ -1536,6 +1962,70 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1536
1962
|
return stream;
|
|
1537
1963
|
}
|
|
1538
1964
|
|
|
1965
|
+
function commandCwd(ctx: unknown): string {
|
|
1966
|
+
const value = (ctx as { cwd?: unknown })?.cwd;
|
|
1967
|
+
return typeof value === "string" && value.length > 0 ? value : process.cwd();
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
async function tryOpenExtensionManagerSettings(ctx: { ui: ExtensionUIContext }): Promise<boolean> {
|
|
1971
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
1972
|
+
const openQuickSettings = host[Symbol.for("vstack.pi.extension-manager.open-quick-settings")];
|
|
1973
|
+
if (typeof openQuickSettings !== "function") return false;
|
|
1974
|
+
try {
|
|
1975
|
+
await (openQuickSettings as (ctx: unknown, hint?: string) => Promise<void>)(ctx, "@vanillagreen/pi-claude-bridge");
|
|
1976
|
+
return true;
|
|
1977
|
+
} catch {
|
|
1978
|
+
return false;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
|
|
1983
|
+
const config = loadConfig(commandCwd(ctx));
|
|
1984
|
+
ctx.ui.notify([
|
|
1985
|
+
`Claude bridge: ${config.enabled === false ? "disabled" : "enabled"}`,
|
|
1986
|
+
`Extra usage auto-helper: ${extraUsageAllowed(config) ? "on" : "off"} (settings)`,
|
|
1987
|
+
`Use /claude-bridge:extra to run Claude Code /extra-usage now.`,
|
|
1988
|
+
].join("\n"), "info");
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function registerBridgeCommands(pi: ExtensionAPI): void {
|
|
1992
|
+
const guard = pi as unknown as Record<PropertyKey, unknown>;
|
|
1993
|
+
if (guard[COMMANDS_REGISTERED_KEY]) return;
|
|
1994
|
+
guard[COMMANDS_REGISTERED_KEY] = true;
|
|
1995
|
+
|
|
1996
|
+
const runExtraUsage = async (ctx: { ui: ExtensionUIContext; cwd?: string }) => {
|
|
1997
|
+
const cwd = commandCwd(ctx);
|
|
1998
|
+
if (extraUsageHelperInFlight) {
|
|
1999
|
+
ctx.ui.notify("Claude extra usage helper already running.", "info");
|
|
2000
|
+
await extraUsageHelperInFlight.catch(() => undefined);
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
try {
|
|
2004
|
+
ctx.ui.notify("Claude extra usage helper starting…", "info");
|
|
2005
|
+
extraUsageHelperInFlight = runExtraUsageHelper(cwd)
|
|
2006
|
+
.finally(() => { extraUsageHelperInFlight = null; });
|
|
2007
|
+
const message = await extraUsageHelperInFlight;
|
|
2008
|
+
ctx.ui.notify(`Claude extra usage helper: ${message}`, "info");
|
|
2009
|
+
} catch (error) {
|
|
2010
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2011
|
+
ctx.ui.notify(`Claude extra usage helper failed: ${message}`, "error");
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
|
|
2015
|
+
pi.registerCommand("claude-bridge", {
|
|
2016
|
+
description: "Open Claude bridge settings/status",
|
|
2017
|
+
handler: async (args: string, ctx) => {
|
|
2018
|
+
if (args.trim()) ctx.ui.notify("Unknown /claude-bridge argument. Use /claude-bridge:extra to run Claude Code /extra-usage.", "warning");
|
|
2019
|
+
if (await tryOpenExtensionManagerSettings(ctx)) return;
|
|
2020
|
+
showBridgeStatus(ctx);
|
|
2021
|
+
},
|
|
2022
|
+
});
|
|
2023
|
+
pi.registerCommand("claude-bridge:extra", {
|
|
2024
|
+
description: "Run Claude Code /extra-usage through claude-bridge",
|
|
2025
|
+
handler: async (_args: string, ctx) => runExtraUsage(ctx),
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1539
2029
|
// --- Extension registration ---
|
|
1540
2030
|
|
|
1541
2031
|
export default function (pi: ExtensionAPI) {
|
|
@@ -1545,6 +2035,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1545
2035
|
|
|
1546
2036
|
const config = loadConfig(process.cwd());
|
|
1547
2037
|
debug("loadConfig:", JSON.stringify(config));
|
|
2038
|
+
registerBridgeCommands(pi);
|
|
1548
2039
|
if (config.enabled === false) {
|
|
1549
2040
|
debug("provider: disabled by configuration");
|
|
1550
2041
|
return;
|
|
@@ -1589,6 +2080,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1589
2080
|
// triggers CC's autocompact-thrashing guard (issue #8). Force the next
|
|
1590
2081
|
// call down the REBUILD path so CC sees the current history.
|
|
1591
2082
|
const markRebuild = (event: string) => {
|
|
2083
|
+
if (ctx().activeQuery) {
|
|
2084
|
+
reportToolResultMismatch(ctx(), event, sharedSession?.cwd ?? process.cwd());
|
|
2085
|
+
}
|
|
1592
2086
|
if (sharedSession) {
|
|
1593
2087
|
debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
|
|
1594
2088
|
sharedSession = { ...sharedSession, needsRebuild: true };
|