@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/connector-cache.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
// Cross-PROCESS cache of the connector inventory
|
|
1
|
+
// Cross-PROCESS cache of the connector inventory.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 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
|
|
5
|
-
// out with no declarations and
|
|
5
|
+
// out with no declarations and preserves the failure this cache prevents.
|
|
6
6
|
//
|
|
7
7
|
// An in-process cache cannot help the consumer that needs it most. drovr builds
|
|
8
8
|
// a sidecar lazily on the first bridge round and, since their sidecars are
|
|
9
|
-
// per-SESSION,
|
|
10
|
-
// once per chat, indefinitely, and every one of those is a cold process. The
|
|
9
|
+
// per-SESSION, each chat starts a separate cold process. The
|
|
11
10
|
// cache therefore has to survive process boundaries.
|
|
12
11
|
//
|
|
13
12
|
// Keyed by credential scope, because that is what selects the account: the org
|
|
@@ -18,7 +17,7 @@
|
|
|
18
17
|
// wrong-version cache returns undefined and the caller falls back to today's
|
|
19
18
|
// behaviour — the same fail-open contract as the inventory call itself.
|
|
20
19
|
//
|
|
21
|
-
// The ON-DISK FORMAT HAS AN EXTERNAL READER
|
|
20
|
+
// The ON-DISK FORMAT HAS AN EXTERNAL READER. drovr quarantines this
|
|
22
21
|
// bundle to its sidecar process, so rather than calling `listAccountConnectors`
|
|
23
22
|
// in-process it re-implements the reader half — path
|
|
24
23
|
// `<piUserDir()>/connector-cache/<sha256(CLAUDE_CONFIG_DIR).hex[0..16]>.json`,
|
|
@@ -43,7 +42,7 @@ import type { ConnectorEntry } from "./connector-inventory.js";
|
|
|
43
42
|
const CACHE_VERSION = 2;
|
|
44
43
|
/** Long enough to be useful across a machine's lifetime, short enough that a
|
|
45
44
|
* removed connector stops being declared without needing a manual purge. A
|
|
46
|
-
* stale entry is not dangerous — a connector that
|
|
45
|
+
* stale entry is not dangerous — a connector that does not resolve simply
|
|
47
46
|
* fails to connect, which is the fail-open path — so this is hygiene, not a
|
|
48
47
|
* correctness boundary. */
|
|
49
48
|
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -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.
|
|
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
|
|
@@ -18,12 +18,11 @@
|
|
|
18
18
|
// installed. It says nothing about whether a given connector's MCP server has
|
|
19
19
|
// finished attaching inside the `claude` child that is about to run a turn —
|
|
20
20
|
// this is a plain HTTPS call and does not consult that process at all. The two
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// conservatively — it under-reported, which fails safe). They are now separately
|
|
21
|
+
// are separate: the ToolSearch probe can report only what is attached, so an
|
|
22
|
+
// inventory must not imply availability. They are
|
|
24
23
|
// observable and can legitimately disagree: a correct `complete: true` inventory
|
|
25
24
|
// can name Slack while `mcp__claude_ai_Slack__*` is not yet callable in this
|
|
26
|
-
// process
|
|
25
|
+
// process. Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
|
|
27
26
|
// availability and keep an attach-time check on the call path; do not derive
|
|
28
27
|
// "can I call this tool right now" from this result.
|
|
29
28
|
//
|
|
@@ -47,8 +46,7 @@ export type ConnectorEntry = {
|
|
|
47
46
|
/**
|
|
48
47
|
* Account-side install state. `"connected"` marks the connectors the CLI
|
|
49
48
|
* actually attempts; everything else it never gives a `Starting connection`
|
|
50
|
-
* line at all.
|
|
51
|
-
* app account, and the CLI connected exactly those 7.
|
|
49
|
+
* line at all. The CLI attempts exactly the entries marked `connected`.
|
|
52
50
|
*/
|
|
53
51
|
installState?: string;
|
|
54
52
|
description?: string;
|
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. 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
|
|
@@ -32,15 +32,15 @@ export function readCredentialFile(path: string): string | undefined {
|
|
|
32
32
|
const connectorServerCache = new Map<string, Record<string, unknown>>();
|
|
33
33
|
const connectorServerPending = new Set<string>();
|
|
34
34
|
// Epoch ms of the last FAILED inventory attempt per scope, so a persistently
|
|
35
|
-
// failing account cools down instead of issuing one
|
|
36
|
-
//
|
|
35
|
+
// failing account cools down instead of issuing one HTTPS request per turn
|
|
36
|
+
// Missing credentials are exempt: that check is a local file read
|
|
37
37
|
// with no request to bound, and a just-completed `claude login` must take
|
|
38
38
|
// effect on the next turn.
|
|
39
39
|
const connectorServerFailureAt = new Map<string, number>();
|
|
40
40
|
|
|
41
41
|
// Deadline on the inventory round trip. Without one, a hung claude.ai request
|
|
42
42
|
// held the pending flag forever — same budget as the account-host probe's
|
|
43
|
-
// ACCOUNT_PROBE_DEADLINE_MS
|
|
43
|
+
// ACCOUNT_PROBE_DEADLINE_MS.
|
|
44
44
|
const CONNECTOR_PRIME_TIMEOUT_MS = 10_000;
|
|
45
45
|
const CONNECTOR_PRIME_FAILURE_COOLDOWN_MS = 60_000;
|
|
46
46
|
|
|
@@ -81,12 +81,12 @@ export function connectorCredentialEnv(claudeConfigDir: string | undefined = pro
|
|
|
81
81
|
//
|
|
82
82
|
// FAILS OPEN throughout: no credentials, a failed inventory, or a thrown call
|
|
83
83
|
// all resolve to "declare nothing" rather than breaking the turn. Failures are
|
|
84
|
-
// NOT cached
|
|
84
|
+
// NOT cached. Caching a transient registration failure would pin `{}` for the
|
|
85
85
|
// process lifetime, keeping every later turn undeclared and the disk-cache
|
|
86
86
|
// fallback unreachable. Leaving the key unset makes the next snapshot retry;
|
|
87
87
|
// the pending set dedupes concurrent fetches, and a failed inventory attempt
|
|
88
88
|
// stamps a per-scope cooldown so a persistently failing account backs off
|
|
89
|
-
// instead of re-priming on every turn
|
|
89
|
+
// instead of re-priming on every turn.
|
|
90
90
|
export function primeConnectorServers(claudeConfigDir?: string, overrides: PrimeConnectorOverrides = {}): void {
|
|
91
91
|
const key = connectorScopeKey(claudeConfigDir);
|
|
92
92
|
if (connectorServerCache.has(key) || connectorServerPending.has(key)) return;
|
|
@@ -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.
|
|
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.
|
|
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. 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 ---
|
|
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, and
|
|
122
122
|
// the connectors allowlist hook DENIED the two discovery tools it exists to
|
|
123
|
-
// permit
|
|
123
|
+
// permit. 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> = {
|
|
@@ -144,14 +144,18 @@ function deliveredSpellings(name: string): string[] {
|
|
|
144
144
|
// REQUEST-side spellings: this list feeds the SDK option surface (the
|
|
145
145
|
// disallowedTools filter in toolIsolationForQuery) and is the exported public
|
|
146
146
|
// name. Delivered-side checks use CONNECTOR_DISCOVERY_TOOL_NAMES below.
|
|
147
|
-
|
|
147
|
+
const MCP_RESOURCE_TOOLS = ["ListMcpResources", "ReadMcpResource"];
|
|
148
|
+
export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", ...MCP_RESOURCE_TOOLS];
|
|
148
149
|
|
|
149
|
-
// Delivered-side membership
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// request-side list directly made the fail-closed allowlist hook DENY the two
|
|
153
|
-
// resource tools it exists to permit (vstack#1011).
|
|
150
|
+
// Delivered-side membership sets carry both spellings, derived from the one
|
|
151
|
+
// alias declaration above. The resource-only set keeps those audit calls
|
|
152
|
+
// dispatchable without making ToolSearch a Pi call.
|
|
154
153
|
const CONNECTOR_DISCOVERY_TOOL_NAMES = new Set(CONNECTOR_DISCOVERY_TOOLS.flatMap(deliveredSpellings));
|
|
154
|
+
const MCP_RESOURCE_TOOL_NAMES = new Set(MCP_RESOURCE_TOOLS.flatMap(deliveredSpellings));
|
|
155
|
+
|
|
156
|
+
export function isMcpResourceTool(name: string): boolean {
|
|
157
|
+
return MCP_RESOURCE_TOOL_NAMES.has(name);
|
|
158
|
+
}
|
|
155
159
|
|
|
156
160
|
// --- Connector WRITE tool control (read-inline / write-by-approval) ---
|
|
157
161
|
//
|
|
@@ -243,12 +247,12 @@ function connectorNameWords(segment: string): string[] {
|
|
|
243
247
|
// exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
|
|
244
248
|
//
|
|
245
249
|
// PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
|
|
246
|
-
// dependents that gate real user-facing approvals on them
|
|
250
|
+
// dependents that gate real user-facing approvals on them:
|
|
247
251
|
//
|
|
248
252
|
// memsira routes connector writes through its own gated approval flow
|
|
249
253
|
// drovr keeps its chat sidecar permanently write-`deny` and runs an
|
|
250
254
|
// approved write as a separate one-shot `claude -p` scoped by
|
|
251
|
-
// `--allowedTools` to exactly one connector tool
|
|
255
|
+
// `--allowedTools` to exactly one connector tool
|
|
252
256
|
//
|
|
253
257
|
// Both pin the actions they expose against this classification, because "the
|
|
254
258
|
// sidecar structurally cannot do this itself" is THIS module's claim, not
|
|
@@ -329,23 +333,21 @@ export function isConnectorTool(name: string | undefined): boolean {
|
|
|
329
333
|
// `content_block_start` / assistant-message blocks, the exact fields
|
|
330
334
|
// processStreamEvent/processAssistantMessage read. Stream names are a
|
|
331
335
|
// DELIVERED surface (see SDK_TOOL_ALIASES): the two MCP-resource built-ins'
|
|
332
|
-
// request-side spellings
|
|
333
|
-
//
|
|
334
|
-
// via deliveredSpellings rather than hand-copying them.
|
|
336
|
+
// request-side spellings do not belong in this set because they match nothing.
|
|
337
|
+
// Derive every aliased name via deliveredSpellings rather than hand-copying it.
|
|
335
338
|
//
|
|
336
|
-
// The MCP-resource tools are
|
|
339
|
+
// The MCP-resource tools are EXCLUDED under BOTH spellings:
|
|
337
340
|
// a resource read is a real account-surface access, and both consumer hosts
|
|
338
341
|
// audit it through the Pi mirror (an out-of-process sidecar has no view of the
|
|
339
342
|
// bridge's own connector-call entries or the child transcript), so it stays
|
|
340
|
-
// mirrored into Pi. Do not
|
|
341
|
-
//
|
|
342
|
-
// all — see isAllowlistedConnectorSessionTool (vstack#1011).
|
|
343
|
+
// mirrored into Pi. Do not add either spelling to this set. The allowlist hook
|
|
344
|
+
// is what lets those calls run; see isAllowlistedConnectorSessionTool.
|
|
343
345
|
const CHILD_INTERNAL_TOOLS = new Set(["ToolSearch", "ScheduleWakeup"]);
|
|
344
346
|
|
|
345
347
|
/**
|
|
346
348
|
* True for a Claude Code built-in meta-tool the child resolves in-process.
|
|
347
349
|
*
|
|
348
|
-
* Mirroring one into the Pi stream
|
|
350
|
+
* Mirroring one into the Pi stream would make Pi's agent loop dispatch a
|
|
349
351
|
* tool it does not have and deliver an error result for an id no MCP handler
|
|
350
352
|
* ever claimed. The result queued in `pendingResults` until the reaper dropped
|
|
351
353
|
* it — one "dropped 1 tool result(s) whose handler never matched (ToolSearch)"
|
|
@@ -375,13 +377,13 @@ export function isChildInternalTool(name: string | undefined): boolean {
|
|
|
375
377
|
* miss, and write a synthetic `Tool <name> not found` error result into the
|
|
376
378
|
* transcript — while the child went on and executed the real call. The Pi
|
|
377
379
|
* transcript then RECORDED A FAILURE FOR A CALL THAT SUCCEEDED, next to an
|
|
378
|
-
* answer built from the real payload, so the model's correct answer
|
|
379
|
-
*
|
|
380
|
+
* answer built from the real payload, so the model's correct answer appears
|
|
381
|
+
* fabricated. The false result is also projected
|
|
380
382
|
* back into the child's session on a rebuild (`syncSharedSession`), which is
|
|
381
383
|
* how a lie in a mirror becomes a lie in the conversation of record.
|
|
382
384
|
*
|
|
383
385
|
* 2. Claude Code's own in-process meta-tools (`isChildInternalTool`), which the
|
|
384
|
-
* child resolves without any dispatcher at all
|
|
386
|
+
* child resolves without any dispatcher at all.
|
|
385
387
|
*
|
|
386
388
|
* Takes the RAW SDK tool name, before `mapToolName` — child-executed names have
|
|
387
389
|
* no Pi-side counterpart, so mapping them is meaningless. Accepts a missing
|
|
@@ -487,7 +489,7 @@ export function connectorWriteDenyHook(): HookCallback {
|
|
|
487
489
|
// the tool call proceed (fail OPEN) — so any exception in this body
|
|
488
490
|
// must convert to a deny, never an allow. Today's body is pure string
|
|
489
491
|
// checks on schema-validated input; the catch pins that invariant for
|
|
490
|
-
//
|
|
492
|
+
// subsequent changes to this body.
|
|
491
493
|
try {
|
|
492
494
|
if (input.hook_event_name !== "PreToolUse") return { continue: true };
|
|
493
495
|
// A non-string tool name cannot be classified, and this hook fails
|
|
@@ -507,7 +509,7 @@ export function connectorWriteDenyHook(): HookCallback {
|
|
|
507
509
|
// stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
|
|
508
510
|
// Naming one host told a different app's model to use a product it has never
|
|
509
511
|
// heard of, which is confusing at exactly the moment someone is debugging a
|
|
510
|
-
// refused write
|
|
512
|
+
// refused write. Each host describes its own approval flow in its
|
|
511
513
|
// own prompt; this string only has to say that one exists.
|
|
512
514
|
function connectorWriteDenyOutput(toolName: string) {
|
|
513
515
|
return {
|
|
@@ -533,8 +535,8 @@ function connectorWriteDenyOutput(toolName: string) {
|
|
|
533
535
|
//
|
|
534
536
|
// This is a DELIVERED-side check — `name` is a hook's `input.tool_name`, which
|
|
535
537
|
// carries the canonical spelling — so membership is tested against
|
|
536
|
-
// CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list
|
|
537
|
-
//
|
|
538
|
+
// CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list.
|
|
539
|
+
// The allowlist hook also carries the mirroring rule: the
|
|
538
540
|
// MCP-resource tools are deliberately NOT child-internal so every resource
|
|
539
541
|
// read mirrors into Pi as the consumers' audit surface — a mirror that can
|
|
540
542
|
// only exist if this allowlist lets the call execute. Denying the canonical
|
|
@@ -669,14 +671,12 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
|
|
|
669
671
|
/**
|
|
670
672
|
* Explicit `mcpServers` declarations for the account's CONNECTED connectors.
|
|
671
673
|
*
|
|
672
|
-
* Why this exists
|
|
674
|
+
* Why this exists: claude.ai connectors load async and non-blocking,
|
|
673
675
|
* and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
|
|
674
676
|
* CLI has even fetched the connector list. The model therefore composes its first
|
|
675
677
|
* answer against a manifest containing no connectors and says it has no access,
|
|
676
|
-
* while the connector attaches
|
|
677
|
-
*
|
|
678
|
-
* 7/20 baseline runs versus 20/20 with the declaration, and "I don't have access"
|
|
679
|
-
* went 13/20 → 0/20, one-sided Fisher exact p = 6.4e-6. Confirmed at seven
|
|
678
|
+
* while the connector attaches later and is never asked. The declaration must
|
|
679
|
+
* be available before the first query. Confirmed at seven
|
|
680
680
|
* declarations over a further 30 runs: 5/10 → 10/10 calls, 5/10 → 0/10 denials.
|
|
681
681
|
*
|
|
682
682
|
* It is also FASTER, which is the opposite of what the startup barrier suggests.
|
|
@@ -736,9 +736,8 @@ export function connectorMcpServers(inventory: ConnectorInventory): Record<strin
|
|
|
736
736
|
|
|
737
737
|
/**
|
|
738
738
|
* `CLAUDE_BRIDGE_CONNECTOR_DECLARE=off` (or `0`/`false`/`no`) disables explicit
|
|
739
|
-
* connector declarations while leaving connectors themselves enabled.
|
|
740
|
-
*
|
|
741
|
-
* manifest again.
|
|
739
|
+
* connector declarations while leaving connectors themselves enabled. Without
|
|
740
|
+
* declarations, connector loading races the turn-1 manifest.
|
|
742
741
|
*/
|
|
743
742
|
export function connectorDeclarationsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
744
743
|
const v = (env.CLAUDE_BRIDGE_CONNECTOR_DECLARE ?? "").trim().toLowerCase();
|
package/src/consume-query.ts
CHANGED
|
@@ -111,7 +111,7 @@ export async function consumeQuery(
|
|
|
111
111
|
if (account) {
|
|
112
112
|
// Thunk, not a value: this runs once per SDK message — including one
|
|
113
113
|
// stream_event per streamed token — and debug() only evaluates function
|
|
114
|
-
// args after its DEBUG early return
|
|
114
|
+
// args after its DEBUG early return.
|
|
115
115
|
debug("consumeQuery: managed message", () => JSON.stringify({
|
|
116
116
|
type: message.type,
|
|
117
117
|
subtype: (message as any).subtype,
|
|
@@ -203,8 +203,8 @@ export async function consumeQuery(
|
|
|
203
203
|
}
|
|
204
204
|
// Other non-success subtypes (error_max_turns,
|
|
205
205
|
// error_during_execution) surface at completion via the held
|
|
206
|
-
// failure
|
|
207
|
-
//
|
|
206
|
+
// failure. These turns require an explicit error event instead of
|
|
207
|
+
// silent completion. Session persistence and deferred replay still run.
|
|
208
208
|
}
|
|
209
209
|
break;
|
|
210
210
|
case "system":
|
package/src/convert.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<strin
|
|
|
28
28
|
// `McpClaudeAiSlackSlackSearchChannels`) that appeared in the child's
|
|
29
29
|
// projected history, so the model imitated it on the next turn and got a
|
|
30
30
|
// real `Tool ... not found` from the MCP dispatcher before retrying the
|
|
31
|
-
// canonical name — one wasted round-trip per affected call
|
|
31
|
+
// canonical name — one wasted round-trip per affected call.
|
|
32
32
|
//
|
|
33
33
|
// Connector names stopped reaching this function at all once they stopped
|
|
34
34
|
// being mirrored as Pi tool calls (isChildExecutedTool), so this is the
|
package/src/debug.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function debug(...args: unknown[]) {
|
|
|
50
50
|
if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
|
|
51
51
|
// A function argument is a lazy payload: hot-path call sites (per-token
|
|
52
52
|
// stream events) pass a thunk so the expensive formatting only runs when
|
|
53
|
-
// DEBUG is on — fmt is only reached past the early return
|
|
53
|
+
// DEBUG is on — fmt is only reached past the early return.
|
|
54
54
|
if (typeof a === "function") return fmt((a as () => unknown)());
|
|
55
55
|
return JSON.stringify(a);
|
|
56
56
|
};
|
|
@@ -121,7 +121,7 @@ export function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?:
|
|
|
121
121
|
/** Diagnostic dump — for "should never happen" paths. Gated on the same
|
|
122
122
|
* CLAUDE_BRIDGE_DEBUG flag as debug(): the entries carry session metadata
|
|
123
123
|
* and land in a log outside any host app's retention/cleanup boundary, so
|
|
124
|
-
* a host that has not opted into debugging must get no disk write
|
|
124
|
+
* a host that has not opted into debugging must get no disk write. */
|
|
125
125
|
export function diagDump(label: string, data: Record<string, unknown>) {
|
|
126
126
|
if (!DEBUG) return;
|
|
127
127
|
try {
|
package/src/index.ts
CHANGED
|
@@ -79,7 +79,7 @@ export { cancelScheduledSessionPersistence, conversationFingerprint, conversatio
|
|
|
79
79
|
export { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, claudeAuthSourceLabel, supportsNativeProvider } from "./native-provider.js";
|
|
80
80
|
export { DEFAULT_STREAM_IDLE_TIMEOUT_MS, STREAM_IDLE_BACKOFF_HINT_MS, STREAM_IDLE_TIMEOUT_ENV, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, streamIdleTimeoutMsFromEnv, type StreamIdleTimeoutInfo, type StreamIdleWatchdog, type StreamIdleWatchdogState } from "./stream-idle-watchdog.js";
|
|
81
81
|
export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isUsageLimitMessage, normalizeRateLimitUtilization, resetTimestampMs, uniqueNonEmptyLines } from "./rate-limit.js";
|
|
82
|
-
export { mapToolName } from "./tool-mapping.js";
|
|
82
|
+
export { isPiDispatchable, mapToolName } from "./tool-mapping.js";
|
|
83
83
|
export { cancelScheduledToolUseEnd, endToolUseTurn, finalizeToolUseTurnFromMcpInvocation, noteChildExecutedToolResults, processAssistantMessage, processStreamEvent, reapStaleQueuedResults, scheduleToolUseTurnEnd } from "./assistant-stream.js";
|
|
84
84
|
export {
|
|
85
85
|
accountSessionScope,
|
|
@@ -239,18 +239,17 @@ 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.
|
|
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
|
|
249
|
-
* but never walks below `capturedThrough`, the position an earlier callback
|
|
248
|
+
* message, but never walks below `capturedThrough`, the position a prior callback
|
|
250
249
|
* of the SAME query already captured (or deliberately held at, for an
|
|
251
250
|
* all-empty run). Without that lower bound a second mid-query steer re-planned
|
|
252
251
|
* the whole run from scratch and the first steer was queued — and delivered to
|
|
253
|
-
* Claude — twice
|
|
252
|
+
* Claude — twice. */
|
|
254
253
|
export function planDeferredUserReplay(messages: Context["messages"], capturedThrough = 0): DeferredUserReplayPlan {
|
|
255
254
|
let runStart = messages.length;
|
|
256
255
|
while (runStart > capturedThrough && messages[runStart - 1]?.role === "user") runStart--;
|
|
@@ -299,7 +298,7 @@ export function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
|
299
298
|
// namespace belongs to the child's own MCP servers, so a Pi tool sitting
|
|
300
299
|
// on it would be advertised a SECOND time under our prefix — two names
|
|
301
300
|
// for one capability, and the model picking the wrong one gets a real
|
|
302
|
-
// `Tool
|
|
301
|
+
// `Tool... not found` from the dispatcher. It would also
|
|
303
302
|
// be uncallable in any case: a `tool_use` under that namespace is treated
|
|
304
303
|
// as child-executed and never handed to Pi (isChildExecutedTool), so
|
|
305
304
|
// filtering here is what makes the two halves agree end to end.
|
|
@@ -418,8 +417,8 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
|
|
|
418
417
|
// 2. consumeQuery() iterates the SDK generator, pushing events to currentPiStream
|
|
419
418
|
// 3. On tool_use: ends the current pi stream, nulls it out. The MCP handler
|
|
420
419
|
// blocks the generator naturally — no events arrive until resolved.
|
|
421
|
-
// 4. Pi executes the tool
|
|
422
|
-
// resolve the MCP handler, and the generator unblocks
|
|
420
|
+
// 4. Pi executes the tool and calls streamSimple again. We swap in the replacement
|
|
421
|
+
// stream, resolve the MCP handler, and the generator unblocks into that stream.
|
|
423
422
|
//
|
|
424
423
|
// Note: resetTurnState clears turnSawStreamEvent while the generator may still
|
|
425
424
|
// have queued messages from the previous turn. This is safe because step 3 nulls
|
|
@@ -502,7 +501,7 @@ function applyProviderRegistration(trigger: string): void {
|
|
|
502
501
|
debug(`${trigger}: native registration upsert, credentialed=${credentialed} (module=${moduleInstanceId})`);
|
|
503
502
|
// Start the connector inventory now, not on the first turn: the query path
|
|
504
503
|
// can only read a synchronous snapshot, so priming here is what gets the
|
|
505
|
-
// declarations in place before turn 1
|
|
504
|
+
// declarations in place before turn 1. Fire and forget —
|
|
506
505
|
// registration must not wait on the network. Primes the DEFAULT credential
|
|
507
506
|
// scope only; managed profiles are primed per request in their own scope.
|
|
508
507
|
if (hasClaudeCredentials() && connectorsEnabledFor(loadConfig(process.cwd()))) primeConnectorServers();
|
|
@@ -529,7 +528,7 @@ function applyProviderRegistration(trigger: string): void {
|
|
|
529
528
|
}
|
|
530
529
|
}
|
|
531
530
|
|
|
532
|
-
/** Provider entry point. Pi calls this for each
|
|
531
|
+
/** Provider entry point. Pi calls this for each prompt and each tool result.
|
|
533
532
|
* Two cases: tool result delivery (active query) or fresh query. Exported for
|
|
534
533
|
* the rotation-stream unit tests, which drive it with a fake SDK factory. */
|
|
535
534
|
export function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
|
|
@@ -619,7 +618,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
619
618
|
if (queryCtx.pendingToolCalls.size > 0) {
|
|
620
619
|
// A waiting handler whose call never reached Pi can never be answered —
|
|
621
620
|
// fail it now with a retryable error instead of letting the SDK await it
|
|
622
|
-
//
|
|
621
|
+
// indefinitely.
|
|
623
622
|
// Forwarded-but-unanswered handlers stay: steer-split batches legitimately
|
|
624
623
|
// deliver their results in a later callback.
|
|
625
624
|
const stranded = drainStrandedToolCalls(queryCtx);
|
|
@@ -646,7 +645,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
646
645
|
// so we save them for replay as continuation queries after consumeQuery ends.
|
|
647
646
|
// The cursor may only advance over messages actually captured for replay:
|
|
648
647
|
// claiming Claude owns a user message that was never deferred is permanent
|
|
649
|
-
// silent input loss (
|
|
648
|
+
// silent input loss ( — only the LAST of several trailing user
|
|
650
649
|
// messages was captured while the cursor skipped them all).
|
|
651
650
|
let capturedThrough = context.messages.length;
|
|
652
651
|
if (lastMsgRole === "user") {
|
|
@@ -654,13 +653,13 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
654
653
|
// Math.max-advances with every callback's capturedThrough below), so a
|
|
655
654
|
// second steer callback only queues messages BEYOND what the first one
|
|
656
655
|
// already owns — re-planning the whole trailing run queued the earlier
|
|
657
|
-
// steer twice
|
|
656
|
+
// steer twice. latestCursor, not the shared record's
|
|
658
657
|
// cursor, deliberately: it lives on this QueryContext, so it is correct
|
|
659
658
|
// for reentrant and detached foreign queries too, whose contexts the
|
|
660
|
-
// shared cursor does not index
|
|
659
|
+
// shared cursor does not index.
|
|
661
660
|
const replay = planDeferredUserReplay(context.messages, queryCtx.latestCursor);
|
|
662
661
|
// Image-only runs have no usable text but must still replay — capture
|
|
663
|
-
// whenever EITHER form has content
|
|
662
|
+
// whenever EITHER form has content.
|
|
664
663
|
if (replay.prompt || replay.blocks) {
|
|
665
664
|
ctx().deferredUserMessages.push({ text: replay.prompt ?? "", blocks: replay.blocks ?? undefined });
|
|
666
665
|
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)}`);
|
|
@@ -678,12 +677,12 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
678
677
|
// Cursor may only ADVANCE, and only for a query that holds the record's
|
|
679
678
|
// claim. A reentrant subagent call routed through this instance arrives
|
|
680
679
|
// here with a SHORT foreign context (its own [user…] conversation, not
|
|
681
|
-
// the one the cursor indexes)
|
|
680
|
+
// the one the cursor indexes). Writing its length would shrink the
|
|
682
681
|
// parent cursor and make the next REUSE replay already-owned history.
|
|
683
682
|
// The stackDepth guard covers a pushed subagent context; the detached
|
|
684
683
|
// flag covers a foreign one-shot on the top-level ctx, whose GROWN
|
|
685
684
|
// mid-query context could otherwise out-length the parent's cursor and
|
|
686
|
-
// advance it past history Claude never saw
|
|
685
|
+
// advance it past history Claude never saw; Math.max
|
|
687
686
|
// remains the backstop for a legacy-record foreign context the
|
|
688
687
|
// fingerprint guard could not classify.
|
|
689
688
|
const activeSession = getSharedSession();
|
|
@@ -702,7 +701,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
702
701
|
debug(`provider: orphaned tool result after abort, emitting end_turn`);
|
|
703
702
|
// The detached flag deliberately survives query end: an orphaned result
|
|
704
703
|
// from a foreign one-shot indexes ITS conversation, and writing that
|
|
705
|
-
// length here would move (even shrink) the parent's cursor
|
|
704
|
+
// length here would move (even shrink) the parent's cursor.
|
|
706
705
|
const activeSession = getSharedSession();
|
|
707
706
|
if (activeSession && stackDepth() === 0 && !ctx().detachedFromSharedSession) setSharedSession({ ...activeSession, cursor: context.messages.length });
|
|
708
707
|
const c = ctx(); // capture current context for the microtask
|
|
@@ -883,7 +882,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
883
882
|
const { sessionId: resumeSessionId, promptStart } = syncResult;
|
|
884
883
|
// A FOREIGN-conversation query (conversation-fingerprint mismatch against
|
|
885
884
|
// the shared record — a subagent-shaped request arriving while the parent
|
|
886
|
-
// is IDLE,
|
|
885
|
+
// is IDLE,) also runs as a clean one-shot and gets the same
|
|
887
886
|
// hands-off treatment as a reentrant one below: never persist over the
|
|
888
887
|
// module-level record, never mark it for rebuild. The flag also rides the
|
|
889
888
|
// QueryContext so the mismatch/abort/teardown paths that mutate the record
|
|
@@ -961,13 +960,13 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
961
960
|
|
|
962
961
|
// 4. Capture context for abort handling (must be AFTER pushContext)
|
|
963
962
|
const abortCtx = ctx();
|
|
964
|
-
// Failure metadata consumeQuery
|
|
965
|
-
//
|
|
963
|
+
// Failure metadata from consumeQuery survives an iterator throw. The catch
|
|
964
|
+
// below reuses it instead of re-classifying it; see the C5 note.
|
|
966
965
|
const attemptFailure: { failure?: ClaudeAttemptFailure } = {};
|
|
967
966
|
// A reentrant (subagent) query must never write the module-level shared
|
|
968
967
|
// session: its completion/failure handlers would overwrite the PARENT's
|
|
969
968
|
// record with the child's session id and cursor. A foreign-conversation
|
|
970
|
-
// one-shot
|
|
969
|
+
// one-shot has exactly the same non-claim on the record.
|
|
971
970
|
const persistSession = (next: SessionState | null): void => {
|
|
972
971
|
if (isReentrant || foreignContext) return;
|
|
973
972
|
setSharedSession(next && conversationFp ? { conversationFingerprint: conversationFp, ...next } : next);
|
|
@@ -976,7 +975,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
976
975
|
if (isReentrant || foreignContext) return;
|
|
977
976
|
markSessionForRebuild(opts);
|
|
978
977
|
};
|
|
979
|
-
//
|
|
978
|
+
// invariant: a deferred (mid-query) user message may be dropped only
|
|
980
979
|
// LOUDLY — the cursor already advanced over it on the promise of replay.
|
|
981
980
|
// Callers that keep a session record after a non-empty drop must persist it
|
|
982
981
|
// with needsRebuild so the next turn re-imports the steers from Pi history.
|
|
@@ -1185,7 +1184,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
1185
1184
|
// execute. Deferred user input is dropped LOUDLY, and when steers
|
|
1186
1185
|
// were dropped the record is marked needsRebuild: the cursor already
|
|
1187
1186
|
// advanced over them on the promise of replay, so a plain REUSE next
|
|
1188
|
-
|
|
1187
|
+
// turn would silently lose them forever.
|
|
1189
1188
|
if (!abortCtx.handledTerminalError) surfaceFailure(failure);
|
|
1190
1189
|
const droppedSteers = dropDeferredUserMessages("terminal-failure");
|
|
1191
1190
|
const activeSession = getSharedSession();
|
|
@@ -1235,7 +1234,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
1235
1234
|
|
|
1236
1235
|
const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
|
|
1237
1236
|
// Runs carrying image blocks replay as blocks (wrapPromptStream) so
|
|
1238
|
-
// the images survive; text-only runs stay plain strings
|
|
1237
|
+
// the images survive; text-only runs stay plain strings.
|
|
1239
1238
|
const contQuery = sdkQueryFactory({ prompt: steer.blocks ? wrapPromptStream(steer.blocks) : steer.text, options: contOptions });
|
|
1240
1239
|
abortCtx.activeQuery = contQuery;
|
|
1241
1240
|
|
|
@@ -1250,7 +1249,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
1250
1249
|
if (!abortCtx.handledTerminalError) surfaceFailure(continuation.failure);
|
|
1251
1250
|
// The shifted steer may never have reached the child, and any
|
|
1252
1251
|
// remaining ones certainly did not — the record must rebuild so
|
|
1253
|
-
// they re-import from Pi history
|
|
1252
|
+
// they re-import from Pi history.
|
|
1254
1253
|
if (dropDeferredUserMessages("continuation-failure", steer).length > 0) {
|
|
1255
1254
|
markRebuildForThisQuery();
|
|
1256
1255
|
}
|
|
@@ -1269,7 +1268,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
1269
1268
|
};
|
|
1270
1269
|
recordAttemptFailure(continuationFailure);
|
|
1271
1270
|
if (!abortCtx.handledTerminalError) surfaceFailure(continuationFailure);
|
|
1272
|
-
//
|
|
1271
|
+
// Apply the same rebuild rule as the failure branch above.
|
|
1273
1272
|
if (dropDeferredUserMessages("continuation-error", steer).length > 0) {
|
|
1274
1273
|
markRebuildForThisQuery();
|
|
1275
1274
|
}
|
|
@@ -1291,7 +1290,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
1291
1290
|
if (wasAborted || options?.signal?.aborted) {
|
|
1292
1291
|
markRebuildForThisQuery({ forceRotate: true });
|
|
1293
1292
|
}
|
|
1294
|
-
//
|
|
1293
|
+
// a record kept past this error with steers behind its cursor
|
|
1295
1294
|
// must rebuild so they re-import from Pi history. (The non-abort
|
|
1296
1295
|
// surface path below replaces the record with null, which rebuilds too.)
|
|
1297
1296
|
if (dropDeferredUserMessages("query-error").length > 0) {
|
|
@@ -1351,7 +1350,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
|
|
|
1351
1350
|
...(options ?? {}),
|
|
1352
1351
|
[ROTATION_STATE_KEY]: rotationState,
|
|
1353
1352
|
} as BridgeStreamOptions);
|
|
1354
|
-
// End exactly once per outcome
|
|
1353
|
+
// End exactly once per outcome. Ending in a `finally` ran on
|
|
1355
1354
|
// the throw path too, BEFORE the .catch below could push its error
|
|
1356
1355
|
// event — and EventStream.push is a silent no-op after end, so a failed
|
|
1357
1356
|
// rotation ended the turn with no error event at all. Success ends
|
|
@@ -1445,8 +1444,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1445
1444
|
// branch switch) both mutate pi's messages array out from under the
|
|
1446
1445
|
// bridge. syncSharedSession's REUSE check would otherwise see
|
|
1447
1446
|
// slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
|
|
1448
|
-
// session that
|
|
1449
|
-
|
|
1447
|
+
// session that does not match pi's history. /compact in particular
|
|
1448
|
+
// triggers CC's autocompact-thrashing guard. Force the next
|
|
1450
1449
|
// call down the REBUILD path so CC sees the current history.
|
|
1451
1450
|
const markRebuild = (event: string) => {
|
|
1452
1451
|
const activeSession = getSharedSession();
|
package/src/models.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Canonical selection + display order for the model picker.
|
|
2
2
|
// Extracted from index.ts so tests can import without activating the extension.
|
|
3
3
|
|
|
4
|
-
export const FABLE_MODEL_ID = "claude-fable-5";
|
|
4
|
+
export const FABLE_MODEL_ID = "claude-fable-5-1";
|
|
5
5
|
// Opus 4.8 is both a selectable model and the safety-fallback target for the two
|
|
6
|
-
// primaries whose classifiers can decline a turn (Fable 5, Opus 5).
|
|
6
|
+
// primaries whose classifiers can decline a turn (Fable 5.1, Opus 5).
|
|
7
7
|
export const FABLE_FALLBACK_MODEL_ID = "claude-opus-4-8";
|
|
8
8
|
export const OPUS_5_MODEL_ID = "claude-opus-5";
|
|
9
9
|
export const SONNET_5_MODEL_ID = "claude-sonnet-5";
|
|
@@ -36,7 +36,7 @@ type BridgeModelMetadata = {
|
|
|
36
36
|
const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
|
|
37
37
|
[FABLE_MODEL_ID]: {
|
|
38
38
|
id: FABLE_MODEL_ID,
|
|
39
|
-
name: "Claude Fable 5",
|
|
39
|
+
name: "Claude Fable 5.1",
|
|
40
40
|
reasoning: true,
|
|
41
41
|
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
|
42
42
|
input: ["text", "image"],
|
|
@@ -80,8 +80,8 @@ export function modelDisplayName(modelId: string): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
// Project pi-ai's model entries down to the fields pi's registerProvider expects,
|
|
83
|
-
// keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned
|
|
84
|
-
//
|
|
83
|
+
// keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned metadata for supported
|
|
84
|
+
// IDs absent from pi-ai. Unknown missing IDs are still dropped.
|
|
85
85
|
export function buildModels<T extends { id: string; [key: string]: any }>(piAiModels: T[]) {
|
|
86
86
|
return MODEL_IDS_IN_ORDER
|
|
87
87
|
.map((id) => piAiModels.find((m) => m.id === id) ?? FALLBACK_MODELS[id])
|