@vanillagreen/pi-claude-bridge 3.2.2 → 4.0.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 +7 -7
- package/bundle/index.js +18 -18
- package/package.json +6 -6
- package/src/account-router.ts +2 -2
- package/src/assistant-stream.ts +11 -11
- package/src/bridge-commands.ts +2 -2
- package/src/bridge-state.ts +4 -4
- package/src/config.ts +6 -6
- package/src/connector-cache.ts +2 -2
- package/src/connector-inventory.ts +2 -2
- package/src/connector-runtime.ts +3 -3
- package/src/connectors.ts +14 -14
- package/src/index.ts +14 -14
- package/src/query-options.ts +2 -2
- package/src/query-state.ts +9 -9
- package/src/rate-limit.ts +1 -1
- package/src/request-lane.ts +1 -1
- package/src/session-persistence.ts +4 -4
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Works with OAuth subscription, no API key, no errors.
|
|
4
4
|
|
|
5
|
-

|
|
6
|
+

|
|
7
7
|
|
|
8
8
|
Run Claude Code as the `pi-claude` Pi provider while keeping Pi's tools and TUI.
|
|
9
9
|
|
|
@@ -33,11 +33,11 @@ Via [npm](https://www.npmjs.com/package/@vanillagreen/pi-claude-bridge):
|
|
|
33
33
|
pi install npm:@vanillagreen/pi-claude-bridge
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Via [
|
|
36
|
+
Via [kendex](https://github.com/vanillagreencom/kendex):
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
cargo install --git https://github.com/vanillagreencom/
|
|
40
|
-
|
|
39
|
+
cargo install --git https://github.com/vanillagreencom/kendex.git kendex
|
|
40
|
+
kendex add vanillagreencom/kendex --pi-extension pi-claude-bridge --harness pi -y
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
Restart Pi after installation.
|
|
@@ -50,7 +50,7 @@ Extra Pi context is off by default. Enable per item in the extension manager whe
|
|
|
50
50
|
|
|
51
51
|
## Settings
|
|
52
52
|
|
|
53
|
-
Open `/extensions:settings`; settings appear under the **Pi Claude** tab. Project settings in `.pi/settings.json` apply only after Pi marks the workspace trusted; before trust,
|
|
53
|
+
Open `/extensions:settings`; settings appear under the **Pi Claude** tab. Project settings in `.pi/settings.json` apply only after Pi marks the workspace trusted; before trust, kendex Pi extensions read user/global settings only. The bridge also reads `claude-bridge.json` (`~/.pi/agent/claude-bridge.json`, and `.pi/claude-bridge.json` in a trusted project). Settings the bridge takes from one of those files are shown with the file that supplies them, so the editor reports the value the bridge resolves rather than the default. Changing the setting in the editor writes Pi settings, which take precedence over `claude-bridge.json`.
|
|
54
54
|
|
|
55
55
|
| Group | Setting | What it does |
|
|
56
56
|
| --- | --- | --- |
|
|
@@ -80,7 +80,7 @@ Connector tools run inside Claude Code rather than in Pi, so Pi shows the model'
|
|
|
80
80
|
|
|
81
81
|
`/pi-claude:connectors` lists the Claude account's installed claude.ai connectors by asking the account, not the model, so the answer is complete by construction.
|
|
82
82
|
|
|
83
|
-
Extension-manager settings use flat package-scoped keys under `
|
|
83
|
+
Extension-manager settings use flat package-scoped keys under `kendex.extensionManager.config["@vanillagreen/pi-claude-bridge"]` in `settings.json`. Legacy `claude-bridge.json` configuration nests these options under `provider` (prompt-context flags under `promptContext`); the flat keys are accepted there too. Environment variables work with either format. Connectors remain off by default so Pi owns tool execution.
|
|
84
84
|
|
|
85
85
|
| Extension-manager key | Env var | Values | Default | What it does |
|
|
86
86
|
| --- | --- | --- | --- | --- |
|
package/bundle/index.js
CHANGED
|
@@ -27046,7 +27046,7 @@ function diagDump(label, data) {
|
|
|
27046
27046
|
|
|
27047
27047
|
// src/config.ts
|
|
27048
27048
|
var PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
|
|
27049
|
-
var EXTERNAL_CONFIG_RESOLVER_SYMBOL = /* @__PURE__ */ Symbol.for("
|
|
27049
|
+
var EXTERNAL_CONFIG_RESOLVER_SYMBOL = /* @__PURE__ */ Symbol.for("kendex.pi.extension-config-resolver");
|
|
27050
27050
|
var VALID_EFFORT_LEVELS = /* @__PURE__ */ new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
27051
27051
|
function expandHome(input) {
|
|
27052
27052
|
if (input === "~") return homedir();
|
|
@@ -27077,13 +27077,13 @@ function projectSettingsPath(cwd) {
|
|
|
27077
27077
|
while (true) {
|
|
27078
27078
|
const candidate = join3(current, ".pi", "settings.json");
|
|
27079
27079
|
if (existsSync2(candidate)) return candidate;
|
|
27080
|
-
if (existsSync2(join3(current, ".pi")) || existsSync2(join3(current, ".git")) || existsSync2(join3(current, ".
|
|
27080
|
+
if (existsSync2(join3(current, ".pi")) || existsSync2(join3(current, ".git")) || existsSync2(join3(current, ".kendex-lock.json"))) return candidate;
|
|
27081
27081
|
const parent = dirname2(current);
|
|
27082
27082
|
if (parent === current) return join3(resolve2(cwd), ".pi", "settings.json");
|
|
27083
27083
|
current = parent;
|
|
27084
27084
|
}
|
|
27085
27085
|
}
|
|
27086
|
-
var PROJECT_TRUST_SYMBOL = /* @__PURE__ */ Symbol.for("
|
|
27086
|
+
var PROJECT_TRUST_SYMBOL = /* @__PURE__ */ Symbol.for("kendex.pi.project-trust");
|
|
27087
27087
|
function projectTrustRegistry() {
|
|
27088
27088
|
const host = globalThis;
|
|
27089
27089
|
const existing = host[PROJECT_TRUST_SYMBOL];
|
|
@@ -27130,7 +27130,7 @@ function readManagerConfig(cwd) {
|
|
|
27130
27130
|
if (!existsSync2(path)) continue;
|
|
27131
27131
|
try {
|
|
27132
27132
|
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
27133
|
-
const configRoot = asRecord(asRecord(asRecord(parsed?.
|
|
27133
|
+
const configRoot = asRecord(asRecord(asRecord(parsed?.kendex)?.extensionManager)?.config);
|
|
27134
27134
|
const config2 = asRecord(configRoot?.[PACKAGE_ID]);
|
|
27135
27135
|
if (config2) mergeDeep(merged, path === userPath ? config2 : withoutUserScopeOnlyKeys(config2));
|
|
27136
27136
|
} catch (error51) {
|
|
@@ -28118,7 +28118,7 @@ function extractAllToolResults(messages) {
|
|
|
28118
28118
|
|
|
28119
28119
|
// src/request-lane.ts
|
|
28120
28120
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
28121
|
-
var REQUEST_LANE_SYMBOL = /* @__PURE__ */ Symbol.for("
|
|
28121
|
+
var REQUEST_LANE_SYMBOL = /* @__PURE__ */ Symbol.for("kendex.pi.claude-bridge.request-lane.v1");
|
|
28122
28122
|
function requestLaneStorage() {
|
|
28123
28123
|
const host = globalThis;
|
|
28124
28124
|
let storage = host[REQUEST_LANE_SYMBOL];
|
|
@@ -28262,7 +28262,7 @@ var QueryContext = class {
|
|
|
28262
28262
|
* set endToolUseTurn stamps from the turn's content. A forwarded id is one Pi
|
|
28263
28263
|
* will execute and answer; it must never be emitted again (a lagging stream
|
|
28264
28264
|
* replays the same tool_use into the NEXT turn, and per-message turnBlocks
|
|
28265
|
-
* dedup cannot see across turns —
|
|
28265
|
+
* dedup cannot see across turns — kendex#1469's duplicate executions), and a
|
|
28266
28266
|
* handler waiting on it must be left waiting at the stranded-handler drains.
|
|
28267
28267
|
* Query-scoped, never reset per message. */
|
|
28268
28268
|
forwardedToolCallIds = /* @__PURE__ */ new Set();
|
|
@@ -28294,7 +28294,7 @@ var QueryContext = class {
|
|
|
28294
28294
|
* same reason: a late handler firing after resetToolTracking wiped the
|
|
28295
28295
|
* per-message records must still be able to exact-match the parked/queued
|
|
28296
28296
|
* result of ITS OWN call — without stored args the only fallback is
|
|
28297
|
-
* sole-same-name, which can hand it a LIVE sibling's id (
|
|
28297
|
+
* sole-same-name, which can hand it a LIVE sibling's id (kendex#1469). */
|
|
28298
28298
|
queryToolArgs = /* @__PURE__ */ new Map();
|
|
28299
28299
|
claimedToolCallIds = /* @__PURE__ */ new Set();
|
|
28300
28300
|
deliveredToolResultIds = /* @__PURE__ */ new Set();
|
|
@@ -28310,7 +28310,7 @@ var QueryContext = class {
|
|
|
28310
28310
|
committedOutput = false;
|
|
28311
28311
|
/** True when this query holds NO claim on the module-level shared session
|
|
28312
28312
|
* record: a reentrant (subagent) query, or a foreign-conversation one-shot
|
|
28313
|
-
* (
|
|
28313
|
+
* (kendex#1001). Every shared-record mutation reachable from this context —
|
|
28314
28314
|
* reportToolResultMismatch's needsRebuild/forceRotate mark, the cursor
|
|
28315
28315
|
* advances on the tool-result-delivery and orphaned-result paths — must
|
|
28316
28316
|
* no-op so the PARENT's record stays untouched. Assigned at fresh-query
|
|
@@ -28535,7 +28535,7 @@ var QueryContext = class {
|
|
|
28535
28535
|
* mismatch report for the whole query (queued>0 with 0/0 counters and no tool
|
|
28536
28536
|
* names) and forces a session rebuild per turn. But the boundary does NOT
|
|
28537
28537
|
* prove the handler gave up — the SDK staggers handler invocations, and the
|
|
28538
|
-
* 2026-08-17 deadlock session (
|
|
28538
|
+
* 2026-08-17 deadlock session (kendex#1469) had three of five parallel
|
|
28539
28539
|
* handlers fire after this reap destroyed their results. So the reap parks
|
|
28540
28540
|
* instead of dropping: reports stay clean, and a late handler still gets its
|
|
28541
28541
|
* real result through takeQueuedOrParkedResult.
|
|
@@ -28601,7 +28601,7 @@ var QueryContext = class {
|
|
|
28601
28601
|
};
|
|
28602
28602
|
}
|
|
28603
28603
|
};
|
|
28604
|
-
var QUERY_LANES_SYMBOL = /* @__PURE__ */ Symbol.for("
|
|
28604
|
+
var QUERY_LANES_SYMBOL = /* @__PURE__ */ Symbol.for("kendex.pi.claude-bridge.query-lanes.v1");
|
|
28605
28605
|
function queryLaneStore() {
|
|
28606
28606
|
const host = globalThis;
|
|
28607
28607
|
let store = host[QUERY_LANES_SYMBOL];
|
|
@@ -28764,7 +28764,7 @@ function summarizeMissingToolNames(missing) {
|
|
|
28764
28764
|
}
|
|
28765
28765
|
|
|
28766
28766
|
// src/bridge-state.ts
|
|
28767
|
-
var SHARED_SESSION_LANES_SYMBOL = /* @__PURE__ */ Symbol.for("
|
|
28767
|
+
var SHARED_SESSION_LANES_SYMBOL = /* @__PURE__ */ Symbol.for("kendex.pi.claude-bridge.shared-session-lanes.v1");
|
|
28768
28768
|
function sharedSessionLaneStore() {
|
|
28769
28769
|
const host = globalThis;
|
|
28770
28770
|
let store = host[SHARED_SESSION_LANES_SYMBOL];
|
|
@@ -28797,7 +28797,7 @@ function clearSharedSessionLanes() {
|
|
|
28797
28797
|
store.sessions.clear();
|
|
28798
28798
|
store.defaultSession = null;
|
|
28799
28799
|
}
|
|
28800
|
-
var STARTED_LANES_SYMBOL = /* @__PURE__ */ Symbol.for("
|
|
28800
|
+
var STARTED_LANES_SYMBOL = /* @__PURE__ */ Symbol.for("kendex.pi.claude-bridge.started-lanes.v1");
|
|
28801
28801
|
function startedLaneStore() {
|
|
28802
28802
|
const host = globalThis;
|
|
28803
28803
|
let store = host[STARTED_LANES_SYMBOL];
|
|
@@ -44570,7 +44570,7 @@ import { homedir as homedir4 } from "node:os";
|
|
|
44570
44570
|
import { join as join8 } from "node:path";
|
|
44571
44571
|
|
|
44572
44572
|
// src/rate-limit.ts
|
|
44573
|
-
var RATE_LIMIT_AUTO_RESUME_EVENT = "
|
|
44573
|
+
var RATE_LIMIT_AUTO_RESUME_EVENT = "kendex:rate-limit";
|
|
44574
44574
|
var RATE_LIMIT_TOKEN = "\x1B[31m[rate-limit]\x1B[39m";
|
|
44575
44575
|
var USAGE_LIMIT_PREFIXES = Array.isArray(qO) ? qO : [];
|
|
44576
44576
|
function coerceMessageText(value) {
|
|
@@ -44640,8 +44640,8 @@ function formatAllowedRateLimitWarning(info) {
|
|
|
44640
44640
|
}
|
|
44641
44641
|
|
|
44642
44642
|
// src/account-router.ts
|
|
44643
|
-
var CLAUDE_ACCOUNT_ROUTER_SYMBOL = /* @__PURE__ */ Symbol.for("
|
|
44644
|
-
var CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL = /* @__PURE__ */ Symbol.for("
|
|
44643
|
+
var CLAUDE_ACCOUNT_ROUTER_SYMBOL = /* @__PURE__ */ Symbol.for("kendex.pi.claude-account-router.v1");
|
|
44644
|
+
var CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL = /* @__PURE__ */ Symbol.for("kendex.pi.claude-bridge.account-host.v1");
|
|
44645
44645
|
function resolveClaudeAccountRouter() {
|
|
44646
44646
|
const host = globalThis;
|
|
44647
44647
|
const candidate = host[CLAUDE_ACCOUNT_ROUTER_SYMBOL];
|
|
@@ -44920,7 +44920,7 @@ function restoreSharedSessionFromPi(ctx2) {
|
|
|
44920
44920
|
});
|
|
44921
44921
|
debug(`restoreSharedSession: restored ${persisted.sessionId.slice(0, 8)}, cursor=${cursor}, account=${accountProfileId ?? "default"}`);
|
|
44922
44922
|
}
|
|
44923
|
-
var SCHEDULED_PERSISTENCE_SYMBOL = /* @__PURE__ */ Symbol.for("
|
|
44923
|
+
var SCHEDULED_PERSISTENCE_SYMBOL = /* @__PURE__ */ Symbol.for("kendex.pi.claude-bridge.scheduled-persistence.v1");
|
|
44924
44924
|
function scheduledPersistenceTimers() {
|
|
44925
44925
|
const host = globalThis;
|
|
44926
44926
|
let store = host[SCHEDULED_PERSISTENCE_SYMBOL];
|
|
@@ -45037,7 +45037,7 @@ function verifyWrittenSession2(jsonlPath, expectedSessionId, expectedRecordCount
|
|
|
45037
45037
|
safeNotify(
|
|
45038
45038
|
`Session file issue: ${msg}
|
|
45039
45039
|
cwd=${displayPath(cwd)} realpath=${displayPath(safeRealpath(cwd))}
|
|
45040
|
-
Please copy and paste this message into a new issue at https://github.com/vanillagreencom/
|
|
45040
|
+
Please copy and paste this message into a new issue at https://github.com/vanillagreencom/kendex/issues/new` + (DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
|
|
45041
45041
|
"warning"
|
|
45042
45042
|
);
|
|
45043
45043
|
diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir: claudeDir ?? null });
|
|
@@ -45818,7 +45818,7 @@ function commandCwd(ctx2) {
|
|
|
45818
45818
|
}
|
|
45819
45819
|
async function tryOpenExtensionManagerSettings(ctx2) {
|
|
45820
45820
|
const host = globalThis;
|
|
45821
|
-
const openQuickSettings = host[/* @__PURE__ */ Symbol.for("
|
|
45821
|
+
const openQuickSettings = host[/* @__PURE__ */ Symbol.for("kendex.pi.extension-manager.open-quick-settings")];
|
|
45822
45822
|
if (typeof openQuickSettings !== "function") return false;
|
|
45823
45823
|
try {
|
|
45824
45824
|
await openQuickSettings(ctx2, "@vanillagreen/pi-claude-bridge");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-claude-bridge",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"extensions": [
|
|
16
16
|
"./bundle/index.js"
|
|
17
17
|
],
|
|
18
|
-
"image": "https://raw.githubusercontent.com/vanillagreencom/
|
|
18
|
+
"image": "https://raw.githubusercontent.com/vanillagreencom/kendex/main/pi-extensions/pi-claude-bridge/assets/bridge-demo.png"
|
|
19
19
|
},
|
|
20
|
-
"
|
|
20
|
+
"kendex": {
|
|
21
21
|
"extensionManager": {
|
|
22
22
|
"displayName": "Pi Claude",
|
|
23
23
|
"settings": [
|
|
@@ -168,12 +168,12 @@
|
|
|
168
168
|
],
|
|
169
169
|
"repository": {
|
|
170
170
|
"type": "git",
|
|
171
|
-
"url": "git+https://github.com/vanillagreencom/
|
|
171
|
+
"url": "git+https://github.com/vanillagreencom/kendex.git",
|
|
172
172
|
"directory": "pi-extensions/pi-claude-bridge"
|
|
173
173
|
},
|
|
174
|
-
"homepage": "https://github.com/vanillagreencom/
|
|
174
|
+
"homepage": "https://github.com/vanillagreencom/kendex/tree/main/pi-extensions/pi-claude-bridge",
|
|
175
175
|
"bugs": {
|
|
176
|
-
"url": "https://github.com/vanillagreencom/
|
|
176
|
+
"url": "https://github.com/vanillagreencom/kendex/issues"
|
|
177
177
|
},
|
|
178
178
|
"author": "vanillagreen",
|
|
179
179
|
"publishConfig": {
|
package/src/account-router.ts
CHANGED
|
@@ -13,8 +13,8 @@ import { join } from "node:path";
|
|
|
13
13
|
import { debug } from "./debug.js";
|
|
14
14
|
import { resetTimestampMs } from "./rate-limit.js";
|
|
15
15
|
|
|
16
|
-
export const CLAUDE_ACCOUNT_ROUTER_SYMBOL = Symbol.for("
|
|
17
|
-
export const CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL = Symbol.for("
|
|
16
|
+
export const CLAUDE_ACCOUNT_ROUTER_SYMBOL = Symbol.for("kendex.pi.claude-account-router.v1");
|
|
17
|
+
export const CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL = Symbol.for("kendex.pi.claude-bridge.account-host.v1");
|
|
18
18
|
|
|
19
19
|
export interface ClaudeAccountRoute {
|
|
20
20
|
profileId: string;
|
package/src/assistant-stream.ts
CHANGED
|
@@ -88,7 +88,7 @@ const TOOL_USE_END_GRACE_MS = 1500;
|
|
|
88
88
|
|
|
89
89
|
/** End the current pi stream as a tool_use turn boundary. Safe to call when the
|
|
90
90
|
* turn already ended (no-op). Every end path funnels here, so this is where
|
|
91
|
-
* two invariants are enforced by construction (
|
|
91
|
+
* two invariants are enforced by construction (kendex#1469): a block still
|
|
92
92
|
* carrying partialJson never ships — Pi executes the done message's content,
|
|
93
93
|
* and truncated arguments must never execute — and every call that DOES ship
|
|
94
94
|
* is stamped forwarded so no lagging replay can dispatch it again. */
|
|
@@ -105,7 +105,7 @@ export function endToolUseTurn(c: QueryContext): void {
|
|
|
105
105
|
}
|
|
106
106
|
// Every tool call Pi is about to execute from this turn is owed a result and
|
|
107
107
|
// must never be dispatched again: a lagging stream replays the same tool_use
|
|
108
|
-
// into the NEXT turn, whose per-message dedup cannot see it (
|
|
108
|
+
// into the NEXT turn, whose per-message dedup cannot see it (kendex#1469).
|
|
109
109
|
for (const block of c.turnOutput.content as Array<{ type?: string; id?: unknown }>) {
|
|
110
110
|
if (block?.type === "toolCall" && typeof block.id === "string") c.forwardedToolCallIds.add(block.id);
|
|
111
111
|
}
|
|
@@ -148,7 +148,7 @@ export function scheduleToolUseTurnEnd(c: QueryContext, action: () => void, sour
|
|
|
148
148
|
* entries would start poisoning mismatch reports — but it does NOT prove the
|
|
149
149
|
* handler gave up: the SDK staggers handler invocations, and in the 2026-08-17
|
|
150
150
|
* deadlock session three of five parallel handlers fired after this point
|
|
151
|
-
* (
|
|
151
|
+
* (kendex#1469). Parked results stay consumable through
|
|
152
152
|
* takeQueuedOrParkedResult; one that is never consumed belongs to a call the
|
|
153
153
|
* SDK abandoned client-side (permission denial), which is exactly what the
|
|
154
154
|
* notice describes.
|
|
@@ -187,7 +187,7 @@ export const FINALIZE_MAX_REARMS = 3;
|
|
|
187
187
|
* so end the pi stream like the `message_stop` path — with this handler's
|
|
188
188
|
* schema-validated arguments, never a partial parse — after settling every
|
|
189
189
|
* sibling whose handler has fired and giving a merely-lagging stream up to
|
|
190
|
-
* FINALIZE_MAX_REARMS extra grace periods for the rest (
|
|
190
|
+
* FINALIZE_MAX_REARMS extra grace periods for the rest (kendex#1469).
|
|
191
191
|
*
|
|
192
192
|
* The dead-stream guard is a backstop: the grace timer's own stream-identity
|
|
193
193
|
* check means this normally never runs after the turn ended. The primary
|
|
@@ -205,7 +205,7 @@ export function finalizeToolUseTurnFromMcpInvocation(
|
|
|
205
205
|
// be answered — the yield that could have replayed its call was consumed
|
|
206
206
|
// against a null stream, and the dead-mark below suppresses any replay
|
|
207
207
|
// that has not happened yet. Failing it now is what turns the observed
|
|
208
|
-
// multi-hour session deadlock into one retryable error (
|
|
208
|
+
// multi-hour session deadlock into one retryable error (kendex#1469).
|
|
209
209
|
if (failStrandedToolCall(queryCtx, toolCallId)) {
|
|
210
210
|
debug(`mcp handler: ${toolName} [${toolCallId}] stranded — turn ended before its call reached Pi; resolved with error`);
|
|
211
211
|
diagDump("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
|
|
@@ -221,7 +221,7 @@ export function finalizeToolUseTurnFromMcpInvocation(
|
|
|
221
221
|
// with the COMPLETE schema-validated input, so the handler's copy is
|
|
222
222
|
// authoritative — the streamed partial JSON is by definition behind it.
|
|
223
223
|
// Settling from the partial is what forwarded `{}`-argument calls that
|
|
224
|
-
// Pi then executed and errored (
|
|
224
|
+
// Pi then executed and errored (kendex#1469), exactly the divergence
|
|
225
225
|
// the synthesize branch below never had.
|
|
226
226
|
block.arguments = mappedArgs;
|
|
227
227
|
queryCtx.updateToolCallArgs(block.id, block.arguments);
|
|
@@ -355,7 +355,7 @@ export function processStreamEvent(
|
|
|
355
355
|
if (typeof streamedId === "string" && (c.forwardedToolCallIds.has(streamedId) || c.deadToolCallIds.has(streamedId))) {
|
|
356
356
|
// A lagging stream replaying a call Pi already executed — or one whose
|
|
357
357
|
// handler was already failed as stranded — into a later turn. Mirroring
|
|
358
|
-
// it would make Pi dispatch it a second time (
|
|
358
|
+
// it would make Pi dispatch it a second time (kendex#1469).
|
|
359
359
|
c.suppressedStreamIndexes.add(event.index);
|
|
360
360
|
debug(`processStreamEvent: tool_use ${streamedId} already ${c.forwardedToolCallIds.has(streamedId) ? "forwarded" : "dead"} — suppressing duplicate stream block`);
|
|
361
361
|
return;
|
|
@@ -364,7 +364,7 @@ export function processStreamEvent(
|
|
|
364
364
|
// Same turn, same id: the completed-message yield beat the stream (its
|
|
365
365
|
// block is already recorded with complete arguments). A second
|
|
366
366
|
// partialJson copy would ship the id twice in one done message — and
|
|
367
|
-
// if its stop never arrives, ship it truncated (
|
|
367
|
+
// if its stop never arrives, ship it truncated (kendex#1469).
|
|
368
368
|
c.suppressedStreamIndexes.add(event.index);
|
|
369
369
|
debug(`processStreamEvent: tool_use ${streamedId} already recorded in this turn — suppressing duplicate stream block`);
|
|
370
370
|
return;
|
|
@@ -494,7 +494,7 @@ function appendMissingToolUsesFromAssistant(
|
|
|
494
494
|
// array of a turnOutput that endToolUseTurn already handed Pi BY REFERENCE
|
|
495
495
|
// in its done event, so a push here appends calls into a delivered message
|
|
496
496
|
// behind Pi's back — whether Pi's dispatch enumerates before or after the
|
|
497
|
-
// push is a microtask race (
|
|
497
|
+
// push is a microtask race (kendex#1469).
|
|
498
498
|
const streamLive = Boolean(c.currentPiStream && c.turnOutput);
|
|
499
499
|
let sawToolUse = false;
|
|
500
500
|
for (const block of assistantMsg.content) {
|
|
@@ -511,7 +511,7 @@ function appendMissingToolUsesFromAssistant(
|
|
|
511
511
|
if (existingIdx < 0 && (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
|
|
512
512
|
// Completed-message replay of a call Pi already executed in an earlier
|
|
513
513
|
// turn (or one already failed as stranded). Not a live Pi turn boundary:
|
|
514
|
-
// sawToolUse stays false for it, and no block is emitted (
|
|
514
|
+
// sawToolUse stays false for it, and no block is emitted (kendex#1469).
|
|
515
515
|
debug(`assistant message: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
|
|
516
516
|
continue;
|
|
517
517
|
}
|
|
@@ -665,7 +665,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
665
665
|
// handler was already failed as stranded). This exact path produced
|
|
666
666
|
// the observed duplicate dispatches: the completed-message yield lands
|
|
667
667
|
// in the callback AFTER a grace finalize already ended the call's
|
|
668
|
-
// turn, and per-message dedup cannot see across turns (
|
|
668
|
+
// turn, and per-message dedup cannot see across turns (kendex#1469).
|
|
669
669
|
// Not recorded either — a forwarded call must not be claimable again.
|
|
670
670
|
debug(`processAssistantMessage fallback: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
|
|
671
671
|
continue;
|
package/src/bridge-commands.ts
CHANGED
|
@@ -17,7 +17,7 @@ function commandCwd(ctx: unknown): string {
|
|
|
17
17
|
|
|
18
18
|
async function tryOpenExtensionManagerSettings(ctx: { ui: ExtensionUIContext }): Promise<boolean> {
|
|
19
19
|
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
20
|
-
const openQuickSettings = host[Symbol.for("
|
|
20
|
+
const openQuickSettings = host[Symbol.for("kendex.pi.extension-manager.open-quick-settings")];
|
|
21
21
|
if (typeof openQuickSettings !== "function") return false;
|
|
22
22
|
try {
|
|
23
23
|
await (openQuickSettings as (ctx: unknown, hint?: string) => Promise<void>)(ctx, "@vanillagreen/pi-claude-bridge");
|
|
@@ -35,7 +35,7 @@ function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
|
|
|
35
35
|
].join("\n"), "info");
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Deterministic connector enumeration for the host app (
|
|
38
|
+
// Deterministic connector enumeration for the host app (kendex#838). Reports the
|
|
39
39
|
// failure reason rather than an empty list, so "no connectors" and "could not
|
|
40
40
|
// check" stay distinguishable.
|
|
41
41
|
async function reportConnectorInventory(ctx: {
|
package/src/bridge-state.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface SessionState {
|
|
|
23
23
|
// never rewrite those opening messages — compact/tree-nav mutations set
|
|
24
24
|
// needsRebuild instead — so a component mismatch marks a FOREIGN
|
|
25
25
|
// conversation (a subagent-shaped query arriving while the parent is idle,
|
|
26
|
-
//
|
|
26
|
+
// kendex#1001) that must run as a clean one-shot without touching this
|
|
27
27
|
// record. The user component must always match; the assistant component is
|
|
28
28
|
// compared only when BOTH sides carry one, so a record stamped on turn 1
|
|
29
29
|
// (no assistant yet) still matches its own grown conversation and upgrades
|
|
@@ -56,7 +56,7 @@ interface SharedSessionLaneStoreV1 {
|
|
|
56
56
|
sessions: Map<string, SessionState | null>;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const SHARED_SESSION_LANES_SYMBOL = Symbol.for("
|
|
59
|
+
const SHARED_SESSION_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.shared-session-lanes.v1");
|
|
60
60
|
|
|
61
61
|
function sharedSessionLaneStore(): SharedSessionLaneStoreV1 {
|
|
62
62
|
const host = globalThis as Record<symbol, unknown>;
|
|
@@ -107,7 +107,7 @@ export function clearSharedSessionLanes(): void {
|
|
|
107
107
|
// registry above because session_start and session_shutdown can reach different
|
|
108
108
|
// module instances (`/reload` mid-session, a child agent's own copy) and both
|
|
109
109
|
// must see the same entry.
|
|
110
|
-
const STARTED_LANES_SYMBOL = Symbol.for("
|
|
110
|
+
const STARTED_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.started-lanes.v1");
|
|
111
111
|
|
|
112
112
|
function startedLaneStore(): WeakMap<object, string> {
|
|
113
113
|
const host = globalThis as Record<symbol, unknown>;
|
|
@@ -242,7 +242,7 @@ export function reportToolResultMismatch(
|
|
|
242
242
|
queryCtx.reportedToolResultMismatch = true;
|
|
243
243
|
// The single choke point every mismatch path funnels through (abort,
|
|
244
244
|
// unmatched result, stream-idle, teardown). A context with no claim on
|
|
245
|
-
// the shared record (reentrant subagent or foreign one-shot,
|
|
245
|
+
// the shared record (reentrant subagent or foreign one-shot, kendex#1001)
|
|
246
246
|
// still gets the full diagnostics below, but its unresolved tool state is
|
|
247
247
|
// its own — marking the PARENT's record needsRebuild/forceRotate here
|
|
248
248
|
// would flush the parent's prompt cache for a query that never touched
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// User-facing extension config. Legacy config is loaded from
|
|
2
|
-
// ~/.pi/agent/claude-bridge.json and .pi/claude-bridge.json.
|
|
2
|
+
// ~/.pi/agent/claude-bridge.json and .pi/claude-bridge.json. kendex extension
|
|
3
3
|
// manager config is loaded from settings.json and overrides legacy files in
|
|
4
4
|
// normal Pi sessions. Isolated embedding hosts consume only the authoritative
|
|
5
5
|
// user claude-bridge.json and never read extension-manager settings.
|
|
@@ -13,11 +13,11 @@ import { debug } from "./debug.js";
|
|
|
13
13
|
export const PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Registry the
|
|
16
|
+
* Registry the kendex extension manager reads to display the value an
|
|
17
17
|
* extension actually resolves for a manifest key, for extensions whose config
|
|
18
18
|
* has channels the manager does not own. Keyed by package id.
|
|
19
19
|
*/
|
|
20
|
-
const EXTERNAL_CONFIG_RESOLVER_SYMBOL = Symbol.for("
|
|
20
|
+
const EXTERNAL_CONFIG_RESOLVER_SYMBOL = Symbol.for("kendex.pi.extension-config-resolver");
|
|
21
21
|
|
|
22
22
|
export type BridgeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
|
|
23
23
|
|
|
@@ -138,14 +138,14 @@ function projectSettingsPath(cwd: string): string {
|
|
|
138
138
|
while (true) {
|
|
139
139
|
const candidate = join(current, ".pi", "settings.json");
|
|
140
140
|
if (existsSync(candidate)) return candidate;
|
|
141
|
-
if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".
|
|
141
|
+
if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".kendex-lock.json"))) return candidate;
|
|
142
142
|
const parent = dirname(current);
|
|
143
143
|
if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
|
|
144
144
|
current = parent;
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
const PROJECT_TRUST_SYMBOL = Symbol.for("
|
|
148
|
+
const PROJECT_TRUST_SYMBOL = Symbol.for("kendex.pi.project-trust");
|
|
149
149
|
|
|
150
150
|
interface ProjectTrustRegistry {
|
|
151
151
|
projectSettings?: Map<string, boolean>;
|
|
@@ -212,7 +212,7 @@ function readManagerConfig(cwd: string): SettingsRecord {
|
|
|
212
212
|
if (!existsSync(path)) continue;
|
|
213
213
|
try {
|
|
214
214
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
215
|
-
const configRoot = asRecord(asRecord(asRecord(parsed?.
|
|
215
|
+
const configRoot = asRecord(asRecord(asRecord(parsed?.kendex)?.extensionManager)?.config);
|
|
216
216
|
const config = asRecord(configRoot?.[PACKAGE_ID]);
|
|
217
217
|
if (config) mergeDeep(merged, path === userPath ? config : withoutUserScopeOnlyKeys(config));
|
|
218
218
|
} catch (error) {
|
package/src/connector-cache.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Cross-PROCESS cache of the connector inventory (
|
|
1
|
+
// Cross-PROCESS cache of the connector inventory (kendex#870).
|
|
2
2
|
//
|
|
3
3
|
// #868 primes the inventory at provider registration, but the fetch takes ~1.5s
|
|
4
4
|
// while the first query is built at ~0.5-0.8s, so turn 1 of a cold sidecar goes
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// wrong-version cache returns undefined and the caller falls back to today's
|
|
19
19
|
// behaviour — the same fail-open contract as the inventory call itself.
|
|
20
20
|
//
|
|
21
|
-
// The ON-DISK FORMAT HAS AN EXTERNAL READER (
|
|
21
|
+
// The ON-DISK FORMAT HAS AN EXTERNAL READER (kendex#892). drovr quarantines this
|
|
22
22
|
// bundle to its sidecar process, so rather than calling `listAccountConnectors`
|
|
23
23
|
// in-process it re-implements the reader half — path
|
|
24
24
|
// `<piUserDir()>/connector-cache/<sha256(CLAUDE_CONFIG_DIR).hex[0..16]>.json`,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// nothing in the result distinguishes "these are the connectors" from "these are
|
|
6
6
|
// the connectors the search happened to return this time". Downstream then stored
|
|
7
7
|
// that lower bound as authoritative, so an account with Slack attached could
|
|
8
|
-
// report an inventory without Slack and no failure signal (
|
|
8
|
+
// report an inventory without Slack and no failure signal (kendex#838).
|
|
9
9
|
//
|
|
10
10
|
// This module asks the account instead of the model. Verified live against a
|
|
11
11
|
// personal claude_max org: the endpoint is POST (a GET returns 405) and each
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// conservatively — it under-reported, which fails safe). They are now separately
|
|
24
24
|
// observable and can legitimately disagree: a correct `complete: true` inventory
|
|
25
25
|
// can name Slack while `mcp__claude_ai_Slack__*` is not yet callable in this
|
|
26
|
-
// process (
|
|
26
|
+
// process (kendex#832). Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
|
|
27
27
|
// availability and keep an attach-time check on the call path; do not derive
|
|
28
28
|
// "can I call this tool right now" from this result.
|
|
29
29
|
//
|
package/src/connector-runtime.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Connector prime/snapshot host: the per-credential-scope inventory cache the
|
|
2
|
-
// query path reads synchronously (
|
|
2
|
+
// query path reads synchronously (kendex#832/#870). Extracted from index.ts —
|
|
3
3
|
// this is process-lifetime runtime state, not provider streaming logic.
|
|
4
4
|
//
|
|
5
5
|
// Connector declarations for the query path, cached per credential scope. The
|
|
@@ -124,7 +124,7 @@ export function primeConnectorServers(claudeConfigDir?: string, overrides: Prime
|
|
|
124
124
|
connectorServerFailureAt.delete(key);
|
|
125
125
|
// Persist so the NEXT cold process has this synchronously. Priming always
|
|
126
126
|
// loses the race against turn 1 in its own process; a cache written by an
|
|
127
|
-
// earlier run is the only thing turn 1 can read in time (
|
|
127
|
+
// earlier run is the only thing turn 1 can read in time (kendex#870).
|
|
128
128
|
if (writeCachedConnectors(inventory.connectors, key)) {
|
|
129
129
|
debug(`connectors: cached ${inventory.connectors.length} entries`);
|
|
130
130
|
}
|
|
@@ -148,7 +148,7 @@ export function connectorServersSnapshot(claudeConfigDir?: string): Record<strin
|
|
|
148
148
|
primeConnectorServers(claudeConfigDir);
|
|
149
149
|
// Fall back to the previous run's inventory, read synchronously. This is the
|
|
150
150
|
// only thing that can populate turn 1 of a cold process, because priming
|
|
151
|
-
// cannot finish before the first query is built (
|
|
151
|
+
// cannot finish before the first query is built (kendex#870).
|
|
152
152
|
const cached = readCachedConnectors(key);
|
|
153
153
|
if (!cached) return {};
|
|
154
154
|
const servers = connectorMcpServers({ ok: true, complete: true, connectors: cached });
|
package/src/connectors.ts
CHANGED
|
@@ -57,7 +57,7 @@ export function connectorsEnabledFor(config?: Config): boolean {
|
|
|
57
57
|
// isolation (no sources), which drops the connectors even with
|
|
58
58
|
// ENABLE_CLAUDEAI_MCP_SERVERS=1. So connectors mode must pass SOME source list.
|
|
59
59
|
//
|
|
60
|
-
// It must be `["user"]` and nothing more (
|
|
60
|
+
// It must be `["user"]` and nothing more (kendex#990). Connector state lives in
|
|
61
61
|
// USER scope — the account's config dir (CLAUDE_CONFIG_DIR for managed router
|
|
62
62
|
// profiles) — so user scope is sufficient for connectors to surface. Claude
|
|
63
63
|
// Code settings files can also carry an `env` map and `apiKeyHelper`; including
|
|
@@ -98,7 +98,7 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
|
|
|
98
98
|
"mcp__claude_ai_Atlassian__*",
|
|
99
99
|
];
|
|
100
100
|
|
|
101
|
-
// --- The SDK's two-name trap for built-in tools (
|
|
101
|
+
// --- The SDK's two-name trap for built-in tools (kendex#1007, kendex#1011) ---
|
|
102
102
|
//
|
|
103
103
|
// The CLI gives some built-ins TWO spellings, and which one you see depends on
|
|
104
104
|
// which SURFACE the name crosses:
|
|
@@ -118,9 +118,9 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
|
|
|
118
118
|
// This map declares each alias pair ONCE; every delivered-side membership set
|
|
119
119
|
// derives its spellings from it instead of hand-copying names. Both prior
|
|
120
120
|
// instances of the trap were exactly that hand-copy: CHILD_INTERNAL_TOOLS held
|
|
121
|
-
// request-side spellings that no stream name ever matched (
|
|
121
|
+
// request-side spellings that no stream name ever matched (kendex#1007), and
|
|
122
122
|
// the connectors allowlist hook DENIED the two discovery tools it exists to
|
|
123
|
-
// permit (
|
|
123
|
+
// permit (kendex#1011). Delivered-side sets accept BOTH spellings, so a CLI
|
|
124
124
|
// version that drops the aliasing cannot reintroduce the bug in either
|
|
125
125
|
// direction.
|
|
126
126
|
const SDK_TOOL_ALIASES: Record<string, string> = {
|
|
@@ -150,7 +150,7 @@ export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", "ListMcpResources", "Rea
|
|
|
150
150
|
// each entry, derived from SDK_TOOL_ALIASES. A PreToolUse hook's `tool_name`
|
|
151
151
|
// carries the canonical spelling (`ListMcpResourcesTool`), so testing the
|
|
152
152
|
// request-side list directly made the fail-closed allowlist hook DENY the two
|
|
153
|
-
// resource tools it exists to permit (
|
|
153
|
+
// resource tools it exists to permit (kendex#1011).
|
|
154
154
|
const CONNECTOR_DISCOVERY_TOOL_NAMES = new Set(CONNECTOR_DISCOVERY_TOOLS.flatMap(deliveredSpellings));
|
|
155
155
|
|
|
156
156
|
// --- Connector WRITE tool control (read-inline / write-by-approval) ---
|
|
@@ -243,7 +243,7 @@ function connectorNameWords(segment: string): string[] {
|
|
|
243
243
|
// exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
|
|
244
244
|
//
|
|
245
245
|
// PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
|
|
246
|
-
// dependents that gate real user-facing approvals on them (
|
|
246
|
+
// dependents that gate real user-facing approvals on them (kendex#892):
|
|
247
247
|
//
|
|
248
248
|
// memsira routes connector writes through its own gated approval flow
|
|
249
249
|
// drovr keeps its chat sidecar permanently write-`deny` and runs an
|
|
@@ -330,7 +330,7 @@ export function isConnectorTool(name: string | undefined): boolean {
|
|
|
330
330
|
// processStreamEvent/processAssistantMessage read. Stream names are a
|
|
331
331
|
// DELIVERED surface (see SDK_TOOL_ALIASES): the two MCP-resource built-ins'
|
|
332
332
|
// request-side spellings used to sit in this set and never matched anything
|
|
333
|
-
// (
|
|
333
|
+
// (kendex#1007). If an aliased name is ever added here, derive its spellings
|
|
334
334
|
// via deliveredSpellings rather than hand-copying them.
|
|
335
335
|
//
|
|
336
336
|
// The MCP-resource tools are now EXCLUDED deliberately, under BOTH spellings:
|
|
@@ -338,14 +338,14 @@ export function isConnectorTool(name: string | undefined): boolean {
|
|
|
338
338
|
// audit it through the Pi mirror (an out-of-process sidecar has no view of the
|
|
339
339
|
// bridge's own connector-call entries or the child transcript), so it stays
|
|
340
340
|
// mirrored into Pi. Do not re-add either spelling without revisiting that
|
|
341
|
-
// decision in
|
|
342
|
-
// all — see isAllowlistedConnectorSessionTool (
|
|
341
|
+
// decision in kendex#1007. The allowlist hook is what lets those calls run at
|
|
342
|
+
// all — see isAllowlistedConnectorSessionTool (kendex#1011).
|
|
343
343
|
const CHILD_INTERNAL_TOOLS = new Set(["ToolSearch", "ScheduleWakeup"]);
|
|
344
344
|
|
|
345
345
|
/**
|
|
346
346
|
* True for a Claude Code built-in meta-tool the child resolves in-process.
|
|
347
347
|
*
|
|
348
|
-
* Mirroring one into the Pi stream (
|
|
348
|
+
* Mirroring one into the Pi stream (kendex#980) made Pi's agent loop dispatch a
|
|
349
349
|
* tool it does not have and deliver an error result for an id no MCP handler
|
|
350
350
|
* ever claimed. The result queued in `pendingResults` until the reaper dropped
|
|
351
351
|
* it — one "dropped 1 tool result(s) whose handler never matched (ToolSearch)"
|
|
@@ -381,7 +381,7 @@ export function isChildInternalTool(name: string | undefined): boolean {
|
|
|
381
381
|
* how a lie in a mirror becomes a lie in the conversation of record.
|
|
382
382
|
*
|
|
383
383
|
* 2. Claude Code's own in-process meta-tools (`isChildInternalTool`), which the
|
|
384
|
-
* child resolves without any dispatcher at all (
|
|
384
|
+
* child resolves without any dispatcher at all (kendex#980).
|
|
385
385
|
*
|
|
386
386
|
* Takes the RAW SDK tool name, before `mapToolName` — child-executed names have
|
|
387
387
|
* no Pi-side counterpart, so mapping them is meaningless. Accepts a missing
|
|
@@ -507,7 +507,7 @@ export function connectorWriteDenyHook(): HookCallback {
|
|
|
507
507
|
// stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
|
|
508
508
|
// Naming one host told a different app's model to use a product it has never
|
|
509
509
|
// heard of, which is confusing at exactly the moment someone is debugging a
|
|
510
|
-
// refused write (
|
|
510
|
+
// refused write (kendex#892). Each host describes its own approval flow in its
|
|
511
511
|
// own prompt; this string only has to say that one exists.
|
|
512
512
|
function connectorWriteDenyOutput(toolName: string) {
|
|
513
513
|
return {
|
|
@@ -534,7 +534,7 @@ function connectorWriteDenyOutput(toolName: string) {
|
|
|
534
534
|
// This is a DELIVERED-side check — `name` is a hook's `input.tool_name`, which
|
|
535
535
|
// carries the canonical spelling — so membership is tested against
|
|
536
536
|
// CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list
|
|
537
|
-
// (
|
|
537
|
+
// (kendex#1011). That also carries kendex#1007's mirroring decision: the
|
|
538
538
|
// MCP-resource tools are deliberately NOT child-internal so every resource
|
|
539
539
|
// read mirrors into Pi as the consumers' audit surface — a mirror that can
|
|
540
540
|
// only exist if this allowlist lets the call execute. Denying the canonical
|
|
@@ -669,7 +669,7 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
|
|
|
669
669
|
/**
|
|
670
670
|
* Explicit `mcpServers` declarations for the account's CONNECTED connectors.
|
|
671
671
|
*
|
|
672
|
-
* Why this exists (
|
|
672
|
+
* Why this exists (kendex#832): claude.ai connectors load async and non-blocking,
|
|
673
673
|
* and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
|
|
674
674
|
* CLI has even fetched the connector list. The model therefore composes its first
|
|
675
675
|
* answer against a manifest containing no connectors and says it has no access,
|
package/src/index.ts
CHANGED
|
@@ -239,18 +239,18 @@ export interface DeferredUserReplayPlan {
|
|
|
239
239
|
// with no image blocks).
|
|
240
240
|
prompt: string | null;
|
|
241
241
|
// Present when the run carries image blocks — the replay must send these
|
|
242
|
-
// (via wrapPromptStream) or the images are silently lost (
|
|
242
|
+
// (via wrapPromptStream) or the images are silently lost (kendex#993).
|
|
243
243
|
blocks: ContentBlockParam[] | null;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
/** Plan replay of user messages pi injected mid-query (steer drain, followUp).
|
|
247
247
|
* Captures the ENTIRE trailing consecutive user run, not just the last
|
|
248
|
-
* message — dropping the earlier ones was silent input loss (
|
|
248
|
+
* message — dropping the earlier ones was silent input loss (kendex#967) —
|
|
249
249
|
* but never walks below `capturedThrough`, the position an earlier callback
|
|
250
250
|
* of the SAME query already captured (or deliberately held at, for an
|
|
251
251
|
* all-empty run). Without that lower bound a second mid-query steer re-planned
|
|
252
252
|
* the whole run from scratch and the first steer was queued — and delivered to
|
|
253
|
-
* Claude — twice (
|
|
253
|
+
* Claude — twice (kendex#1009). */
|
|
254
254
|
export function planDeferredUserReplay(messages: Context["messages"], capturedThrough = 0): DeferredUserReplayPlan {
|
|
255
255
|
let runStart = messages.length;
|
|
256
256
|
while (runStart > capturedThrough && messages[runStart - 1]?.role === "user") runStart--;
|
|
@@ -502,7 +502,7 @@ function applyProviderRegistration(trigger: string): void {
|
|
|
502
502
|
debug(`${trigger}: native registration upsert, credentialed=${credentialed} (module=${moduleInstanceId})`);
|
|
503
503
|
// Start the connector inventory now, not on the first turn: the query path
|
|
504
504
|
// can only read a synchronous snapshot, so priming here is what gets the
|
|
505
|
-
// declarations in place before turn 1 (
|
|
505
|
+
// declarations in place before turn 1 (kendex#832). Fire and forget —
|
|
506
506
|
// registration must not wait on the network. Primes the DEFAULT credential
|
|
507
507
|
// scope only; managed profiles are primed per request in their own scope.
|
|
508
508
|
if (hasClaudeCredentials() && connectorsEnabledFor(loadConfig(process.cwd()))) primeConnectorServers();
|
|
@@ -619,7 +619,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
619
619
|
if (queryCtx.pendingToolCalls.size > 0) {
|
|
620
620
|
// A waiting handler whose call never reached Pi can never be answered —
|
|
621
621
|
// fail it now with a retryable error instead of letting the SDK await it
|
|
622
|
-
// forever (the 2026-08-17 five-and-a-half-hour deadlock,
|
|
622
|
+
// forever (the 2026-08-17 five-and-a-half-hour deadlock, kendex#1469).
|
|
623
623
|
// Forwarded-but-unanswered handlers stay: steer-split batches legitimately
|
|
624
624
|
// deliver their results in a later callback.
|
|
625
625
|
const stranded = drainStrandedToolCalls(queryCtx);
|
|
@@ -646,7 +646,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
646
646
|
// so we save them for replay as continuation queries after consumeQuery ends.
|
|
647
647
|
// The cursor may only advance over messages actually captured for replay:
|
|
648
648
|
// claiming Claude owns a user message that was never deferred is permanent
|
|
649
|
-
// silent input loss (
|
|
649
|
+
// silent input loss (kendex#967 — only the LAST of several trailing user
|
|
650
650
|
// messages was captured while the cursor skipped them all).
|
|
651
651
|
let capturedThrough = context.messages.length;
|
|
652
652
|
if (lastMsgRole === "user") {
|
|
@@ -654,13 +654,13 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
654
654
|
// Math.max-advances with every callback's capturedThrough below), so a
|
|
655
655
|
// second steer callback only queues messages BEYOND what the first one
|
|
656
656
|
// already owns — re-planning the whole trailing run queued the earlier
|
|
657
|
-
// steer twice (
|
|
657
|
+
// steer twice (kendex#1009). latestCursor, not the shared record's
|
|
658
658
|
// cursor, deliberately: it lives on this QueryContext, so it is correct
|
|
659
659
|
// for reentrant and detached foreign queries too, whose contexts the
|
|
660
|
-
// shared cursor does not index (
|
|
660
|
+
// shared cursor does not index (kendex#1001).
|
|
661
661
|
const replay = planDeferredUserReplay(context.messages, queryCtx.latestCursor);
|
|
662
662
|
// Image-only runs have no usable text but must still replay — capture
|
|
663
|
-
// whenever EITHER form has content (
|
|
663
|
+
// whenever EITHER form has content (kendex#993).
|
|
664
664
|
if (replay.prompt || replay.blocks) {
|
|
665
665
|
ctx().deferredUserMessages.push({ text: replay.prompt ?? "", blocks: replay.blocks ?? undefined });
|
|
666
666
|
debug(`provider: deferred ${replay.userMessageCount} user message(s) for replay after query${replay.blocks ? ` (${replay.blocks.length} blocks incl. images)` : ""}: ${(replay.prompt ?? "[image-only]").slice(0, 60)}`);
|
|
@@ -683,7 +683,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
683
683
|
// The stackDepth guard covers a pushed subagent context; the detached
|
|
684
684
|
// flag covers a foreign one-shot on the top-level ctx, whose GROWN
|
|
685
685
|
// mid-query context could otherwise out-length the parent's cursor and
|
|
686
|
-
// advance it past history Claude never saw (
|
|
686
|
+
// advance it past history Claude never saw (kendex#1001); Math.max
|
|
687
687
|
// remains the backstop for a legacy-record foreign context the
|
|
688
688
|
// fingerprint guard could not classify.
|
|
689
689
|
const activeSession = getSharedSession();
|
|
@@ -702,7 +702,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
702
702
|
debug(`provider: orphaned tool result after abort, emitting end_turn`);
|
|
703
703
|
// The detached flag deliberately survives query end: an orphaned result
|
|
704
704
|
// from a foreign one-shot indexes ITS conversation, and writing that
|
|
705
|
-
// length here would move (even shrink) the parent's cursor (
|
|
705
|
+
// length here would move (even shrink) the parent's cursor (kendex#1001).
|
|
706
706
|
const activeSession = getSharedSession();
|
|
707
707
|
if (activeSession && stackDepth() === 0 && !ctx().detachedFromSharedSession) setSharedSession({ ...activeSession, cursor: context.messages.length });
|
|
708
708
|
const c = ctx(); // capture current context for the microtask
|
|
@@ -883,7 +883,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
883
883
|
const { sessionId: resumeSessionId, promptStart } = syncResult;
|
|
884
884
|
// A FOREIGN-conversation query (conversation-fingerprint mismatch against
|
|
885
885
|
// the shared record — a subagent-shaped request arriving while the parent
|
|
886
|
-
// is IDLE,
|
|
886
|
+
// is IDLE, kendex#1001) also runs as a clean one-shot and gets the same
|
|
887
887
|
// hands-off treatment as a reentrant one below: never persist over the
|
|
888
888
|
// module-level record, never mark it for rebuild. The flag also rides the
|
|
889
889
|
// QueryContext so the mismatch/abort/teardown paths that mutate the record
|
|
@@ -967,7 +967,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
967
967
|
// A reentrant (subagent) query must never write the module-level shared
|
|
968
968
|
// session: its completion/failure handlers would overwrite the PARENT's
|
|
969
969
|
// record with the child's session id and cursor. A foreign-conversation
|
|
970
|
-
// one-shot (
|
|
970
|
+
// one-shot (kendex#1001) has exactly the same non-claim on the record.
|
|
971
971
|
const persistSession = (next: SessionState | null): void => {
|
|
972
972
|
if (isReentrant || foreignContext) return;
|
|
973
973
|
setSharedSession(next && conversationFp ? { conversationFingerprint: conversationFp, ...next } : next);
|
|
@@ -1235,7 +1235,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
1235
1235
|
|
|
1236
1236
|
const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
|
|
1237
1237
|
// Runs carrying image blocks replay as blocks (wrapPromptStream) so
|
|
1238
|
-
// the images survive; text-only runs stay plain strings (
|
|
1238
|
+
// the images survive; text-only runs stay plain strings (kendex#993).
|
|
1239
1239
|
const contQuery = sdkQueryFactory({ prompt: steer.blocks ? wrapPromptStream(steer.blocks) : steer.text, options: contOptions });
|
|
1240
1240
|
abortCtx.activeQuery = contQuery;
|
|
1241
1241
|
|
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 (kendex#832).
|
|
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 (kendex#990). 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;
|
package/src/query-state.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { currentRequestLaneId } from "./request-lane.js";
|
|
|
16
16
|
* `text` is the joined text form (previews, and the replay prompt when no
|
|
17
17
|
* image blocks were captured). `blocks` is present when the run carried
|
|
18
18
|
* images — the replay must send the blocks or the images are silently lost
|
|
19
|
-
* (
|
|
19
|
+
* (kendex#993). */
|
|
20
20
|
export interface DeferredUserMessage {
|
|
21
21
|
text: string;
|
|
22
22
|
blocks?: ContentBlockParam[];
|
|
@@ -40,7 +40,7 @@ export interface PendingToolCall {
|
|
|
40
40
|
/** The MCP invocation's schema-validated arguments. The SDK hands the handler
|
|
41
41
|
* the COMPLETE input, so this is the authoritative copy — the grace-timer
|
|
42
42
|
* finalize settles a still-partial streamed block from here instead of from
|
|
43
|
-
* its truncated partial JSON (
|
|
43
|
+
* its truncated partial JSON (kendex#1469: a `{}` settle made Pi execute
|
|
44
44
|
* empty-argument calls). */
|
|
45
45
|
args: Record<string, unknown>;
|
|
46
46
|
/** `QueryContext.callbackGeneration` at registration. A handler from an older
|
|
@@ -106,7 +106,7 @@ export function strandedToolCallResult(): McpResult {
|
|
|
106
106
|
* forwarded (Pi owes it a result — steer-split deliveries arrive turns later)
|
|
107
107
|
* or nothing is waiting. Marks the id dead so a lagging stream replay can
|
|
108
108
|
* 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 (
|
|
109
|
+
* would execute the call a second time behind the model's back (kendex#1469).
|
|
110
110
|
* Returns true when a handler was failed. */
|
|
111
111
|
export function failStrandedToolCall(queryCtx: QueryContext, id: string): boolean {
|
|
112
112
|
if (queryCtx.forwardedToolCallIds.has(id)) return false;
|
|
@@ -263,7 +263,7 @@ export class QueryContext {
|
|
|
263
263
|
* set endToolUseTurn stamps from the turn's content. A forwarded id is one Pi
|
|
264
264
|
* will execute and answer; it must never be emitted again (a lagging stream
|
|
265
265
|
* replays the same tool_use into the NEXT turn, and per-message turnBlocks
|
|
266
|
-
* dedup cannot see across turns —
|
|
266
|
+
* dedup cannot see across turns — kendex#1469's duplicate executions), and a
|
|
267
267
|
* handler waiting on it must be left waiting at the stranded-handler drains.
|
|
268
268
|
* Query-scoped, never reset per message. */
|
|
269
269
|
forwardedToolCallIds = new Set<string>();
|
|
@@ -295,7 +295,7 @@ export class QueryContext {
|
|
|
295
295
|
* same reason: a late handler firing after resetToolTracking wiped the
|
|
296
296
|
* per-message records must still be able to exact-match the parked/queued
|
|
297
297
|
* 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 (
|
|
298
|
+
* sole-same-name, which can hand it a LIVE sibling's id (kendex#1469). */
|
|
299
299
|
queryToolArgs = new Map<string, Record<string, unknown>>();
|
|
300
300
|
claimedToolCallIds = new Set<string>();
|
|
301
301
|
deliveredToolResultIds = new Set<string>();
|
|
@@ -311,7 +311,7 @@ export class QueryContext {
|
|
|
311
311
|
committedOutput = false;
|
|
312
312
|
/** True when this query holds NO claim on the module-level shared session
|
|
313
313
|
* record: a reentrant (subagent) query, or a foreign-conversation one-shot
|
|
314
|
-
* (
|
|
314
|
+
* (kendex#1001). Every shared-record mutation reachable from this context —
|
|
315
315
|
* reportToolResultMismatch's needsRebuild/forceRotate mark, the cursor
|
|
316
316
|
* advances on the tool-result-delivery and orphaned-result paths — must
|
|
317
317
|
* no-op so the PARENT's record stays untouched. Assigned at fresh-query
|
|
@@ -517,7 +517,7 @@ export class QueryContext {
|
|
|
517
517
|
// Ids whose RESULT already sits queued or parked. A handler can fire after
|
|
518
518
|
// the message boundary wiped the per-message records — by then Pi has
|
|
519
519
|
// executed its call and only these query-scoped stores still know it
|
|
520
|
-
// (
|
|
520
|
+
// (kendex#1469: the boundary reap used to make such a handler error out
|
|
521
521
|
// and the model re-run an already-executed side-effectful call). An
|
|
522
522
|
// exact-args match here outranks the live sole-same-name fallback below,
|
|
523
523
|
// so a late handler can never steal a live sibling's id while its own
|
|
@@ -586,7 +586,7 @@ export class QueryContext {
|
|
|
586
586
|
* mismatch report for the whole query (queued>0 with 0/0 counters and no tool
|
|
587
587
|
* names) and forces a session rebuild per turn. But the boundary does NOT
|
|
588
588
|
* prove the handler gave up — the SDK staggers handler invocations, and the
|
|
589
|
-
* 2026-08-17 deadlock session (
|
|
589
|
+
* 2026-08-17 deadlock session (kendex#1469) had three of five parallel
|
|
590
590
|
* handlers fire after this reap destroyed their results. So the reap parks
|
|
591
591
|
* instead of dropping: reports stay clean, and a late handler still gets its
|
|
592
592
|
* real result through takeQueuedOrParkedResult.
|
|
@@ -675,7 +675,7 @@ interface QueryLaneStoreV1 {
|
|
|
675
675
|
sessionLanes: Map<string, QueryLaneState>;
|
|
676
676
|
}
|
|
677
677
|
|
|
678
|
-
const QUERY_LANES_SYMBOL = Symbol.for("
|
|
678
|
+
const QUERY_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.query-lanes.v1");
|
|
679
679
|
|
|
680
680
|
function queryLaneStore(): QueryLaneStoreV1 {
|
|
681
681
|
const host = globalThis as Record<symbol, unknown>;
|
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
|
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>;
|
|
@@ -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
|
|
|
@@ -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
|
);
|
|
@@ -523,7 +523,7 @@ export function syncSharedSession(
|
|
|
523
523
|
);
|
|
524
524
|
const incomingFingerprint = conversationFingerprint(messages);
|
|
525
525
|
|
|
526
|
-
// FOREIGN-CONVERSATION guard (Case 6,
|
|
526
|
+
// FOREIGN-CONVERSATION guard (Case 6, kendex#1001). 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 (kendex#993).
|
|
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
|