@vanillagreen/pi-claude-bridge 1.6.2 → 1.9.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 +87 -6
- package/bundle/connector-inventory.js +137 -0
- package/bundle/index.js +28385 -22407
- package/package.json +10 -6
- package/src/agents-md.ts +12 -4
- package/src/assistant-stream.ts +307 -0
- package/src/auth-presence.ts +158 -0
- package/src/bridge-state.ts +136 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +83 -5
- package/src/connector-inventory.ts +281 -0
- package/src/connectors.ts +359 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +339 -1428
- package/src/models.ts +22 -1
- package/src/prompt-context.ts +3 -8
- package/src/query-state.ts +42 -0
- package/src/rate-limit.ts +63 -0
- package/src/session-persistence.ts +329 -0
- package/src/stream-idle-watchdog.ts +134 -0
- package/src/tool-mapping.ts +53 -0
package/src/models.ts
CHANGED
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
// Extracted from index.ts so tests can import without activating the extension.
|
|
4
4
|
|
|
5
5
|
export const FABLE_MODEL_ID = "claude-fable-5";
|
|
6
|
+
// Opus 4.8 is both a selectable model and the safety-fallback target for the two
|
|
7
|
+
// primaries whose classifiers can decline a turn (Fable 5, Opus 5).
|
|
6
8
|
export const FABLE_FALLBACK_MODEL_ID = "claude-opus-4-8";
|
|
9
|
+
export const OPUS_5_MODEL_ID = "claude-opus-5";
|
|
7
10
|
export const SONNET_5_MODEL_ID = "claude-sonnet-5";
|
|
8
11
|
|
|
9
12
|
export function fallbackModelForPrimaryModel(modelId: string): string | undefined {
|
|
10
|
-
return modelId === FABLE_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : undefined;
|
|
13
|
+
return modelId === FABLE_MODEL_ID || modelId === OPUS_5_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : undefined;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export const MODEL_IDS_IN_ORDER = [
|
|
14
17
|
FABLE_MODEL_ID,
|
|
18
|
+
OPUS_5_MODEL_ID,
|
|
15
19
|
FABLE_FALLBACK_MODEL_ID,
|
|
16
20
|
"claude-opus-4-7",
|
|
17
21
|
"claude-opus-4-6",
|
|
@@ -40,6 +44,15 @@ const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
|
|
|
40
44
|
contextWindow: 1000000,
|
|
41
45
|
maxTokens: 128000,
|
|
42
46
|
},
|
|
47
|
+
[OPUS_5_MODEL_ID]: {
|
|
48
|
+
id: OPUS_5_MODEL_ID,
|
|
49
|
+
name: "Claude Opus 5",
|
|
50
|
+
reasoning: true,
|
|
51
|
+
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
|
52
|
+
input: ["text", "image"],
|
|
53
|
+
contextWindow: 1000000,
|
|
54
|
+
maxTokens: 128000,
|
|
55
|
+
},
|
|
43
56
|
[FABLE_FALLBACK_MODEL_ID]: {
|
|
44
57
|
id: FABLE_FALLBACK_MODEL_ID,
|
|
45
58
|
name: "Claude Opus 4.8",
|
|
@@ -53,12 +66,20 @@ const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
|
|
|
53
66
|
id: SONNET_5_MODEL_ID,
|
|
54
67
|
name: "Claude Sonnet 5",
|
|
55
68
|
reasoning: true,
|
|
69
|
+
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
|
56
70
|
input: ["text", "image"],
|
|
57
71
|
contextWindow: 1000000,
|
|
58
72
|
maxTokens: 128000,
|
|
59
73
|
},
|
|
60
74
|
};
|
|
61
75
|
|
|
76
|
+
// Human label for the safety-fallback notice. Every id that participates in a
|
|
77
|
+
// fallbackModelForPrimaryModel pairing has an entry above; the raw id is the
|
|
78
|
+
// last-resort label so an unmapped pairing still reads sensibly.
|
|
79
|
+
export function modelDisplayName(modelId: string): string {
|
|
80
|
+
return FALLBACK_MODELS[modelId]?.name ?? modelId;
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
// Project pi-ai's model entries down to the fields pi's registerProvider expects,
|
|
63
84
|
// keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned future IDs when pi-ai
|
|
64
85
|
// has not shipped metadata for them yet. Unknown missing IDs are still dropped.
|
package/src/prompt-context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
|
-
import { homedir } from "os";
|
|
3
2
|
import { dirname, join, resolve } from "path";
|
|
3
|
+
import { isolatedFromEnv, piUserDir } from "./config.js";
|
|
4
4
|
|
|
5
5
|
export interface PromptContextSettings {
|
|
6
6
|
includeAppendSystemPromptMd?: boolean;
|
|
@@ -14,12 +14,6 @@ export interface PromptContextAppend {
|
|
|
14
14
|
labels: string[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
function piUserDir(): string {
|
|
18
|
-
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
19
|
-
if (configured) return resolve(configured.replace(/^~(?=\/|$)/, homedir()));
|
|
20
|
-
return join(homedir(), ".pi", "agent");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
17
|
function readTrimmed(path: string): string | undefined {
|
|
24
18
|
try {
|
|
25
19
|
if (!existsSync(path)) return undefined;
|
|
@@ -46,7 +40,8 @@ export function readAppendSystemPromptFiles(cwd: string): Array<{ label: string;
|
|
|
46
40
|
const files: Array<{ label: string; path: string }> = [
|
|
47
41
|
{ label: "global APPEND_SYSTEM.md", path: join(piUserDir(), "APPEND_SYSTEM.md") },
|
|
48
42
|
];
|
|
49
|
-
|
|
43
|
+
// Isolated mode: no cwd-ancestor discovery — the host app owns the prompt surface.
|
|
44
|
+
const projectPath = isolatedFromEnv() ? undefined : findProjectAppendSystem(cwd);
|
|
50
45
|
if (projectPath) files.push({ label: "project .pi/APPEND_SYSTEM.md", path: projectPath });
|
|
51
46
|
|
|
52
47
|
const seen = new Set<string>();
|
package/src/query-state.ts
CHANGED
|
@@ -14,6 +14,48 @@ export interface PendingToolCall {
|
|
|
14
14
|
resolve: (result: McpResult) => void;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Why pending MCP handlers were drained without a real tool result. A drained
|
|
18
|
+
// handler is waiting on a result pi will now never deliver, so the drain must
|
|
19
|
+
// resolve as an error — never as a successful result whose text merely says the
|
|
20
|
+
// turn died, which a consumer cannot tell apart from a tool that genuinely
|
|
21
|
+
// returned that string. The cause is carried because an abort, an idle timeout,
|
|
22
|
+
// and a plain end-with-stragglers are different things to act on.
|
|
23
|
+
export type ToolCallDrainCause = "abort" | "stream-idle-timeout" | "query-end";
|
|
24
|
+
|
|
25
|
+
const DRAIN_CAUSE_TEXT: Record<ToolCallDrainCause, string> = {
|
|
26
|
+
"abort": "the turn was aborted",
|
|
27
|
+
"stream-idle-timeout": "the Claude Code stream went idle and the turn timed out",
|
|
28
|
+
"query-end": "the query ended",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function interruptedToolCallResult(cause: ToolCallDrainCause): McpResult {
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: "text", text: `Claude bridge: ${DRAIN_CAUSE_TEXT[cause]} before this tool call's result was delivered. The call did not complete and produced no output.` }],
|
|
34
|
+
isError: true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Precedence matches the forceRotate expression at the query-teardown site: an
|
|
39
|
+
// explicit abort (pi's signal or our own abort handler) outranks a stream-idle
|
|
40
|
+
// timeout, which outranks a plain end with stragglers.
|
|
41
|
+
export function toolCallDrainCause(flags: { wasAborted?: boolean; signalAborted?: boolean; streamIdleTimedOut?: boolean }): ToolCallDrainCause {
|
|
42
|
+
if (flags.wasAborted || flags.signalAborted) return "abort";
|
|
43
|
+
if (flags.streamIdleTimedOut) return "stream-idle-timeout";
|
|
44
|
+
return "query-end";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolves every handler still waiting on `queryCtx` with an error result naming
|
|
48
|
+
* `cause`, clears the map, and returns how many were drained. Scoped to the one
|
|
49
|
+
* context it is given — never touches a sibling or parent query's handlers. */
|
|
50
|
+
export function drainPendingToolCalls(queryCtx: QueryContext, cause: ToolCallDrainCause): number {
|
|
51
|
+
const drained = queryCtx.pendingToolCalls.size;
|
|
52
|
+
if (drained === 0) return 0;
|
|
53
|
+
const result = interruptedToolCallResult(cause);
|
|
54
|
+
for (const pending of queryCtx.pendingToolCalls.values()) pending.resolve(result);
|
|
55
|
+
queryCtx.pendingToolCalls.clear();
|
|
56
|
+
return drained;
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
export interface TurnToolCallRecord {
|
|
18
60
|
id: string;
|
|
19
61
|
toolName: string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
|
|
2
|
+
export const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
|
|
3
|
+
|
|
4
|
+
export function isExtraUsageRequiredMessage(value: unknown): boolean {
|
|
5
|
+
let text: string;
|
|
6
|
+
if (typeof value === "string") text = value;
|
|
7
|
+
else if (value instanceof Error) text = value.message;
|
|
8
|
+
else {
|
|
9
|
+
try { text = JSON.stringify(value ?? ""); }
|
|
10
|
+
catch { text = String(value); }
|
|
11
|
+
}
|
|
12
|
+
return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function uniqueNonEmptyLines(values: unknown[]): string[] {
|
|
16
|
+
const seen = new Set<string>();
|
|
17
|
+
const out: string[] = [];
|
|
18
|
+
for (const value of values) {
|
|
19
|
+
const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
|
|
20
|
+
if (!text || seen.has(text)) continue;
|
|
21
|
+
seen.add(text);
|
|
22
|
+
out.push(text);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatResetTimestamp(value: unknown): string {
|
|
28
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
|
|
29
|
+
if (!Number.isFinite(parsed)) return "unknown";
|
|
30
|
+
return new Date(parsed).toLocaleString(undefined, {
|
|
31
|
+
day: "numeric",
|
|
32
|
+
hour: "numeric",
|
|
33
|
+
minute: "2-digit",
|
|
34
|
+
month: "short",
|
|
35
|
+
second: "2-digit",
|
|
36
|
+
timeZoneName: "short",
|
|
37
|
+
year: "numeric",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD = 80;
|
|
42
|
+
|
|
43
|
+
export function normalizeRateLimitUtilization(value: unknown): number | undefined {
|
|
44
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
|
|
45
|
+
if (value === 0) return 0;
|
|
46
|
+
// Claude SDK payloads have appeared as both fractions and percentages.
|
|
47
|
+
// Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
|
|
48
|
+
if (value > 0 && value < 1) return value * 100;
|
|
49
|
+
if (value > 1 && value <= 100) return value;
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rateLimitTypeLabel(value: unknown): string {
|
|
54
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
55
|
+
return text || "unknown";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatAllowedRateLimitWarning(info: { status?: unknown; utilization?: unknown; rateLimitType?: unknown } | null | undefined): string | undefined {
|
|
59
|
+
if (info?.status !== "allowed_warning") return undefined;
|
|
60
|
+
const utilization = normalizeRateLimitUtilization(info.utilization);
|
|
61
|
+
if (utilization === undefined || utilization < ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD) return undefined;
|
|
62
|
+
return `Claude rate limit warning: nearing ${rateLimitTypeLabel(info.rateLimitType)} limit; check Claude Code /usage for exact utilization.`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { type AssistantMessage, type Context } from "@earendil-works/pi-ai";
|
|
2
|
+
import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
import { realpathSync, statSync } from "fs";
|
|
5
|
+
import { resolve as pathResolve } from "path";
|
|
6
|
+
import { extensionApi, piUI, reportSyntheticToolResultRepair, setSharedSession, sharedSession, type SessionState } from "./bridge-state.js";
|
|
7
|
+
import { convertPiMessages } from "./convert.js";
|
|
8
|
+
import { DEBUG, DEBUG_LOG_PATH, debug, diagDump } from "./debug.js";
|
|
9
|
+
import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
|
|
10
|
+
import { findUnpairedToolUses } from "./tool-pairing-audit.js";
|
|
11
|
+
|
|
12
|
+
// --- Session persistence ---
|
|
13
|
+
|
|
14
|
+
const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
|
|
15
|
+
|
|
16
|
+
interface PersistedBridgeSessionState extends SessionState {
|
|
17
|
+
fingerprint: string;
|
|
18
|
+
piSessionId?: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fingerprintMessages(messages: Context["messages"]): string {
|
|
23
|
+
const normalized = messages.map((message) => {
|
|
24
|
+
if (message.role === "assistant") {
|
|
25
|
+
return {
|
|
26
|
+
role: message.role,
|
|
27
|
+
provider: (message as AssistantMessage).provider,
|
|
28
|
+
model: (message as AssistantMessage).model,
|
|
29
|
+
content: (message as AssistantMessage).content,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return message;
|
|
33
|
+
});
|
|
34
|
+
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readBuiltSessionContext(sessionManager: unknown): { messages: Context["messages"] } | undefined {
|
|
38
|
+
const built = typeof (sessionManager as any)?.buildSessionContext === "function" ? (sessionManager as any).buildSessionContext() : undefined;
|
|
39
|
+
return Array.isArray(built?.messages) ? built as { messages: Context["messages"] } : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function latestPersistedBridgeSession(sessionManager: unknown): PersistedBridgeSessionState | undefined {
|
|
43
|
+
const entries = typeof (sessionManager as any)?.getEntries === "function" ? (sessionManager as any).getEntries() : [];
|
|
44
|
+
if (!Array.isArray(entries)) return undefined;
|
|
45
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
46
|
+
const entry = entries[i];
|
|
47
|
+
if (entry?.type !== "custom" || entry.customType !== BRIDGE_SESSION_CUSTOM_TYPE) continue;
|
|
48
|
+
const data = entry.data as Partial<PersistedBridgeSessionState> | undefined;
|
|
49
|
+
if (!data || typeof data.sessionId !== "string" || typeof data.cursor !== "number" || typeof data.cwd !== "string" || typeof data.fingerprint !== "string") continue;
|
|
50
|
+
return data as PersistedBridgeSessionState;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function claudeSessionExists(sessionId: string, cwd: string): boolean {
|
|
56
|
+
try {
|
|
57
|
+
const session = openSession({ sessionId, projectPath: cwd, claudeDir: process.env.CLAUDE_CONFIG_DIR });
|
|
58
|
+
statSync(session.jsonlPath);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function canonicalize(p: string | undefined): string | undefined {
|
|
66
|
+
if (!p) return undefined;
|
|
67
|
+
try { return realpathSync.native(p); } catch { return pathResolve(p); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Decides whether a persisted bridge-session marker is safe to restore.
|
|
71
|
+
//
|
|
72
|
+
// The fork case is the load-bearing one: pi/core's createBranchedSession copies
|
|
73
|
+
// every non-label entry from root→leaf into the new session file. That includes
|
|
74
|
+
// our claude-bridge-session markers from the parent. Restoring from them would
|
|
75
|
+
// --resume parent's Claude jsonl on the fork's first turn, leaking conversation
|
|
76
|
+
// past the fork point.
|
|
77
|
+
//
|
|
78
|
+
// Returns undefined when the entry is safe to use, or a short rejection reason
|
|
79
|
+
// for diagnostic logging. Old entries without piSessionId always reject, which
|
|
80
|
+
// degrades safely to the rebuild path.
|
|
81
|
+
export function shouldRestorePersistedBridgeEntry(
|
|
82
|
+
persisted: { piSessionId?: string; cwd: string },
|
|
83
|
+
currentPiSessionId: string | undefined,
|
|
84
|
+
currentCwd: string | undefined,
|
|
85
|
+
): string | undefined {
|
|
86
|
+
if (!persisted.piSessionId) return "missing piSessionId";
|
|
87
|
+
if (currentPiSessionId && persisted.piSessionId !== currentPiSessionId) {
|
|
88
|
+
return `piSessionId mismatch (persisted=${persisted.piSessionId} current=${currentPiSessionId})`;
|
|
89
|
+
}
|
|
90
|
+
if (currentCwd && canonicalize(persisted.cwd) !== canonicalize(currentCwd)) {
|
|
91
|
+
return `cwd mismatch (persisted=${persisted.cwd} current=${currentCwd})`;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function restoreSharedSessionFromPi(ctx: { sessionManager?: unknown; cwd?: string }): void {
|
|
97
|
+
const persisted = latestPersistedBridgeSession(ctx.sessionManager);
|
|
98
|
+
if (!persisted) return;
|
|
99
|
+
const currentPiSessionId = typeof (ctx.sessionManager as any)?.getSessionId === "function" ? (ctx.sessionManager as any).getSessionId() : undefined;
|
|
100
|
+
const currentCwd = typeof (ctx.sessionManager as any)?.getCwd === "function" ? (ctx.sessionManager as any).getCwd() : ctx.cwd;
|
|
101
|
+
const rejection = shouldRestorePersistedBridgeEntry(persisted, currentPiSessionId, currentCwd);
|
|
102
|
+
if (rejection) {
|
|
103
|
+
debug(`restoreSharedSession: ${rejection} — forcing rebuild`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const built = readBuiltSessionContext(ctx.sessionManager);
|
|
107
|
+
if (!built) return;
|
|
108
|
+
const cursor = Math.max(0, Math.min(persisted.cursor, built.messages.length));
|
|
109
|
+
const fingerprint = fingerprintMessages(built.messages.slice(0, cursor));
|
|
110
|
+
if (fingerprint !== persisted.fingerprint) {
|
|
111
|
+
debug(`restoreSharedSession: fingerprint mismatch for ${persisted.sessionId.slice(0, 8)}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!claudeSessionExists(persisted.sessionId, persisted.cwd)) {
|
|
115
|
+
debug(`restoreSharedSession: Claude session missing for ${persisted.sessionId.slice(0, 8)}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
setSharedSession({ sessionId: persisted.sessionId, cursor, cwd: persisted.cwd });
|
|
119
|
+
debug(`restoreSharedSession: restored ${persisted.sessionId.slice(0, 8)}, cursor=${cursor}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function schedulePersistSharedSession(ctxLike?: { sessionManager?: unknown }): void {
|
|
123
|
+
if (!extensionApi || !sharedSession || !ctxLike?.sessionManager) return;
|
|
124
|
+
const snapshot = { ...sharedSession };
|
|
125
|
+
const timer = setTimeout(() => {
|
|
126
|
+
try {
|
|
127
|
+
const built = readBuiltSessionContext(ctxLike.sessionManager);
|
|
128
|
+
if (!built) return;
|
|
129
|
+
const cursor = Math.max(0, Math.min(snapshot.cursor, built.messages.length));
|
|
130
|
+
const data: PersistedBridgeSessionState = {
|
|
131
|
+
...snapshot,
|
|
132
|
+
cursor,
|
|
133
|
+
fingerprint: fingerprintMessages(built.messages.slice(0, cursor)),
|
|
134
|
+
piSessionId: typeof (ctxLike.sessionManager as any)?.getSessionId === "function" ? (ctxLike.sessionManager as any).getSessionId() : undefined,
|
|
135
|
+
updatedAt: new Date().toISOString(),
|
|
136
|
+
};
|
|
137
|
+
extensionApi?.appendEntry(BRIDGE_SESSION_CUSTOM_TYPE, data);
|
|
138
|
+
debug(`persistSharedSession: saved ${data.sessionId.slice(0, 8)}, cursor=${data.cursor}`);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
debug("persistSharedSession failed:", error);
|
|
141
|
+
}
|
|
142
|
+
}, 0);
|
|
143
|
+
timer.unref?.();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Convert pi messages to Anthropic API format for session import.
|
|
147
|
+
// Lossy: non-Anthropic thinking blocks are dropped (no valid signature). User and
|
|
148
|
+
// tool-result image blocks are preserved when possible. If assistant blocks are
|
|
149
|
+
// otherwise incompatible, convertPiMessages emits a text placeholder so the record
|
|
150
|
+
// sequence stays valid before repairToolPairing runs.
|
|
151
|
+
function convertAndImportMessages(
|
|
152
|
+
session: ReturnType<typeof createSession>,
|
|
153
|
+
messages: Context["messages"],
|
|
154
|
+
customToolNameToSdk?: Map<string, string>,
|
|
155
|
+
cwd?: string,
|
|
156
|
+
): void {
|
|
157
|
+
const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
|
|
158
|
+
|
|
159
|
+
debug(`convertAndImportMessages: ${messages.length} pi msgs → ${anthropicMessages.length} anthropic msgs`);
|
|
160
|
+
debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
|
|
161
|
+
const c = m.content;
|
|
162
|
+
if (typeof c === "string") return `[${i}]${m.role}:text`;
|
|
163
|
+
if (Array.isArray(c)) return `[${i}]${m.role}:${(c).map((b) => b.type).join("+")}`;
|
|
164
|
+
return `[${i}]${m.role}:?`;
|
|
165
|
+
}).join(" "));
|
|
166
|
+
if (sanitizedIds.size > 0) {
|
|
167
|
+
debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
|
|
168
|
+
[...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
|
|
169
|
+
}
|
|
170
|
+
// Pre-repair for debug logging; importMessages also repairs internally (idempotent).
|
|
171
|
+
const missingToolResults = findUnpairedToolUses(anthropicMessages);
|
|
172
|
+
const repaired = repairToolPairing(anthropicMessages);
|
|
173
|
+
if (missingToolResults.length > 0) {
|
|
174
|
+
reportSyntheticToolResultRepair(missingToolResults, {
|
|
175
|
+
cwd,
|
|
176
|
+
messageCount: messages.length,
|
|
177
|
+
anthropicMessageCount: anthropicMessages.length,
|
|
178
|
+
sessionId: session.sessionId,
|
|
179
|
+
jsonlPath: session.jsonlPath,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (repaired.length !== anthropicMessages.length) {
|
|
183
|
+
debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
|
|
184
|
+
}
|
|
185
|
+
if (repaired.length) session.importMessages(repaired);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface SyncResult {
|
|
189
|
+
sessionId: string | null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Ensure the shared session has all messages up to (but not including) the last user message.
|
|
194
|
+
* Returns session ID to resume from, or null if no resume needed.
|
|
195
|
+
*/
|
|
196
|
+
// Read the session file we just wrote and sanity-check it. Warns instead of
|
|
197
|
+
// throwing — CC may be more tolerant than our checks, so a false positive
|
|
198
|
+
// shouldn't block the user. Pure logic is in session-verify.js; this wrapper
|
|
199
|
+
// fans each warning out to debug log + piUI notify + diagDump.
|
|
200
|
+
function verifyWrittenSession(
|
|
201
|
+
jsonlPath: string,
|
|
202
|
+
expectedSessionId: string,
|
|
203
|
+
expectedRecordCount: number,
|
|
204
|
+
cwd: string,
|
|
205
|
+
): void {
|
|
206
|
+
const warnings = _verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount);
|
|
207
|
+
for (const msg of warnings) {
|
|
208
|
+
debug(`WARNING session verify: ${msg}`);
|
|
209
|
+
piUI?.notify(
|
|
210
|
+
`Session file issue: ${msg}\n` +
|
|
211
|
+
`cwd=${cwd} realpath=${safeRealpath(cwd)} CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"}\n` +
|
|
212
|
+
`Please copy and paste this message into a new issue at https://github.com/elidickinson/pi-claude-bridge/issues/new` +
|
|
213
|
+
(DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
|
|
214
|
+
"warning",
|
|
215
|
+
);
|
|
216
|
+
diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? null });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function safeRealpath(p: string): string {
|
|
221
|
+
try { return realpathSync(p); } catch (e) { return `<failed: ${(e as Error).message}>`; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Diagnostic snapshot of where a session file was just written. Catches the
|
|
225
|
+
// class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
|
|
226
|
+
// from ~/.claude/projects/<Y> (symlinks, CLAUDE_CONFIG_DIR, hash mismatch).
|
|
227
|
+
function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
|
|
228
|
+
const realCwd = safeRealpath(cwd);
|
|
229
|
+
let fileSize: number | null = null;
|
|
230
|
+
let fileExists = false;
|
|
231
|
+
try {
|
|
232
|
+
const st = statSync(jsonlPath);
|
|
233
|
+
fileExists = true;
|
|
234
|
+
fileSize = st.size;
|
|
235
|
+
} catch { /* file may not exist yet */ }
|
|
236
|
+
debug(`${label}: cwd=${cwd}`);
|
|
237
|
+
if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${realCwd} (DIFFERS — symlink-resolved path is what CC SDK uses)`);
|
|
238
|
+
debug(`${label}: jsonlPath=${jsonlPath}`);
|
|
239
|
+
debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
|
|
240
|
+
debug(`${label}: env.CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"} HOME=${process.env.HOME ?? "(unset)"}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Two semantic paths:
|
|
244
|
+
// REUSE — pi's history is in sync with the existing sharedSession (or drifted
|
|
245
|
+
// only by the trailing final-assistant message that pi appends after
|
|
246
|
+
// streamSimple returns, which CC's own persisted session already has).
|
|
247
|
+
// Returns the existing sessionId. Keeps CC's prompt cache warm.
|
|
248
|
+
// REBUILD — no session yet, or pi's history has diverged (non-trailing
|
|
249
|
+
// missed messages, e.g. another provider took a turn). Wipes the existing
|
|
250
|
+
// session file (if any) and writes a fresh one containing all prior
|
|
251
|
+
// messages, reusing the same sessionId across rebuilds so UUIDs stay
|
|
252
|
+
// stable for the lifetime of pi's session.
|
|
253
|
+
//
|
|
254
|
+
// Why a full rebuild rather than patching:
|
|
255
|
+
// Injecting deltas into an existing session creates a branch that CC's
|
|
256
|
+
// --resume doesn't follow (documented attempt prior to this). A complete
|
|
257
|
+
// overwrite at the same path is simpler and correct.
|
|
258
|
+
//
|
|
259
|
+
// Why reuse the sessionId across rebuilds:
|
|
260
|
+
// CC re-reads the JSONL on every --resume call — no in-process UUID
|
|
261
|
+
// caching. Validated in tests/exp-session-clear.mjs, including the case
|
|
262
|
+
// where CC had appended its own tool_use/tool_result records between
|
|
263
|
+
// rebuilds. Preserving the UUID means stable log correlation across
|
|
264
|
+
// provider switches and no orphaned session files.
|
|
265
|
+
//
|
|
266
|
+
// Log strings still say "Case 1/2/3/4" so existing diagnostics (int-cache.sh,
|
|
267
|
+
// int-session-resume.mjs) keep grepping the same anchors.
|
|
268
|
+
export function syncSharedSession(
|
|
269
|
+
messages: Context["messages"],
|
|
270
|
+
cwd: string,
|
|
271
|
+
customToolNameToSdk?: Map<string, string>,
|
|
272
|
+
modelId?: string,
|
|
273
|
+
): SyncResult {
|
|
274
|
+
const priorMessages = messages.slice(0, -1); // everything before the new user prompt
|
|
275
|
+
|
|
276
|
+
// REUSE path
|
|
277
|
+
if (sharedSession && !sharedSession.needsRebuild) {
|
|
278
|
+
const missed = priorMessages.slice(sharedSession.cursor);
|
|
279
|
+
const trailingAssistantOnly =
|
|
280
|
+
missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
|
|
281
|
+
if (missed.length === 0 || trailingAssistantOnly) {
|
|
282
|
+
if (trailingAssistantOnly) {
|
|
283
|
+
setSharedSession({ ...sharedSession, cursor: priorMessages.length, cwd });
|
|
284
|
+
}
|
|
285
|
+
debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
|
|
286
|
+
debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
|
|
287
|
+
return { sessionId: sharedSession.sessionId };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// REBUILD path
|
|
292
|
+
if (priorMessages.length === 0) {
|
|
293
|
+
debug(`Case 1: clean start, ${messages.length} total messages`);
|
|
294
|
+
debug(`syncResult: path=clean-start`);
|
|
295
|
+
return { sessionId: null };
|
|
296
|
+
}
|
|
297
|
+
const previousSessionId = sharedSession?.sessionId;
|
|
298
|
+
const previousCursor = sharedSession?.cursor ?? 0;
|
|
299
|
+
// preserveId: rebuild in place (deleteSession + createSession with the
|
|
300
|
+
// existing UUID), so prompt-cache UUIDs stay stable for log correlation
|
|
301
|
+
// and for any tools that key off them. Skipped only when there's a
|
|
302
|
+
// concurrent writer we shouldn't race — see forceRotate docs above.
|
|
303
|
+
const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
|
|
304
|
+
if (preserveId) {
|
|
305
|
+
// Wipe prior jsonl + companion dir (no-op if nothing to wipe).
|
|
306
|
+
deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
|
|
307
|
+
}
|
|
308
|
+
const session = createSession({
|
|
309
|
+
projectPath: cwd,
|
|
310
|
+
claudeDir: process.env.CLAUDE_CONFIG_DIR,
|
|
311
|
+
...(preserveId ? { sessionId: previousSessionId } : {}),
|
|
312
|
+
...(modelId ? { model: modelId } : {}),
|
|
313
|
+
});
|
|
314
|
+
convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
|
|
315
|
+
session.save();
|
|
316
|
+
verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
|
|
317
|
+
setSharedSession({ sessionId: session.sessionId, cursor: priorMessages.length, cwd });
|
|
318
|
+
if (previousSessionId === undefined) {
|
|
319
|
+
debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
|
|
320
|
+
} else if (preserveId) {
|
|
321
|
+
const missedCount = priorMessages.length - previousCursor;
|
|
322
|
+
debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
|
|
323
|
+
} else {
|
|
324
|
+
debug(`Case 4 post-abort: ${priorMessages.length} total → new session ${session.sessionId.slice(0, 8)} (was ${previousSessionId.slice(0, 8)}, rotated to avoid race with orphan writer), ${session.messages.length} records`);
|
|
325
|
+
}
|
|
326
|
+
debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
|
|
327
|
+
debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
|
|
328
|
+
return { sessionId: session.sessionId };
|
|
329
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type AssistantMessage, type AssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type QueryContext } from "./query-state.js";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
|
|
5
|
+
export const STREAM_IDLE_BACKOFF_HINT_MS = 60_000;
|
|
6
|
+
export const STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
|
|
7
|
+
|
|
8
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
9
|
+
|
|
10
|
+
export interface StreamIdleWatchdogState {
|
|
11
|
+
activeQuery: unknown | null;
|
|
12
|
+
currentPiStream: AssistantMessageEventStream | null;
|
|
13
|
+
turnOutput: AssistantMessage | null;
|
|
14
|
+
turnSawStreamEvent: boolean;
|
|
15
|
+
turnStarted: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StreamIdleTimeoutInfo {
|
|
19
|
+
idleMs: number;
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StreamIdleWatchdog {
|
|
24
|
+
dispose: () => void;
|
|
25
|
+
noteChunk: () => void;
|
|
26
|
+
refresh: () => void;
|
|
27
|
+
timedOut: () => boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const activeStreamIdleWatchdogs = new WeakMap<QueryContext, StreamIdleWatchdog>();
|
|
31
|
+
|
|
32
|
+
function parseDurationLiteralMs(value: string, defaultUnit: "ms" | "s" = "s"): number | undefined {
|
|
33
|
+
const text = value.trim().toLowerCase();
|
|
34
|
+
if (!text) return undefined;
|
|
35
|
+
if (["off", "false", "disabled", "disable"].includes(text)) return 0;
|
|
36
|
+
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
|
|
37
|
+
if (!match) return undefined;
|
|
38
|
+
const amount = Number(match[1]);
|
|
39
|
+
if (!Number.isFinite(amount) || amount < 0) return undefined;
|
|
40
|
+
const unit = (match[2] ?? defaultUnit).toLowerCase();
|
|
41
|
+
const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(unit)
|
|
42
|
+
? 1
|
|
43
|
+
: ["s", "sec", "secs", "second", "seconds"].includes(unit)
|
|
44
|
+
? 1000
|
|
45
|
+
: ["m", "min", "mins", "minute", "minutes"].includes(unit)
|
|
46
|
+
? 60_000
|
|
47
|
+
: undefined;
|
|
48
|
+
if (multiplier === undefined) return undefined;
|
|
49
|
+
const ms = Math.round(amount * multiplier);
|
|
50
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function streamIdleTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
|
|
54
|
+
const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
|
|
55
|
+
if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
56
|
+
return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatDurationShort(ms: number): string {
|
|
60
|
+
if (ms < 180_000 && ms % 1000 === 0) return `${ms / 1000}s`;
|
|
61
|
+
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
|
|
62
|
+
if (ms % 1000 === 0) return `${ms / 1000}s`;
|
|
63
|
+
return `${ms}ms`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildStreamIdleTimeoutErrorMessage(timeoutMs: number): string {
|
|
67
|
+
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)}.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createStreamIdleWatchdog({
|
|
71
|
+
clearTimer = (timer: TimerHandle) => clearTimeout(timer),
|
|
72
|
+
getState,
|
|
73
|
+
now = () => Date.now(),
|
|
74
|
+
onTimeout,
|
|
75
|
+
setTimer = (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
|
76
|
+
timeoutMs,
|
|
77
|
+
}: {
|
|
78
|
+
clearTimer?: (timer: TimerHandle) => void;
|
|
79
|
+
getState: () => StreamIdleWatchdogState;
|
|
80
|
+
now?: () => number;
|
|
81
|
+
onTimeout: (info: StreamIdleTimeoutInfo) => void;
|
|
82
|
+
setTimer?: (fn: () => void, delayMs: number) => TimerHandle;
|
|
83
|
+
timeoutMs: number;
|
|
84
|
+
}): StreamIdleWatchdog {
|
|
85
|
+
let disposed = false;
|
|
86
|
+
let lastChunkAt = now();
|
|
87
|
+
let timer: TimerHandle | null = null;
|
|
88
|
+
let didTimeout = false;
|
|
89
|
+
|
|
90
|
+
const clear = () => {
|
|
91
|
+
if (!timer) return;
|
|
92
|
+
try { clearTimer(timer); } catch { /* best effort */ }
|
|
93
|
+
timer = null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const shouldMonitor = (state: StreamIdleWatchdogState): boolean => Boolean(
|
|
97
|
+
timeoutMs > 0
|
|
98
|
+
&& state.activeQuery
|
|
99
|
+
&& state.currentPiStream
|
|
100
|
+
&& state.turnOutput
|
|
101
|
+
&& !state.turnStarted
|
|
102
|
+
&& !state.turnSawStreamEvent,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const schedule = () => {
|
|
106
|
+
clear();
|
|
107
|
+
if (disposed || didTimeout || timeoutMs <= 0) return;
|
|
108
|
+
const state = getState();
|
|
109
|
+
if (!shouldMonitor(state)) return;
|
|
110
|
+
const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
|
|
111
|
+
const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
|
|
112
|
+
const idleMs = Math.max(0, now() - idleStartedAt);
|
|
113
|
+
if (idleMs >= timeoutMs) {
|
|
114
|
+
didTimeout = true;
|
|
115
|
+
onTimeout({ idleMs, timeoutMs });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
|
|
119
|
+
(timer as { unref?: () => void }).unref?.();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
dispose: () => {
|
|
124
|
+
disposed = true;
|
|
125
|
+
clear();
|
|
126
|
+
},
|
|
127
|
+
noteChunk: () => {
|
|
128
|
+
lastChunkAt = now();
|
|
129
|
+
schedule();
|
|
130
|
+
},
|
|
131
|
+
refresh: schedule,
|
|
132
|
+
timedOut: () => didTimeout,
|
|
133
|
+
};
|
|
134
|
+
}
|