@huseynsnmz/pi-suite 0.2.2 → 0.2.4
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/CHANGELOG.md +34 -0
- package/README.md +13 -4
- package/package.json +12 -2
- package/packages/board/README.md +1 -1
- package/packages/board/package.json +2 -1
- package/packages/commands/README.md +1 -1
- package/packages/commands/src/index.ts +22 -0
- package/packages/frame/README.md +8 -2
- package/packages/frame/docs/reference.md +1 -1
- package/packages/frame/src/index.ts +8 -3
- package/packages/frame/src/kit/settings/files.ts +8 -8
- package/packages/frame/src/kit/settings/store.ts +3 -1
- package/packages/frame/src/ui/editor.ts +64 -7
- package/packages/frame/src/ui/history-search.ts +21 -0
- package/packages/frame/src/ui/pi-entries.ts +2 -0
- package/packages/frame/src/ui/thinking.ts +34 -0
- package/packages/memory/README.md +20 -1
- package/packages/memory/src/index.ts +60 -1
- package/packages/memory/src/mastermind.ts +110 -0
- package/packages/memory/src/playbook.ts +92 -0
- package/packages/modes/README.md +11 -3
- package/packages/modes/skills/review-permissions/SKILL.md +11 -2
- package/packages/modes/src/index.ts +29 -11
- package/packages/modes/src/judge-gate.ts +78 -7
- package/packages/modes/src/modes.ts +4 -1
- package/packages/modes/src/review/apply.mjs +1 -1
- package/packages/modes/src/review/collect.mjs +45 -2
- package/packages/modes/src/workdir.ts +105 -0
- package/packages/router/README.md +30 -9
- package/packages/router/src/config.ts +30 -28
- package/packages/router/src/index.ts +70 -13
- package/packages/router/src/route.ts +16 -10
- package/packages/subagent/LICENSE +21 -0
- package/packages/subagent/README.md +17 -0
- package/packages/subagent/package.json +22 -0
- package/packages/subagent/src/index.ts +76 -0
- package/packages/subscription/LICENSE +21 -0
- package/packages/subscription/README.md +60 -0
- package/packages/subscription/package.json +23 -0
- package/packages/subscription/src/anchors.ts +75 -0
- package/packages/subscription/src/billing.ts +39 -0
- package/packages/subscription/src/catalog.ts +310 -0
- package/packages/subscription/src/cc-sessions.ts +122 -0
- package/packages/subscription/src/config.ts +34 -0
- package/packages/subscription/src/convert.ts +161 -0
- package/packages/subscription/src/index.ts +69 -0
- package/packages/subscription/src/mcp.ts +78 -0
- package/packages/subscription/src/models.ts +65 -0
- package/packages/subscription/src/prompt.ts +57 -0
- package/packages/subscription/src/provider.ts +617 -0
- package/packages/subscription/src/session.ts +294 -0
- package/packages/subscription/src/util.ts +73 -0
- package/packages/web/README.md +5 -5
- package/packages/web/src/config.ts +13 -23
- package/packages/web/src/index.ts +11 -7
- package/packages/web/src/reader.ts +15 -4
- package/packages/web/src/validate.ts +2 -2
- package/src/index.ts +93 -11
- package/src/settings-ui.ts +63 -0
- package/src/settings.ts +117 -15
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
6
|
+
import { assistantBlocks, type TurnMessage, userTurnBlocks } from "./convert.ts";
|
|
7
|
+
|
|
8
|
+
function projectsRoot(): string {
|
|
9
|
+
return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Claude Code's per-project session directory: the working directory's realpath, sanitized. */
|
|
13
|
+
export function projectDir(cwd: string): string {
|
|
14
|
+
let real = cwd;
|
|
15
|
+
try {
|
|
16
|
+
real = realpathSync(cwd);
|
|
17
|
+
} catch {}
|
|
18
|
+
return join(projectsRoot(), real.replace(/[^a-zA-Z0-9]/g, "-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Path of a session's transcript, wherever Claude Code filed it. */
|
|
22
|
+
export function sessionFile(cwd: string, sessionId: string): string | undefined {
|
|
23
|
+
const name = `${sessionId}.jsonl`;
|
|
24
|
+
const direct = join(projectDir(cwd), name);
|
|
25
|
+
if (existsSync(direct)) return direct;
|
|
26
|
+
try {
|
|
27
|
+
for (const dir of readdirSync(projectsRoot())) {
|
|
28
|
+
const f = join(projectsRoot(), dir, name);
|
|
29
|
+
if (existsSync(f)) return f;
|
|
30
|
+
}
|
|
31
|
+
} catch {}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Legacy-anchor fallback: the uuid of the last transcript entry Claude Code wrote for API
|
|
37
|
+
* message `messageId` (it stores one entry per content block). New anchors capture this uuid
|
|
38
|
+
* from the SDK stream instead; this scan serves anchors persisted before that was possible.
|
|
39
|
+
*/
|
|
40
|
+
export function findMessageUuid(cwd: string, sessionId: string, messageId: string): string | undefined {
|
|
41
|
+
const file = sessionFile(cwd, sessionId);
|
|
42
|
+
if (!file) return undefined;
|
|
43
|
+
let uuid: string | undefined;
|
|
44
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
45
|
+
if (!line.includes(messageId)) continue;
|
|
46
|
+
try {
|
|
47
|
+
const e = JSON.parse(line);
|
|
48
|
+
if (e.type === "assistant" && e.message?.id === messageId && !e.isSidechain) uuid = e.uuid;
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
return uuid;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Write a Claude Code transcript for history that never ran through Claude Code (another
|
|
56
|
+
* provider's turns, a compacted session, a session from before this extension). Returns the
|
|
57
|
+
* session id and the uuid to resume at. `messages` must end with an assistant message.
|
|
58
|
+
*/
|
|
59
|
+
export function synthesizeSession(cwd: string, model: string, messages: TurnMessage[]): { sessionId: string; at: string } {
|
|
60
|
+
const sessionId = randomUUID();
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
let parent: string | null = null;
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const common = { isSidechain: false, userType: "external", entrypoint: "sdk-cli", cwd, sessionId };
|
|
65
|
+
// Thinking signatures are only needed (and only checked) inside the trailing tool loop;
|
|
66
|
+
// earlier thinking is dropped by the API anyway.
|
|
67
|
+
let lastUserText = -1;
|
|
68
|
+
messages.forEach((m, i) => {
|
|
69
|
+
if (m.role === "user") lastUserText = i;
|
|
70
|
+
});
|
|
71
|
+
let i = 0;
|
|
72
|
+
let n = 0;
|
|
73
|
+
while (i < messages.length) {
|
|
74
|
+
const uuid = randomUUID();
|
|
75
|
+
const timestamp = new Date(now - (messages.length - i) * 1000).toISOString();
|
|
76
|
+
const m = messages[i];
|
|
77
|
+
if (m.role === "assistant") {
|
|
78
|
+
const keepThinking = i > lastUserText && m.model === model && (m.api === "claude-agent-sdk" || m.api === "anthropic-messages");
|
|
79
|
+
const content = assistantBlocks(m as AssistantMessage, keepThinking);
|
|
80
|
+
lines.push(
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
parentUuid: parent,
|
|
83
|
+
...common,
|
|
84
|
+
type: "assistant",
|
|
85
|
+
message: {
|
|
86
|
+
id: `msg_pi_${(n++).toString(36)}_${sessionId.slice(0, 8)}`,
|
|
87
|
+
type: "message",
|
|
88
|
+
role: "assistant",
|
|
89
|
+
model,
|
|
90
|
+
content: content.length > 0 ? content : [{ type: "text", text: "(empty)" }],
|
|
91
|
+
stop_reason: content.some((c) => c.type === "tool_use") ? "tool_use" : "end_turn",
|
|
92
|
+
stop_sequence: null,
|
|
93
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
94
|
+
},
|
|
95
|
+
uuid,
|
|
96
|
+
timestamp,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
i++;
|
|
100
|
+
} else {
|
|
101
|
+
let j = i;
|
|
102
|
+
while (j < messages.length && messages[j].role !== "assistant") j++;
|
|
103
|
+
const content = userTurnBlocks(messages.slice(i, j));
|
|
104
|
+
lines.push(
|
|
105
|
+
JSON.stringify({
|
|
106
|
+
parentUuid: parent,
|
|
107
|
+
...common,
|
|
108
|
+
type: "user",
|
|
109
|
+
message: { role: "user", content: content.length > 0 ? content : [{ type: "text", text: "(empty)" }] },
|
|
110
|
+
uuid,
|
|
111
|
+
timestamp,
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
i = j;
|
|
115
|
+
}
|
|
116
|
+
parent = uuid;
|
|
117
|
+
}
|
|
118
|
+
mkdirSync(projectDir(cwd), { recursive: true });
|
|
119
|
+
// The whole conversation, so only you can read it, as with Claude Code's own files.
|
|
120
|
+
writeFileSync(join(projectDir(cwd), `${sessionId}.jsonl`), `${lines.join("\n")}\n`, { mode: 0o600, flag: "wx" });
|
|
121
|
+
return { sessionId, at: parent! };
|
|
122
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readSection } from "../../../src/settings.ts";
|
|
2
|
+
|
|
3
|
+
export interface Config {
|
|
4
|
+
pathToClaudeCodeExecutable?: string;
|
|
5
|
+
/** "claude" (default): a pi prompt prefix written for Claude. "pi": pi's default prompt. */
|
|
6
|
+
promptMode?: "claude" | "pi";
|
|
7
|
+
maxSessions?: number;
|
|
8
|
+
idleMinutes?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const intIn = (lo: number, hi: number) => (n: unknown): n is number => Number.isInteger(n) && (n as number) >= lo && (n as number) <= hi;
|
|
12
|
+
const validMaxSessions = intIn(1, 16);
|
|
13
|
+
const validIdleMinutes = intIn(1, 24 * 60);
|
|
14
|
+
|
|
15
|
+
/** Its settings are all conveniences, so broken ones just mean the defaults. */
|
|
16
|
+
function readSaved(agentDir: string): Record<string, unknown> {
|
|
17
|
+
try {
|
|
18
|
+
return readSection(agentDir, "subscription");
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Global only: a repository must not choose which program runs, or how many, or how your prompt is built. */
|
|
25
|
+
export function resolveConfig(where: { agentDir: string; env: Record<string, string | undefined> }): Config {
|
|
26
|
+
const saved = readSaved(where.agentDir);
|
|
27
|
+
const path = where.env.PI_CLAUDE_SDK_CLAUDE_PATH ?? saved.pathToClaudeCodeExecutable;
|
|
28
|
+
return {
|
|
29
|
+
...(typeof path === "string" && path !== "" ? { pathToClaudeCodeExecutable: path } : {}),
|
|
30
|
+
...(validMaxSessions(saved.maxSessions) ? { maxSessions: saved.maxSessions } : {}),
|
|
31
|
+
...(validIdleMinutes(saved.idleMinutes) ? { idleMinutes: saved.idleMinutes } : {}),
|
|
32
|
+
promptMode: where.env.PI_CLAUDE_SDK_PROMPT === "pi" || (where.env.PI_CLAUDE_SDK_PROMPT === undefined && saved.promptMode === "pi") ? "pi" : "claude",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage,
|
|
3
|
+
ImageContent,
|
|
4
|
+
Message,
|
|
5
|
+
SystemMessage,
|
|
6
|
+
TextContent,
|
|
7
|
+
Tool,
|
|
8
|
+
ToolResultMessage,
|
|
9
|
+
TranscriptContext,
|
|
10
|
+
UserMessage,
|
|
11
|
+
} from "@earendil-works/pi-ai";
|
|
12
|
+
import { getCurrentTools, getInitialSystemMessage, getSystemMessageText, renderSystemMessageUpdate, toToolDeclaration } from "@earendil-works/pi-ai";
|
|
13
|
+
import { sha, stableStringify } from "./util.ts";
|
|
14
|
+
|
|
15
|
+
export const MCP_SERVER = "pi";
|
|
16
|
+
export const MCP_PREFIX = `mcp__${MCP_SERVER}__`;
|
|
17
|
+
|
|
18
|
+
/** A transcript message the provider consumes: everything but the leading system message. */
|
|
19
|
+
export type TurnMessage = UserMessage | AssistantMessage | ToolResultMessage | SystemMessage;
|
|
20
|
+
|
|
21
|
+
export interface Transcript {
|
|
22
|
+
/** Leading system prompt text; fixed for the lifetime of a Claude Code session. */
|
|
23
|
+
systemPrompt: string;
|
|
24
|
+
/** Tools in effect for this request (after every transcript delta). */
|
|
25
|
+
tools: Tool[];
|
|
26
|
+
toolsHash: string;
|
|
27
|
+
messages: TurnMessage[];
|
|
28
|
+
/** chain[i] identifies the conversation state after messages[0..i). chain[0] is the prompt alone. */
|
|
29
|
+
chain: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Prepare pi's transcript for Claude Code. Failed or aborted assistant turns are dropped, as pi's
|
|
34
|
+
* built-in Anthropic provider does, together with tool results that answer their calls.
|
|
35
|
+
*/
|
|
36
|
+
export function readTranscript(context: TranscriptContext): Transcript {
|
|
37
|
+
const leading = getInitialSystemMessage(context.messages);
|
|
38
|
+
const systemPrompt = leading ? getSystemMessageText(leading) : "";
|
|
39
|
+
const tools = getCurrentTools(context.messages).map(toToolDeclaration);
|
|
40
|
+
const messages: TurnMessage[] = [];
|
|
41
|
+
const droppedCalls = new Set<string>();
|
|
42
|
+
for (const m of context.messages) {
|
|
43
|
+
if (m === leading) continue;
|
|
44
|
+
if (m.role === "assistant" && (m.stopReason === "error" || m.stopReason === "aborted")) {
|
|
45
|
+
for (const c of m.content) if (c.type === "toolCall") droppedCalls.add(c.id);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (m.role === "toolResult" && droppedCalls.has(m.toolCallId)) continue;
|
|
49
|
+
messages.push(m as TurnMessage);
|
|
50
|
+
}
|
|
51
|
+
const chain = [sha(`prompt\0${systemPrompt}`)];
|
|
52
|
+
for (const m of messages) chain.push(sha(`${chain[chain.length - 1]}\0${fingerprint(m)}`));
|
|
53
|
+
return { systemPrompt, tools, toolsHash: sha(stableStringify(tools)), messages, chain };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Content identity of a message: what the model sees, without timestamps, usage or ids pi adds. */
|
|
57
|
+
export function fingerprint(m: TurnMessage): string {
|
|
58
|
+
switch (m.role) {
|
|
59
|
+
case "user":
|
|
60
|
+
return stableStringify(["u", normContent(m.content)]);
|
|
61
|
+
case "toolResult":
|
|
62
|
+
return stableStringify(["r", m.toolCallId, normContent(m.content), !!m.isError]);
|
|
63
|
+
case "system":
|
|
64
|
+
return stableStringify(["s", renderSystemMessageUpdate(m)]);
|
|
65
|
+
case "assistant":
|
|
66
|
+
return stableStringify([
|
|
67
|
+
"a",
|
|
68
|
+
m.content
|
|
69
|
+
.filter((c) => !(c.type === "text" && c.text.length === 0))
|
|
70
|
+
.map((c) =>
|
|
71
|
+
c.type === "text"
|
|
72
|
+
? ["t", c.text]
|
|
73
|
+
: c.type === "thinking"
|
|
74
|
+
? ["k", c.thinking, c.thinkingSignature ?? "", !!c.redacted]
|
|
75
|
+
: ["c", c.id, c.name, c.arguments],
|
|
76
|
+
),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normContent(content: string | (TextContent | ImageContent)[]): unknown {
|
|
82
|
+
if (typeof content === "string") return [["t", content]];
|
|
83
|
+
return content.map((c) => (c.type === "text" ? ["t", c.text] : ["i", c.mimeType, sha(c.data)]));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- pi → Anthropic content -------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
89
|
+
/** Anthropic tool_use ids must match ^[a-zA-Z0-9_-]+$; ids from other providers may not. */
|
|
90
|
+
export function toolUseId(id: string): string {
|
|
91
|
+
return ID_RE.test(id) ? id : `toolu_x${sha(id).slice(0, 24)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function toImageBlock(c: ImageContent) {
|
|
95
|
+
return { type: "image" as const, source: { type: "base64" as const, media_type: c.mimeType, data: c.data } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function userBlocks(content: string | (TextContent | ImageContent)[]): any[] {
|
|
99
|
+
if (typeof content === "string") return content.length > 0 ? [{ type: "text", text: content }] : [];
|
|
100
|
+
return content
|
|
101
|
+
.filter((c) => c.type !== "text" || c.text.length > 0)
|
|
102
|
+
.map((c) => (c.type === "text" ? { type: "text", text: c.text } : toImageBlock(c)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function toolResultBlock(m: ToolResultMessage): any {
|
|
106
|
+
const content = m.content.map((c) => (c.type === "text" ? { type: "text", text: c.text } : toImageBlock(c)));
|
|
107
|
+
return {
|
|
108
|
+
type: "tool_result",
|
|
109
|
+
tool_use_id: toolUseId(m.toolCallId),
|
|
110
|
+
content: content.length > 0 ? content : [{ type: "text", text: "(no output)" }],
|
|
111
|
+
...(m.isError ? { is_error: true } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A later system message (prompt or section change), delivered as a system reminder in the user turn. */
|
|
116
|
+
export function systemUpdateText(m: SystemMessage): string | undefined {
|
|
117
|
+
const text = renderSystemMessageUpdate(m).trim();
|
|
118
|
+
return text ? `<system-reminder>\n${text}\n</system-reminder>` : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Render consecutive non-assistant messages as one Anthropic user message. Tool results come
|
|
123
|
+
* first, as the API requires, then reminders and user text in transcript order.
|
|
124
|
+
*/
|
|
125
|
+
export function userTurnBlocks(tail: TurnMessage[]): any[] {
|
|
126
|
+
const results: any[] = [];
|
|
127
|
+
const rest: any[] = [];
|
|
128
|
+
for (const m of tail) {
|
|
129
|
+
if (m.role === "toolResult") results.push(toolResultBlock(m));
|
|
130
|
+
else if (m.role === "user") rest.push(...userBlocks(m.content));
|
|
131
|
+
else if (m.role === "system") {
|
|
132
|
+
const text = systemUpdateText(m);
|
|
133
|
+
if (text) rest.push({ type: "text", text });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return [...results, ...rest];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Assistant content for a synthesized Claude Code transcript. */
|
|
140
|
+
export function assistantBlocks(m: AssistantMessage, keepThinking: boolean): any[] {
|
|
141
|
+
const blocks: any[] = [];
|
|
142
|
+
for (const c of m.content) {
|
|
143
|
+
if (c.type === "text") {
|
|
144
|
+
if (c.text.length > 0) blocks.push({ type: "text", text: c.text });
|
|
145
|
+
} else if (c.type === "thinking") {
|
|
146
|
+
if (!keepThinking || !c.thinkingSignature) continue;
|
|
147
|
+
if (c.redacted) blocks.push({ type: "redacted_thinking", data: c.thinkingSignature });
|
|
148
|
+
else blocks.push({ type: "thinking", thinking: c.thinking, signature: c.thinkingSignature });
|
|
149
|
+
} else if (c.type === "toolCall") {
|
|
150
|
+
blocks.push({ type: "tool_use", id: toolUseId(c.id), name: MCP_PREFIX + c.name, input: c.arguments ?? {} });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return blocks;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Map a tool_use name emitted by Claude back to pi's tool name. */
|
|
157
|
+
export function piToolName(name: string): string {
|
|
158
|
+
return name.startsWith(MCP_PREFIX) ? name.slice(MCP_PREFIX.length) : name;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type { Message };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { EXTRA_USAGE_NO, EXTRA_USAGE_QUESTION, EXTRA_USAGE_YES } from "./billing.ts";
|
|
4
|
+
import { resolveConfig } from "./config.ts";
|
|
5
|
+
import { anthropicChatModels, claudeModels } from "./models.ts";
|
|
6
|
+
import { applyClaudePrompt } from "./prompt.ts";
|
|
7
|
+
import { API, ClaudeSdkProvider } from "./provider.ts";
|
|
8
|
+
|
|
9
|
+
export const PROVIDER = "claude-sdk";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* pi extension: Claude models through the Claude Agent SDK, authenticated by the local Claude
|
|
13
|
+
* Code login (Pro/Max subscription). pi keeps its own agent loop, tools, prompt and compaction;
|
|
14
|
+
* Claude Code is used only as the authenticated model transport.
|
|
15
|
+
*/
|
|
16
|
+
export default function (pi: ExtensionAPI) {
|
|
17
|
+
const config = resolveConfig({ agentDir: getAgentDir(), env: process.env });
|
|
18
|
+
const debugPath = process.env.PI_CLAUDE_SDK_DEBUG;
|
|
19
|
+
let session: ExtensionContext | undefined;
|
|
20
|
+
const provider = new ClaudeSdkProvider({
|
|
21
|
+
// With nobody to ask, as in `pi -p` or a subagent, it stops rather than spend your money.
|
|
22
|
+
confirmExtraUsage: async (_info, signal) => {
|
|
23
|
+
if (!session?.hasUI) return false;
|
|
24
|
+
return (await session.ui.select(EXTRA_USAGE_QUESTION, [EXTRA_USAGE_NO, EXTRA_USAGE_YES], { signal })) === EXTRA_USAGE_YES;
|
|
25
|
+
},
|
|
26
|
+
pathToClaudeCodeExecutable: config.pathToClaudeCodeExecutable,
|
|
27
|
+
...(config.maxSessions ? { maxSessions: config.maxSessions } : {}),
|
|
28
|
+
...(config.idleMinutes ? { idleMs: config.idleMinutes * 60_000 } : {}),
|
|
29
|
+
debug: debugPath ? (msg) => appendFileSync(debugPath, `${new Date().toISOString()} ${msg}\n`) : undefined,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const register = (models = claudeModels()) =>
|
|
33
|
+
pi.registerProvider(PROVIDER, {
|
|
34
|
+
name: "Claude (subscription via Agent SDK)",
|
|
35
|
+
// Claude Code authenticates itself; pi only needs a non-empty key to treat the provider as configured.
|
|
36
|
+
apiKey: "claude-code-login",
|
|
37
|
+
api: API as any,
|
|
38
|
+
baseUrl: "claude-agent-sdk://local",
|
|
39
|
+
models,
|
|
40
|
+
streamSimple: provider.streamSimple,
|
|
41
|
+
});
|
|
42
|
+
register();
|
|
43
|
+
|
|
44
|
+
// Pick up Claude models newer than the bundled snapshot from pi's own catalog.
|
|
45
|
+
let refreshed = false;
|
|
46
|
+
pi.on("session_start", (_event, ctx) => {
|
|
47
|
+
session = ctx;
|
|
48
|
+
if (refreshed) return;
|
|
49
|
+
refreshed = true;
|
|
50
|
+
try {
|
|
51
|
+
const live = anthropicChatModels(ctx.modelRegistry.getAll());
|
|
52
|
+
const known = new Set(claudeModels().map((m) => m.id));
|
|
53
|
+
if (live.some((m) => !/-\d{8}$/.test(m.id) && !known.has(m.id))) register(claudeModels(live));
|
|
54
|
+
} catch {
|
|
55
|
+
// The snapshot stays registered.
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const docs = { readme: getReadmePath(), docs: getDocsPath(), examples: getExamplesPath() };
|
|
60
|
+
pi.on("before_agent_start", (event, ctx) => applyClaudePrompt(event, ctx.model, config.promptMode ?? "claude", docs));
|
|
61
|
+
|
|
62
|
+
const shutdown = () => provider.shutdown();
|
|
63
|
+
process.once("exit", shutdown);
|
|
64
|
+
pi.on("session_shutdown", () => {
|
|
65
|
+
session = undefined;
|
|
66
|
+
process.off("exit", shutdown);
|
|
67
|
+
shutdown();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The little of MCP that Claude Code needs to see pi's tools: the Agent SDK hands this object a
|
|
2
|
+
// transport and passes JSON-RPC through it. The MCP SDK would bring a web server and more to do this.
|
|
3
|
+
import type { Tool } from "@earendil-works/pi-ai";
|
|
4
|
+
|
|
5
|
+
export interface ToolResult {
|
|
6
|
+
content: unknown[];
|
|
7
|
+
isError?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Transport {
|
|
11
|
+
onmessage?: (message: JsonRpc) => void;
|
|
12
|
+
start(): Promise<void>;
|
|
13
|
+
send(message: unknown): Promise<void>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface JsonRpc {
|
|
18
|
+
id?: string | number;
|
|
19
|
+
method?: string;
|
|
20
|
+
params?: { protocolVersion?: string; _meta?: Record<string, unknown> };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const METHOD_NOT_FOUND = -32601;
|
|
24
|
+
const INTERNAL_ERROR = -32603;
|
|
25
|
+
|
|
26
|
+
export class ToolServer {
|
|
27
|
+
readonly #tools: unknown[];
|
|
28
|
+
readonly #call: (toolUseId: string | undefined) => Promise<ToolResult>;
|
|
29
|
+
#transport: Transport | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(name: string, tools: readonly Tool[], call: (toolUseId: string | undefined) => Promise<ToolResult>) {
|
|
32
|
+
this.name = name;
|
|
33
|
+
this.#call = call;
|
|
34
|
+
this.#tools = tools.map((tool) => ({
|
|
35
|
+
name: tool.name,
|
|
36
|
+
description: tool.description,
|
|
37
|
+
inputSchema: { type: "object", ...(JSON.parse(JSON.stringify(tool.parameters ?? {})) as object) },
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
readonly name: string;
|
|
42
|
+
|
|
43
|
+
async connect(transport: Transport): Promise<void> {
|
|
44
|
+
this.#transport = transport;
|
|
45
|
+
transport.onmessage = (message) => void this.#handle(message);
|
|
46
|
+
await transport.start();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async close(): Promise<void> {
|
|
50
|
+
await this.#transport?.close();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async #handle(message: JsonRpc): Promise<void> {
|
|
54
|
+
// A notification has no id and wants no answer.
|
|
55
|
+
if (message.id === undefined) return;
|
|
56
|
+
const answer = await this.#answer(message);
|
|
57
|
+
await this.#transport?.send({ jsonrpc: "2.0", id: message.id, ...answer }).catch(() => undefined);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async #answer(message: JsonRpc): Promise<{ result: unknown } | { error: { code: number; message: string } }> {
|
|
61
|
+
switch (message.method) {
|
|
62
|
+
case "initialize":
|
|
63
|
+
return { result: { protocolVersion: message.params?.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: this.name, version: "1.0.0" } } };
|
|
64
|
+
case "ping":
|
|
65
|
+
return { result: {} };
|
|
66
|
+
case "tools/list":
|
|
67
|
+
return { result: { tools: this.#tools } };
|
|
68
|
+
case "tools/call":
|
|
69
|
+
try {
|
|
70
|
+
return { result: await this.#call(message.params?._meta?.["claudecode/toolUseId"] as string | undefined) };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return { error: { code: INTERNAL_ERROR, message: (error as Error).message } };
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
return { error: { code: METHOD_NOT_FOUND, message: `Unknown method ${message.method}` } };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { CATALOG } from "./catalog.ts";
|
|
4
|
+
|
|
5
|
+
type ProviderChatModelConfig = Extract<ProviderModelConfig, { reasoning: boolean }>;
|
|
6
|
+
type CatalogModel = Pick<Model<Api>, "id" | "name" | "reasoning" | "input" | "cost" | "contextWindow" | "maxTokens" | "thinkingLevelMap">;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Chat models offered through Claude Code. Limits, prices and thinking levels come from pi's
|
|
10
|
+
* Anthropic catalog: a snapshot at load (so `--model` works at startup), refreshed from pi's live
|
|
11
|
+
* registry once a session starts (see index.ts). Dated aliases are skipped; Claude Code resolves
|
|
12
|
+
* the undated ids itself.
|
|
13
|
+
*/
|
|
14
|
+
export function claudeModels(source: readonly CatalogModel[] = CATALOG as unknown as CatalogModel[]): ProviderChatModelConfig[] {
|
|
15
|
+
return source
|
|
16
|
+
.filter((m) => !/-\d{8}$/.test(m.id))
|
|
17
|
+
.map((m) => ({
|
|
18
|
+
id: m.id,
|
|
19
|
+
name: `${m.name} (Claude subscription)`,
|
|
20
|
+
reasoning: m.reasoning,
|
|
21
|
+
input: m.input,
|
|
22
|
+
cost: m.cost,
|
|
23
|
+
contextWindow: m.contextWindow,
|
|
24
|
+
maxTokens: m.maxTokens,
|
|
25
|
+
...(m.thinkingLevelMap ? { thinkingLevelMap: m.thinkingLevelMap } : {}),
|
|
26
|
+
// No promptCache: Claude Code already writes one-hour cache entries, and pi's cache
|
|
27
|
+
// warmer (a one-token replay of an earlier request) cannot be expressed through it.
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** pi's Anthropic models that can back this provider. */
|
|
32
|
+
export function anthropicChatModels(models: readonly (CatalogModel & { provider: string; type?: string })[]): CatalogModel[] {
|
|
33
|
+
return models.filter((m) => m.provider === "anthropic" && (m.type === undefined || m.type === "chat"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ThinkingOption = { type: "adaptive"; display: "summarized" } | { type: "disabled" } | { type: "enabled"; budgetTokens: number; display: "summarized" };
|
|
37
|
+
|
|
38
|
+
const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Map a pi thinking level to Agent SDK thinking options. pi resolves the level through the
|
|
42
|
+
* model's thinkingLevelMap before it reaches the provider, so the value is already the
|
|
43
|
+
* provider-native effort when the model declares one.
|
|
44
|
+
*/
|
|
45
|
+
export function thinkingOptions(
|
|
46
|
+
level: string | undefined,
|
|
47
|
+
hasLevelMap: boolean,
|
|
48
|
+
): { thinking: ThinkingOption; effort?: "low" | "medium" | "high" | "xhigh" | "max" } {
|
|
49
|
+
if (!level || level === "off") return { thinking: { type: "disabled" } };
|
|
50
|
+
if (hasLevelMap) {
|
|
51
|
+
const effort = EFFORTS.has(level) ? (level as any) : level === "minimal" ? "low" : "medium";
|
|
52
|
+
return { thinking: { type: "adaptive", display: "summarized" }, effort };
|
|
53
|
+
}
|
|
54
|
+
// Budget-based models (e.g. Haiku 4.5).
|
|
55
|
+
const budgets: Record<string, number> = { minimal: 1024, low: 2048, medium: 8192, high: 16384, xhigh: 32000, max: 48000 };
|
|
56
|
+
return { thinking: { type: "enabled", budgetTokens: budgets[level] ?? 8192, display: "summarized" } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A budget counts toward the output cap, so the cap grows to fit it, as in pi's Anthropic provider, up to what the model allows. */
|
|
60
|
+
export function fitThinking(thinking: ThinkingOption, maxTokens: number, modelMax: number): { thinking: ThinkingOption; maxOutputTokens: number } {
|
|
61
|
+
if (thinking.type !== "enabled") return { thinking, maxOutputTokens: maxTokens };
|
|
62
|
+
const maxOutputTokens = Math.min(maxTokens + thinking.budgetTokens, modelMax);
|
|
63
|
+
const budgetTokens = maxOutputTokens > thinking.budgetTokens ? thinking.budgetTokens : Math.max(1024, maxOutputTokens - 1024);
|
|
64
|
+
return { thinking: { ...thinking, budgetTokens }, maxOutputTokens };
|
|
65
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { BeforeAgentStartEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type PromptMode = "claude" | "pi";
|
|
4
|
+
type PromptOptions = BeforeAgentStartEvent["systemPromptOptions"];
|
|
5
|
+
export interface DocPaths {
|
|
6
|
+
readme: string;
|
|
7
|
+
docs: string;
|
|
8
|
+
examples: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* pi's prompt prefix rewritten for Claude. pi drops its own tools, rules and docs sections when a
|
|
13
|
+
* custom prefix is set, so this renders them itself: every selected tool and every guideline pi or
|
|
14
|
+
* another extension contributed. Context files, skills and the working directory stay pi's.
|
|
15
|
+
*/
|
|
16
|
+
export function claudePreamble(o: PromptOptions, docs: DocPaths): string {
|
|
17
|
+
const tools = o.selectedTools.filter((name) => o.toolSnippets[name]).map((name) => `- ${name}: ${o.toolSnippets[name]}`);
|
|
18
|
+
const guidelines = new Set<string>();
|
|
19
|
+
const searchTools = ["grep", "find", "ls"].some((t) => o.selectedTools.includes(t));
|
|
20
|
+
if (o.selectedTools.includes("bash") && !searchTools) guidelines.add("Use bash for file discovery and search (ls, rg, find).");
|
|
21
|
+
for (const name of o.selectedTools) for (const g of o.toolGuidelines[name] ?? []) guidelines.add(g.trim());
|
|
22
|
+
for (const g of o.promptGuidelines) guidelines.add(g.trim());
|
|
23
|
+
guidelines.delete("");
|
|
24
|
+
|
|
25
|
+
return `You are Claude, working as a coding agent in pi, a terminal coding harness. You help the user by reading files, running commands, and editing code in their project.
|
|
26
|
+
|
|
27
|
+
Tools:
|
|
28
|
+
${tools.length > 0 ? tools.join("\n") : "(none)"}
|
|
29
|
+
Other tools may be available; their definitions describe them.
|
|
30
|
+
|
|
31
|
+
How to work:
|
|
32
|
+
- Understand before you change: read the relevant code and search the project before editing. Don't guess file contents or APIs.
|
|
33
|
+
- Make independent tool calls in parallel, such as reading several files at once. Sequence only calls that depend on each other.
|
|
34
|
+
- Change existing files with targeted edits rather than rewrites. Create files only when the task needs them.
|
|
35
|
+
- Stay within the request: no unrequested features, refactors, or comments.
|
|
36
|
+
- Verify: run the project's tests, type checker, or the program itself when they exist, and fix what fails. If you can't verify, say so.
|
|
37
|
+
- Report faithfully: never claim that something passed, ran, or exists unless you saw it.
|
|
38
|
+
- If the request is ambiguous in a way that changes the result, ask. Otherwise state your assumption and proceed.
|
|
39
|
+
- Be concise. Lead with the outcome and show file paths clearly.${guidelines.size > 0 ? `\n\nGuidelines:\n${[...guidelines].map((g) => `- ${g}`).join("\n")}` : ""}
|
|
40
|
+
|
|
41
|
+
Questions about pi itself (extensions, skills, settings, its SDK): read ${docs.readme}, the docs in ${docs.docs} and the examples in ${docs.examples} first.`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* before_agent_start: give Claude models the Claude prefix. A user's own SYSTEM.md (customPrompt)
|
|
46
|
+
* always wins, and other providers keep pi's default prompt.
|
|
47
|
+
*/
|
|
48
|
+
export function applyClaudePrompt(
|
|
49
|
+
event: { systemPromptOptions: PromptOptions },
|
|
50
|
+
model: { provider: string } | undefined,
|
|
51
|
+
mode: PromptMode,
|
|
52
|
+
docs: DocPaths = { readme: "README.md", docs: "docs", examples: "examples" },
|
|
53
|
+
): void {
|
|
54
|
+
if (mode !== "claude" || model?.provider !== "claude-sdk") return;
|
|
55
|
+
if (event.systemPromptOptions.customPrompt) return;
|
|
56
|
+
event.systemPromptOptions.customPrompt = claudePreamble(event.systemPromptOptions, docs);
|
|
57
|
+
}
|