@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.
- package/CHANGELOG.md +16 -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 +2 -2
- package/packages/modes/src/index.ts +3 -6
- 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
|
@@ -1,40 +1,53 @@
|
|
|
1
|
-
// ~/.pi/agent/
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
1
|
+
// The router section of ~/.pi/agent/suite.json. Global only: a repository must not choose where your code gets sent.
|
|
2
|
+
import { readSection, writeSection } from "../../../src/settings.ts";
|
|
4
3
|
|
|
5
4
|
/** The judge's model lives only in the judge's own config, so there's one answer to which model judges. */
|
|
6
5
|
export const ROLES = ["plan", "build", "reader", "subagent", "fallback"] as const;
|
|
7
6
|
export type Role = (typeof ROLES)[number];
|
|
8
7
|
|
|
8
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
9
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
10
|
+
/** A role left out uses the level selected with /effort. */
|
|
11
|
+
export type RoleThinking = Partial<Record<Role, ThinkingLevel>>;
|
|
12
|
+
|
|
9
13
|
export interface RouterConfig {
|
|
10
14
|
plan: string;
|
|
11
15
|
build: string;
|
|
12
16
|
reader?: string;
|
|
13
17
|
subagent?: string;
|
|
14
18
|
fallback?: string;
|
|
19
|
+
thinking?: RoleThinking;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
|
-
export type SavedConfig = Partial<Record<Role, string
|
|
22
|
+
export type SavedConfig = Partial<Record<Role, string>> & { thinking?: RoleThinking };
|
|
18
23
|
|
|
19
24
|
export const VIRTUAL_PROVIDER = "router";
|
|
20
25
|
export const VIRTUAL_ID = "auto";
|
|
21
26
|
|
|
22
|
-
export function configPath(agentDir: string): string {
|
|
23
|
-
return join(agentDir, "router.json");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
27
|
export function parseRef(ref: string): { provider: string; id: string } {
|
|
27
28
|
const at = ref.indexOf("/");
|
|
28
29
|
return { provider: ref.slice(0, at), id: ref.slice(at + 1) };
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
function parseThinking(raw: unknown): void {
|
|
33
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("suite.json router: thinking must be an object");
|
|
34
|
+
for (const [role, level] of Object.entries(raw)) {
|
|
35
|
+
if (!(ROLES as readonly string[]).includes(role)) throw new Error(`suite.json router: thinking has an unknown role "${role}". Use ${ROLES.join(", ")}`);
|
|
36
|
+
if (!(THINKING_LEVELS as readonly unknown[]).includes(level)) throw new Error(`suite.json router: thinking.${role} must be one of ${THINKING_LEVELS.join(", ")}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
/** Throws on anything it doesn't understand, so a typo can't quietly send work to the wrong model. */
|
|
32
41
|
export function parseConfig(raw: unknown): SavedConfig {
|
|
33
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("
|
|
42
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("suite.json router must be an object");
|
|
34
43
|
for (const [role, ref] of Object.entries(raw)) {
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
if (role === "thinking") {
|
|
45
|
+
parseThinking(ref);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!(ROLES as readonly string[]).includes(role)) throw new Error(`suite.json router: unknown role "${role}". Use ${ROLES.join(", ")}`);
|
|
49
|
+
if (typeof ref !== "string" || !/^[^/\s]+\/\S+$/.test(ref)) throw new Error(`suite.json router: ${role} must look like "provider/model"`);
|
|
50
|
+
if (ref.startsWith(`${VIRTUAL_PROVIDER}/`)) throw new Error(`suite.json router: ${role} can't be a virtual model`);
|
|
38
51
|
}
|
|
39
52
|
return raw as SavedConfig;
|
|
40
53
|
}
|
|
@@ -43,23 +56,11 @@ export function isComplete(saved: SavedConfig): saved is RouterConfig {
|
|
|
43
56
|
return saved.plan !== undefined && saved.build !== undefined;
|
|
44
57
|
}
|
|
45
58
|
|
|
46
|
-
/**
|
|
59
|
+
/** Nothing there is a router nobody has set up yet. Something broken throws. */
|
|
47
60
|
export function loadConfig(agentDir: string): SavedConfig {
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
text = readFileSync(configPath(agentDir), "utf8");
|
|
51
|
-
} catch (error) {
|
|
52
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
53
|
-
throw error;
|
|
54
|
-
}
|
|
55
|
-
return parseConfig(JSON.parse(text));
|
|
61
|
+
return parseConfig(readSection(agentDir, "router"));
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
export function saveConfig(agentDir: string, saved: SavedConfig): void {
|
|
59
|
-
|
|
60
|
-
const named = configPath(agentDir);
|
|
61
|
-
const path = existsSync(named) ? realpathSync(named) : named;
|
|
62
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
63
|
-
writeFileSync(temp, `${JSON.stringify(saved, null, 2)}\n`);
|
|
64
|
-
renameSync(temp, path);
|
|
65
|
+
writeSection(agentDir, "router", saved);
|
|
65
66
|
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
1
2
|
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
|
|
3
|
-
import { isComplete, loadConfig, parseConfig, parseRef, type Role, ROLES, type SavedConfig, saveConfig, VIRTUAL_ID, VIRTUAL_PROVIDER } from "./config.ts";
|
|
4
|
+
import { isComplete, loadConfig, parseConfig, parseRef, type Role, ROLES, type SavedConfig, saveConfig, THINKING_LEVELS, type ThinkingLevel, VIRTUAL_ID, VIRTUAL_PROVIDER } from "./config.ts";
|
|
4
5
|
import { judgeModel, setJudgeModel } from "./judge.ts";
|
|
5
6
|
import { chooseModel, type RouterState } from "./route.ts";
|
|
6
7
|
|
|
7
8
|
/** Other extensions emit these with an object, and whoever knows fills it in, whatever the load order. */
|
|
8
9
|
const READER_PROBE = "pi-router:reader-model";
|
|
9
10
|
const MODE_PROBE = "pi-modes:mode";
|
|
11
|
+
const SETTINGS_PROBE = "pi-suite:router-settings";
|
|
10
12
|
|
|
11
|
-
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
12
13
|
const VIRTUAL_API = "pi-virtual";
|
|
14
|
+
const SELECTED_LEVEL = "same as /effort";
|
|
13
15
|
|
|
14
16
|
const JUDGE = "judge";
|
|
15
17
|
const JUDGE_HELP = "decides permission asks for pi-permission-classifier";
|
|
@@ -46,7 +48,9 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
46
48
|
|
|
47
49
|
pi.events.on(READER_PROBE, (data) => {
|
|
48
50
|
if (!isComplete(saved)) return;
|
|
49
|
-
|
|
51
|
+
const probe = data as { model?: string; thinking?: ThinkingLevel };
|
|
52
|
+
probe.model = saved.reader ?? saved.build;
|
|
53
|
+
probe.thinking = saved.thinking?.[saved.reader === undefined ? "build" : "reader"];
|
|
50
54
|
});
|
|
51
55
|
|
|
52
56
|
function currentMode(): string | undefined {
|
|
@@ -56,8 +60,8 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
function register(): void {
|
|
59
|
-
//
|
|
60
|
-
if (registered
|
|
63
|
+
// Always listed. With nothing set or a broken file it stops with the reason, rather than Pi picking another model.
|
|
64
|
+
if (registered) return;
|
|
61
65
|
registered = true;
|
|
62
66
|
pi.registerVirtualModel<RouterState>({
|
|
63
67
|
provider: VIRTUAL_PROVIDER,
|
|
@@ -73,14 +77,14 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
73
77
|
subagent: process.env.PI_SUBAGENT_CHILD === "1",
|
|
74
78
|
state: request.state,
|
|
75
79
|
previous: refOf(request.previous?.model),
|
|
76
|
-
failed: request.failed && { model: refOf(request.failed.model) as string, errorMessage: request.failed.message.errorMessage },
|
|
80
|
+
failed: request.failed && { model: refOf(request.failed.model) as string, errorMessage: request.failed.message.errorMessage, thinking: request.failed.thinkingLevel },
|
|
77
81
|
messages: request.messages,
|
|
78
82
|
});
|
|
79
83
|
const { provider, id } = parseRef(choice.model);
|
|
80
84
|
const model = ctx.modelRegistry.find(provider, id);
|
|
81
85
|
if (model === undefined) throw new Error(`pi-router: ${choice.model} isn't a known model. Run /router`);
|
|
82
86
|
if (!ctx.modelRegistry.hasConfiguredAuth(model)) throw new Error(`pi-router: no login for ${choice.model}. Run /login or /router`);
|
|
83
|
-
return { model, thinkingLevel: request.thinkingLevel, state: choice.state };
|
|
87
|
+
return { model, thinkingLevel: choice.thinking ?? request.thinkingLevel, state: choice.state };
|
|
84
88
|
},
|
|
85
89
|
});
|
|
86
90
|
}
|
|
@@ -94,9 +98,15 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
94
98
|
ctx.ui.notify("The permission judge has no model of its own, so with router/auto it can't judge and every ask comes to you. Set judge in /router.", "warning");
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
function warnIfNotSetUp(ctx: ExtensionContext): void {
|
|
102
|
+
if (problem !== undefined || isComplete(saved) || !ctx.hasUI || refOf(ctx.model) !== `${VIRTUAL_PROVIDER}/${VIRTUAL_ID}`) return;
|
|
103
|
+
ctx.ui.notify("router/auto has no models yet. Run /router to choose plan and build.", "warning");
|
|
104
|
+
}
|
|
105
|
+
|
|
97
106
|
pi.on("session_start", (_event, ctx) => {
|
|
98
107
|
register();
|
|
99
108
|
if (problem !== undefined && ctx.hasUI) ctx.ui.notify(problem, "error");
|
|
109
|
+
warnIfNotSetUp(ctx);
|
|
100
110
|
warnAboutJudge(ctx);
|
|
101
111
|
});
|
|
102
112
|
// Pi keeps virtual models across a reload, so one left here would outlive this extension being turned off.
|
|
@@ -105,18 +115,45 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
105
115
|
registered = false;
|
|
106
116
|
pi.unregisterVirtualModel(VIRTUAL_PROVIDER, VIRTUAL_ID);
|
|
107
117
|
});
|
|
108
|
-
pi.on("model_select", (_event, ctx) =>
|
|
118
|
+
pi.on("model_select", (_event, ctx) => {
|
|
119
|
+
warnIfNotSetUp(ctx);
|
|
120
|
+
warnAboutJudge(ctx);
|
|
121
|
+
});
|
|
109
122
|
|
|
110
123
|
function setRole(role: Role, ref: string | undefined): void {
|
|
111
124
|
// Another session may have changed it since this one read it.
|
|
112
125
|
const next: SavedConfig = { ...loadConfig(agentDir) };
|
|
113
126
|
if (ref === undefined) delete next[role];
|
|
114
127
|
else next[role] = ref;
|
|
128
|
+
// Another model may not have the same levels, so this role's level is chosen again.
|
|
129
|
+
if (next.thinking !== undefined) {
|
|
130
|
+
const { [role]: _dropped, ...others } = next.thinking;
|
|
131
|
+
if (Object.keys(others).length > 0) next.thinking = others;
|
|
132
|
+
else delete next.thinking;
|
|
133
|
+
}
|
|
115
134
|
saved = parseConfig(next);
|
|
116
135
|
saveConfig(agentDir, saved);
|
|
117
136
|
register();
|
|
118
137
|
}
|
|
119
138
|
|
|
139
|
+
function setThinking(role: Role, level: ThinkingLevel): void {
|
|
140
|
+
const next: SavedConfig = { ...loadConfig(agentDir) };
|
|
141
|
+
next.thinking = { ...next.thinking, [role]: level };
|
|
142
|
+
saved = parseConfig(next);
|
|
143
|
+
saveConfig(agentDir, saved);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Offers only what the chosen model can do. A model with no levels to choose has nothing to ask. */
|
|
147
|
+
async function chooseThinking(ctx: ExtensionContext, role: Role, ref: string): Promise<void> {
|
|
148
|
+
const { provider, id } = parseRef(ref);
|
|
149
|
+
const model = ctx.modelRegistry.find(provider, id);
|
|
150
|
+
const levels = model === undefined ? [] : getSupportedThinkingLevels(model);
|
|
151
|
+
if (levels.length < 2) return;
|
|
152
|
+
const picked = await ctx.ui.select(`Reasoning for ${role} on ${ref}`, [SELECTED_LEVEL, ...levels]);
|
|
153
|
+
if (picked === undefined || picked === SELECTED_LEVEL) return;
|
|
154
|
+
setThinking(role, picked as ThinkingLevel);
|
|
155
|
+
}
|
|
156
|
+
|
|
120
157
|
async function chooseJudge(ctx: ExtensionContext, models: readonly string[]): Promise<void> {
|
|
121
158
|
const picked = await ctx.ui.select(`${JUDGE} ${JUDGE_HELP}`, ["not set", ...models]);
|
|
122
159
|
if (picked === undefined) return;
|
|
@@ -131,7 +168,7 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
131
168
|
async function chooseRoles(ctx: ExtensionContext): Promise<void> {
|
|
132
169
|
if (problem !== undefined) {
|
|
133
170
|
// Saving over it would throw away whatever the file held.
|
|
134
|
-
ctx.ui.notify(`${problem}. Fix
|
|
171
|
+
ctx.ui.notify(`${problem}. Fix the router section of ~/.pi/agent/suite.json, then /reload.`, "error");
|
|
135
172
|
return;
|
|
136
173
|
}
|
|
137
174
|
const models = ctx.modelRegistry.getAvailable().filter((model) => model.api !== VIRTUAL_API).map((model) => `${model.provider}/${model.id}`);
|
|
@@ -141,7 +178,11 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
141
178
|
}
|
|
142
179
|
for (;;) {
|
|
143
180
|
const judge = judgeModel(agentDir);
|
|
144
|
-
const
|
|
181
|
+
const shown = (role: Role): string => {
|
|
182
|
+
const level = saved.thinking?.[role];
|
|
183
|
+
return `${saved[role] ?? UNSET[role] ?? "not set"}${saved[role] !== undefined && level !== undefined ? ` · ${level}` : ""}`;
|
|
184
|
+
};
|
|
185
|
+
const rows = [...ROLES.map((role) => `${role}: ${shown(role)}`), `${JUDGE}: ${judge ?? (judge === null ? "not set" : "not installed")}`];
|
|
145
186
|
const row = await ctx.ui.select("Router: which role? (Esc when done)", rows);
|
|
146
187
|
if (row === undefined) break;
|
|
147
188
|
const role = ROLES[rows.indexOf(row)];
|
|
@@ -153,15 +194,22 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
153
194
|
const picked = await ctx.ui.select(`${role} ${ROLE_HELP[role]}`, [...(unset === undefined ? [] : [unset]), ...models]);
|
|
154
195
|
if (picked === undefined) continue;
|
|
155
196
|
try {
|
|
156
|
-
|
|
197
|
+
const ref = picked === unset ? undefined : picked;
|
|
198
|
+
setRole(role, ref);
|
|
199
|
+
if (ref !== undefined) await chooseThinking(ctx, role, ref);
|
|
157
200
|
} catch (error) {
|
|
158
201
|
ctx.ui.notify((error as Error).message, "error");
|
|
159
202
|
}
|
|
160
203
|
}
|
|
161
|
-
if (isComplete(saved)) ctx.ui.notify(
|
|
162
|
-
else ctx.ui.notify(
|
|
204
|
+
if (!isComplete(saved)) ctx.ui.notify("router/auto needs both plan and build before it can answer.", "warning");
|
|
205
|
+
else if (refOf(ctx.model) !== `${VIRTUAL_PROVIDER}/${VIRTUAL_ID}`) ctx.ui.notify(`Choose ${VIRTUAL_PROVIDER}/${VIRTUAL_ID} in /model to use these.`, "info");
|
|
163
206
|
}
|
|
164
207
|
|
|
208
|
+
// /suite opens this screen too, without knowing how it works.
|
|
209
|
+
pi.events.on(SETTINGS_PROBE, (data) => {
|
|
210
|
+
(data as { open?: (ctx: ExtensionContext) => Promise<void> }).open = chooseRoles;
|
|
211
|
+
});
|
|
212
|
+
|
|
165
213
|
pi.registerCommand("router", {
|
|
166
214
|
description: "Choose which model does what for router/auto",
|
|
167
215
|
handler: async (_args, ctx) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Which real model answers a request. Pure, so every rule can be tested without Pi.
|
|
2
|
-
import type { RouterConfig } from "./config.ts";
|
|
2
|
+
import type { Role, RouterConfig, ThinkingLevel } from "./config.ts";
|
|
3
3
|
|
|
4
4
|
export interface RouterState {
|
|
5
5
|
phase: "plan" | "build";
|
|
@@ -15,12 +15,14 @@ export interface RouteInput {
|
|
|
15
15
|
subagent: boolean;
|
|
16
16
|
state: RouterState | undefined;
|
|
17
17
|
previous: string | undefined;
|
|
18
|
-
failed: { model: string; errorMessage: string | undefined } | undefined;
|
|
18
|
+
failed: { model: string; errorMessage: string | undefined; thinking?: ThinkingLevel } | undefined;
|
|
19
19
|
messages: readonly { role: string; toolName?: string; isError?: boolean }[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface RouteChoice {
|
|
23
23
|
model: string;
|
|
24
|
+
/** Only when the role has its own. Otherwise the level selected with /effort applies. */
|
|
25
|
+
thinking?: ThinkingLevel;
|
|
24
26
|
/** Only when it changed, because Pi stores every new object it gets. */
|
|
25
27
|
state?: RouterState;
|
|
26
28
|
}
|
|
@@ -42,9 +44,13 @@ function nextState(input: RouteInput): RouterState {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export function chooseModel(config: RouterConfig, input: RouteInput): RouteChoice {
|
|
45
|
-
const
|
|
47
|
+
const forRole = (role: Role, model: string): Pick<RouteChoice, "model" | "thinking"> => {
|
|
48
|
+
const thinking = config.thinking?.[role];
|
|
49
|
+
return thinking === undefined ? { model } : { model, thinking };
|
|
50
|
+
};
|
|
51
|
+
const home = input.subagent && config.subagent !== undefined ? forRole("subagent", config.subagent) : forRole("build", config.build);
|
|
46
52
|
// Compaction summaries, and any other extension's call that names the virtual model.
|
|
47
|
-
if (input.reason === "direct") return
|
|
53
|
+
if (input.reason === "direct") return home;
|
|
48
54
|
|
|
49
55
|
// Worked out first, so an edit made on the fallback or during a retry still counts.
|
|
50
56
|
const state = input.subagent ? input.state : nextState(input);
|
|
@@ -52,16 +58,16 @@ export function chooseModel(config: RouterConfig, input: RouteInput): RouteChoic
|
|
|
52
58
|
|
|
53
59
|
if (input.reason === "retry" && input.failed) {
|
|
54
60
|
const overloaded = OVERLOADED.test(input.failed.errorMessage ?? "");
|
|
55
|
-
|
|
56
|
-
return { model:
|
|
61
|
+
if (overloaded && config.fallback !== undefined && config.fallback !== input.failed.model) return { ...forRole("fallback", config.fallback), ...changed };
|
|
62
|
+
return { model: input.failed.model, ...(input.failed.thinking === undefined ? {} : { thinking: input.failed.thinking }), ...changed };
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
// Back on the first choice at the next message, not on every tool call in between.
|
|
60
66
|
if (input.reason === "continuation" && input.previous !== undefined && input.previous === config.fallback) {
|
|
61
|
-
return {
|
|
67
|
+
return { ...forRole("fallback", input.previous), ...changed };
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
if (input.subagent) return
|
|
65
|
-
const
|
|
66
|
-
return {
|
|
70
|
+
if (input.subagent) return home;
|
|
71
|
+
const choice = input.mode === "plan" || state?.phase === "plan" ? forRole("plan", config.plan) : forRole("build", config.build);
|
|
72
|
+
return { ...choice, ...changed };
|
|
67
73
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jarno Keskikangas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# pi-subscription
|
|
2
|
+
|
|
3
|
+
Claude models in Pi on your Claude login, through Anthropic's Agent SDK. Pi keeps its own agent loop, tools, sessions and compaction, and Claude Code only carries the requests. It asks before anything is billed as extra usage.
|
|
4
|
+
|
|
5
|
+
Based on [pi-claude-subscription](https://github.com/jkeskikangas/pi-claude-subscription) by Jarno Keskikangas, used under the MIT license.
|
|
6
|
+
|
|
7
|
+
## Use it
|
|
8
|
+
|
|
9
|
+
This part is off until you run `/suite on subscription`, because its models count as logged in whether you are or not, and because of the terms below. Log in to Claude Code (`claude`, then `/login`), then pick a `claude-sdk/...` model in `/model`, such as `claude-sdk/claude-sonnet-5-5`. `/suite off subscription` removes them and moves you to another model.
|
|
10
|
+
|
|
11
|
+
It uses Claude Code's login, never Pi's. So that nothing else decides where your requests or login go, it removes `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_CUSTOM_HEADERS` and the Bedrock, Vertex and Foundry switches from Claude Code's environment.
|
|
12
|
+
|
|
13
|
+
## Extra usage
|
|
14
|
+
|
|
15
|
+
When Claude Code reports that requests are billed as extra usage, you're asked before going on:
|
|
16
|
+
|
|
17
|
+
- **No, stop:** nothing more is sent, and each new message asks again, until the limit resets or a new session starts.
|
|
18
|
+
- **Yes, bill extra usage for this session:** it goes on, and doesn't ask again until requests are back on your plan, or a new session starts, including `/new`, `/clear` and `/reload`.
|
|
19
|
+
|
|
20
|
+
The report normally comes after the reply, so that reply is kept and the next request asks before sending anything. If a report arrives while a reply is being written, the reply is stopped. After a yes it runs again if nothing had appeared, and otherwise you send your message again.
|
|
21
|
+
|
|
22
|
+
With nobody to ask, as in `pi -p` or a subagent, the answer is no.
|
|
23
|
+
|
|
24
|
+
What this can't do:
|
|
25
|
+
|
|
26
|
+
- **The first request over the limit is billed.** Claude Code only reports billing with a response, and in testing that report came after the whole reply, including a slow one. So one reply can be billed before you're asked, or more if a tool loop has requests in flight. Your Claude settings are the hard limit: leave extra usage off, or set a monthly cap, if that matters.
|
|
27
|
+
- **It goes by what Claude Code reports.** It's been tested with the real Claude Code binary against a fake server that sends those reports, not with a real bill. Check your usage page after your first requests.
|
|
28
|
+
- **The costs Pi shows are estimates** at API prices, not what your plan charges.
|
|
29
|
+
|
|
30
|
+
A request can also be refused with `Third-party apps now draw from your extra usage`. Nothing runs then, and the error says what to do.
|
|
31
|
+
|
|
32
|
+
## Terms
|
|
33
|
+
|
|
34
|
+
Anthropic's [legal page](https://code.claude.com/docs/en/legal-and-compliance) says subscription logins are for ordinary use of Claude Code and Anthropic's own apps, and that products built on the Agent SDK should use API keys. It doesn't address a personal extension like this one, and it says Anthropic may enforce its rules without notice. So this could stop working, and the risk is to your account. An [API key](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) is the clear-cut alternative.
|
|
35
|
+
|
|
36
|
+
## System prompt
|
|
37
|
+
|
|
38
|
+
For `claude-sdk` models, Pi's default prompt is replaced by one written for Claude. It says it's working in Pi, and keeps every tool and guideline from Pi and other extensions, along with context files, skills and the working directory. Your own `SYSTEM.md` always wins.
|
|
39
|
+
|
|
40
|
+
Requests use Pi's default prompt instead with `router/auto`, in `plan` and `read-only` modes, and when the memory part has notes to add, because those build on Pi's prompt.
|
|
41
|
+
|
|
42
|
+
Anthropic's billing has been seen to depend on the prompt: upstream saw Pi's default prompt refused where this one ran on the plan. So expect the refusal above, or the question about extra usage, in those cases. To always use Pi's prompt, set **System prompt** to `pi` in `/suite`, or `PI_CLAUDE_SDK_PROMPT=pi`.
|
|
43
|
+
|
|
44
|
+
## Settings
|
|
45
|
+
|
|
46
|
+
All optional, in `/suite` or under `subscription` in `~/.pi/agent/suite.json`:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{ "subscription": { "pathToClaudeCodeExecutable": "/opt/homebrew/bin/claude", "maxSessions": 3, "idleMinutes": 20, "promptMode": "claude" } }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
There's no setting per project, so a repository can't choose which program runs. `PI_CLAUDE_SDK_CLAUDE_PATH` overrides the executable, and `PI_CLAUDE_SDK_DEBUG=/path/log` writes how each request was routed.
|
|
53
|
+
|
|
54
|
+
## What it leaves on disk
|
|
55
|
+
|
|
56
|
+
Conversations are copied into Claude Code's session files under `~/.claude/projects`, readable only by you, so they can show up in Claude Code's `/resume`. `~/.pi/agent/claude-sdk/anchors.jsonl` records where each conversation is in those files.
|
|
57
|
+
|
|
58
|
+
## Dependencies
|
|
59
|
+
|
|
60
|
+
It needs `@anthropic-ai/claude-agent-sdk`, pinned to an exact version and loaded only on the first request to one of these models. That package includes a Claude Code binary of about 270 MB for your platform. npm also installs what it lists as required, including the MCP SDK and through it `express` and `hono`. Nothing here loads those: this part has its own small tool server, and the Agent SDK bundles its own copy.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huseynsnmz/pi-subscription",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude models in Pi on your Claude login, asking before anything is billed as extra usage.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": true,
|
|
8
|
+
"scripts": {
|
|
9
|
+
"check": "tsc --noEmit",
|
|
10
|
+
"test": "node --test --test-timeout=20000 test/*.test.ts"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@earendil-works/pi-ai": "*",
|
|
14
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@earendil-works/pi-ai": "1.5.0",
|
|
18
|
+
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
19
|
+
"@types/node": "26.6.6",
|
|
20
|
+
"typebox": "1.4.3",
|
|
21
|
+
"typescript": "7.0.2"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a conversation state lives in Claude Code's own session files: after the assistant
|
|
7
|
+
* message `messageId` of session `sessionId`. Keyed by the pi transcript chain hash, so a pi
|
|
8
|
+
* session that diverges from a live Claude Code process (abort, branch, restart) resumes the
|
|
9
|
+
* exact bytes Claude Code sent before and keeps its prompt cache. `entryUuid` is the Claude
|
|
10
|
+
* Code transcript-entry uuid streamed with that message; anchors written before it was
|
|
11
|
+
* captured carry only the API message id and are resolved by scanning the transcript.
|
|
12
|
+
*/
|
|
13
|
+
export interface Anchor {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
messageId: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
entryUuid?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_ENTRIES = 20_000;
|
|
21
|
+
/** It names your folders and sessions. */
|
|
22
|
+
const PRIVATE = 0o600;
|
|
23
|
+
|
|
24
|
+
export class AnchorRegistry {
|
|
25
|
+
private map = new Map<string, Anchor>();
|
|
26
|
+
private loaded = false;
|
|
27
|
+
|
|
28
|
+
private readonly file: string;
|
|
29
|
+
|
|
30
|
+
constructor(file = join(getAgentDir(), "claude-sdk", "anchors.jsonl")) {
|
|
31
|
+
this.file = file;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private load(): void {
|
|
35
|
+
if (this.loaded) return;
|
|
36
|
+
this.loaded = true;
|
|
37
|
+
if (!existsSync(this.file)) return;
|
|
38
|
+
const lines = readFileSync(this.file, "utf8").split("\n");
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
try {
|
|
42
|
+
const { k, s, m, c, u } = JSON.parse(line);
|
|
43
|
+
this.map.set(k, { sessionId: s, messageId: m, cwd: c, ...(typeof u === "string" ? { entryUuid: u } : {}) });
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
if (lines.length > MAX_ENTRIES * 2) this.compact();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private compact(): void {
|
|
50
|
+
const entries = [...this.map.entries()].slice(-MAX_ENTRIES);
|
|
51
|
+
this.map = new Map(entries);
|
|
52
|
+
writeFileSync(this.file, entries.map(([k, a]) => line(k, a)).join(""), { mode: PRIVATE });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get(key: string): Anchor | undefined {
|
|
56
|
+
this.load();
|
|
57
|
+
return this.map.get(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
put(key: string, anchor: Anchor): void {
|
|
61
|
+
this.load();
|
|
62
|
+
this.map.delete(key);
|
|
63
|
+
this.map.set(key, anchor);
|
|
64
|
+
try {
|
|
65
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
66
|
+
appendFileSync(this.file, line(key, anchor), { mode: PRIVATE });
|
|
67
|
+
} catch {
|
|
68
|
+
// Persistence is an optimization; the in-memory map still serves this process.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function line(k: string, a: Anchor): string {
|
|
74
|
+
return `${JSON.stringify({ k, s: a.sessionId, m: a.messageId, c: a.cwd, ...(a.entryUuid ? { u: a.entryUuid } : {}) })}\n`;
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// How Claude Code says a request is billed. Its rate_limit_event arrives with the response, so the
|
|
2
|
+
// first such request can only be stopped once it has started. Later ones are asked about first.
|
|
3
|
+
export interface RateLimitInfo {
|
|
4
|
+
status?: string;
|
|
5
|
+
resetsAt?: number;
|
|
6
|
+
overageResetsAt?: number;
|
|
7
|
+
rateLimitType?: string;
|
|
8
|
+
isUsingOverage?: boolean;
|
|
9
|
+
overageInUse?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function billsExtraUsage(info: RateLimitInfo | undefined): boolean {
|
|
13
|
+
if (info === undefined) return false;
|
|
14
|
+
// Over the plan's limit the status is "rejected", yet the request is served from extra usage.
|
|
15
|
+
if (info.isUsingOverage === true) return true;
|
|
16
|
+
return info.status !== "rejected" && (info.overageInUse === true || info.rateLimitType === "overage");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const EXTRA_USAGE_QUESTION = "Claude says requests are now billed as extra usage, beyond your plan's limits. Nothing more is sent unless you allow it. Allow it?";
|
|
20
|
+
export const EXTRA_USAGE_NO = "No, stop";
|
|
21
|
+
export const EXTRA_USAGE_YES = "Yes, bill extra usage for this session";
|
|
22
|
+
|
|
23
|
+
export const EXTRA_USAGE_STOPPED = "Stopped, because requests are billed as extra usage and it wasn't approved. Anything used before it stopped may still be billed.";
|
|
24
|
+
export const EXTRA_USAGE_RESEND = "Extra usage is approved for this session. The reply had already started, so send your message again.";
|
|
25
|
+
|
|
26
|
+
/** Whether this report still holds. With no end time to go by, it holds until a request says otherwise. */
|
|
27
|
+
export function stillBilling(info: RateLimitInfo | undefined, nowSeconds: number): boolean {
|
|
28
|
+
if (info === undefined) return false;
|
|
29
|
+
const until = info.resetsAt;
|
|
30
|
+
return until === undefined || until > nowSeconds;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const THIRD_PARTY = /third-party apps now draw from your extra usage/i;
|
|
34
|
+
|
|
35
|
+
/** Claude's wording alone doesn't say what happened to the request, or what to do about it. */
|
|
36
|
+
export function explainError(text: string): string {
|
|
37
|
+
if (!THIRD_PARTY.test(text)) return text;
|
|
38
|
+
return `${text}\nAnthropic refused this request as third-party use. To go on, turn on extra usage in your Claude settings or choose another model. You'll be asked here before it's billed.`;
|
|
39
|
+
}
|