@vanillagreen/pi-claude-bridge 3.2.2 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -90
- package/bundle/index.js +33818 -25171
- package/package.json +8 -7
- package/src/account-router.ts +5 -5
- package/src/assistant-stream.ts +94 -79
- package/src/auth-presence.ts +1 -2
- package/src/bridge-commands.ts +2 -2
- package/src/bridge-state.ts +7 -8
- package/src/config.ts +20 -12
- package/src/connector-audit.ts +7 -11
- package/src/connector-cache.ts +6 -7
- package/src/connector-inventory.ts +5 -7
- package/src/connector-runtime.ts +8 -8
- package/src/connectors.ts +34 -35
- package/src/consume-query.ts +3 -3
- package/src/convert.ts +1 -1
- package/src/debug.ts +2 -2
- package/src/index.ts +30 -31
- package/src/models.ts +5 -5
- package/src/query-options.ts +4 -4
- package/src/query-state.ts +38 -44
- package/src/query-teardown.ts +3 -3
- package/src/rate-limit.ts +2 -2
- package/src/request-lane.ts +1 -1
- package/src/session-persistence.ts +10 -10
- package/src/tool-mapping.ts +41 -8
- package/src/tool-pairing-audit.ts +5 -6
package/src/query-options.ts
CHANGED
|
@@ -83,7 +83,7 @@ export function buildClaudeQueryOptions(input: BuildClaudeQueryOptionsInput): Bu
|
|
|
83
83
|
const connectorWriteMode = connectorWriteModeFor(bridgeConfig);
|
|
84
84
|
// Declare the account's connected connectors explicitly so `alwaysLoad` can
|
|
85
85
|
// hold startup until they attach — otherwise the turn-1 manifest is built
|
|
86
|
-
// before the CLI has fetched them
|
|
86
|
+
// before the CLI has fetched them.
|
|
87
87
|
const connectorServers = enableCloudMcp ? connectorServersSnapshot(accountScope.claudeConfigDir) : {};
|
|
88
88
|
const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
|
|
89
89
|
const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
|
|
@@ -98,7 +98,7 @@ export function buildClaudeQueryOptions(input: BuildClaudeQueryOptionsInput): Bu
|
|
|
98
98
|
// ignores auto-discovered filesystem MCP servers while Pi owns tool execution.
|
|
99
99
|
// Connectors mode needs settings resolution ON but restricted to USER scope
|
|
100
100
|
// only — project/local settings files can smuggle `env`/`apiKeyHelper` from
|
|
101
|
-
// a hostile checkout
|
|
101
|
+
// a hostile checkout. Full rationale on settingSourcesForQuery.
|
|
102
102
|
const settingSources: SettingSource[] | undefined = settingSourcesForQuery(
|
|
103
103
|
enableCloudMcp, appendSystemPrompt, providerSettings.settingSources);
|
|
104
104
|
const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
|
|
@@ -113,7 +113,7 @@ export function buildClaudeQueryOptions(input: BuildClaudeQueryOptionsInput): Bu
|
|
|
113
113
|
|
|
114
114
|
const extraArgs: Record<string, string | null> = {};
|
|
115
115
|
// Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
|
|
116
|
-
// Force summarized so thinking_delta events arrive.
|
|
116
|
+
// Force summarized so thinking_delta events arrive.
|
|
117
117
|
// Deliberately the raw flag, NOT the typed `thinking` option: every non-disabled
|
|
118
118
|
// ThinkingConfig also emits `--thinking adaptive` or `--max-thinking-tokens`
|
|
119
119
|
// (verified in sdk.mjs flag mapping), so the typed form cannot set display
|
|
@@ -136,7 +136,7 @@ export function buildClaudeQueryOptions(input: BuildClaudeQueryOptionsInput): Bu
|
|
|
136
136
|
// DISABLE_AUTO_COMPACT=1: pi owns context-management and propagates its own
|
|
137
137
|
// /compact via session_compact (see handler in the extension entry). Letting CC
|
|
138
138
|
// also autocompact would double-flush the prompt cache and races pi's
|
|
139
|
-
|
|
139
|
+
// threshold with CC's, including CC's anti-thrashing guard.
|
|
140
140
|
// Manual /compact in CC still works (we never invoke it).
|
|
141
141
|
// When connectors are enabled, allow claude.ai cloud MCP servers so the
|
|
142
142
|
// authenticated account's Gmail/Calendar/Drive tools load. Default stays "0".
|
package/src/query-state.ts
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// All per-query and per-turn mutable state lives here. Reentrant queries
|
|
4
4
|
// (subagents) push the parent context onto a stack and get a fresh instance.
|
|
5
|
-
// Adding a new field = one property on the class.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
6
|
+
// Separate from index.ts so tests can import it without activating the extension.
|
|
8
7
|
|
|
9
8
|
import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
|
10
9
|
import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
|
|
@@ -15,8 +14,7 @@ import { currentRequestLaneId } from "./request-lane.js";
|
|
|
15
14
|
/** A mid-query user run captured for replay after the active query ends.
|
|
16
15
|
* `text` is the joined text form (previews, and the replay prompt when no
|
|
17
16
|
* image blocks were captured). `blocks` is present when the run carried
|
|
18
|
-
* images — the replay must send the blocks or the images are silently lost
|
|
19
|
-
* (vstack#993). */
|
|
17
|
+
* images — the replay must send the blocks or the images are silently lost. */
|
|
20
18
|
export interface DeferredUserMessage {
|
|
21
19
|
text: string;
|
|
22
20
|
blocks?: ContentBlockParam[];
|
|
@@ -25,7 +23,7 @@ export interface DeferredUserMessage {
|
|
|
25
23
|
/** Diag payload for a deferred-message drop: counts, sites, and lengths only.
|
|
26
24
|
* The messages are user-authored prompt text and the diag log sits outside
|
|
27
25
|
* any host app's retention boundary, so no content — not even a preview —
|
|
28
|
-
* may appear in the entry
|
|
26
|
+
* may appear in the entry. */
|
|
29
27
|
export function summarizeDroppedUserMessages(site: string, dropped: DeferredUserMessage[]): Record<string, unknown> {
|
|
30
28
|
return {
|
|
31
29
|
site,
|
|
@@ -40,7 +38,7 @@ export interface PendingToolCall {
|
|
|
40
38
|
/** The MCP invocation's schema-validated arguments. The SDK hands the handler
|
|
41
39
|
* the COMPLETE input, so this is the authoritative copy — the grace-timer
|
|
42
40
|
* finalize settles a still-partial streamed block from here instead of from
|
|
43
|
-
* its truncated partial JSON (
|
|
41
|
+
* its truncated partial JSON (a `{}` settle would make Pi execute
|
|
44
42
|
* empty-argument calls). */
|
|
45
43
|
args: Record<string, unknown>;
|
|
46
44
|
/** `QueryContext.callbackGeneration` at registration. A handler from an older
|
|
@@ -106,7 +104,7 @@ export function strandedToolCallResult(): McpResult {
|
|
|
106
104
|
* forwarded (Pi owes it a result — steer-split deliveries arrive turns later)
|
|
107
105
|
* or nothing is waiting. Marks the id dead so a lagging stream replay can
|
|
108
106
|
* never forward it AFTER the model was told it failed — that late forward
|
|
109
|
-
* would execute the call a second time behind the model's back
|
|
107
|
+
* would execute the call a second time behind the model's back.
|
|
110
108
|
* Returns true when a handler was failed. */
|
|
111
109
|
export function failStrandedToolCall(queryCtx: QueryContext, id: string): boolean {
|
|
112
110
|
if (queryCtx.forwardedToolCallIds.has(id)) return false;
|
|
@@ -166,7 +164,7 @@ export function takeQueuedOrParkedResult(queryCtx: QueryContext, id: string): Mc
|
|
|
166
164
|
export interface ConnectorCallAuditState {
|
|
167
165
|
name: string;
|
|
168
166
|
/** The child session that issued it, captured when the call was seen — a
|
|
169
|
-
* continuation query gets
|
|
167
|
+
* continuation query gets its own, and a call is audited against the session
|
|
170
168
|
* that actually made it. */
|
|
171
169
|
childSessionId?: string;
|
|
172
170
|
recorded: boolean;
|
|
@@ -253,19 +251,18 @@ export class QueryContext {
|
|
|
253
251
|
pendingToolCalls = new Map<string, PendingToolCall>();
|
|
254
252
|
pendingResults = new Map<string, McpResult>();
|
|
255
253
|
/** Results a message-boundary reap moved OUT of pendingResults so they stop
|
|
256
|
-
* poisoning mismatch reports, kept CONSUMABLE for a handler that fires later
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* boundary. Query-scoped, bounded by the query's tool-call count. */
|
|
254
|
+
* poisoning mismatch reports, kept CONSUMABLE for a handler that fires later:
|
|
255
|
+
* Pi delivers a turn's results in one callback while the SDK staggers handler
|
|
256
|
+
* invocations past the next message boundary, so a boundary never proves that
|
|
257
|
+
* no consumer will come. Query-scoped, bounded by the query's tool-call count. */
|
|
261
258
|
reapedResults = new Map<string, McpResult>();
|
|
262
259
|
/** Every tool-call id this query has handed to Pi inside an ENDED turn — the
|
|
263
260
|
* set endToolUseTurn stamps from the turn's content. A forwarded id is one Pi
|
|
264
261
|
* will execute and answer; it must never be emitted again (a lagging stream
|
|
265
262
|
* replays the same tool_use into the NEXT turn, and per-message turnBlocks
|
|
266
|
-
* dedup cannot see across turns
|
|
267
|
-
*
|
|
268
|
-
*
|
|
263
|
+
* dedup cannot see across turns), and a handler waiting on it must be left
|
|
264
|
+
* waiting at the stranded-handler drains. Query-scoped, never reset per
|
|
265
|
+
* message. */
|
|
269
266
|
forwardedToolCallIds = new Set<string>();
|
|
270
267
|
/** Ids whose waiting handler was resolved with strandedToolCallResult. The
|
|
271
268
|
* model has been told these calls failed; forwarding one later would execute
|
|
@@ -287,15 +284,15 @@ export class QueryContext {
|
|
|
287
284
|
* resets at every message boundary, but `pendingResults` is query-scoped, so a
|
|
288
285
|
* result stranded there outlives the message that named it. Without this map a
|
|
289
286
|
* teardown report can only say "1 queued" with empty toolNames and 0/0
|
|
290
|
-
* counters —
|
|
291
|
-
*
|
|
287
|
+
* counters — an unactionable record. Bounded by the number of tool calls in
|
|
288
|
+
* one query.
|
|
292
289
|
*/
|
|
293
290
|
queryToolNames = new Map<string, string>();
|
|
294
291
|
/** id → last-known arguments, query-scoped like queryToolNames and for the
|
|
295
292
|
* same reason: a late handler firing after resetToolTracking wiped the
|
|
296
293
|
* per-message records must still be able to exact-match the parked/queued
|
|
297
294
|
* result of ITS OWN call — without stored args the only fallback is
|
|
298
|
-
* sole-same-name, which can hand it a LIVE sibling's id
|
|
295
|
+
* sole-same-name, which can hand it a LIVE sibling's id. */
|
|
299
296
|
queryToolArgs = new Map<string, Record<string, unknown>>();
|
|
300
297
|
claimedToolCallIds = new Set<string>();
|
|
301
298
|
deliveredToolResultIds = new Set<string>();
|
|
@@ -305,13 +302,13 @@ export class QueryContext {
|
|
|
305
302
|
deferredUserMessages: DeferredUserMessage[] = [];
|
|
306
303
|
handledTerminalError = false;
|
|
307
304
|
// Once visible text/thinking, a complete tool call, or a child-executed
|
|
308
|
-
//
|
|
309
|
-
// another account (duplicate side effects). Query-scoped, not per-turn:
|
|
305
|
+
// connector/foreign-MCP dispatch reaches Pi, the request must never be
|
|
306
|
+
// replayed on another account (duplicate side effects). Query-scoped, not per-turn:
|
|
310
307
|
// resetTurnState must not clear it.
|
|
311
308
|
committedOutput = false;
|
|
312
309
|
/** True when this query holds NO claim on the module-level shared session
|
|
313
|
-
* record: a reentrant (subagent) query, or a foreign-conversation one-shot
|
|
314
|
-
*
|
|
310
|
+
* record: a reentrant (subagent) query, or a foreign-conversation one-shot.
|
|
311
|
+
* Every shared-record mutation reachable from this context —
|
|
315
312
|
* reportToolResultMismatch's needsRebuild/forceRotate mark, the cursor
|
|
316
313
|
* advances on the tool-result-delivery and orphaned-result paths — must
|
|
317
314
|
* no-op so the PARENT's record stays untouched. Assigned at fresh-query
|
|
@@ -349,18 +346,17 @@ export class QueryContext {
|
|
|
349
346
|
childSessionId: string | undefined;
|
|
350
347
|
/** Anthropic content-block indexes of the current assistant message that carry
|
|
351
348
|
* a child-executed tool_use. Scoped to one message: cleared at message_start,
|
|
352
|
-
* and an index is released as soon as
|
|
349
|
+
* and an index is released as soon as another block starts there. */
|
|
353
350
|
childExecutedStreamIndexes = new Set<number>();
|
|
354
351
|
|
|
355
352
|
// Usage accounting for a Pi turn that spans SEVERAL child assistant messages.
|
|
356
353
|
//
|
|
357
354
|
// Every child message is a separate billed API call, and each reports its own
|
|
358
355
|
// counters — `message_start`/`message_delta` REPLACE rather than accumulate. A
|
|
359
|
-
// Pi turn
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
// cache-write tokens lost on a single connector turn).
|
|
356
|
+
// Pi turn that ends at its first tool call spans one child message, where
|
|
357
|
+
// replacing is right. A turn containing a child-executed connector call keeps
|
|
358
|
+
// running across the child's follow-up messages, so replacing would silently
|
|
359
|
+
// drop everything the earlier ones billed.
|
|
364
360
|
//
|
|
365
361
|
// So: `turnUsageCarry` holds the totals of the child messages already COMPLETE
|
|
366
362
|
// in this Pi turn, `currentMessageUsage` holds the one in flight, and the Pi
|
|
@@ -420,8 +416,8 @@ export class QueryContext {
|
|
|
420
416
|
this.turnSawStreamEvent = false;
|
|
421
417
|
this.turnSawToolCall = false;
|
|
422
418
|
this.handledTerminalError = false;
|
|
423
|
-
// A
|
|
424
|
-
// end-timer for it must not fire into
|
|
419
|
+
// A fresh pi message means the previous turn's stream is done with; an
|
|
420
|
+
// armed end-timer for it must not fire into this turn's state.
|
|
425
421
|
if (this.scheduledToolUseEnd) {
|
|
426
422
|
clearTimeout(this.scheduledToolUseEnd.timer);
|
|
427
423
|
this.scheduledToolUseEnd = null;
|
|
@@ -432,8 +428,8 @@ export class QueryContext {
|
|
|
432
428
|
this.currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
433
429
|
this.currentMessageId = undefined;
|
|
434
430
|
// Tool-call tracking is NOT reset here — it persists across the
|
|
435
|
-
// tool-result delivery callback for the same assistant message.
|
|
436
|
-
// assistant
|
|
431
|
+
// tool-result delivery callback for the same assistant message. Each
|
|
432
|
+
// assistant message boundary calls resetToolTracking() explicitly.
|
|
437
433
|
}
|
|
438
434
|
|
|
439
435
|
resetToolTracking(): void {
|
|
@@ -517,7 +513,7 @@ export class QueryContext {
|
|
|
517
513
|
// Ids whose RESULT already sits queued or parked. A handler can fire after
|
|
518
514
|
// the message boundary wiped the per-message records — by then Pi has
|
|
519
515
|
// executed its call and only these query-scoped stores still know it
|
|
520
|
-
// (
|
|
516
|
+
// (dropping them at the boundary would make such a handler error out
|
|
521
517
|
// and the model re-run an already-executed side-effectful call). An
|
|
522
518
|
// exact-args match here outranks the live sole-same-name fallback below,
|
|
523
519
|
// so a late handler can never steal a live sibling's id while its own
|
|
@@ -556,10 +552,9 @@ export class QueryContext {
|
|
|
556
552
|
// - the handler receives the MCP server's schema-VALIDATED copy of
|
|
557
553
|
// the input (zod may strip unknown keys or apply defaults) while
|
|
558
554
|
// the record holds the raw streamed input.
|
|
559
|
-
// Refusing here
|
|
560
|
-
// the child while pi's real result
|
|
561
|
-
//
|
|
562
|
-
// same-type sole-candidate claim is strictly safer than that. With
|
|
555
|
+
// Refusing here strands the call outright: the handler errors into
|
|
556
|
+
// the child while pi's real result sits queued forever. A same-type
|
|
557
|
+
// sole-candidate claim is strictly safer than that. With
|
|
563
558
|
// SEVERAL same-name candidates and no exact match we still refuse —
|
|
564
559
|
// cross-pairing two live calls is the one outcome worse than failing.
|
|
565
560
|
chosen = byName[0];
|
|
@@ -585,9 +580,8 @@ export class QueryContext {
|
|
|
585
580
|
* assistant fallback). Left in pendingResults, each entry poisons every later
|
|
586
581
|
* mismatch report for the whole query (queued>0 with 0/0 counters and no tool
|
|
587
582
|
* names) and forces a session rebuild per turn. But the boundary does NOT
|
|
588
|
-
* prove the handler gave up — the SDK staggers handler invocations, and
|
|
589
|
-
*
|
|
590
|
-
* handlers fire after this reap destroyed their results. So the reap parks
|
|
583
|
+
* prove the handler gave up — the SDK staggers handler invocations, and
|
|
584
|
+
* handlers in a parallel batch routinely fire after it. So the reap parks
|
|
591
585
|
* instead of dropping: reports stay clean, and a late handler still gets its
|
|
592
586
|
* real result through takeQueuedOrParkedResult.
|
|
593
587
|
*/
|
|
@@ -629,9 +623,9 @@ export class QueryContext {
|
|
|
629
623
|
const counts = new Map<string, number>();
|
|
630
624
|
if (affectedIds.size > 0) {
|
|
631
625
|
// Name the affected ids from the query-scoped map, not just this
|
|
632
|
-
|
|
626
|
+
// message's records: a queued straggler from a prior child message is
|
|
633
627
|
// exactly the case a mismatch report exists for, and this message's
|
|
634
|
-
// turnToolCalls
|
|
628
|
+
// turnToolCalls does not know it.
|
|
635
629
|
for (const id of affectedIds) {
|
|
636
630
|
const name = this.queryToolNames.get(id)
|
|
637
631
|
?? this.turnToolCalls.find((call) => call.id === id)?.toolName
|
|
@@ -675,7 +669,7 @@ interface QueryLaneStoreV1 {
|
|
|
675
669
|
sessionLanes: Map<string, QueryLaneState>;
|
|
676
670
|
}
|
|
677
671
|
|
|
678
|
-
const QUERY_LANES_SYMBOL = Symbol.for("
|
|
672
|
+
const QUERY_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.query-lanes.v1");
|
|
679
673
|
|
|
680
674
|
function queryLaneStore(): QueryLaneStoreV1 {
|
|
681
675
|
const host = globalThis as Record<symbol, unknown>;
|
package/src/query-teardown.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// operates on the ONE context captured at query start — never the live ctx().
|
|
3
3
|
// The two only differ while a reentrant (subagent) context is pushed, which is
|
|
4
4
|
// exactly when a parent query ending abnormally (abort, child process death)
|
|
5
|
-
//
|
|
6
|
-
// flush, and activeQuery clear
|
|
5
|
+
// teardown must run against the parent state. Using the subagent state skips
|
|
6
|
+
// the parent's drain, audit flush, and activeQuery clear, which leaks handlers.
|
|
7
7
|
|
|
8
8
|
import { reportToolResultMismatch } from "./bridge-state.js";
|
|
9
9
|
import { flushConnectorCallAudit } from "./connector-audit.js";
|
|
@@ -11,7 +11,7 @@ import { debug } from "./debug.js";
|
|
|
11
11
|
import { drainPendingToolCalls, popContextFor, type QueryContext, type ToolCallDrainCause } from "./query-state.js";
|
|
12
12
|
|
|
13
13
|
/** Tear down `queryCtx` after its SDK query settled. No-ops when the query is
|
|
14
|
-
*
|
|
14
|
+
* is not the context's active one (a continuation replaced it, or teardown
|
|
15
15
|
* already ran). Returns true when teardown actually ran. */
|
|
16
16
|
export function teardownQuery(
|
|
17
17
|
queryCtx: QueryContext,
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { USAGE_LIMIT_ERROR_PREFIXES } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
|
|
3
|
-
export const RATE_LIMIT_AUTO_RESUME_EVENT = "
|
|
3
|
+
export const RATE_LIMIT_AUTO_RESUME_EVENT = "kendex:rate-limit";
|
|
4
4
|
export const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
|
|
5
5
|
|
|
6
6
|
// The SDK export is @alpha — degrade to "no match" (pre-0.3.220 behavior) if a
|
|
@@ -79,7 +79,7 @@ export function normalizeRateLimitUtilization(value: unknown): number | undefine
|
|
|
79
79
|
// (100%) because that is the fail-closed direction — under the fraction
|
|
80
80
|
// convention 1 is the fully-consumed case the warning exists to surface,
|
|
81
81
|
// while under the percent convention 1% sits below the threshold anyway,
|
|
82
|
-
// so nothing is lost by warning
|
|
82
|
+
// so nothing is lost by warning.
|
|
83
83
|
if (value > 0 && value <= 1) return value * 100;
|
|
84
84
|
if (value > 1 && value <= 100) return value;
|
|
85
85
|
return undefined;
|
package/src/request-lane.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
10
10
|
* keeps that identity attached to every promise, SDK iterator, and timer born
|
|
11
11
|
* during the provider call without threading the id through every helper.
|
|
12
12
|
*/
|
|
13
|
-
const REQUEST_LANE_SYMBOL = Symbol.for("
|
|
13
|
+
const REQUEST_LANE_SYMBOL = Symbol.for("kendex.pi.claude-bridge.request-lane.v1");
|
|
14
14
|
|
|
15
15
|
function requestLaneStorage(): AsyncLocalStorage<string> {
|
|
16
16
|
const host = globalThis as Record<symbol, unknown>;
|
|
@@ -155,7 +155,7 @@ function canonicalize(p: string | undefined): string | undefined {
|
|
|
155
155
|
// Decides whether a persisted bridge-session marker is safe to restore.
|
|
156
156
|
//
|
|
157
157
|
// The fork case is the load-bearing one: pi/core's createBranchedSession copies
|
|
158
|
-
// every non-label entry from root→leaf into the
|
|
158
|
+
// every non-label entry from root→leaf into the fork session file. That includes
|
|
159
159
|
// our claude-bridge-session markers from the parent. Restoring from them would
|
|
160
160
|
// --resume parent's Claude jsonl on the fork's first turn, leaking conversation
|
|
161
161
|
// past the fork point.
|
|
@@ -226,7 +226,7 @@ export function restoreSharedSessionFromPi(ctx: { sessionManager?: unknown; cwd?
|
|
|
226
226
|
// instance can differ. Scheduling again for the same manager REPLACES the
|
|
227
227
|
// pending timer: each fire appends the record's state as of its schedule and
|
|
228
228
|
// restore reads the last marker, so the superseded entry is a stale duplicate.
|
|
229
|
-
const SCHEDULED_PERSISTENCE_SYMBOL = Symbol.for("
|
|
229
|
+
const SCHEDULED_PERSISTENCE_SYMBOL = Symbol.for("kendex.pi.claude-bridge.scheduled-persistence.v1");
|
|
230
230
|
|
|
231
231
|
type PersistenceTimer = ReturnType<typeof setTimeout>;
|
|
232
232
|
|
|
@@ -287,7 +287,7 @@ export function schedulePersistSharedSession(ctxLike?: { sessionManager?: unknow
|
|
|
287
287
|
// A failed persist means the next startup restores a stale (or no)
|
|
288
288
|
// bridge marker and silently rebuilds — worth a diagnostic entry.
|
|
289
289
|
// Like all diagDump output this lands only under CLAUDE_BRIDGE_DEBUG=1
|
|
290
|
-
//
|
|
290
|
+
// and the failure itself stays non-fatal either way.
|
|
291
291
|
diagDump("persist_shared_session_failed", {
|
|
292
292
|
sessionId: snapshot.sessionId.slice(0, 8),
|
|
293
293
|
cursor: snapshot.cursor,
|
|
@@ -391,7 +391,7 @@ export function planIncrementalPromptBatch(
|
|
|
391
391
|
|
|
392
392
|
// A cursor past the end is PROOF this messages array is not the conversation
|
|
393
393
|
// the cursor describes (e.g. a reentrant subagent's short context arriving
|
|
394
|
-
// while the parent's cursor is large). Clamping it
|
|
394
|
+
// while the parent's cursor is large). Clamping it would fabricate a REUSE
|
|
395
395
|
// plan against foreign history — reject so the caller takes the rebuild path.
|
|
396
396
|
if (cursor > lastIndex) {
|
|
397
397
|
debug(`planIncrementalPromptBatch: rejected — cursor=${cursor} beyond last index ${lastIndex}; messages are not the conversation this cursor describes`);
|
|
@@ -438,7 +438,7 @@ function verifyWrittenSession(
|
|
|
438
438
|
safeNotify(
|
|
439
439
|
`Session file issue: ${msg}\n` +
|
|
440
440
|
`cwd=${displayPath(cwd)} realpath=${displayPath(safeRealpath(cwd))}\n` +
|
|
441
|
-
`Please copy and paste this message into a new issue at https://github.com/vanillagreencom/
|
|
441
|
+
`Please copy and paste this message into a new issue at https://github.com/vanillagreencom/kendex/issues/new` +
|
|
442
442
|
(DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
|
|
443
443
|
"warning",
|
|
444
444
|
);
|
|
@@ -479,7 +479,7 @@ function debugSessionPaths(label: string, cwd: string, jsonlPath: string, claude
|
|
|
479
479
|
// promptStart can never land on a user message Claude already persisted:
|
|
480
480
|
// Claude owns [0, cursor), promptStart starts at the cursor and only ever
|
|
481
481
|
// advances (past the one optional assistant), so everything from
|
|
482
|
-
// promptStart on is
|
|
482
|
+
// promptStart on is uncaptured input. Returns the existing sessionId. Keeps CC's
|
|
483
483
|
// prompt cache warm.
|
|
484
484
|
// REBUILD — no session yet, or pi's history has diverged (non-trailing
|
|
485
485
|
// missed messages, e.g. another provider took a turn). Wipes the existing
|
|
@@ -509,12 +509,12 @@ export function syncSharedSession(
|
|
|
509
509
|
account?: AccountSessionScope,
|
|
510
510
|
): SyncResult {
|
|
511
511
|
const sharedSession = getSharedSession();
|
|
512
|
-
const priorMessages = messages.slice(0, -1); // everything before the
|
|
512
|
+
const priorMessages = messages.slice(0, -1); // everything before the current user prompt
|
|
513
513
|
const accountProfileId = account?.accountProfileId;
|
|
514
514
|
const scopeConfigDir = account?.claudeConfigDir; // resolved dir for managed, undefined for legacy
|
|
515
515
|
// What cc-session-io reads/writes. Managed requests always carry a resolved
|
|
516
516
|
// dir (accountSessionScope) so this never falls back to the process env the
|
|
517
|
-
// child
|
|
517
|
+
// child does not see; legacy keeps the env rule unchanged.
|
|
518
518
|
const claudeDir = scopeConfigDir ?? process.env.CLAUDE_CONFIG_DIR;
|
|
519
519
|
const sameAccount = Boolean(
|
|
520
520
|
sharedSession &&
|
|
@@ -523,7 +523,7 @@ export function syncSharedSession(
|
|
|
523
523
|
);
|
|
524
524
|
const incomingFingerprint = conversationFingerprint(messages);
|
|
525
525
|
|
|
526
|
-
// FOREIGN-CONVERSATION guard
|
|
526
|
+
// FOREIGN-CONVERSATION guard. A subagent-shaped query
|
|
527
527
|
// arriving while the parent is IDLE is not reentrant, so it lands here as an
|
|
528
528
|
// outermost query. Without an identity check its short foreign context takes
|
|
529
529
|
// the REBUILD path — rewriting the PARENT's session file from foreign
|
|
@@ -568,7 +568,7 @@ export function syncSharedSession(
|
|
|
568
568
|
// Read the pre-update cursor first: setSharedSession reassigns the live
|
|
569
569
|
// binding, so comparing against sharedSession.cursor afterwards would
|
|
570
570
|
// always be equal and the "advanced past trailing assistant" debug
|
|
571
|
-
// branch could never print
|
|
571
|
+
// branch could never print.
|
|
572
572
|
const cursorBeforeUpdate = sharedSession.cursor;
|
|
573
573
|
// A REUSE match proves identity, so the anchor may only strengthen here:
|
|
574
574
|
// a pre-3.1.1 record adopts it outright, and a turn-1 user-only anchor
|
package/src/tool-mapping.ts
CHANGED
|
@@ -1,11 +1,48 @@
|
|
|
1
|
+
import { isMcpResourceTool } from "./connectors.js";
|
|
1
2
|
import { MCP_SERVER_NAME, MCP_TOOL_PREFIX } from "./skills.js";
|
|
2
3
|
|
|
3
4
|
const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
|
|
4
5
|
read: "read", write: "write", edit: "edit", bash: "bash",
|
|
5
6
|
};
|
|
6
7
|
|
|
8
|
+
const BRIDGED_TOOL_PREFIXES = [
|
|
9
|
+
MCP_TOOL_PREFIX,
|
|
10
|
+
`mcp__${MCP_SERVER_NAME.replace(/-/g, "_")}__`,
|
|
11
|
+
`mcp/${MCP_SERVER_NAME}/`,
|
|
12
|
+
`mcp/${MCP_SERVER_NAME.replace(/-/g, "_")}/`,
|
|
13
|
+
];
|
|
14
|
+
|
|
7
15
|
// --- Provider helpers: tool name mapping ---
|
|
8
16
|
|
|
17
|
+
function bridgedToolSuffix(normalized: string): string | undefined {
|
|
18
|
+
const prefix = BRIDGED_TOOL_PREFIXES.find((candidate) => normalized.startsWith(candidate));
|
|
19
|
+
return prefix ? normalized.slice(prefix.length) : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isForeignMcpTool(name: unknown): boolean {
|
|
23
|
+
if (typeof name !== "string") return false;
|
|
24
|
+
const normalized = name.toLowerCase();
|
|
25
|
+
return (normalized.startsWith("mcp__") || normalized.startsWith("mcp/")) && bridgedToolSuffix(normalized) === undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isPiDispatchable(name: unknown, customToolNameToPi?: Map<string, string>): boolean {
|
|
29
|
+
if (typeof name !== "string" || !name) return false;
|
|
30
|
+
const normalized = name.toLowerCase();
|
|
31
|
+
const hasManifest = Boolean(customToolNameToPi?.size);
|
|
32
|
+
if (customToolNameToPi?.has(name) || customToolNameToPi?.has(normalized)) return true;
|
|
33
|
+
const bridgedSuffix = bridgedToolSuffix(normalized);
|
|
34
|
+
if (bridgedSuffix !== undefined) {
|
|
35
|
+
if (!hasManifest) return true;
|
|
36
|
+
return customToolNameToPi?.has(`${MCP_TOOL_PREFIX}${bridgedSuffix}`) ?? false;
|
|
37
|
+
}
|
|
38
|
+
// A foreign MCP namespace belongs to a child-loaded server, not Pi's bridge.
|
|
39
|
+
if (isForeignMcpTool(name)) return false;
|
|
40
|
+
// Resource discovery is deliberately mirrored as Pi's account-access audit.
|
|
41
|
+
if (isMcpResourceTool(name)) return true;
|
|
42
|
+
// A populated manifest is authoritative: every other bare name is a naming slip.
|
|
43
|
+
return !hasManifest;
|
|
44
|
+
}
|
|
45
|
+
|
|
9
46
|
export function mapToolName(name: string, customToolNameToPi?: Map<string, string>): string {
|
|
10
47
|
const normalized = name.toLowerCase();
|
|
11
48
|
const builtin = SDK_TO_PI_TOOL_NAME[normalized];
|
|
@@ -14,19 +51,15 @@ export function mapToolName(name: string, customToolNameToPi?: Map<string, strin
|
|
|
14
51
|
const mapped = customToolNameToPi.get(name) ?? customToolNameToPi.get(normalized);
|
|
15
52
|
if (mapped) return mapped;
|
|
16
53
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`mcp/${MCP_SERVER_NAME}/`,
|
|
21
|
-
`mcp/${MCP_SERVER_NAME.replace(/-/g, "_")}/`,
|
|
22
|
-
]) {
|
|
23
|
-
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
|
|
54
|
+
const bridgedSuffix = bridgedToolSuffix(normalized);
|
|
55
|
+
if (bridgedSuffix !== undefined) {
|
|
56
|
+
return customToolNameToPi?.get(`${MCP_TOOL_PREFIX}${bridgedSuffix}`) ?? bridgedSuffix;
|
|
24
57
|
}
|
|
25
58
|
return name;
|
|
26
59
|
}
|
|
27
60
|
|
|
28
61
|
// Renames for Claude Code SDK param names that differ from pi's native names.
|
|
29
|
-
// Keys not listed here pass through unchanged, so
|
|
62
|
+
// Keys not listed here pass through unchanged, so additional Pi parameters work automatically.
|
|
30
63
|
const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
|
|
31
64
|
read: { file_path: "path" },
|
|
32
65
|
write: { file_path: "path" },
|
|
@@ -131,13 +131,12 @@ export const LOST_TOOL_RESULT_TEXT =
|
|
|
131
131
|
* IN PLACE, before cc-session-io's repairToolPairing runs.
|
|
132
132
|
*
|
|
133
133
|
* repairToolPairing backfills with a bare "[no tool result recorded]" — a
|
|
134
|
-
* placeholder the model reads as tool OUTPUT and
|
|
135
|
-
*
|
|
136
|
-
* returned). An is_error result that says what happened and what to do turns a
|
|
134
|
+
* placeholder the model reads as tool OUTPUT and continues to reason from.
|
|
135
|
+
* An is_error result that says what happened and what to do turns a
|
|
137
136
|
* silent correctness hazard into a recoverable failure.
|
|
138
137
|
*
|
|
139
138
|
* Results are prepended to the immediately following user message (tool_result
|
|
140
|
-
* blocks must lead a user message), or a
|
|
139
|
+
* blocks must lead a user message), or a synthetic user message is inserted when none
|
|
141
140
|
* follows. `missing` must come from findUnpairedToolUses on the same array.
|
|
142
141
|
*/
|
|
143
142
|
export function insertLostToolResultPlaceholders(
|
|
@@ -151,8 +150,8 @@ export function insertLostToolResultPlaceholders(
|
|
|
151
150
|
group.push(item);
|
|
152
151
|
byAssistant.set(item.assistantIndex, group);
|
|
153
152
|
}
|
|
154
|
-
// Descending order
|
|
155
|
-
//
|
|
153
|
+
// Descending order ensures that inserting a user message does not shift an index
|
|
154
|
+
// for a group that is earlier in the array.
|
|
156
155
|
for (const assistantIndex of [...byAssistant.keys()].sort((a, b) => b - a)) {
|
|
157
156
|
const group = byAssistant.get(assistantIndex)!;
|
|
158
157
|
const blocks = group.map((item) => block(item.id));
|