@vanillagreen/pi-claude-bridge 2.0.0 → 3.2.2
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 +40 -135
- package/bundle/connector-inventory.js +6 -3
- package/bundle/index.js +2432 -1123
- package/package.json +11 -20
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +189 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +157 -14
- package/src/config.ts +170 -20
- package/src/connector-cache.ts +43 -13
- package/src/connector-inventory.ts +14 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +286 -40
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +6 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +769 -702
- package/src/models.ts +0 -7
- package/src/native-provider.ts +9 -4
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +296 -40
- package/src/rate-limit.ts +17 -14
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +369 -54
- package/src/tool-pairing-audit.ts +69 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Background SDK-stream consumer + the failure/metadata capture that rides it.
|
|
2
|
+
// Extracted from index.ts (pure move): consumeQuery iterates one SDK query's
|
|
3
|
+
// generator and pushes events into the query's captured Pi stream.
|
|
4
|
+
|
|
5
|
+
import { type Model } from "@earendil-works/pi-ai";
|
|
6
|
+
import { type query } from "@anthropic-ai/claude-agent-sdk";
|
|
7
|
+
import {
|
|
8
|
+
classifyClaudeFailure,
|
|
9
|
+
rateLimitResetFromInfo,
|
|
10
|
+
rateLimitResetMs,
|
|
11
|
+
rateLimitTypeFromInfo,
|
|
12
|
+
safeRouterCall,
|
|
13
|
+
type ClaudeAccountFailureKind,
|
|
14
|
+
type ClaudeAccountRoute,
|
|
15
|
+
type ClaudeAccountRouterV1,
|
|
16
|
+
} from "./account-router.js";
|
|
17
|
+
import { ensureTurnStarted, noteChildExecutedToolResults, processAssistantMessage, processStreamEvent, updateTurnOutputModel } from "./assistant-stream.js";
|
|
18
|
+
import { extensionApi, safeNotify } from "./bridge-state.js";
|
|
19
|
+
import { type Config } from "./config.js";
|
|
20
|
+
import { debug } from "./debug.js";
|
|
21
|
+
import { fallbackModelForPrimaryModel, modelDisplayName } from "./models.js";
|
|
22
|
+
import { type QueryContext } from "./query-state.js";
|
|
23
|
+
import { RATE_LIMIT_AUTO_RESUME_EVENT, RATE_LIMIT_TOKEN, formatAllowedRateLimitWarning, formatResetTimestamp, isUsageLimitMessage, uniqueNonEmptyLines } from "./rate-limit.js";
|
|
24
|
+
import { activeStreamIdleWatchdogs } from "./stream-idle-watchdog.js";
|
|
25
|
+
|
|
26
|
+
export function emitRateLimitEvent(payload: Record<string, unknown>): void {
|
|
27
|
+
try {
|
|
28
|
+
extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
|
|
29
|
+
} catch {
|
|
30
|
+
// Cross-extension broker is best-effort only.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The fastMode setting silently no-ops when Claude Code declines fast mode.
|
|
35
|
+
// Surface the typed fast_mode_disabled_reason (SDK 0.3.219+) once per distinct
|
|
36
|
+
// reason so an enabled-but-inert setting explains itself instead of looking
|
|
37
|
+
// broken. Module-level dedup: the same reason repeats on every init message.
|
|
38
|
+
let lastFastModeDisabledNoticeReason: string | null = null;
|
|
39
|
+
|
|
40
|
+
const FAST_MODE_DISABLED_REASON_TEXT: Record<string, string> = {
|
|
41
|
+
disabled_by_env: "disabled by an environment variable",
|
|
42
|
+
extra_usage_disabled: "extra usage is disabled for this account",
|
|
43
|
+
free: "not available on the free plan",
|
|
44
|
+
model_not_allowed: "not available for this model",
|
|
45
|
+
network_error: "the eligibility check hit a network error",
|
|
46
|
+
not_first_party: "not available for this account type",
|
|
47
|
+
preference: "disabled by a Claude Code preference",
|
|
48
|
+
sdk_opt_in_required: "the SDK opt-in is missing",
|
|
49
|
+
unknown: "unavailable for an unknown reason",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function noteFastModeDisabledReason(message: unknown, bridgeConfig: Config): void {
|
|
53
|
+
if (bridgeConfig.provider?.fastMode !== true) return;
|
|
54
|
+
const reason = (message as { fast_mode_disabled_reason?: unknown }).fast_mode_disabled_reason;
|
|
55
|
+
// "pending" means the CLI is still deciding — not a verdict worth announcing.
|
|
56
|
+
if (typeof reason !== "string" || reason === "pending") return;
|
|
57
|
+
if (reason === lastFastModeDisabledNoticeReason) return;
|
|
58
|
+
lastFastModeDisabledNoticeReason = reason;
|
|
59
|
+
const text = FAST_MODE_DISABLED_REASON_TEXT[reason] ?? `unavailable (${reason})`;
|
|
60
|
+
safeNotify(`Pi Claude: fast mode is enabled in settings but Claude Code declined it — ${text}.`, "warning");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
|
|
64
|
+
* Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
|
|
65
|
+
* an assistant message (completed blocks). On tool_use, the stream is ended by
|
|
66
|
+
* whichever path handles it first (processStreamEvent or processAssistantMessage),
|
|
67
|
+
* and the MCP handler blocks the generator until pi delivers the tool result. */
|
|
68
|
+
export interface ClaudeAttemptFailure {
|
|
69
|
+
kind?: ClaudeAccountFailureKind;
|
|
70
|
+
message: string;
|
|
71
|
+
rateLimitInfo?: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ConsumeQueryResult {
|
|
75
|
+
capturedSessionId?: string;
|
|
76
|
+
failure?: ClaudeAttemptFailure;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function consumeQuery(
|
|
80
|
+
sdkQuery: ReturnType<typeof query>,
|
|
81
|
+
// The CAPTURED context of the query being consumed, never the live ctx():
|
|
82
|
+
// an MCP tool can push a reentrant subagent context while this iterator is
|
|
83
|
+
// suspended, and reading live state then would consult the WRONG query — a
|
|
84
|
+
// recovered success could retain its failure and surface an error, or a
|
|
85
|
+
// child session id could stamp the subagent's context.
|
|
86
|
+
queryCtx: QueryContext,
|
|
87
|
+
customToolNameToPi: Map<string, string>,
|
|
88
|
+
model: Model<any>,
|
|
89
|
+
bridgeConfig: Config,
|
|
90
|
+
wasAborted: () => boolean,
|
|
91
|
+
account?: ClaudeAccountRoute,
|
|
92
|
+
router?: ClaudeAccountRouterV1,
|
|
93
|
+
// Mirror of the held failure for the caller's .catch: the SDK iterator can
|
|
94
|
+
// THROW after the failure-signal message (a rejected rate_limit_event is the
|
|
95
|
+
// known case), and the rejection loses this function's return value. Without
|
|
96
|
+
// the mirror the catch re-classifies from the thrown error, misses
|
|
97
|
+
// rateLimitInfo, and double-counts the router cooldown.
|
|
98
|
+
attemptFailureBox?: { failure?: ClaudeAttemptFailure },
|
|
99
|
+
): Promise<ConsumeQueryResult> {
|
|
100
|
+
let capturedSessionId: string | undefined;
|
|
101
|
+
let failure: ClaudeAttemptFailure | undefined;
|
|
102
|
+
let accountProbe: Promise<void> | undefined;
|
|
103
|
+
const holdFailure = (next: ClaudeAttemptFailure | undefined): void => {
|
|
104
|
+
failure = next;
|
|
105
|
+
if (attemptFailureBox) attemptFailureBox.failure = next;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
for await (const message of sdkQuery) {
|
|
109
|
+
if (wasAborted()) break;
|
|
110
|
+
activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
|
|
111
|
+
if (account) {
|
|
112
|
+
// Thunk, not a value: this runs once per SDK message — including one
|
|
113
|
+
// stream_event per streamed token — and debug() only evaluates function
|
|
114
|
+
// args after its DEBUG early return (VST-15).
|
|
115
|
+
debug("consumeQuery: managed message", () => JSON.stringify({
|
|
116
|
+
type: message.type,
|
|
117
|
+
subtype: (message as any).subtype,
|
|
118
|
+
error: (message as any).error,
|
|
119
|
+
eventType: (message as any).event?.type,
|
|
120
|
+
deltaType: (message as any).event?.delta?.type,
|
|
121
|
+
contentType: (message as any).event?.content_block?.type,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
if (!queryCtx.turnOutput) continue;
|
|
125
|
+
// Only RENDERING needs a live Pi stream. Failure metadata and
|
|
126
|
+
// child-executed tool results must be captured even when a tool-use turn
|
|
127
|
+
// boundary has nulled the stream — skipping them there dropped terminal
|
|
128
|
+
// failure classification and audited late connector results "unobserved".
|
|
129
|
+
const streamLive = Boolean(queryCtx.currentPiStream);
|
|
130
|
+
|
|
131
|
+
switch (message.type) {
|
|
132
|
+
case "stream_event":
|
|
133
|
+
if (!streamLive) break;
|
|
134
|
+
processStreamEvent(message, customToolNameToPi, model, queryCtx);
|
|
135
|
+
break;
|
|
136
|
+
case "assistant": {
|
|
137
|
+
// Claude Code emits a synthetic assistant text block carrying friendly
|
|
138
|
+
// rate/auth error copy before the SDK throws. On a managed attempt it
|
|
139
|
+
// is not model output: forwarding it would commit the stream and make
|
|
140
|
+
// safe pre-output account failover impossible, so hold it as failure
|
|
141
|
+
// metadata (with or without a live stream). A legacy attempt renders
|
|
142
|
+
// it exactly as before.
|
|
143
|
+
const sdkError = (message as any).error;
|
|
144
|
+
if (sdkError && account) {
|
|
145
|
+
if (!failure) holdFailure({ kind: classifyClaudeFailure(sdkError), message: String(sdkError) });
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
if (!streamLive && !queryCtx.turnSawToolCall) break;
|
|
149
|
+
processAssistantMessage(message, model, customToolNameToPi, queryCtx);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "result":
|
|
153
|
+
// A failure signal followed by a result whose visible output already
|
|
154
|
+
// committed (e.g. the SDK's fallback-model reroute recovering after a
|
|
155
|
+
// rejected rate limit) means the query ultimately SUCCEEDED: the
|
|
156
|
+
// failure was informational and must neither surface an error after
|
|
157
|
+
// the answer nor skip session persistence.
|
|
158
|
+
if (failure && message.subtype === "success" && queryCtx.committedOutput) {
|
|
159
|
+
debug(`consumeQuery: clearing informational ${failure.kind ?? "unclassified"} failure — query recovered with committed output`);
|
|
160
|
+
holdFailure(undefined);
|
|
161
|
+
}
|
|
162
|
+
// The SDK can label the synthetic friendly error carrier as a
|
|
163
|
+
// successful result immediately before its iterator throws. Once a
|
|
164
|
+
// managed attempt holds a terminal failure signal, that text is still
|
|
165
|
+
// error metadata, not assistant output.
|
|
166
|
+
if (account && failure) break;
|
|
167
|
+
if (!queryCtx.turnSawStreamEvent && message.subtype === "success") {
|
|
168
|
+
if (!streamLive) break;
|
|
169
|
+
const text = message.result || "";
|
|
170
|
+
// The no-stream-events assistant fallback may have already rendered
|
|
171
|
+
// this exact text (it does not set turnSawStreamEvent) — re-pushing
|
|
172
|
+
// it here is the other half of the duplicated-output bug.
|
|
173
|
+
if (queryCtx.turnBlocks.some((b: any) => b.type === "text" && b.text === text)) {
|
|
174
|
+
debug("consumeQuery: result text already rendered by assistant fallback; skipping duplicate");
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
ensureTurnStarted(queryCtx);
|
|
178
|
+
queryCtx.turnBlocks.push({ type: "text", text });
|
|
179
|
+
const idx = queryCtx.turnBlocks.length - 1;
|
|
180
|
+
queryCtx.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: queryCtx.turnOutput });
|
|
181
|
+
queryCtx.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: queryCtx.turnOutput });
|
|
182
|
+
queryCtx.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: queryCtx.turnOutput });
|
|
183
|
+
} else if (message.subtype !== "success") {
|
|
184
|
+
const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
|
|
185
|
+
const errors = errorLines.length > 0 ? errorLines.join("\n") : String((message as any).result || message.subtype || "Claude Code request failed");
|
|
186
|
+
const usageLimit = isUsageLimitMessage(message);
|
|
187
|
+
if (!failure || !failure.rateLimitInfo) {
|
|
188
|
+
holdFailure({ kind: usageLimit ? "rate-limit" : classifyClaudeFailure(errors), message: errors });
|
|
189
|
+
}
|
|
190
|
+
// Managed attempts keep terminal error copy buffered as metadata so
|
|
191
|
+
// a pre-output failure can move to another subscription profile.
|
|
192
|
+
if (account) break;
|
|
193
|
+
if (usageLimit) {
|
|
194
|
+
// isUsageLimitMessage matches the CLI's own usage-limit copy (SDK
|
|
195
|
+
// USAGE_LIMIT_ERROR_PREFIXES). Surface it immediately, exactly as
|
|
196
|
+
// before, and suppress the SDK's raw follow-up throw.
|
|
197
|
+
queryCtx.handledTerminalError = true;
|
|
198
|
+
queryCtx.turnOutput.stopReason = "error";
|
|
199
|
+
queryCtx.turnOutput.errorMessage = errors;
|
|
200
|
+
queryCtx.currentPiStream?.push({ type: "error", reason: "error", error: queryCtx.turnOutput });
|
|
201
|
+
queryCtx.currentPiStream?.end();
|
|
202
|
+
queryCtx.currentPiStream = null;
|
|
203
|
+
}
|
|
204
|
+
// Other non-success subtypes (error_max_turns,
|
|
205
|
+
// error_during_execution) surface at completion via the held
|
|
206
|
+
// failure — an explicit error event where these turns previously
|
|
207
|
+
// ended silently. Session persistence and deferred replay still run.
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
case "system":
|
|
211
|
+
if (!streamLive) break;
|
|
212
|
+
if ((message as any).subtype === "init" && (message as any).session_id) {
|
|
213
|
+
capturedSessionId = (message as any).session_id;
|
|
214
|
+
// Also on this message's query context, so the connector-call audit
|
|
215
|
+
// trail can name the child session that executed a call — including
|
|
216
|
+
// from the teardown flush, which runs outside this function's scope.
|
|
217
|
+
queryCtx.childSessionId = capturedSessionId;
|
|
218
|
+
noteFastModeDisabledReason(message, bridgeConfig);
|
|
219
|
+
if (account && router && !accountProbe) {
|
|
220
|
+
accountProbe = Promise.allSettled([
|
|
221
|
+
sdkQuery.accountInfo().then((info) => router.recordIdentity(account.profileId, {
|
|
222
|
+
email: info.email,
|
|
223
|
+
organization: info.organization,
|
|
224
|
+
subscriptionType: info.subscriptionType,
|
|
225
|
+
})),
|
|
226
|
+
sdkQuery.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()
|
|
227
|
+
.then((usage) => router.recordUsage(account.profileId, usage)),
|
|
228
|
+
]).then((results) => {
|
|
229
|
+
const labels = ["recordIdentity", "recordUsage"];
|
|
230
|
+
results.forEach((result, i) => {
|
|
231
|
+
if (result.status === "rejected") debug(`consumeQuery: account probe ${labels[i]} rejected:`, result.reason);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
} else if ((message as any).subtype === "model_refusal_fallback") {
|
|
236
|
+
const originalModel = (message as any).original_model;
|
|
237
|
+
const fallbackModel = (message as any).fallback_model;
|
|
238
|
+
updateTurnOutputModel(fallbackModel, queryCtx);
|
|
239
|
+
debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
|
|
240
|
+
// Notify only for reroutes we configured, so an unexpected pairing from
|
|
241
|
+
// Claude Code is still logged above but not announced as one of ours.
|
|
242
|
+
if (typeof fallbackModel === "string" && typeof originalModel === "string" && fallbackModelForPrimaryModel(originalModel) === fallbackModel) {
|
|
243
|
+
safeNotify(
|
|
244
|
+
`Pi Claude switched ${modelDisplayName(originalModel)} to ${modelDisplayName(fallbackModel)} after Claude Code safety fallback.`,
|
|
245
|
+
"info",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
case "user":
|
|
251
|
+
// Mostly the SDK echoing the prompt back — nothing to render. The one
|
|
252
|
+
// thing worth reading is a child-executed tool's real result, which
|
|
253
|
+
// arrives here and nowhere else — including AFTER a tool-use turn
|
|
254
|
+
// boundary nulled the stream (noteChildExecutedToolResults is
|
|
255
|
+
// side-effect-free on the Pi stream).
|
|
256
|
+
noteChildExecutedToolResults(message, queryCtx);
|
|
257
|
+
break;
|
|
258
|
+
case "rate_limit_event": {
|
|
259
|
+
if (!streamLive) break;
|
|
260
|
+
const info = (message as any).rate_limit_info as Record<string, unknown> | undefined;
|
|
261
|
+
debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
262
|
+
if (info?.status === "rejected") {
|
|
263
|
+
const rateLimitType = rateLimitTypeFromInfo(info);
|
|
264
|
+
const resetAt = rateLimitResetFromInfo(info);
|
|
265
|
+
const resetAtMs = rateLimitResetMs(info);
|
|
266
|
+
const reason = `${rateLimitType ?? "unknown"} rate limit`;
|
|
267
|
+
if (account && router) {
|
|
268
|
+
// Rotation may still recover this request, so hold the rejection
|
|
269
|
+
// as failure metadata and teach the router the reset time now.
|
|
270
|
+
// Surfacing (event + toast) happens once in surfaceFailure — only
|
|
271
|
+
// if the attempt is not replayed on another profile.
|
|
272
|
+
holdFailure({ kind: "rate-limit", message: reason, rateLimitInfo: info });
|
|
273
|
+
safeRouterCall("recordRateLimit", () => router.recordRateLimit(account.profileId, info, model.id));
|
|
274
|
+
} else {
|
|
275
|
+
// Legacy: notify once and set NO failure state. The SDK's own
|
|
276
|
+
// fallback-model path may still stream a successful recovery, and
|
|
277
|
+
// that turn must complete exactly like any other success.
|
|
278
|
+
const resetsAt = formatResetTimestamp(resetAtMs ?? resetAt);
|
|
279
|
+
emitRateLimitEvent({
|
|
280
|
+
model: model.id,
|
|
281
|
+
provider: model.provider,
|
|
282
|
+
rateLimitType,
|
|
283
|
+
reason,
|
|
284
|
+
resetAt,
|
|
285
|
+
...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
|
|
286
|
+
source: "claude-bridge",
|
|
287
|
+
status: "rejected",
|
|
288
|
+
});
|
|
289
|
+
safeNotify(`${RATE_LIMIT_TOKEN} Claude ${reason} hit — resets ${resetsAt}`, "warning");
|
|
290
|
+
}
|
|
291
|
+
} else if (info?.status === "allowed_warning") {
|
|
292
|
+
const warning = formatAllowedRateLimitWarning(info);
|
|
293
|
+
if (warning) safeNotify(warning, "warning");
|
|
294
|
+
else debug("consumeQuery: suppressed low/ambiguous allowed_warning rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
default:
|
|
299
|
+
debug("consumeQuery: unhandled SDK message type", message.type);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (accountProbe) {
|
|
305
|
+
await Promise.race([
|
|
306
|
+
accountProbe,
|
|
307
|
+
new Promise<void>((resolve) => setTimeout(resolve, 1_500)),
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
debug(`consumeQuery: for-await loop exited, wasAborted=${wasAborted()}, capturedSessionId=${capturedSessionId?.slice(0, 8) ?? "none"}, failure=${failure?.kind ?? "none"}`);
|
|
311
|
+
return { capturedSessionId, failure };
|
|
312
|
+
}
|
package/src/convert.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ContentBlock, Message as SessionMessage } from "cc-session-io";
|
|
|
6
6
|
import { pascalCase } from "change-case";
|
|
7
7
|
import { isChildExecutedTool } from "./connectors.js";
|
|
8
8
|
|
|
9
|
-
export const PROVIDER_ID = "claude
|
|
9
|
+
export const PROVIDER_ID = "pi-claude";
|
|
10
10
|
|
|
11
11
|
export const PI_TO_SDK_TOOL_NAME: Record<string, string> = {
|
|
12
12
|
read: "Read", write: "Write", edit: "Edit", bash: "Bash",
|
|
@@ -134,21 +134,17 @@ export function convertPiMessages(
|
|
|
134
134
|
if (toolMessages.length === 0) return;
|
|
135
135
|
anthropicMessages.push({
|
|
136
136
|
role: "user",
|
|
137
|
-
content: toolMessages.map((toolMsg) =>
|
|
138
|
-
const content = toolResultContentToAnthropic(toolMsg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>);
|
|
139
|
-
return {
|
|
140
|
-
type: "tool_result",
|
|
141
|
-
tool_use_id: sanitizeToolId((toolMsg as { toolCallId: string }).toolCallId, sanitizedIds),
|
|
142
|
-
content: content || "",
|
|
143
|
-
is_error: (toolMsg as { isError?: boolean }).isError,
|
|
144
|
-
};
|
|
145
|
-
}),
|
|
137
|
+
content: toolMessages.map((toolMsg) => toolResultToAnthropicBlock(toolMsg, sanitizedIds)),
|
|
146
138
|
});
|
|
147
139
|
};
|
|
148
140
|
|
|
149
141
|
for (let i = 0; i < messages.length; i++) {
|
|
150
142
|
const msg = messages[i];
|
|
151
143
|
if (msg.role === "user") {
|
|
144
|
+
// Rebuild imports each pi user message as its OWN record. The REUSE path
|
|
145
|
+
// (extractUserPrompt/extractUserPromptBlocks in index.ts) instead merges a
|
|
146
|
+
// trailing user run into one "\n\n"-joined prompt — accepted divergence,
|
|
147
|
+
// see the comment there; the merged form is never re-imported here.
|
|
152
148
|
anthropicMessages.push(userMessageToAnthropic(msg));
|
|
153
149
|
} else if (msg.role === "assistant") {
|
|
154
150
|
const content = Array.isArray(msg.content) ? msg.content : [];
|
package/src/debug.ts
CHANGED
|
@@ -13,11 +13,27 @@ export function diagLogPath(): string {
|
|
|
13
13
|
return process.env.CLAUDE_BRIDGE_DIAG_PATH || join(piUserDir(), "claude-bridge-diag.log");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/** Trailing clause for user-facing integrity notifications. With DEBUG on the
|
|
17
|
+
* diag log exists and is worth pointing at; without it the file was never
|
|
18
|
+
* written (diagDump early-returns), so point at the switch that would have
|
|
19
|
+
* captured a dump instead of at a path that does not exist. */
|
|
20
|
+
export function diagGuidance(): string {
|
|
21
|
+
return DEBUG
|
|
22
|
+
? `see ${diagLogPath()}`
|
|
23
|
+
: "re-run with CLAUDE_BRIDGE_DEBUG=1 to capture a diagnostic dump";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Ensure log directories exist when debug is enabled. 0o700/0o600 throughout:
|
|
27
|
+
// these logs carry prompt previews and session metadata and belong to the user
|
|
28
|
+
// alone — same discipline as diagDump.
|
|
17
29
|
if (DEBUG) {
|
|
18
30
|
try {
|
|
19
|
-
mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
|
|
31
|
+
mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true, mode: 0o700 });
|
|
20
32
|
mkdirSync(dirname(diagLogPath()), { recursive: true, mode: 0o700 });
|
|
33
|
+
// mode on mkdir/append applies only at CREATION — repair permissions on
|
|
34
|
+
// dirs and logs that predate the 0o700/0o600 hardening.
|
|
35
|
+
chmodSync(dirname(DEBUG_LOG_PATH), 0o700);
|
|
36
|
+
chmodSync(DEBUG_LOG_PATH, 0o600);
|
|
21
37
|
} catch {
|
|
22
38
|
// If directory creation fails, debug functions will throw on first use
|
|
23
39
|
}
|
|
@@ -32,10 +48,48 @@ export function debug(...args: unknown[]) {
|
|
|
32
48
|
const fmt = (a: unknown): string => {
|
|
33
49
|
if (typeof a === "string") return a;
|
|
34
50
|
if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
|
|
51
|
+
// A function argument is a lazy payload: hot-path call sites (per-token
|
|
52
|
+
// stream events) pass a thunk so the expensive formatting only runs when
|
|
53
|
+
// DEBUG is on — fmt is only reached past the early return (VST-15).
|
|
54
|
+
if (typeof a === "function") return fmt((a as () => unknown)());
|
|
35
55
|
return JSON.stringify(a);
|
|
36
56
|
};
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
// A throwing thunk or JSON.stringify (circular structure, BigInt) must not
|
|
58
|
+
// escape debug() — one call site sits inside the SDK stream loop, where a
|
|
59
|
+
// formatting failure would abort the user's turn exactly when they enabled
|
|
60
|
+
// debugging. A failed arg degrades to a placeholder; the rest still log.
|
|
61
|
+
const safeFmt = (a: unknown): string => {
|
|
62
|
+
let out: string | undefined;
|
|
63
|
+
try {
|
|
64
|
+
out = fmt(a);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// The caught value's own conversion can throw too (a thrown
|
|
67
|
+
// null-prototype object, a throwing toString, an Error whose
|
|
68
|
+
// message is a Symbol — template interpolation would rethrow) —
|
|
69
|
+
// degrade to a constant rather than let the placeholder escape.
|
|
70
|
+
let reason = "formatting failed";
|
|
71
|
+
try {
|
|
72
|
+
reason = String(error instanceof Error ? error.message : error);
|
|
73
|
+
} catch { /* keep the constant */ }
|
|
74
|
+
return `[unprintable: ${reason}]`;
|
|
75
|
+
}
|
|
76
|
+
// JSON.stringify returns undefined (not a string) for undefined,
|
|
77
|
+
// Symbol, AND objects whose toJSON returns undefined — render the
|
|
78
|
+
// slot explicitly instead of letting join() silently drop it. That
|
|
79
|
+
// last shape means `a` can be an arbitrary object here, and its
|
|
80
|
+
// toString can throw, so this conversion needs the same guard as the
|
|
81
|
+
// catch path. (A thunk that returned one of these renders as plain
|
|
82
|
+
// "undefined".)
|
|
83
|
+
if (out !== undefined) return out;
|
|
84
|
+
if (typeof a === "function") return "undefined";
|
|
85
|
+
try {
|
|
86
|
+
return String(a);
|
|
87
|
+
} catch {
|
|
88
|
+
return "[unprintable: formatting failed]";
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const msg = args.map(safeFmt).join(" ");
|
|
92
|
+
try { appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`, { mode: 0o600 }); } catch { /* debug is best effort */ }
|
|
39
93
|
}
|
|
40
94
|
|
|
41
95
|
// Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
|
|
@@ -50,7 +104,7 @@ export function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?:
|
|
|
50
104
|
const seq = nextCliDebugSeq++;
|
|
51
105
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
52
106
|
const logDir = join(dirname(DEBUG_LOG_PATH), "cc-cli-logs");
|
|
53
|
-
try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
|
|
107
|
+
try { mkdirSync(logDir, { recursive: true, mode: 0o700 }); chmodSync(logDir, 0o700); } catch { /* ignore */ }
|
|
54
108
|
const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
|
|
55
109
|
debug(`cli-debug: ${tag} #${seq} → ${debugFile}`);
|
|
56
110
|
return {
|
|
@@ -64,8 +118,12 @@ export function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?:
|
|
|
64
118
|
};
|
|
65
119
|
}
|
|
66
120
|
|
|
67
|
-
/**
|
|
121
|
+
/** Diagnostic dump — for "should never happen" paths. Gated on the same
|
|
122
|
+
* CLAUDE_BRIDGE_DEBUG flag as debug(): the entries carry session metadata
|
|
123
|
+
* and land in a log outside any host app's retention/cleanup boundary, so
|
|
124
|
+
* a host that has not opted into debugging must get no disk write (VST-15). */
|
|
68
125
|
export function diagDump(label: string, data: Record<string, unknown>) {
|
|
126
|
+
if (!DEBUG) return;
|
|
69
127
|
try {
|
|
70
128
|
const ts = new Date().toISOString();
|
|
71
129
|
const entry = { ts, moduleInstanceId, label, ...data };
|