@viniciosrab/pi-claude-bridge 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ // Long-lived streaming-input prompt for query().
2
+ //
3
+ // The SDK accepts `prompt: AsyncIterable<SDKUserMessage>` and pumps it to the
4
+ // CLI's stdin. Keeping that iterable parked for the life of the query lets us
5
+ // write a steer to stdin *while* a tool is running, which is what makes CC
6
+ // drain it at the next tool boundary (`priority: "next"`) instead of treating
7
+ // it as a follow-up turn.
8
+ //
9
+ // The ack is the load-bearing part. `push()` resolves on the line *after*
10
+ // `yield`, and the SDK's pump is `for await (m of stream) { await
11
+ // transport.write(m) }` — so resuming past the yield proves the write to stdin
12
+ // completed. Callers await that before releasing the MCP tool result, which
13
+ // travels back over the same stdin FIFO; winning that race is what guarantees
14
+ // CC sees the steer before the tool result.
15
+ //
16
+ // The drain and the FIFO ordering are CC CLI internals, not SDK contract, so
17
+ // this can break under a CC upgrade without any type error.
18
+
19
+ import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
20
+
21
+ export interface PromptStream {
22
+ stream: AsyncGenerator<SDKUserMessage>;
23
+ /** Enqueue a message; resolves once the SDK has written it to stdin.
24
+ * Rejects (never hangs) if the stream is already ended or failed. */
25
+ push: (msg: SDKUserMessage) => Promise<void>;
26
+ /** Close the input: the generator returns, the SDK closes the CLI's stdin. */
27
+ end: () => void;
28
+ /** Abandon the input, rejecting every queued and in-flight ack. */
29
+ fail: (error: Error) => void;
30
+ }
31
+
32
+ export function makePromptStream(): PromptStream {
33
+ type Item = { msg: SDKUserMessage; resolve: () => void; reject: (e: Error) => void };
34
+ const queue: Item[] = [];
35
+ // The item currently parked at `yield`. Tracked separately so fail() can
36
+ // settle it — a dying CLI may abandon the pump without ever resuming us,
37
+ // and an unsettled ack would wedge tool-result delivery forever.
38
+ let inflight: Item | null = null;
39
+ let wake: (() => void) | null = null;
40
+ let done = false;
41
+ let failure: Error | null = null;
42
+
43
+ const kick = () => { wake?.(); wake = null; };
44
+
45
+ async function* gen(): AsyncGenerator<SDKUserMessage> {
46
+ try {
47
+ while (true) {
48
+ while (queue.length === 0 && !done && !failure) {
49
+ await new Promise<void>((resolve) => { wake = resolve; });
50
+ }
51
+ if (failure) throw failure;
52
+ const item = queue.shift();
53
+ if (!item) return; // ended and drained
54
+ inflight = item;
55
+ try {
56
+ yield item.msg;
57
+ item.resolve();
58
+ } finally {
59
+ // Reached either normally (no-op, already resolved) or when the
60
+ // pump abandons iteration — a `for await` break/throw calls
61
+ // gen.return(), which resumes the yield as a return.
62
+ item.reject(new Error("prompt stream closed"));
63
+ inflight = null;
64
+ }
65
+ }
66
+ } finally {
67
+ // No consumer left to drain the queue, so nothing would ever settle a
68
+ // later push. Closing here keeps the reject-never-hang contract a
69
+ // property of this module rather than of every call site.
70
+ done = true;
71
+ }
72
+ }
73
+
74
+ return {
75
+ stream: gen(),
76
+ push: (msg) => failure || done
77
+ ? Promise.reject(failure ?? new Error("prompt stream closed"))
78
+ : new Promise<void>((resolve, reject) => { queue.push({ msg, resolve, reject }); kick(); }),
79
+ end: () => { done = true; kick(); },
80
+ fail: (error) => {
81
+ // First failure wins: the query's `finally` fails the stream a second
82
+ // time with a generic "query ended", which would otherwise mask the
83
+ // real cause its `catch` recorded.
84
+ if (failure) return;
85
+ failure = error;
86
+ queue.splice(0).forEach((item) => item.reject(error));
87
+ inflight?.reject(error);
88
+ kick();
89
+ },
90
+ };
91
+ }
92
+
93
+ /** `uuid` is deliberately omitted: we need no dedup, and supplying one makes
94
+ * CC's stdin loop do a session lookup on the message. */
95
+ export function userMessage(content: SDKUserMessage["message"]["content"], priority?: SDKUserMessage["priority"]): SDKUserMessage {
96
+ return {
97
+ type: "user",
98
+ message: { role: "user", content } as SDKUserMessage["message"],
99
+ parent_tool_use_id: null,
100
+ ...(priority ? { priority } : {}),
101
+ };
102
+ }
@@ -0,0 +1,112 @@
1
+ // Query state: QueryContext class.
2
+ //
3
+ // All per-query and per-turn mutable state lives here. Reentrant queries
4
+ // (subagents) each get their own QueryContext instance, managed by index.ts.
5
+ // Adding a new field = one property on the class.
6
+ //
7
+ // Extracted from index.ts so tests can import without activating the extension.
8
+
9
+ import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
10
+ import type { McpResult } from "./extract-tool-results.js";
11
+ import type { PromptStream } from "./prompt-stream.js";
12
+
13
+ export interface PendingToolCall {
14
+ toolName: string;
15
+ resolve: (result: McpResult) => void;
16
+ }
17
+
18
+ export class QueryContext {
19
+ // Query-scoped (fully isolated per query)
20
+ activeQuery: unknown | null = null;
21
+ currentPiStream: AssistantMessageEventStream | null = null;
22
+ latestCursor = 0;
23
+ pendingToolCalls = new Map<string, PendingToolCall>();
24
+ pendingResults = new Map<string, McpResult>();
25
+ /** tool_use ids emitted this turn. Sole purpose is routing a delivered result
26
+ * to the owning query when several queries are in flight — pairing a result
27
+ * to its call is done by id from Claude's tools/call _meta, not from here. */
28
+ turnToolCallIds: string[] = [];
29
+ /** Streaming-input handle for the active query — how steers reach CC mid-turn. */
30
+ promptStream: PromptStream | null = null;
31
+ /** Last rate-limit rejection seen on this query. Claude Code sends it just before the
32
+ * failure it caused, which is the only thing tying the two together. */
33
+ rateLimitRejection: { rateLimitType?: string; resetsAt?: number } | null = null;
34
+ /** Highest 5% utilization bucket we notified for, so repeat rate_limit_event spam is suppressed. */
35
+ lastRateLimitWarnStep: number | null = null;
36
+ lastRateLimitWarnThreshold: number | undefined;
37
+ /** pi session this query serves, from SimpleStreamOptions.sessionId at fresh-query
38
+ * setup. A bridge process serves several pi sessions at once (subagents run their
39
+ * own AgentSessions), and history rewrites must only discard the rewriting
40
+ * session's parked queries — this is the match key. Null when the host did not
41
+ * supply an id.
42
+ */
43
+ piSessionId: string | null = null;
44
+ /** pi rewrote the history this query was built from (session_compact,
45
+ * session_tree in its own pi session). Set by markRebuildForSession, consumed
46
+ * by the tool-result delivery that discards the query. Not session-wide state:
47
+ * it dies with the context it belongs to, so it cannot leak into later turns
48
+ * the way a module flag does.
49
+ */
50
+ historyStale = false;
51
+ /** A steer never reached CC. A first query has no session mirror yet, so
52
+ * completion must carry this into the mirror it creates. */
53
+ missedSteer = false;
54
+
55
+ // Per-turn (reset together)
56
+ turnOutput: AssistantMessage | null = null;
57
+ turnStarted = false;
58
+ turnSawStreamEvent = false;
59
+ turnSawToolCall = false;
60
+ /** API message id from the last message_start, and whether its message_stop has
61
+ * arrived. An `assistant` message under a different id while the stream is still
62
+ * open is Claude Code's non-streaming fallback for a stalled stream. */
63
+ turnStreamMessageId: string | undefined;
64
+ turnStreamOpen = false;
65
+ /** turnBlocks length at that message_start: where an abandoned attempt's blocks begin. */
66
+ turnStreamBlockStart = 0;
67
+
68
+ get turnBlocks(): Array<any> {
69
+ if (!this.turnOutput) throw new Error("turnBlocks accessed before resetTurnState");
70
+ return this.turnOutput.content;
71
+ }
72
+
73
+ /** Answer every parked MCP handler with `reason` and forget the turn's queued
74
+ * results. Called when the query it belongs to is going away (abort, error,
75
+ * normal end). Handlers must be *resolved*, not rejected: an error reply is
76
+ * still a reply, and a handler left awaiting a subprocess that is gone keeps
77
+ * CC's tools/call open forever, which wedges pi's turn behind it. */
78
+ releasePendingToolCalls(reason: string): void {
79
+ for (const pending of this.pendingToolCalls.values()) pending.resolve({ content: [{ type: "text", text: reason }] });
80
+ this.pendingToolCalls.clear();
81
+ this.pendingResults.clear();
82
+ }
83
+
84
+ resetTurnState(model: Model<any>): void {
85
+ this.turnOutput = {
86
+ role: "assistant", content: [],
87
+ api: model.api, provider: model.provider, model: model.id,
88
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
89
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
90
+ stopReason: "stop", timestamp: Date.now(),
91
+ };
92
+ this.turnStarted = false;
93
+ this.turnSawStreamEvent = false;
94
+ this.turnSawToolCall = false;
95
+ this.turnStreamMessageId = undefined;
96
+ this.turnStreamOpen = false;
97
+ this.turnStreamBlockStart = 0;
98
+ // turnToolCallIds is NOT reset — it persists across tool-result delivery
99
+ // callbacks within the same assistant message so results can be routed to
100
+ // this query while its handlers are still pending.
101
+ }
102
+ }
103
+
104
+ let _ctx = new QueryContext();
105
+
106
+ export function ctx(): QueryContext { return _ctx; }
107
+
108
+ // Test-only: replace the module-level context so test files start clean.
109
+ // Not called from production.
110
+ export function resetCtx(): void {
111
+ _ctx = new QueryContext();
112
+ }
@@ -0,0 +1,38 @@
1
+ // Pure session-file integrity check. Returns an array of warning strings;
2
+ // callers decide how to surface them (debug log, piUI, diagDump, etc.).
3
+ // Extracted from index.ts so tests can import without activating the extension.
4
+
5
+ import { statSync, readFileSync } from "fs";
6
+
7
+ export function verifyWrittenSession(jsonlPath: string, expectedSessionId: string, expectedRecordCount: number): string[] {
8
+ const warnings = [];
9
+ let st;
10
+ try {
11
+ st = statSync(jsonlPath);
12
+ } catch (e) {
13
+ warnings.push(`file missing after save — path=${jsonlPath} err=${e.message}`);
14
+ return warnings;
15
+ }
16
+ let content;
17
+ try {
18
+ content = readFileSync(jsonlPath, "utf8");
19
+ } catch (e) {
20
+ warnings.push(`file unreadable — path=${jsonlPath} size=${st.size} err=${e.message}`);
21
+ return warnings;
22
+ }
23
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
24
+ if (lines.length !== expectedRecordCount) {
25
+ warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
26
+ return warnings;
27
+ }
28
+ try {
29
+ const firstRec = JSON.parse(lines[0]);
30
+ const lastRec = JSON.parse(lines[lines.length - 1]);
31
+ if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
32
+ warnings.push(`sessionId drift — expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
33
+ }
34
+ } catch (e) {
35
+ warnings.push(`malformed JSONL — path=${jsonlPath} err=${e.message}`);
36
+ }
37
+ return warnings;
38
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { formatSkillsForPrompt, type Skill } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const MCP_SERVER_NAME = "custom-tools";
4
+ export const MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
5
+
6
+ export type SkillReadTool = "mcp" | "native" | "none";
7
+
8
+ export function renderSkillsBlock(skills: Skill[], readTool: SkillReadTool): string | undefined {
9
+ if (readTool === "none" || skills.length === 0) return undefined;
10
+ const block = formatSkillsForPrompt(skills).trim();
11
+ if (!block) return undefined;
12
+ return readTool === "mcp" ? rewriteSkillsBlock(block) : block;
13
+ }
14
+
15
+ export function rewriteSkillsBlock(skillsBlock: string): string {
16
+ return skillsBlock.replace(
17
+ "Use the read tool to load a skill's file",
18
+ `Use the read tool (mcp__${MCP_SERVER_NAME}__read) to load a skill's file`,
19
+ );
20
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Translate pi's transcript-shaped provider input into the prompt/tools fields used by
3
+ * the bridge's downstream consumers.
4
+ *
5
+ * System messages carry the base prompt and tool set plus later section patches and tool
6
+ * deltas. pi-ai replays that state, but preserves section replay order. Prompt capture
7
+ * keys come from pi's canonical section builder, so a deleted and re-added section must
8
+ * be ranked back into canonical order before the bridge performs its exact-key lookup.
9
+ */
10
+ import {
11
+ contentText,
12
+ getCurrentSystemMessage,
13
+ getCurrentTools,
14
+ type Context,
15
+ type SystemMessage,
16
+ } from "@earendil-works/pi-ai";
17
+
18
+ /** pi's canonical built-in section order; custom sections follow their replay position.
19
+ * Known limitation, inherited from the pre-swap replay: if pi reorders existing custom
20
+ * sections while patching an unrelated one, the replay diverges from getSystemPrompt()
21
+ * and the exact-key capture lookup throws on that legitimate turn. Untriggered today:
22
+ * pi builds custom sections in one patch per render. */
23
+ const SECTION_RANK = new Map<string, number>([
24
+ ["preamble", 0], ["tools", 1], ["rules", 2], ["docs", 3], ["addendum", 4],
25
+ ["project_context", 5], ["skills", 6], ["cwd", 7],
26
+ ]);
27
+
28
+ /**
29
+ * The map's entries, stably sorted by canonical rank. A name absent from the rank table
30
+ * inherits its replayed predecessor's rank, so an already-canonical replay remains
31
+ * unchanged and custom sections retain their position.
32
+ */
33
+ function stableRanked(sections: Map<string, string>, ranks: Map<string, number>): Map<string, string> {
34
+ let predecessorRank = -1;
35
+ const ranked = [...sections].map(([name, value]) => {
36
+ const rank = ranks.get(name) ?? predecessorRank;
37
+ predecessorRank = rank;
38
+ return { name, value, rank };
39
+ });
40
+ ranked.sort((a, b) => a.rank - b.rank);
41
+ return new Map(ranked.map(({ name, value }) => [name, value]));
42
+ }
43
+
44
+ /** Render replayed system state in the same section order as pi's prompt builder. */
45
+ function canonicalSystemPrompt(message: SystemMessage | undefined): string | undefined {
46
+ if (!message) return undefined;
47
+ const sections = new Map<string, string>(
48
+ Object.entries(message.sections ?? {}).filter((entry): entry is [string, string] => entry[1] !== null),
49
+ );
50
+ const parts = [contentText(message.content), ...stableRanked(sections, SECTION_RANK).values()]
51
+ .filter((part) => part.length > 0);
52
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
53
+ }
54
+
55
+ /**
56
+ * Restore the prompt and tools fields expected by the bridge and remove prompt-state
57
+ * messages from conversation history. Contexts without system messages are returned
58
+ * unchanged because systemless one-off calls already use the bridge-compatible shape.
59
+ */
60
+ export function toBridgeContext(context: Context): Context {
61
+ if (!context.messages.some((message) => message.role === "system")) return context;
62
+ const tools = getCurrentTools(context.messages);
63
+ return {
64
+ ...context,
65
+ systemPrompt: canonicalSystemPrompt(getCurrentSystemMessage(context.messages)),
66
+ tools: tools.length > 0 ? tools : undefined,
67
+ messages: nonSystemMessages(context.messages),
68
+ };
69
+ }
70
+
71
+ /** `messages` with every prompt-state system message removed from conversation history. */
72
+ export function nonSystemMessages<T extends { role: string }>(messages: readonly T[]): T[] {
73
+ return messages.filter((message) => message.role !== "system");
74
+ }