pi-claude-agent-sdk 0.7.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/LICENSE +21 -0
- package/README.md +114 -0
- package/assets/claude-bridge1.png +0 -0
- package/assets/claude-bridge2.png +0 -0
- package/package.json +68 -0
- package/src/agents-md.ts +20 -0
- package/src/askclaude-ui.ts +90 -0
- package/src/config.ts +78 -0
- package/src/convert.ts +186 -0
- package/src/extract-tool-results.ts +47 -0
- package/src/index.ts +2036 -0
- package/src/mcp-server.ts +86 -0
- package/src/models.ts +95 -0
- package/src/prompt-stream.ts +102 -0
- package/src/query-state.ts +79 -0
- package/src/session-verify.ts +38 -0
- package/src/skills.ts +24 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// In-process MCP server that exposes pi tools to Claude Code.
|
|
2
|
+
//
|
|
3
|
+
// Pi declares tool parameters as TypeBox objects, which are already JSON
|
|
4
|
+
// Schema at runtime — the same thing MCP puts on the wire. This serves them
|
|
5
|
+
// verbatim instead of going through the SDK's `createSdkMcpServer`, which only
|
|
6
|
+
// accepts Zod and therefore forces a JSON Schema → Zod → JSON Schema round
|
|
7
|
+
// trip. That round trip is lossy below the top level: nested objects collapse
|
|
8
|
+
// to open records and `anyOf`/`const` vanish, so Claude saw only the first
|
|
9
|
+
// level of any tool with a nested schema — including the builtin `edit`.
|
|
10
|
+
//
|
|
11
|
+
// Handlers go on the underlying protocol server rather than through
|
|
12
|
+
// `McpServer.registerTool`, which is the Zod-only path. Skipping registerTool
|
|
13
|
+
// also skips its argument validation, which is what we want: pi validates and
|
|
14
|
+
// executes tools itself, and the arguments MCP sees are discarded. A rejection
|
|
15
|
+
// there would only prevent the handler from running, stranding the call.
|
|
16
|
+
//
|
|
17
|
+
// This rests on the Agent SDK treating what we hand it as an opaque JSON-RPC
|
|
18
|
+
// endpoint: `connectSdkMcpServer` in sdk.mjs calls `instance.connect(transport)`
|
|
19
|
+
// and nothing else, so none of McpServer's higher-level machinery is required.
|
|
20
|
+
// The `McpServer` wrapper is kept only because the SDK's `mcpServers` option is
|
|
21
|
+
// typed against that class. If this breaks after an SDK update, check whether
|
|
22
|
+
// the SDK began inspecting the instance — reading registered tools, or expecting
|
|
23
|
+
// tools/list_changed notifications we never send.
|
|
24
|
+
|
|
25
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
27
|
+
import type { McpResult } from "./extract-tool-results.js";
|
|
28
|
+
|
|
29
|
+
// Claude Code stamps every tools/call with the id of the tool_use block it came
|
|
30
|
+
// from. That is the only reliable way to pair a call with its result: call order
|
|
31
|
+
// is not guaranteed to match the order the tool_use blocks were emitted, so
|
|
32
|
+
// counting calls mispairs results as soon as the two diverge.
|
|
33
|
+
//
|
|
34
|
+
// This is a Claude Code extension, not part of the MCP spec — CC sets it in
|
|
35
|
+
// `src/services/mcp/client.ts` (see reference-code/claude-code-rip). If CC ever
|
|
36
|
+
// stops sending it, every tool call fails with the error below rather than
|
|
37
|
+
// silently pairing results to the wrong call, which is the intended tradeoff.
|
|
38
|
+
const TOOL_USE_ID_META = "claudecode/toolUseId";
|
|
39
|
+
|
|
40
|
+
export interface McpToolDef {
|
|
41
|
+
name: string;
|
|
42
|
+
description: string;
|
|
43
|
+
inputSchema: unknown;
|
|
44
|
+
handler: (toolCallId: string) => Promise<McpResult>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// MCP requires an object schema. Pi types tool parameters as any TypeBox schema,
|
|
48
|
+
// so a scalar or array one typechecks but cannot go on the wire — that is a bug
|
|
49
|
+
// in the tool, and reporting it at startup names the culprit. Degrading it to
|
|
50
|
+
// "takes no arguments" instead would surface much later as Claude calling the
|
|
51
|
+
// tool with no arguments and pi's own validation rejecting them.
|
|
52
|
+
function assertObjectSchema(tool: McpToolDef): void {
|
|
53
|
+
const schema = tool.inputSchema as Record<string, unknown> | undefined;
|
|
54
|
+
if (!schema || schema.type !== "object") {
|
|
55
|
+
throw new Error(`${tool.name}: MCP tool parameters must be an object schema, got ${JSON.stringify(schema)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createToolServer(name: string, tools: McpToolDef[]) {
|
|
60
|
+
const server = new McpServer({ name, version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
61
|
+
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
62
|
+
for (const tool of tools) assertObjectSchema(tool);
|
|
63
|
+
|
|
64
|
+
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
65
|
+
tools: tools.map((tool) => ({
|
|
66
|
+
name: tool.name,
|
|
67
|
+
description: tool.description,
|
|
68
|
+
inputSchema: tool.inputSchema as Record<string, unknown>,
|
|
69
|
+
})),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
73
|
+
const tool = byName.get(request.params.name);
|
|
74
|
+
if (!tool) throw new Error(`Unknown tool: ${request.params.name}`);
|
|
75
|
+
const toolCallId = request.params._meta?.[TOOL_USE_ID_META];
|
|
76
|
+
if (typeof toolCallId !== "string") {
|
|
77
|
+
throw new Error(`${tool.name}: tools/call is missing _meta["${TOOL_USE_ID_META}"] — cannot pair the result with its tool call`);
|
|
78
|
+
}
|
|
79
|
+
// Narrowed deliberately: McpResult also carries `toolCallId`, which is our
|
|
80
|
+
// own bookkeeping for pairing and not part of MCP's CallToolResult.
|
|
81
|
+
const { content, isError } = await tool.handler(toolCallId);
|
|
82
|
+
return { content, isError };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return { type: "sdk" as const, name, instance: server };
|
|
86
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Canonical selection + display order for the model picker.
|
|
2
|
+
// `resolveModel` returns the first partial match, so `opus` resolves to the first-listed opus entry.
|
|
3
|
+
// Extracted from index.ts so tests can import without activating the extension.
|
|
4
|
+
|
|
5
|
+
export const MODEL_IDS_IN_ORDER = ["claude-fable-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-5", "claude-sonnet-4-6", "claude-haiku-4-5"];
|
|
6
|
+
|
|
7
|
+
// Project pi-ai's model entries down to the fields pi's registerProvider expects,
|
|
8
|
+
// and keep MODEL_IDS_IN_ORDER ordering. IDs missing from pi-ai are silently dropped.
|
|
9
|
+
// Context-dependent display labels are applied after plan/long-context config is known.
|
|
10
|
+
export function buildModels<T extends { id: string; [key: string]: any }>(piAiModels: T[]) {
|
|
11
|
+
return MODEL_IDS_IN_ORDER
|
|
12
|
+
.map((id) => piAiModels.find((m) => m.id === id))
|
|
13
|
+
.filter((m) => m != null)
|
|
14
|
+
// Forward thinkingLevelMap so pi-ai's per-model overrides (e.g. opus-4-8
|
|
15
|
+
// mapping xhigh→xhigh and max→max) are visible to the effort lookup.
|
|
16
|
+
.map(({ id, name, reasoning, input, contextWindow, maxTokens, thinkingLevelMap }) => ({
|
|
17
|
+
id,
|
|
18
|
+
name,
|
|
19
|
+
reasoning, input, contextWindow, maxTokens,
|
|
20
|
+
thinkingLevelMap,
|
|
21
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type LongContextSettings = {
|
|
26
|
+
plan: "pro" | "max";
|
|
27
|
+
longContextExtraUsage: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ClaudeCodeRuntimeModel = {
|
|
31
|
+
cliModelId: string;
|
|
32
|
+
contextWindow: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const TWO_HUNDRED_K_CONTEXT = 200_000;
|
|
36
|
+
const ONE_M_CONTEXT = 1_000_000;
|
|
37
|
+
|
|
38
|
+
// Measured Claude Agent SDK subscription/OAuth behavior. Do not infer this from
|
|
39
|
+
// pi-ai's advertised contextWindow: bare Opus 4.7 serves 1M, bare Opus 4.8 does
|
|
40
|
+
// not, and [1m] entitlement differs by model. See diag/CONTEXT-SIZE.md.
|
|
41
|
+
export function resolveClaudeCodeRuntimeModel(modelId: string, settings: LongContextSettings): ClaudeCodeRuntimeModel {
|
|
42
|
+
switch (modelId) {
|
|
43
|
+
case "claude-opus-5":
|
|
44
|
+
return { cliModelId: "claude-opus-5[1m]", contextWindow: ONE_M_CONTEXT };
|
|
45
|
+
case "claude-opus-4-8":
|
|
46
|
+
return { cliModelId: "claude-opus-4-8[1m]", contextWindow: ONE_M_CONTEXT };
|
|
47
|
+
case "claude-opus-4-7":
|
|
48
|
+
return { cliModelId: "claude-opus-4-7", contextWindow: ONE_M_CONTEXT };
|
|
49
|
+
case "claude-opus-4-6": {
|
|
50
|
+
const useOneM = settings.plan === "max" || settings.longContextExtraUsage;
|
|
51
|
+
return {
|
|
52
|
+
cliModelId: useOneM ? "claude-opus-4-6[1m]" : "claude-opus-4-6",
|
|
53
|
+
contextWindow: useOneM ? ONE_M_CONTEXT : TWO_HUNDRED_K_CONTEXT,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
case "claude-fable-5":
|
|
57
|
+
return { cliModelId: "claude-fable-5[1m]", contextWindow: ONE_M_CONTEXT };
|
|
58
|
+
case "claude-sonnet-5":
|
|
59
|
+
return { cliModelId: "claude-sonnet-5[1m]", contextWindow: ONE_M_CONTEXT };
|
|
60
|
+
case "claude-sonnet-4-6":
|
|
61
|
+
return {
|
|
62
|
+
cliModelId: settings.longContextExtraUsage ? "claude-sonnet-4-6[1m]" : "claude-sonnet-4-6",
|
|
63
|
+
contextWindow: settings.longContextExtraUsage ? ONE_M_CONTEXT : TWO_HUNDRED_K_CONTEXT,
|
|
64
|
+
};
|
|
65
|
+
case "claude-haiku-4-5":
|
|
66
|
+
return { cliModelId: "claude-haiku-4-5", contextWindow: TWO_HUNDRED_K_CONTEXT };
|
|
67
|
+
default:
|
|
68
|
+
console.error(`claude-bridge: encountered model ${modelId} with no known context size, defaulting to 200K`);
|
|
69
|
+
return { cliModelId: modelId, contextWindow: TWO_HUNDRED_K_CONTEXT };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function claudeCodeModelId(model: { id: string }, settings: LongContextSettings): string {
|
|
74
|
+
return resolveClaudeCodeRuntimeModel(model.id, settings).cliModelId;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function resolveModel<T extends { id: string }>(models: T[], input: string): T | undefined {
|
|
78
|
+
const lower = input.toLowerCase();
|
|
79
|
+
return models.find((m) => m.id === lower || m.id.includes(lower));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Produce the model metadata registered with pi. The registered contextWindow must
|
|
83
|
+
// match the window the bridge actually requests from Claude Code, or pi's status
|
|
84
|
+
// bar and auto-compaction threshold will misreport. The runtime policy is based
|
|
85
|
+
// on measured SDK behavior - see diag/CONTEXT-SIZE.md
|
|
86
|
+
export function applyLongContext<T extends { id: string; name: string; contextWindow?: number | null }>(
|
|
87
|
+
models: T[],
|
|
88
|
+
settings: LongContextSettings,
|
|
89
|
+
): T[] {
|
|
90
|
+
return models.map((m) => {
|
|
91
|
+
const { contextWindow } = resolveClaudeCodeRuntimeModel(m.id, settings);
|
|
92
|
+
const name = contextWindow > TWO_HUNDRED_K_CONTEXT && !/\b1M\b/i.test(m.name) ? `${m.name} 1M` : m.name;
|
|
93
|
+
return contextWindow === m.contextWindow && name === m.name ? m : { ...m, contextWindow, name };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -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,79 @@
|
|
|
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
|
+
|
|
32
|
+
// Per-turn (reset together)
|
|
33
|
+
turnOutput: AssistantMessage | null = null;
|
|
34
|
+
turnStarted = false;
|
|
35
|
+
turnSawStreamEvent = false;
|
|
36
|
+
turnSawToolCall = false;
|
|
37
|
+
|
|
38
|
+
get turnBlocks(): Array<any> {
|
|
39
|
+
if (!this.turnOutput) throw new Error("turnBlocks accessed before resetTurnState");
|
|
40
|
+
return this.turnOutput.content;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Answer every parked MCP handler with `reason` and forget the turn's queued
|
|
44
|
+
* results. Called when the query it belongs to is going away (abort, error,
|
|
45
|
+
* normal end). Handlers must be *resolved*, not rejected: an error reply is
|
|
46
|
+
* still a reply, and a handler left awaiting a subprocess that is gone keeps
|
|
47
|
+
* CC's tools/call open forever, which wedges pi's turn behind it. */
|
|
48
|
+
releasePendingToolCalls(reason: string): void {
|
|
49
|
+
for (const pending of this.pendingToolCalls.values()) pending.resolve({ content: [{ type: "text", text: reason }] });
|
|
50
|
+
this.pendingToolCalls.clear();
|
|
51
|
+
this.pendingResults.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resetTurnState(model: Model<any>): void {
|
|
55
|
+
this.turnOutput = {
|
|
56
|
+
role: "assistant", content: [],
|
|
57
|
+
api: model.api, provider: model.provider, model: model.id,
|
|
58
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
|
|
59
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
60
|
+
stopReason: "stop", timestamp: Date.now(),
|
|
61
|
+
};
|
|
62
|
+
this.turnStarted = false;
|
|
63
|
+
this.turnSawStreamEvent = false;
|
|
64
|
+
this.turnSawToolCall = false;
|
|
65
|
+
// turnToolCallIds is NOT reset — it persists across tool-result delivery
|
|
66
|
+
// callbacks within the same assistant message so results can be routed to
|
|
67
|
+
// this query while its handlers are still pending.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let _ctx = new QueryContext();
|
|
72
|
+
|
|
73
|
+
export function ctx(): QueryContext { return _ctx; }
|
|
74
|
+
|
|
75
|
+
// Test-only: replace the module-level context so test files start clean.
|
|
76
|
+
// Not called from production.
|
|
77
|
+
export function resetCtx(): void {
|
|
78
|
+
_ctx = new QueryContext();
|
|
79
|
+
}
|
|
@@ -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,24 @@
|
|
|
1
|
+
// Skills block extraction + MCP naming constants.
|
|
2
|
+
// Extracted from index.ts so tests can import without activating the extension.
|
|
3
|
+
|
|
4
|
+
export const MCP_SERVER_NAME = "custom-tools";
|
|
5
|
+
export const MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
|
|
6
|
+
|
|
7
|
+
// Extract skills block from pi's system prompt for forwarding to Claude Code.
|
|
8
|
+
export function extractSkillsBlock(systemPrompt?: string): string | undefined {
|
|
9
|
+
if (!systemPrompt) return undefined;
|
|
10
|
+
const startMarker = "The following skills provide specialized instructions for specific tasks.";
|
|
11
|
+
const endMarker = "</available_skills>";
|
|
12
|
+
const start = systemPrompt.indexOf(startMarker);
|
|
13
|
+
if (start === -1) return undefined;
|
|
14
|
+
const end = systemPrompt.indexOf(endMarker, start);
|
|
15
|
+
if (end === -1) return undefined;
|
|
16
|
+
return rewriteSkillsBlock(systemPrompt.slice(start, end + endMarker.length).trim());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function rewriteSkillsBlock(skillsBlock: string): string {
|
|
20
|
+
return skillsBlock.replace(
|
|
21
|
+
"Use the read tool to load a skill's file",
|
|
22
|
+
`Use the read tool (mcp__${MCP_SERVER_NAME}__read) to load a skill's file`,
|
|
23
|
+
);
|
|
24
|
+
}
|