@huseynsnmz/pi-suite 0.2.2 → 0.2.3

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +8 -1
  3. package/package.json +6 -1
  4. package/packages/board/README.md +1 -1
  5. package/packages/commands/README.md +1 -1
  6. package/packages/commands/src/index.ts +22 -0
  7. package/packages/frame/README.md +8 -2
  8. package/packages/frame/docs/reference.md +1 -1
  9. package/packages/frame/src/index.ts +8 -3
  10. package/packages/frame/src/kit/settings/files.ts +8 -8
  11. package/packages/frame/src/kit/settings/store.ts +3 -1
  12. package/packages/frame/src/ui/editor.ts +64 -7
  13. package/packages/frame/src/ui/history-search.ts +21 -0
  14. package/packages/frame/src/ui/pi-entries.ts +2 -0
  15. package/packages/frame/src/ui/thinking.ts +34 -0
  16. package/packages/memory/README.md +1 -1
  17. package/packages/modes/README.md +2 -2
  18. package/packages/modes/src/index.ts +3 -6
  19. package/packages/router/README.md +29 -9
  20. package/packages/router/src/config.ts +28 -27
  21. package/packages/router/src/index.ts +61 -13
  22. package/packages/router/src/route.ts +16 -10
  23. package/packages/subscription/LICENSE +21 -0
  24. package/packages/subscription/README.md +60 -0
  25. package/packages/subscription/package.json +23 -0
  26. package/packages/subscription/src/anchors.ts +75 -0
  27. package/packages/subscription/src/billing.ts +39 -0
  28. package/packages/subscription/src/catalog.ts +310 -0
  29. package/packages/subscription/src/cc-sessions.ts +122 -0
  30. package/packages/subscription/src/config.ts +34 -0
  31. package/packages/subscription/src/convert.ts +161 -0
  32. package/packages/subscription/src/index.ts +69 -0
  33. package/packages/subscription/src/mcp.ts +78 -0
  34. package/packages/subscription/src/models.ts +65 -0
  35. package/packages/subscription/src/prompt.ts +57 -0
  36. package/packages/subscription/src/provider.ts +617 -0
  37. package/packages/subscription/src/session.ts +294 -0
  38. package/packages/subscription/src/util.ts +73 -0
  39. package/packages/web/README.md +5 -5
  40. package/packages/web/src/config.ts +13 -23
  41. package/packages/web/src/index.ts +11 -7
  42. package/packages/web/src/reader.ts +15 -4
  43. package/packages/web/src/validate.ts +2 -2
  44. package/src/index.ts +92 -10
  45. package/src/settings-ui.ts +63 -0
  46. package/src/settings.ts +116 -15
@@ -0,0 +1,310 @@
1
+ // Generated by scripts/gen-catalog.mjs from pi's Anthropic catalog. Do not edit.
2
+ export const CATALOG = [
3
+ {
4
+ "id": "claude-fable-5",
5
+ "name": "Claude Fable 5",
6
+ "reasoning": true,
7
+ "input": [
8
+ "text",
9
+ "image"
10
+ ],
11
+ "cost": {
12
+ "input": 10,
13
+ "output": 50,
14
+ "cacheRead": 1,
15
+ "cacheWrite": 12.5
16
+ },
17
+ "contextWindow": 1000000,
18
+ "maxTokens": 128000,
19
+ "thinkingLevelMap": {
20
+ "off": null,
21
+ "xhigh": "xhigh",
22
+ "max": "max"
23
+ }
24
+ },
25
+ {
26
+ "id": "claude-fable-5-1",
27
+ "name": "Claude Fable 5.1",
28
+ "reasoning": true,
29
+ "input": [
30
+ "text",
31
+ "image"
32
+ ],
33
+ "cost": {
34
+ "input": 10,
35
+ "output": 50,
36
+ "cacheRead": 0.25,
37
+ "cacheWrite": 12.5
38
+ },
39
+ "contextWindow": 1000000,
40
+ "maxTokens": 128000,
41
+ "thinkingLevelMap": {
42
+ "off": null,
43
+ "xhigh": "xhigh",
44
+ "max": "max"
45
+ }
46
+ },
47
+ {
48
+ "id": "claude-haiku-4-5",
49
+ "name": "Claude Haiku 4.5 (latest)",
50
+ "reasoning": true,
51
+ "input": [
52
+ "text",
53
+ "image"
54
+ ],
55
+ "cost": {
56
+ "input": 1,
57
+ "output": 5,
58
+ "cacheRead": 0.1,
59
+ "cacheWrite": 1.25
60
+ },
61
+ "contextWindow": 200000,
62
+ "maxTokens": 64000
63
+ },
64
+ {
65
+ "id": "claude-haiku-5-5",
66
+ "name": "Claude Haiku 5.5",
67
+ "reasoning": true,
68
+ "input": [
69
+ "text",
70
+ "image"
71
+ ],
72
+ "cost": {
73
+ "input": 0.1,
74
+ "output": 0.5,
75
+ "cacheRead": 0.01,
76
+ "cacheWrite": 0.125,
77
+ "tiers": [
78
+ {
79
+ "inputTokensAbove": 100000,
80
+ "input": 0.5,
81
+ "output": 2.5,
82
+ "cacheRead": 0.05,
83
+ "cacheWrite": 0.625
84
+ }
85
+ ]
86
+ },
87
+ "contextWindow": 1000000,
88
+ "maxTokens": 128000,
89
+ "thinkingLevelMap": {
90
+ "off": null,
91
+ "minimal": null,
92
+ "low": "low",
93
+ "medium": "medium",
94
+ "high": "high",
95
+ "xhigh": "xhigh",
96
+ "max": "max"
97
+ }
98
+ },
99
+ {
100
+ "id": "claude-opus-4-5",
101
+ "name": "Claude Opus 4.5 (latest)",
102
+ "reasoning": true,
103
+ "input": [
104
+ "text",
105
+ "image"
106
+ ],
107
+ "cost": {
108
+ "input": 5,
109
+ "output": 25,
110
+ "cacheRead": 0.5,
111
+ "cacheWrite": 6.25
112
+ },
113
+ "contextWindow": 200000,
114
+ "maxTokens": 64000
115
+ },
116
+ {
117
+ "id": "claude-opus-4-6",
118
+ "name": "Claude Opus 4.6",
119
+ "reasoning": true,
120
+ "input": [
121
+ "text",
122
+ "image"
123
+ ],
124
+ "cost": {
125
+ "input": 5,
126
+ "output": 25,
127
+ "cacheRead": 0.5,
128
+ "cacheWrite": 6.25
129
+ },
130
+ "contextWindow": 1000000,
131
+ "maxTokens": 128000,
132
+ "thinkingLevelMap": {
133
+ "max": "max"
134
+ }
135
+ },
136
+ {
137
+ "id": "claude-opus-4-7",
138
+ "name": "Claude Opus 4.7",
139
+ "reasoning": true,
140
+ "input": [
141
+ "text",
142
+ "image"
143
+ ],
144
+ "cost": {
145
+ "input": 5,
146
+ "output": 25,
147
+ "cacheRead": 0.5,
148
+ "cacheWrite": 6.25
149
+ },
150
+ "contextWindow": 1000000,
151
+ "maxTokens": 128000,
152
+ "thinkingLevelMap": {
153
+ "xhigh": "xhigh",
154
+ "max": "max"
155
+ }
156
+ },
157
+ {
158
+ "id": "claude-opus-4-8",
159
+ "name": "Claude Opus 4.8",
160
+ "reasoning": true,
161
+ "input": [
162
+ "text",
163
+ "image"
164
+ ],
165
+ "cost": {
166
+ "input": 5,
167
+ "output": 25,
168
+ "cacheRead": 0.5,
169
+ "cacheWrite": 6.25
170
+ },
171
+ "contextWindow": 1000000,
172
+ "maxTokens": 128000,
173
+ "thinkingLevelMap": {
174
+ "xhigh": "xhigh",
175
+ "max": "max"
176
+ }
177
+ },
178
+ {
179
+ "id": "claude-opus-5",
180
+ "name": "Claude Opus 5",
181
+ "reasoning": true,
182
+ "input": [
183
+ "text",
184
+ "image"
185
+ ],
186
+ "cost": {
187
+ "input": 5,
188
+ "output": 25,
189
+ "cacheRead": 0.5,
190
+ "cacheWrite": 6.25
191
+ },
192
+ "contextWindow": 1000000,
193
+ "maxTokens": 128000,
194
+ "thinkingLevelMap": {
195
+ "off": null,
196
+ "xhigh": "xhigh",
197
+ "max": "max"
198
+ }
199
+ },
200
+ {
201
+ "id": "claude-opus-5-5",
202
+ "name": "Claude Opus 5.5",
203
+ "reasoning": true,
204
+ "input": [
205
+ "text",
206
+ "image"
207
+ ],
208
+ "cost": {
209
+ "input": 4,
210
+ "output": 20,
211
+ "cacheRead": 0.2,
212
+ "cacheWrite": 5
213
+ },
214
+ "contextWindow": 1000000,
215
+ "maxTokens": 128000,
216
+ "thinkingLevelMap": {
217
+ "off": null,
218
+ "minimal": null,
219
+ "low": "low",
220
+ "medium": "medium",
221
+ "high": "high",
222
+ "xhigh": "xhigh",
223
+ "max": "max"
224
+ }
225
+ },
226
+ {
227
+ "id": "claude-sonnet-4-5",
228
+ "name": "Claude Sonnet 4.5 (latest)",
229
+ "reasoning": true,
230
+ "input": [
231
+ "text",
232
+ "image"
233
+ ],
234
+ "cost": {
235
+ "input": 3,
236
+ "output": 15,
237
+ "cacheRead": 0.3,
238
+ "cacheWrite": 3.75
239
+ },
240
+ "contextWindow": 1000000,
241
+ "maxTokens": 64000
242
+ },
243
+ {
244
+ "id": "claude-sonnet-4-6",
245
+ "name": "Claude Sonnet 4.6",
246
+ "reasoning": true,
247
+ "input": [
248
+ "text",
249
+ "image"
250
+ ],
251
+ "cost": {
252
+ "input": 3,
253
+ "output": 15,
254
+ "cacheRead": 0.3,
255
+ "cacheWrite": 3.75
256
+ },
257
+ "contextWindow": 1000000,
258
+ "maxTokens": 128000,
259
+ "thinkingLevelMap": {
260
+ "max": "max"
261
+ }
262
+ },
263
+ {
264
+ "id": "claude-sonnet-5",
265
+ "name": "Claude Sonnet 5",
266
+ "reasoning": true,
267
+ "input": [
268
+ "text",
269
+ "image"
270
+ ],
271
+ "cost": {
272
+ "input": 2,
273
+ "output": 10,
274
+ "cacheRead": 0.2,
275
+ "cacheWrite": 2.5
276
+ },
277
+ "contextWindow": 1000000,
278
+ "maxTokens": 128000,
279
+ "thinkingLevelMap": {
280
+ "xhigh": "xhigh",
281
+ "max": "max"
282
+ }
283
+ },
284
+ {
285
+ "id": "claude-sonnet-5-5",
286
+ "name": "Claude Sonnet 5.5",
287
+ "reasoning": true,
288
+ "input": [
289
+ "text",
290
+ "image"
291
+ ],
292
+ "cost": {
293
+ "input": 2,
294
+ "output": 10,
295
+ "cacheRead": 0.2,
296
+ "cacheWrite": 2.5
297
+ },
298
+ "contextWindow": 1000000,
299
+ "maxTokens": 128000,
300
+ "thinkingLevelMap": {
301
+ "off": null,
302
+ "minimal": null,
303
+ "low": "low",
304
+ "medium": "medium",
305
+ "high": "high",
306
+ "xhigh": "xhigh",
307
+ "max": "max"
308
+ }
309
+ }
310
+ ] as const;
@@ -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
+ }