@huseynsnmz/pi-suite 0.2.1 → 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.
- package/CHANGELOG.md +23 -0
- package/README.md +8 -1
- package/package.json +6 -1
- package/packages/board/README.md +1 -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 +1 -1
- package/packages/modes/README.md +6 -2
- package/packages/modes/src/index.ts +9 -7
- package/packages/modes/src/modes.ts +18 -1
- package/packages/router/README.md +29 -9
- package/packages/router/src/config.ts +28 -27
- package/packages/router/src/index.ts +61 -13
- package/packages/router/src/route.ts +16 -10
- 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 +92 -10
- package/src/settings-ui.ts +63 -0
- package/src/settings.ts +116 -15
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Api,
|
|
3
|
+
AssistantMessage,
|
|
4
|
+
AssistantMessageEventStream,
|
|
5
|
+
Model,
|
|
6
|
+
SimpleStreamOptions,
|
|
7
|
+
StopReason,
|
|
8
|
+
TextContent,
|
|
9
|
+
ThinkingContent,
|
|
10
|
+
ToolCall,
|
|
11
|
+
ToolResultMessage,
|
|
12
|
+
TranscriptContext,
|
|
13
|
+
} from "@earendil-works/pi-ai";
|
|
14
|
+
import { calculateCost, createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
15
|
+
import { AnchorRegistry } from "./anchors.ts";
|
|
16
|
+
import { billsExtraUsage, explainError, EXTRA_USAGE_RESEND, EXTRA_USAGE_STOPPED, type RateLimitInfo, stillBilling } from "./billing.ts";
|
|
17
|
+
import { findMessageUuid, sessionFile, synthesizeSession } from "./cc-sessions.ts";
|
|
18
|
+
import { piToolName, readTranscript, type Transcript, type TurnMessage, userTurnBlocks, fingerprint } from "./convert.ts";
|
|
19
|
+
import { fitThinking, thinkingOptions } from "./models.ts";
|
|
20
|
+
import { type ClaudeSession, errorText, LiveSession, type SessionSpec } from "./session.ts";
|
|
21
|
+
import { sha } from "./util.ts";
|
|
22
|
+
|
|
23
|
+
export const API = "claude-agent-sdk";
|
|
24
|
+
|
|
25
|
+
export interface ProviderOptions {
|
|
26
|
+
pathToClaudeCodeExecutable?: string;
|
|
27
|
+
/** Live Claude Code processes kept for reuse (one per concurrent conversation). */
|
|
28
|
+
maxSessions?: number;
|
|
29
|
+
/** Close a process idle this long; the conversation resumes from disk on its next turn. */
|
|
30
|
+
idleMs?: number;
|
|
31
|
+
registry?: AnchorRegistry;
|
|
32
|
+
debug?: (msg: string) => void;
|
|
33
|
+
cwd?: () => string;
|
|
34
|
+
/** Creates the Claude Code session; tests substitute a scripted fake. */
|
|
35
|
+
createSession?: (spec: SessionSpec, chain: string, count: number) => ClaudeSession;
|
|
36
|
+
/** Asks you whether a request may be billed as extra usage. Without it, the answer is no. */
|
|
37
|
+
confirmExtraUsage?: (info: RateLimitInfo, signal?: AbortSignal) => Promise<boolean>;
|
|
38
|
+
/** Forks a persisted Claude Code session at a transcript-entry uuid; tests substitute a fake. */
|
|
39
|
+
forkSession?: (sessionId: string, cwd: string, at: string) => Promise<{ sessionId: string }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How a request was attached to Claude Code; exposed for tests and the debug log. */
|
|
43
|
+
export type Route = "live" | "resume" | "synth" | "fresh";
|
|
44
|
+
|
|
45
|
+
export class ClaudeSdkProvider {
|
|
46
|
+
private sessions: ClaudeSession[] = [];
|
|
47
|
+
/** Every session whose process may still run, pooled or not: the leak backstop closes from here. */
|
|
48
|
+
private readonly all = new Set<ClaudeSession>();
|
|
49
|
+
/** Sessions a provider call is reading right now (not the `busy` flag, which a failed attach could leave set). */
|
|
50
|
+
private readonly active = new Set<ClaudeSession>();
|
|
51
|
+
private readonly registry: AnchorRegistry;
|
|
52
|
+
private readonly opts: Required<Omit<ProviderOptions, "registry" | "pathToClaudeCodeExecutable" | "debug" | "createSession" | "forkSession" | "confirmExtraUsage">> & ProviderOptions;
|
|
53
|
+
private timer?: NodeJS.Timeout;
|
|
54
|
+
lastRoute?: Route;
|
|
55
|
+
private extraUsageApproved = false;
|
|
56
|
+
/** The latest report that requests are billed as extra usage, until one says they're not. */
|
|
57
|
+
private billing?: RateLimitInfo;
|
|
58
|
+
private question?: { answer: Promise<boolean>; dismiss: AbortController; waiting: number };
|
|
59
|
+
|
|
60
|
+
constructor(opts: ProviderOptions = {}) {
|
|
61
|
+
this.registry = opts.registry ?? new AnchorRegistry();
|
|
62
|
+
// An option explicitly set to undefined means "not configured": it must not overwrite a
|
|
63
|
+
// default. (The extension once passed idleMs: undefined that way, and every idle-sweep
|
|
64
|
+
// comparison against undefined is false, so the sweep closed in-flight sessions.)
|
|
65
|
+
const given = Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== undefined));
|
|
66
|
+
this.opts = { maxSessions: 3, idleMs: 20 * 60_000, cwd: () => process.cwd(), ...given };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
readonly streamSimple = (model: Model<Api>, context: TranscriptContext, options?: SimpleStreamOptions): AssistantMessageEventStream => {
|
|
70
|
+
const stream = createAssistantMessageEventStream();
|
|
71
|
+
const output: AssistantMessage = {
|
|
72
|
+
role: "assistant",
|
|
73
|
+
content: [],
|
|
74
|
+
api: model.api,
|
|
75
|
+
provider: model.provider,
|
|
76
|
+
model: model.id,
|
|
77
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
78
|
+
stopReason: "pending",
|
|
79
|
+
timestamp: Date.now(),
|
|
80
|
+
};
|
|
81
|
+
void this.run(model, context, options, stream, output);
|
|
82
|
+
return stream;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
private log(msg: string): void {
|
|
86
|
+
this.opts.debug?.(msg);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async run(model: Model<Api>, context: TranscriptContext, options: SimpleStreamOptions | undefined, stream: AssistantMessageEventStream, output: AssistantMessage): Promise<void> {
|
|
90
|
+
let session: ClaudeSession | undefined;
|
|
91
|
+
let started = false;
|
|
92
|
+
const fail = (reason: "error" | "aborted", message: string) => {
|
|
93
|
+
output.stopReason = reason;
|
|
94
|
+
output.errorMessage = message;
|
|
95
|
+
if (!started) stream.push({ type: "start", partial: output });
|
|
96
|
+
stream.push({ type: "error", reason, error: output });
|
|
97
|
+
stream.end();
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
if (options?.maxTokens === 1) {
|
|
101
|
+
// A cache-warming replay. Claude Code keeps its own one-hour cache; answer without
|
|
102
|
+
// touching the live session (a replay of an older context would otherwise end it).
|
|
103
|
+
output.stopReason = "length";
|
|
104
|
+
stream.push({ type: "start", partial: output });
|
|
105
|
+
stream.push({ type: "done", reason: "length", message: output });
|
|
106
|
+
stream.end();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const stopped = await this.needsStopping(options?.signal);
|
|
110
|
+
if (stopped) return fail(...stopped);
|
|
111
|
+
const t = readTranscript(context);
|
|
112
|
+
if (t.messages.length === 0 || t.messages[t.messages.length - 1].role === "assistant") {
|
|
113
|
+
return fail("error", "The transcript must end with a user message or tool results");
|
|
114
|
+
}
|
|
115
|
+
const effort = thinkingOptions(options?.reasoning, !!(model as any).thinkingLevelMap);
|
|
116
|
+
const fitted = fitThinking(effort.thinking, options?.maxTokens ?? model.maxTokens, model.maxTokens);
|
|
117
|
+
const thinking = { ...effort, thinking: fitted.thinking };
|
|
118
|
+
const spec = {
|
|
119
|
+
cwd: this.opts.cwd(),
|
|
120
|
+
model: model.id,
|
|
121
|
+
systemPrompt: t.systemPrompt,
|
|
122
|
+
tools: t.tools,
|
|
123
|
+
toolsHash: t.toolsHash,
|
|
124
|
+
// The output cap is fixed when the process starts, so a different one needs a different process.
|
|
125
|
+
thinkingKey: JSON.stringify([thinking, options?.cacheRetention === "none", fitted.maxOutputTokens]),
|
|
126
|
+
noCache: options?.cacheRetention === "none",
|
|
127
|
+
...thinking,
|
|
128
|
+
maxOutputTokens: fitted.maxOutputTokens,
|
|
129
|
+
pathToClaudeCodeExecutable: this.opts.pathToClaudeCodeExecutable,
|
|
130
|
+
debug: this.opts.debug,
|
|
131
|
+
} satisfies SessionSpec;
|
|
132
|
+
|
|
133
|
+
for (;;) {
|
|
134
|
+
const attached = await this.attach(t, spec);
|
|
135
|
+
session = attached.session;
|
|
136
|
+
this.lastRoute = attached.route;
|
|
137
|
+
this.log(`route=${attached.route} session=${session.sessionId} prefix=${attached.prefix}/${t.messages.length}`);
|
|
138
|
+
|
|
139
|
+
// A report may be waiting from the last response. Tool results would send another request.
|
|
140
|
+
for (const report of session.events.take((event: any) => event.type === "rate_limit_event")) this.noteBilling((report as any).rate_limit_info);
|
|
141
|
+
const stoppedNow = await this.needsStopping(options?.signal);
|
|
142
|
+
if (stoppedNow) return fail(...stoppedNow);
|
|
143
|
+
|
|
144
|
+
const payload = await options?.onPayload?.({ sessionId: session.sessionId, route: attached.route, model: model.id, tail: t.messages.length - attached.prefix }, model);
|
|
145
|
+
void payload; // The request body is built inside Claude Code; there is nothing to replace.
|
|
146
|
+
|
|
147
|
+
// Hand pi's new messages to Claude Code.
|
|
148
|
+
const tail = t.messages.slice(attached.prefix);
|
|
149
|
+
const results = tail.filter((m): m is ToolResultMessage => m.role === "toolResult");
|
|
150
|
+
const rest = tail.filter((m) => m.role !== "toolResult");
|
|
151
|
+
if (attached.route === "live" && session.awaiting.size > 0) {
|
|
152
|
+
// Mid-turn: a message typed while tools ran is queued first so Claude Code adds it at
|
|
153
|
+
// this tool boundary, then the parked tool calls are released.
|
|
154
|
+
const extra = userTurnBlocks(rest);
|
|
155
|
+
if (extra.length > 0) session.send(extra, "next");
|
|
156
|
+
for (const r of results) session.deliver(r);
|
|
157
|
+
} else {
|
|
158
|
+
session.send(userTurnBlocks(tail));
|
|
159
|
+
}
|
|
160
|
+
session.chain = t.chain[t.messages.length];
|
|
161
|
+
session.count = t.messages.length;
|
|
162
|
+
|
|
163
|
+
const step: Awaited<ReturnType<ClaudeSdkProvider["readStep"]>> = await this.readStep(session, model, options, stream, output, started, () => {
|
|
164
|
+
started = true;
|
|
165
|
+
});
|
|
166
|
+
if (step !== "aborted" && "extraUsage" in step) {
|
|
167
|
+
// Closed first, so nothing more is generated while you decide.
|
|
168
|
+
this.release(session);
|
|
169
|
+
this.drop(session);
|
|
170
|
+
session = undefined;
|
|
171
|
+
const stoppedHere = await this.needsStopping(options?.signal);
|
|
172
|
+
if (stoppedHere) return fail(...stoppedHere);
|
|
173
|
+
if (output.content.length > 0) return fail("error", EXTRA_USAGE_RESEND);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (step === "aborted") return fail("aborted", "Request was aborted");
|
|
177
|
+
if (!step.ok) {
|
|
178
|
+
// Claude Code is in an unknown state after a failure; the next call resumes from disk.
|
|
179
|
+
this.drop(session);
|
|
180
|
+
return fail("error", explainError(step.error));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Record the new conversation state so a later divergence can resume right here.
|
|
184
|
+
session.chain = sha(`${session.chain}\0${fingerprint(output)}`);
|
|
185
|
+
session.count += 1;
|
|
186
|
+
if (step.messageId && session.spec.tools.length > 0) {
|
|
187
|
+
const entryUuid = await session.entryUuid(step.messageId);
|
|
188
|
+
this.registry.put(session.chain, { sessionId: session.sessionId, messageId: step.messageId, cwd: session.spec.cwd, ...(entryUuid ? { entryUuid } : {}) });
|
|
189
|
+
}
|
|
190
|
+
session.awaiting = new Set(output.content.filter((c): c is ToolCall => c.type === "toolCall").map((c) => c.id));
|
|
191
|
+
if (output.stopReason !== "toolUse") session.awaiting.clear();
|
|
192
|
+
stream.push({ type: "done", reason: output.stopReason as Extract<StopReason, "stop" | "length" | "toolUse">, message: output });
|
|
193
|
+
stream.end();
|
|
194
|
+
if (session.spec.tools.length === 0) this.drop(session);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (session) this.drop(session);
|
|
199
|
+
fail(options?.signal?.aborted ? "aborted" : "error", errorText(e, session?.stderr));
|
|
200
|
+
} finally {
|
|
201
|
+
if (session) this.release(session);
|
|
202
|
+
this.scheduleSweep();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private release(session: ClaudeSession): void {
|
|
207
|
+
session.busy = false;
|
|
208
|
+
this.active.delete(session);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private noteBilling(info: RateLimitInfo | undefined): void {
|
|
212
|
+
this.log(`rate_limit ${JSON.stringify(info)}`);
|
|
213
|
+
if (billsExtraUsage(info)) {
|
|
214
|
+
this.billing = info;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
this.billing = undefined;
|
|
218
|
+
this.extraUsageApproved = false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** What to end a request with, if requests are billed and that isn't approved. Asks you if need be. */
|
|
222
|
+
private async needsStopping(signal?: AbortSignal): Promise<["error" | "aborted", string] | undefined> {
|
|
223
|
+
if (!stillBilling(this.billing, Date.now() / 1000)) {
|
|
224
|
+
this.billing = undefined;
|
|
225
|
+
this.extraUsageApproved = false;
|
|
226
|
+
}
|
|
227
|
+
if (this.billing === undefined || this.extraUsageApproved) return undefined;
|
|
228
|
+
const allowed = await this.askExtraUsage(this.billing, signal);
|
|
229
|
+
if (signal?.aborted) return ["aborted", "Request was aborted"];
|
|
230
|
+
return allowed ? undefined : ["error", EXTRA_USAGE_STOPPED];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One question at a time, however many requests wait on it. It stays open until the last of them gives up. */
|
|
234
|
+
private askExtraUsage(info: RateLimitInfo, signal?: AbortSignal): Promise<boolean> {
|
|
235
|
+
if (this.question === undefined) {
|
|
236
|
+
const dismiss = new AbortController();
|
|
237
|
+
const answer = (this.opts.confirmExtraUsage?.(info, dismiss.signal) ?? Promise.resolve(false))
|
|
238
|
+
.catch(() => false)
|
|
239
|
+
.then((allowed) => {
|
|
240
|
+
this.question = undefined;
|
|
241
|
+
this.extraUsageApproved = allowed;
|
|
242
|
+
return allowed;
|
|
243
|
+
});
|
|
244
|
+
this.question = { answer, dismiss, waiting: 0 };
|
|
245
|
+
}
|
|
246
|
+
const question = this.question;
|
|
247
|
+
question.waiting += 1;
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
const giveUp = () => {
|
|
250
|
+
question.waiting -= 1;
|
|
251
|
+
if (question.waiting === 0) question.dismiss.abort();
|
|
252
|
+
resolve(false);
|
|
253
|
+
};
|
|
254
|
+
if (signal?.aborted) return giveUp();
|
|
255
|
+
signal?.addEventListener("abort", giveUp, { once: true });
|
|
256
|
+
void question.answer.then((allowed) => {
|
|
257
|
+
signal?.removeEventListener("abort", giveUp);
|
|
258
|
+
resolve(allowed);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Find or create the Claude Code process for this transcript. In order of preference:
|
|
265
|
+
* a live process that has consumed a prefix of it; a persisted Claude Code session resumed
|
|
266
|
+
* at the longest known prefix; a synthesized session; a fresh one.
|
|
267
|
+
*/
|
|
268
|
+
private async attach(t: Transcript, spec: SessionSpec): Promise<{ session: ClaudeSession; prefix: number; route: Route }> {
|
|
269
|
+
const n = t.messages.length;
|
|
270
|
+
for (const s of this.sessions) {
|
|
271
|
+
if (s.dead || s.busy || s.systemPrompt !== t.systemPrompt || s.spec.toolsHash !== t.toolsHash || s.spec.thinkingKey !== spec.thinkingKey) continue;
|
|
272
|
+
if (s.count > n || t.chain[s.count] !== s.chain) continue;
|
|
273
|
+
const tail = t.messages.slice(s.count);
|
|
274
|
+
if (tail.some((m) => m.role === "assistant")) continue;
|
|
275
|
+
const resultIds = new Set(tail.filter((m): m is ToolResultMessage => m.role === "toolResult").map((m) => m.toolCallId));
|
|
276
|
+
if (s.awaiting.size > 0 && ![...s.awaiting].every((id) => resultIds.has(id))) continue;
|
|
277
|
+
if (s.awaiting.size === 0 && [...resultIds].some((id) => !s.servedByClaude.has(id))) continue;
|
|
278
|
+
s.busy = true; // before any await, so a concurrent call cannot select it too
|
|
279
|
+
this.active.add(s);
|
|
280
|
+
s.lastUsed = Date.now();
|
|
281
|
+
if (s.model !== spec.model) {
|
|
282
|
+
try {
|
|
283
|
+
await s.setModel(spec.model);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
// run() never receives this session, so nothing else would release it.
|
|
286
|
+
s.busy = false;
|
|
287
|
+
this.active.delete(s);
|
|
288
|
+
this.drop(s);
|
|
289
|
+
throw e;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { session: s, prefix: s.count, route: "live" };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Longest prefix ending in an assistant message that Claude Code has on disk.
|
|
296
|
+
let resume: { sessionId: string; at?: string } | undefined;
|
|
297
|
+
let prefix = 0;
|
|
298
|
+
let route: Route = "fresh";
|
|
299
|
+
for (let k = n - 1; k >= 1 && spec.tools.length > 0; k--) {
|
|
300
|
+
if (t.messages[k - 1].role !== "assistant") continue;
|
|
301
|
+
const a = this.registry.get(t.chain[k]);
|
|
302
|
+
if (!a || a.cwd !== spec.cwd) continue;
|
|
303
|
+
// Another call is mid-response on that session; resuming would end it.
|
|
304
|
+
if (this.sessions.some((s) => s.busy && s.sessionId === a.sessionId)) continue;
|
|
305
|
+
const at = a.entryUuid ?? findMessageUuid(a.cwd, a.sessionId, a.messageId);
|
|
306
|
+
if (!at) {
|
|
307
|
+
this.log(`anchor ${a.sessionId}/${a.messageId} not found on disk`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// A live process on that session is stale by now. It must be gone before the
|
|
311
|
+
// transcript is forked: Claude Code may still be writing its final entries.
|
|
312
|
+
for (const s of this.sessions) if (s.sessionId === a.sessionId) this.drop(s);
|
|
313
|
+
await this.waitExited(a.sessionId);
|
|
314
|
+
resume = { sessionId: a.sessionId, at };
|
|
315
|
+
try {
|
|
316
|
+
const forked = await (this.opts.forkSession ?? forkPersistedSession)(a.sessionId, spec.cwd, at);
|
|
317
|
+
if (forked.sessionId && forked.sessionId !== a.sessionId) resume = { sessionId: forked.sessionId };
|
|
318
|
+
} catch (e) {
|
|
319
|
+
this.log(`fork anchor ${a.sessionId}/${a.messageId} failed (${errorText(e)}); resuming source session`);
|
|
320
|
+
// Claude Code may have cleaned it up. A shorter prefix, or writing the history out, still works.
|
|
321
|
+
if (!sessionFile(a.cwd, a.sessionId)) {
|
|
322
|
+
resume = undefined;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
prefix = k;
|
|
327
|
+
route = "resume";
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
if (!resume) {
|
|
331
|
+
let last = -1;
|
|
332
|
+
t.messages.forEach((m, i) => {
|
|
333
|
+
if (m.role === "assistant") last = i;
|
|
334
|
+
});
|
|
335
|
+
if (last >= 0) {
|
|
336
|
+
resume = synthesizeSession(spec.cwd, spec.model, t.messages.slice(0, last + 1));
|
|
337
|
+
prefix = last + 1;
|
|
338
|
+
route = "synth";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const create = this.opts.createSession ?? (await liveSessions());
|
|
342
|
+
const session = create({ ...spec, resume }, t.chain[prefix], prefix);
|
|
343
|
+
session.busy = true;
|
|
344
|
+
this.active.add(session);
|
|
345
|
+
this.sessions.push(session);
|
|
346
|
+
this.track(session);
|
|
347
|
+
this.evict();
|
|
348
|
+
return { session, prefix, route };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Consume Claude Code events until the current API response ends, emitting pi events.
|
|
353
|
+
* Returns the API message id, "aborted", or an error.
|
|
354
|
+
*/
|
|
355
|
+
private async readStep(
|
|
356
|
+
session: ClaudeSession,
|
|
357
|
+
model: Model<Api>,
|
|
358
|
+
options: SimpleStreamOptions | undefined,
|
|
359
|
+
stream: AssistantMessageEventStream,
|
|
360
|
+
output: AssistantMessage,
|
|
361
|
+
alreadyStarted: boolean,
|
|
362
|
+
onStart: () => void,
|
|
363
|
+
): Promise<{ ok: true; messageId?: string } | "aborted" | { ok: false; error: string } | { extraUsage: RateLimitInfo }> {
|
|
364
|
+
const signal = options?.signal;
|
|
365
|
+
type Block = (TextContent | ThinkingContent | (ToolCall & { partialJson?: string })) & { index?: number };
|
|
366
|
+
const blocks = output.content as Block[];
|
|
367
|
+
let started = alreadyStarted;
|
|
368
|
+
let messageId: string | undefined;
|
|
369
|
+
let stopReason: string | undefined;
|
|
370
|
+
// A turn can span several API responses: Claude Code continues on its own after an output
|
|
371
|
+
// limit, an empty response or a cut-off stream. They fold into one pi message.
|
|
372
|
+
let inResponse = false;
|
|
373
|
+
let responseStart = 0;
|
|
374
|
+
let turnStop: StopReason | undefined;
|
|
375
|
+
const finished = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
|
|
376
|
+
let current = { ...finished };
|
|
377
|
+
const start = () => {
|
|
378
|
+
if (started) return;
|
|
379
|
+
started = true;
|
|
380
|
+
onStart();
|
|
381
|
+
stream.push({ type: "start", partial: output });
|
|
382
|
+
};
|
|
383
|
+
const setUsage = (u: any) => {
|
|
384
|
+
if (!u) return;
|
|
385
|
+
if (u.input_tokens != null) current.input = u.input_tokens;
|
|
386
|
+
if (u.output_tokens != null) current.output = u.output_tokens;
|
|
387
|
+
if (u.cache_read_input_tokens != null) current.cacheRead = u.cache_read_input_tokens;
|
|
388
|
+
if (u.cache_creation_input_tokens != null) current.cacheWrite = u.cache_creation_input_tokens;
|
|
389
|
+
if (u.cache_creation?.ephemeral_1h_input_tokens != null) current.cacheWrite1h = u.cache_creation.ephemeral_1h_input_tokens;
|
|
390
|
+
output.usage.input = finished.input + current.input;
|
|
391
|
+
output.usage.output = finished.output + current.output;
|
|
392
|
+
output.usage.cacheRead = finished.cacheRead + current.cacheRead;
|
|
393
|
+
output.usage.cacheWrite = finished.cacheWrite + current.cacheWrite;
|
|
394
|
+
const w1h = finished.cacheWrite1h + current.cacheWrite1h;
|
|
395
|
+
if (w1h > 0) output.usage.cacheWrite1h = w1h;
|
|
396
|
+
// pi reads totalTokens as the context size (compaction, footer): that of the latest response,
|
|
397
|
+
// not the sum over a folded turn. The other fields sum, so cost stays right.
|
|
398
|
+
output.usage.totalTokens = current.input + current.cacheRead + current.cacheWrite + current.output;
|
|
399
|
+
output.usage.cost = calculateCost(model, output.usage);
|
|
400
|
+
};
|
|
401
|
+
const onAbort = () => void session.interrupt();
|
|
402
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
403
|
+
try {
|
|
404
|
+
for (;;) {
|
|
405
|
+
let m: any;
|
|
406
|
+
try {
|
|
407
|
+
m = await session.events.next(signal);
|
|
408
|
+
session.lastUsed = Date.now();
|
|
409
|
+
} catch {
|
|
410
|
+
this.drop(session);
|
|
411
|
+
return "aborted";
|
|
412
|
+
}
|
|
413
|
+
if (m === undefined) return { ok: false, error: errorText(new Error("Claude Code exited unexpectedly"), session.stderr) };
|
|
414
|
+
if (m.type === "pi_error") return { ok: false, error: m.error };
|
|
415
|
+
if (m.type === "rate_limit_event") {
|
|
416
|
+
this.noteBilling(m.rate_limit_info);
|
|
417
|
+
if (this.billing === undefined) continue;
|
|
418
|
+
// A finished reply is already paid for, so it's kept, and the next request asks first.
|
|
419
|
+
const finished = !inResponse && blocks.length > 0;
|
|
420
|
+
if (!this.extraUsageApproved && !finished) return { extraUsage: this.billing };
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (m.type === "stream_event" && !m.parent_tool_use_id) {
|
|
424
|
+
const ev = m.event;
|
|
425
|
+
await options?.onProviderStreamEvent?.(ev, model);
|
|
426
|
+
switch (ev.type) {
|
|
427
|
+
case "message_start":
|
|
428
|
+
// The previous response broke off mid-stream and Claude Code retried it: drop its partial blocks.
|
|
429
|
+
if (inResponse) blocks.length = responseStart;
|
|
430
|
+
responseStart = blocks.length;
|
|
431
|
+
inResponse = true;
|
|
432
|
+
current = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
|
|
433
|
+
stopReason = undefined;
|
|
434
|
+
messageId = ev.message.id;
|
|
435
|
+
output.responseId = ev.message.id;
|
|
436
|
+
output.responseModel = ev.message.model;
|
|
437
|
+
setUsage(ev.message.usage);
|
|
438
|
+
start();
|
|
439
|
+
break;
|
|
440
|
+
case "content_block_start": {
|
|
441
|
+
start();
|
|
442
|
+
const cb = ev.content_block;
|
|
443
|
+
if (cb.type === "text") {
|
|
444
|
+
blocks.push({ type: "text", text: "", index: ev.index });
|
|
445
|
+
stream.push({ type: "text_start", contentIndex: blocks.length - 1, partial: output });
|
|
446
|
+
} else if (cb.type === "thinking") {
|
|
447
|
+
blocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: ev.index });
|
|
448
|
+
stream.push({ type: "thinking_start", contentIndex: blocks.length - 1, partial: output });
|
|
449
|
+
} else if (cb.type === "redacted_thinking") {
|
|
450
|
+
blocks.push({ type: "thinking", thinking: "[Reasoning redacted]", thinkingSignature: cb.data, redacted: true, index: ev.index });
|
|
451
|
+
stream.push({ type: "thinking_start", contentIndex: blocks.length - 1, partial: output });
|
|
452
|
+
} else if (cb.type === "tool_use") {
|
|
453
|
+
blocks.push({ type: "toolCall", id: cb.id, name: piToolName(cb.name), arguments: {}, partialJson: "", index: ev.index });
|
|
454
|
+
stream.push({ type: "toolcall_start", contentIndex: blocks.length - 1, partial: output });
|
|
455
|
+
}
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
case "content_block_delta": {
|
|
459
|
+
const i = blocks.findIndex((b) => b.index === ev.index);
|
|
460
|
+
const b = blocks[i];
|
|
461
|
+
if (!b) break;
|
|
462
|
+
const d = ev.delta;
|
|
463
|
+
if (d.type === "text_delta" && b.type === "text") {
|
|
464
|
+
b.text += d.text;
|
|
465
|
+
stream.push({ type: "text_delta", contentIndex: i, delta: d.text, partial: output });
|
|
466
|
+
} else if (d.type === "thinking_delta" && b.type === "thinking") {
|
|
467
|
+
b.thinking += d.thinking;
|
|
468
|
+
stream.push({ type: "thinking_delta", contentIndex: i, delta: d.thinking, partial: output });
|
|
469
|
+
} else if (d.type === "signature_delta" && b.type === "thinking") {
|
|
470
|
+
b.thinkingSignature = (b.thinkingSignature ?? "") + d.signature;
|
|
471
|
+
} else if (d.type === "input_json_delta" && b.type === "toolCall") {
|
|
472
|
+
b.partialJson = (b.partialJson ?? "") + d.partial_json;
|
|
473
|
+
stream.push({ type: "toolcall_delta", contentIndex: i, delta: d.partial_json, partial: output });
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case "content_block_stop": {
|
|
478
|
+
const i = blocks.findIndex((b) => b.index === ev.index);
|
|
479
|
+
const b = blocks[i];
|
|
480
|
+
if (!b) break;
|
|
481
|
+
delete b.index;
|
|
482
|
+
if (b.type === "text") stream.push({ type: "text_end", contentIndex: i, content: b.text, partial: output });
|
|
483
|
+
else if (b.type === "thinking") stream.push({ type: "thinking_end", contentIndex: i, content: b.thinking, partial: output });
|
|
484
|
+
else if (b.type === "toolCall") {
|
|
485
|
+
try {
|
|
486
|
+
b.arguments = b.partialJson ? JSON.parse(b.partialJson) : {};
|
|
487
|
+
} catch {
|
|
488
|
+
b.arguments = {};
|
|
489
|
+
}
|
|
490
|
+
delete b.partialJson;
|
|
491
|
+
stream.push({ type: "toolcall_end", contentIndex: i, toolCall: b, partial: output });
|
|
492
|
+
}
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case "message_delta":
|
|
496
|
+
stopReason = ev.delta?.stop_reason ?? stopReason;
|
|
497
|
+
setUsage(ev.usage);
|
|
498
|
+
break;
|
|
499
|
+
case "message_stop":
|
|
500
|
+
for (const b of blocks) delete b.index;
|
|
501
|
+
inResponse = false;
|
|
502
|
+
Object.assign(finished, {
|
|
503
|
+
input: finished.input + current.input,
|
|
504
|
+
output: finished.output + current.output,
|
|
505
|
+
cacheRead: finished.cacheRead + current.cacheRead,
|
|
506
|
+
cacheWrite: finished.cacheWrite + current.cacheWrite,
|
|
507
|
+
cacheWrite1h: finished.cacheWrite1h + current.cacheWrite1h,
|
|
508
|
+
});
|
|
509
|
+
current = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
|
|
510
|
+
turnStop = mapStop(stopReason);
|
|
511
|
+
output.stopReason = turnStop;
|
|
512
|
+
// A tool call hands control to pi. Any other stop may still be followed by Claude
|
|
513
|
+
// Code's own continuation, so the turn ends only at its result.
|
|
514
|
+
if (turnStop === "toolUse") return { ok: true, messageId };
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
} else if (m.type === "assistant" && m.error && !started) {
|
|
518
|
+
const text = (m.message?.content ?? []).map((c: any) => c.text ?? "").join("");
|
|
519
|
+
return { ok: false, error: text || `Claude Code error: ${m.error}` };
|
|
520
|
+
} else if (m.type === "result") {
|
|
521
|
+
if (m.is_error || m.subtype !== "success") return { ok: false, error: (m.errors ?? []).join("\n") || m.result || `Claude Code ${m.subtype}` };
|
|
522
|
+
output.stopReason = turnStop ?? "stop";
|
|
523
|
+
start();
|
|
524
|
+
return { ok: true, messageId };
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
} finally {
|
|
528
|
+
signal?.removeEventListener("abort", onAbort);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private closing = new Map<string, Promise<void>>();
|
|
533
|
+
|
|
534
|
+
private drop(s: ClaudeSession): void {
|
|
535
|
+
s.close();
|
|
536
|
+
this.sessions = this.sessions.filter((x) => x !== s);
|
|
537
|
+
const exited = s.exited.finally(() => {
|
|
538
|
+
if (this.closing.get(s.sessionId) === exited) this.closing.delete(s.sessionId);
|
|
539
|
+
});
|
|
540
|
+
this.closing.set(s.sessionId, exited);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private async waitExited(sessionId: string): Promise<void> {
|
|
544
|
+
const p = this.closing.get(sessionId);
|
|
545
|
+
if (p) await Promise.race([p, new Promise((r) => setTimeout(r, 10_000).unref())]);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private track(s: ClaudeSession): void {
|
|
549
|
+
this.all.add(s);
|
|
550
|
+
void s.exited.then(() => this.all.delete(s));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private evict(): void {
|
|
554
|
+
const live = this.sessions.filter((s) => !s.dead);
|
|
555
|
+
this.sessions = live;
|
|
556
|
+
// Sessions in a provider call are never evicted, so the pool may briefly exceed maxSessions.
|
|
557
|
+
const idle = this.sessions.filter((s) => !this.active.has(s)).sort((a, b) => a.lastUsed - b.lastUsed);
|
|
558
|
+
while (this.sessions.length > this.opts.maxSessions && idle.length > 0) this.drop(idle.shift()!);
|
|
559
|
+
// Backstop: a process that left the pool without being closed, or a session whose busy flag
|
|
560
|
+
// outlived its call, is closed once more than maxSessions processes run outside provider calls.
|
|
561
|
+
const pooled = (s: ClaudeSession) => (this.sessions.includes(s) ? 1 : 0);
|
|
562
|
+
const stray = [...this.all].filter((s) => !s.dead && !this.active.has(s)).sort((a, b) => pooled(a) - pooled(b) || a.lastUsed - b.lastUsed);
|
|
563
|
+
while (stray.length > this.opts.maxSessions) this.drop(stray.shift()!);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private scheduleSweep(): void {
|
|
567
|
+
if (this.timer) return;
|
|
568
|
+
this.timer = setInterval(() => {
|
|
569
|
+
this.sweep();
|
|
570
|
+
if (this.all.size === 0 && this.timer) {
|
|
571
|
+
clearInterval(this.timer);
|
|
572
|
+
this.timer = undefined;
|
|
573
|
+
}
|
|
574
|
+
}, 60_000);
|
|
575
|
+
this.timer.unref();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Close processes idle longer than idleMs. */
|
|
579
|
+
sweep(now = Date.now()): void {
|
|
580
|
+
for (const s of [...this.all]) {
|
|
581
|
+
if (now - s.lastUsed <= this.opts.idleMs) continue;
|
|
582
|
+
// A pooled session parked on tool calls waits for pi's results; anything else idle goes,
|
|
583
|
+
// including a call that has had no event from Claude Code for that long (its reader then ends).
|
|
584
|
+
if (!this.active.has(s) && this.sessions.includes(s) && s.awaiting.size > 0) continue;
|
|
585
|
+
this.drop(s);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
shutdown(): void {
|
|
590
|
+
for (const s of new Set([...this.sessions, ...this.all])) this.drop(s);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Loaded on the first request, so a Pi that never uses these models never loads it. */
|
|
595
|
+
async function liveSessions(): Promise<NonNullable<ProviderOptions["createSession"]>> {
|
|
596
|
+
const { query } = await import("@anthropic-ai/claude-agent-sdk");
|
|
597
|
+
return (spec, chain, count) => new LiveSession(spec, chain, count, query);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function forkPersistedSession(sessionId: string, cwd: string, at: string): Promise<{ sessionId: string }> {
|
|
601
|
+
const { forkSession } = await import("@anthropic-ai/claude-agent-sdk");
|
|
602
|
+
return forkSession(sessionId, { dir: cwd, upToMessageId: at, title: "pi" });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function mapStop(reason: string | undefined): StopReason {
|
|
606
|
+
switch (reason) {
|
|
607
|
+
case "tool_use":
|
|
608
|
+
return "toolUse";
|
|
609
|
+
case "max_tokens":
|
|
610
|
+
case "model_context_window_exceeded":
|
|
611
|
+
return "length";
|
|
612
|
+
default:
|
|
613
|
+
return "stop";
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export type { TurnMessage };
|