@huseynsnmz/pi-suite 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +13 -4
  3. package/package.json +12 -2
  4. package/packages/board/README.md +1 -1
  5. package/packages/board/package.json +2 -1
  6. package/packages/commands/README.md +1 -1
  7. package/packages/commands/src/index.ts +22 -0
  8. package/packages/frame/README.md +8 -2
  9. package/packages/frame/docs/reference.md +1 -1
  10. package/packages/frame/src/index.ts +8 -3
  11. package/packages/frame/src/kit/settings/files.ts +8 -8
  12. package/packages/frame/src/kit/settings/store.ts +3 -1
  13. package/packages/frame/src/ui/editor.ts +64 -7
  14. package/packages/frame/src/ui/history-search.ts +21 -0
  15. package/packages/frame/src/ui/pi-entries.ts +2 -0
  16. package/packages/frame/src/ui/thinking.ts +34 -0
  17. package/packages/memory/README.md +20 -1
  18. package/packages/memory/src/index.ts +60 -1
  19. package/packages/memory/src/mastermind.ts +110 -0
  20. package/packages/memory/src/playbook.ts +92 -0
  21. package/packages/modes/README.md +11 -3
  22. package/packages/modes/skills/review-permissions/SKILL.md +11 -2
  23. package/packages/modes/src/index.ts +29 -11
  24. package/packages/modes/src/judge-gate.ts +78 -7
  25. package/packages/modes/src/modes.ts +4 -1
  26. package/packages/modes/src/review/apply.mjs +1 -1
  27. package/packages/modes/src/review/collect.mjs +45 -2
  28. package/packages/modes/src/workdir.ts +105 -0
  29. package/packages/router/README.md +30 -9
  30. package/packages/router/src/config.ts +30 -28
  31. package/packages/router/src/index.ts +70 -13
  32. package/packages/router/src/route.ts +16 -10
  33. package/packages/subagent/LICENSE +21 -0
  34. package/packages/subagent/README.md +17 -0
  35. package/packages/subagent/package.json +22 -0
  36. package/packages/subagent/src/index.ts +76 -0
  37. package/packages/subscription/LICENSE +21 -0
  38. package/packages/subscription/README.md +60 -0
  39. package/packages/subscription/package.json +23 -0
  40. package/packages/subscription/src/anchors.ts +75 -0
  41. package/packages/subscription/src/billing.ts +39 -0
  42. package/packages/subscription/src/catalog.ts +310 -0
  43. package/packages/subscription/src/cc-sessions.ts +122 -0
  44. package/packages/subscription/src/config.ts +34 -0
  45. package/packages/subscription/src/convert.ts +161 -0
  46. package/packages/subscription/src/index.ts +69 -0
  47. package/packages/subscription/src/mcp.ts +78 -0
  48. package/packages/subscription/src/models.ts +65 -0
  49. package/packages/subscription/src/prompt.ts +57 -0
  50. package/packages/subscription/src/provider.ts +617 -0
  51. package/packages/subscription/src/session.ts +294 -0
  52. package/packages/subscription/src/util.ts +73 -0
  53. package/packages/web/README.md +5 -5
  54. package/packages/web/src/config.ts +13 -23
  55. package/packages/web/src/index.ts +11 -7
  56. package/packages/web/src/reader.ts +15 -4
  57. package/packages/web/src/validate.ts +2 -2
  58. package/src/index.ts +93 -11
  59. package/src/settings-ui.ts +63 -0
  60. package/src/settings.ts +117 -15
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Bash keeps its folder, the way a shell would: a `cd` at the start of a command carries over to later commands while
3
+ * it stays in the project. Pi starts a new shell for each command, so later ones get the prefix from here, before the
4
+ * permission system reads them. It follows a literal cd, so paths are judged from the right folder, and this works
5
+ * with whichever extension runs bash, such as pi-sandbox. Replacing the bash tool would turn that extension off.
6
+ */
7
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { applyChoices, checkChoices } from "./review/apply.mjs";
12
+ import { ruleFor } from "./review/collect.mjs";
13
+
14
+ const LEADING_CD = /^\s*cd\s+(?:'([^']*)'|"([^"$`\\]*)"|([^\s;&|`$<>()'"\\]+))(?=\s*(?:&&|;|\n|$))/;
15
+ /** Bare when it can be, as a rule like `cd /work/app/*` only matches the path as written. */
16
+ const quoted = (text: string) => (/^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`);
17
+
18
+ function folderAt(path: string): string | undefined {
19
+ try {
20
+ return statSync(path).isDirectory() ? realpathSync(path) : undefined;
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ }
25
+
26
+ /** Where a cd goes. A model often repeats a move it already made, so a path that isn't here is tried from the top and from one up. */
27
+ export function cdTarget(root: string, here: string | undefined, target: string): string | undefined {
28
+ if (target === "-") return undefined;
29
+ const path = target === "~" || target.startsWith("~/") ? join(homedir(), target.slice(1)) : target;
30
+ const from = here ?? root;
31
+ for (const base of [from, root, dirname(from)]) {
32
+ const folder = folderAt(resolve(base, path));
33
+ if (folder !== undefined) return folder;
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ /** The command to really run, and the folder it moves to if it starts with a cd. */
39
+ export function planCommand(root: string, here: string | undefined, command: string): { command: string; move?: string } {
40
+ const match = LEADING_CD.exec(command);
41
+ const move = match === null ? undefined : cdTarget(root, here, match[1] ?? match[2] ?? match[3] ?? "");
42
+ if (match !== null && move !== undefined) return { command: `cd ${quoted(move)}${command.slice(match[0].length)}`, move };
43
+ return { command: here === undefined ? command : `cd ${quoted(here)} && ${command}` };
44
+ }
45
+
46
+ export function isInside(root: string, folder: string): boolean {
47
+ const inside = relative(root, folder);
48
+ return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
49
+ }
50
+
51
+ /** A cd inside the project, as typed or as its full path. Nothing shorter is safe: the permission system doesn't follow a new file's name into the folder a cd reached. */
52
+ export function cdRules(root: string): string[] {
53
+ return [`cd ${root}`, `cd ${root}/*`];
54
+ }
55
+
56
+ /**
57
+ * The permission system judges each part of a chain, so with "*" asking, every `cd x && go test` asks about the cd.
58
+ * Moving inside the project does nothing by itself, so it's allowed for this project, once, with a backup. A cd
59
+ * anywhere else still asks. Whoever removes the rules keeps them removed. Returns what to tell the user.
60
+ */
61
+ export function addCdRules(agentDir: string, root: string): { handled: boolean; notice?: string } {
62
+ const path = join(agentDir, "extensions", "pi-permission-system", "config.json");
63
+ if (root === "/" || !existsSync(path)) return { handled: false };
64
+ try {
65
+ const bash = JSON.parse(readFileSync(path, "utf8")).permission?.bash;
66
+ const asks = bash === undefined || (typeof bash === "object" && bash !== null && ruleFor(bash, `cd ${root}/x`) === "ask");
67
+ if (!asks) return { handled: true };
68
+ const rules = cdRules(root);
69
+ if (checkChoices(agentDir, { bash: rules }).length > 0) return { handled: true }; // A path a rule can't hold.
70
+ applyChoices(agentDir, { bash: rules });
71
+ return { handled: true, notice: `pi-modes allowed cd inside this project in your bash rules, so you aren't asked about each one. The rest of each command, and a cd anywhere else, are still checked. Remove them from ${path} if you'd rather be asked.` };
72
+ } catch {
73
+ return { handled: false }; // Not ours to fix. It's tried again next start.
74
+ }
75
+ }
76
+
77
+ export function registerWorkdir(pi: ExtensionAPI) {
78
+ let here: string | undefined;
79
+ const moves = new Map<string, string>();
80
+ pi.on("session_start", () => {
81
+ here = undefined;
82
+ moves.clear();
83
+ });
84
+ pi.on("tool_result", (event, ctx) => {
85
+ const move = moves.get(event.toolCallId);
86
+ if (move === undefined) return undefined;
87
+ moves.delete(event.toolCallId);
88
+ if (event.isError) return undefined;
89
+ const root = realpathSync(ctx.cwd);
90
+ const stays = isInside(root, move);
91
+ here = stays && move !== root ? move : undefined;
92
+ // Told here rather than in the system prompt, which would cost every request and replace the subscription part's prompt.
93
+ const note = stays ? `Bash stays in ${move} for later commands, so don't repeat the cd.` : `Shell cwd was reset to ${root}`;
94
+ return { content: [...event.content, { type: "text" as const, text: `\n${note}` }] };
95
+ });
96
+ return {
97
+ folder: () => here,
98
+ /** Rewrites a bash call in place. */
99
+ apply(toolCallId: string, input: { command: string }, cwd: string): void {
100
+ const plan = planCommand(realpathSync(cwd), here, input.command);
101
+ input.command = plan.command;
102
+ if (plan.move !== undefined) moves.set(toolCallId, plan.move);
103
+ },
104
+ };
105
+ }
@@ -9,10 +9,12 @@ Adds `router/auto` to [Pi](https://pi.dev): one choice in `/model` that uses a s
9
9
  Requires Pi 1.5 or newer.
10
10
 
11
11
  ```bash
12
- pi install npm:@huseynsnmz/pi-suite@0.2.2
12
+ pi install npm:@huseynsnmz/pi-suite@0.2.4
13
13
  ```
14
14
 
15
- Log in to the providers you want with `/login`, run `/router` to choose a model for each role, then pick `router/auto` in `/model`.
15
+ Log in to the providers you want with `/login`, run `/router` to choose a model for each role, then pick `router/auto` in `/model`. It's always listed there, and until `plan` and `build` are set it stops with a message telling you to run `/router`.
16
+
17
+ In `/model`, `Enter` selects a model for this session and `Ctrl+S` saves it as your default for new sessions.
16
18
 
17
19
  ## Roles
18
20
 
@@ -21,21 +23,40 @@ Log in to the providers you want with `/login`, run `/router` to choose a model
21
23
  | `plan` | Reads and plans, up to the first edit, and everything in `plan` mode | Required |
22
24
  | `build` | Everything after that, and compaction summaries | Required |
23
25
  | `reader` | Reads web pages for [pi-web](https://github.com/huseynsnmz/pi-suite/tree/main/packages/web) | Same as `build` |
26
+ | `mastermind` | Reviews past conversations for `/mastermind`, and writes rules for the other models | Not set |
24
27
  | `judge` | Decides permission asks for `pi-permission-classifier` | See below |
25
28
  | `subagent` | Runs subagents that use `router/auto` | Same as `build` |
26
29
  | `fallback` | Takes over when a provider is overloaded or rate limited | Retries on the same model |
27
30
 
28
- `/router` saves them in `~/.pi/agent/router.json`, apart from `judge`, which goes in the judge's own config:
31
+ `/router` saves them under `router` in `~/.pi/agent/suite.json`, apart from `judge`, which goes in the judge's own config. `/suite` opens the same screen.
32
+
33
+ ```json
34
+ {
35
+ "router": {
36
+ "plan": "anthropic/claude-opus-5-5",
37
+ "build": "anthropic/claude-haiku-5-5",
38
+ "fallback": "openai/gpt-5.6-luna"
39
+ }
40
+ }
41
+ ```
42
+
43
+ There's no setting per project, so a repository can't choose where your code gets sent.
44
+
45
+ ## Reasoning per role
46
+
47
+ After you choose a model for a role, `/router` asks how much reasoning it should use, offering only the levels that model has. It's saved next to the roles:
29
48
 
30
49
  ```json
31
50
  {
32
- "plan": "anthropic/claude-opus-5-5",
33
- "build": "anthropic/claude-haiku-5-5",
34
- "fallback": "openai/gpt-5.6-luna"
51
+ "router": {
52
+ "plan": "anthropic/claude-opus-5-5",
53
+ "build": "anthropic/claude-haiku-5-5",
54
+ "thinking": { "plan": "high", "build": "low" }
55
+ }
35
56
  }
36
57
  ```
37
58
 
38
- There's no setting per project, so a repository can't choose where your code gets sent. The thinking level you pick with `/effort` applies to every role, and Pi lowers it for a model that can't do it.
59
+ A role with no level of its own uses the one you pick with `/effort`. Changing a role's model clears its level, because the new model may not have it. A retry keeps the level of the request that failed. `reader` and `subagent` with no model of their own use `build`'s level. `judge` has no level here, as it's set in the judge's own config.
39
60
 
40
61
  ## How it decides
41
62
 
@@ -50,7 +71,7 @@ There's no setting per project, so a repository can't choose where your code get
50
71
  - Moving from `plan` to `build` happens once in a session. It loses the prompt cache one time, as any change of model does.
51
72
  - The session stays on `build` after that. For a hard follow-up, switch to `plan` mode, or start a new session.
52
73
  - If a request fails because the provider is overloaded, it moves to `fallback` for the rest of that turn. The next message tries the first choice again. Any other failure retries on the same model.
53
- - A role whose model is missing or has no login stops that request with an error naming it, and so does a broken `router.json`. The router doesn't pick another model for you.
74
+ - A role whose model is missing or has no login stops that request with an error naming it, and so do broken router settings. The router doesn't pick another model for you.
54
75
  - Leaving `plan` mode counts as finishing planning, whether you approved a plan or changed mode.
55
76
  - The roles only apply while `router/auto` is selected. On any other model, nothing goes to them, and pages are read as if the router weren't there.
56
77
  - `/suite off router` moves the session to the model that last answered. If `router/auto` is your saved default, later sessions use another available model, as Pi does for any model that no longer exists.
@@ -61,7 +82,7 @@ The phase follows the session tree, so a branch keeps its own.
61
82
 
62
83
  **`modes`:** tells the router the mode. With it off, the router only goes by the first edit.
63
84
 
64
- **`web`:** your `readerModel` in `web.json` still wins over the `reader` role.
85
+ **`web`:** your `readerModel` in the web settings still wins over the `reader` role.
65
86
 
66
87
  **`frame`:** shows who answered, such as `Auto → Haiku`. Pi's own footer already does.
67
88
 
@@ -1,40 +1,54 @@
1
- // ~/.pi/agent/router.json. Global only: a repository must not choose where your code gets sent.
2
- import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
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
- export const ROLES = ["plan", "build", "reader", "subagent", "fallback"] as const;
5
+ export const ROLES = ["plan", "build", "reader", "subagent", "fallback", "mastermind"] 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
+ mastermind?: string;
20
+ thinking?: RoleThinking;
15
21
  }
16
22
 
17
- export type SavedConfig = Partial<Record<Role, string>>;
23
+ export type SavedConfig = Partial<Record<Role, string>> & { thinking?: RoleThinking };
18
24
 
19
25
  export const VIRTUAL_PROVIDER = "router";
20
26
  export const VIRTUAL_ID = "auto";
21
27
 
22
- export function configPath(agentDir: string): string {
23
- return join(agentDir, "router.json");
24
- }
25
-
26
28
  export function parseRef(ref: string): { provider: string; id: string } {
27
29
  const at = ref.indexOf("/");
28
30
  return { provider: ref.slice(0, at), id: ref.slice(at + 1) };
29
31
  }
30
32
 
33
+ function parseThinking(raw: unknown): void {
34
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("suite.json router: thinking must be an object");
35
+ for (const [role, level] of Object.entries(raw)) {
36
+ if (!(ROLES as readonly string[]).includes(role)) throw new Error(`suite.json router: thinking has an unknown role "${role}". Use ${ROLES.join(", ")}`);
37
+ if (!(THINKING_LEVELS as readonly unknown[]).includes(level)) throw new Error(`suite.json router: thinking.${role} must be one of ${THINKING_LEVELS.join(", ")}`);
38
+ }
39
+ }
40
+
31
41
  /** Throws on anything it doesn't understand, so a typo can't quietly send work to the wrong model. */
32
42
  export function parseConfig(raw: unknown): SavedConfig {
33
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("router.json must be an object");
43
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("suite.json router must be an object");
34
44
  for (const [role, ref] of Object.entries(raw)) {
35
- if (!(ROLES as readonly string[]).includes(role)) throw new Error(`router.json: unknown role "${role}". Use ${ROLES.join(", ")}`);
36
- if (typeof ref !== "string" || !/^[^/\s]+\/\S+$/.test(ref)) throw new Error(`router.json: ${role} must look like "provider/model"`);
37
- if (ref.startsWith(`${VIRTUAL_PROVIDER}/`)) throw new Error(`router.json: ${role} can't be a virtual model`);
45
+ if (role === "thinking") {
46
+ parseThinking(ref);
47
+ continue;
48
+ }
49
+ if (!(ROLES as readonly string[]).includes(role)) throw new Error(`suite.json router: unknown role "${role}". Use ${ROLES.join(", ")}`);
50
+ if (typeof ref !== "string" || !/^[^/\s]+\/\S+$/.test(ref)) throw new Error(`suite.json router: ${role} must look like "provider/model"`);
51
+ if (ref.startsWith(`${VIRTUAL_PROVIDER}/`)) throw new Error(`suite.json router: ${role} can't be a virtual model`);
38
52
  }
39
53
  return raw as SavedConfig;
40
54
  }
@@ -43,23 +57,11 @@ export function isComplete(saved: SavedConfig): saved is RouterConfig {
43
57
  return saved.plan !== undefined && saved.build !== undefined;
44
58
  }
45
59
 
46
- /** An absent file is a router nobody has set up yet. A broken one throws. */
60
+ /** Nothing there is a router nobody has set up yet. Something broken throws. */
47
61
  export function loadConfig(agentDir: string): SavedConfig {
48
- let text: string;
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));
62
+ return parseConfig(readSection(agentDir, "router"));
56
63
  }
57
64
 
58
65
  export function saveConfig(agentDir: string, saved: SavedConfig): void {
59
- // A linked file, as in a dotfiles setup, stays linked.
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);
66
+ writeSection(agentDir, "router", saved);
65
67
  }
@@ -1,15 +1,18 @@
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";
10
+ const MASTERMIND_PROBE = "pi-router:mastermind-model";
9
11
  const MODE_PROBE = "pi-modes:mode";
12
+ const SETTINGS_PROBE = "pi-suite:router-settings";
10
13
 
11
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
12
14
  const VIRTUAL_API = "pi-virtual";
15
+ const SELECTED_LEVEL = "same as /effort";
13
16
 
14
17
  const JUDGE = "judge";
15
18
  const JUDGE_HELP = "decides permission asks for pi-permission-classifier";
@@ -19,6 +22,7 @@ const ROLE_HELP: Record<Role, string> = {
19
22
  reader: "reads web pages for pi-web",
20
23
  subagent: "runs subagents that use this virtual model",
21
24
  fallback: "takes over when a provider is overloaded",
25
+ mastermind: "reviews past conversations for /mastermind, and writes rules for the other models",
22
26
  };
23
27
 
24
28
  /** What a role shows, and offers to go back to, with no model of its own. */
@@ -26,6 +30,7 @@ const UNSET: Partial<Record<Role, string>> = {
26
30
  reader: "same as build",
27
31
  subagent: "same as build",
28
32
  fallback: "none",
33
+ mastermind: "none",
29
34
  };
30
35
 
31
36
  function refOf(model: { provider: string; id: string } | undefined): string | undefined {
@@ -46,7 +51,15 @@ export default function piRouter(pi: ExtensionAPI): void {
46
51
 
47
52
  pi.events.on(READER_PROBE, (data) => {
48
53
  if (!isComplete(saved)) return;
49
- (data as { model?: string }).model = saved.reader ?? saved.build;
54
+ const probe = data as { model?: string; thinking?: ThinkingLevel };
55
+ probe.model = saved.reader ?? saved.build;
56
+ probe.thinking = saved.thinking?.[saved.reader === undefined ? "build" : "reader"];
57
+ });
58
+
59
+ pi.events.on(MASTERMIND_PROBE, (data) => {
60
+ const probe = data as { model?: string; thinking?: ThinkingLevel };
61
+ probe.model = saved.mastermind;
62
+ probe.thinking = saved.thinking?.mastermind;
50
63
  });
51
64
 
52
65
  function currentMode(): string | undefined {
@@ -56,8 +69,8 @@ export default function piRouter(pi: ExtensionAPI): void {
56
69
  }
57
70
 
58
71
  function register(): void {
59
- // A broken file still registers, so router/auto stops with the reason rather than Pi picking another model.
60
- if (registered || (!isComplete(saved) && problem === undefined)) return;
72
+ // Always listed. With nothing set or a broken file it stops with the reason, rather than Pi picking another model.
73
+ if (registered) return;
61
74
  registered = true;
62
75
  pi.registerVirtualModel<RouterState>({
63
76
  provider: VIRTUAL_PROVIDER,
@@ -73,14 +86,14 @@ export default function piRouter(pi: ExtensionAPI): void {
73
86
  subagent: process.env.PI_SUBAGENT_CHILD === "1",
74
87
  state: request.state,
75
88
  previous: refOf(request.previous?.model),
76
- failed: request.failed && { model: refOf(request.failed.model) as string, errorMessage: request.failed.message.errorMessage },
89
+ failed: request.failed && { model: refOf(request.failed.model) as string, errorMessage: request.failed.message.errorMessage, thinking: request.failed.thinkingLevel },
77
90
  messages: request.messages,
78
91
  });
79
92
  const { provider, id } = parseRef(choice.model);
80
93
  const model = ctx.modelRegistry.find(provider, id);
81
94
  if (model === undefined) throw new Error(`pi-router: ${choice.model} isn't a known model. Run /router`);
82
95
  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 };
96
+ return { model, thinkingLevel: choice.thinking ?? request.thinkingLevel, state: choice.state };
84
97
  },
85
98
  });
86
99
  }
@@ -94,9 +107,15 @@ export default function piRouter(pi: ExtensionAPI): void {
94
107
  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
108
  }
96
109
 
110
+ function warnIfNotSetUp(ctx: ExtensionContext): void {
111
+ if (problem !== undefined || isComplete(saved) || !ctx.hasUI || refOf(ctx.model) !== `${VIRTUAL_PROVIDER}/${VIRTUAL_ID}`) return;
112
+ ctx.ui.notify("router/auto has no models yet. Run /router to choose plan and build.", "warning");
113
+ }
114
+
97
115
  pi.on("session_start", (_event, ctx) => {
98
116
  register();
99
117
  if (problem !== undefined && ctx.hasUI) ctx.ui.notify(problem, "error");
118
+ warnIfNotSetUp(ctx);
100
119
  warnAboutJudge(ctx);
101
120
  });
102
121
  // Pi keeps virtual models across a reload, so one left here would outlive this extension being turned off.
@@ -105,18 +124,45 @@ export default function piRouter(pi: ExtensionAPI): void {
105
124
  registered = false;
106
125
  pi.unregisterVirtualModel(VIRTUAL_PROVIDER, VIRTUAL_ID);
107
126
  });
108
- pi.on("model_select", (_event, ctx) => warnAboutJudge(ctx));
127
+ pi.on("model_select", (_event, ctx) => {
128
+ warnIfNotSetUp(ctx);
129
+ warnAboutJudge(ctx);
130
+ });
109
131
 
110
132
  function setRole(role: Role, ref: string | undefined): void {
111
133
  // Another session may have changed it since this one read it.
112
134
  const next: SavedConfig = { ...loadConfig(agentDir) };
113
135
  if (ref === undefined) delete next[role];
114
136
  else next[role] = ref;
137
+ // Another model may not have the same levels, so this role's level is chosen again.
138
+ if (next.thinking !== undefined) {
139
+ const { [role]: _dropped, ...others } = next.thinking;
140
+ if (Object.keys(others).length > 0) next.thinking = others;
141
+ else delete next.thinking;
142
+ }
115
143
  saved = parseConfig(next);
116
144
  saveConfig(agentDir, saved);
117
145
  register();
118
146
  }
119
147
 
148
+ function setThinking(role: Role, level: ThinkingLevel): void {
149
+ const next: SavedConfig = { ...loadConfig(agentDir) };
150
+ next.thinking = { ...next.thinking, [role]: level };
151
+ saved = parseConfig(next);
152
+ saveConfig(agentDir, saved);
153
+ }
154
+
155
+ /** Offers only what the chosen model can do. A model with no levels to choose has nothing to ask. */
156
+ async function chooseThinking(ctx: ExtensionContext, role: Role, ref: string): Promise<void> {
157
+ const { provider, id } = parseRef(ref);
158
+ const model = ctx.modelRegistry.find(provider, id);
159
+ const levels = model === undefined ? [] : getSupportedThinkingLevels(model);
160
+ if (levels.length < 2) return;
161
+ const picked = await ctx.ui.select(`Reasoning for ${role} on ${ref}`, [SELECTED_LEVEL, ...levels]);
162
+ if (picked === undefined || picked === SELECTED_LEVEL) return;
163
+ setThinking(role, picked as ThinkingLevel);
164
+ }
165
+
120
166
  async function chooseJudge(ctx: ExtensionContext, models: readonly string[]): Promise<void> {
121
167
  const picked = await ctx.ui.select(`${JUDGE} ${JUDGE_HELP}`, ["not set", ...models]);
122
168
  if (picked === undefined) return;
@@ -131,7 +177,7 @@ export default function piRouter(pi: ExtensionAPI): void {
131
177
  async function chooseRoles(ctx: ExtensionContext): Promise<void> {
132
178
  if (problem !== undefined) {
133
179
  // Saving over it would throw away whatever the file held.
134
- ctx.ui.notify(`${problem}. Fix or delete ~/.pi/agent/router.json, then /reload.`, "error");
180
+ ctx.ui.notify(`${problem}. Fix the router section of ~/.pi/agent/suite.json, then /reload.`, "error");
135
181
  return;
136
182
  }
137
183
  const models = ctx.modelRegistry.getAvailable().filter((model) => model.api !== VIRTUAL_API).map((model) => `${model.provider}/${model.id}`);
@@ -141,7 +187,11 @@ export default function piRouter(pi: ExtensionAPI): void {
141
187
  }
142
188
  for (;;) {
143
189
  const judge = judgeModel(agentDir);
144
- const rows = [...ROLES.map((role) => `${role}: ${saved[role] ?? UNSET[role] ?? "not set"}`), `${JUDGE}: ${judge ?? (judge === null ? "not set" : "not installed")}`];
190
+ const shown = (role: Role): string => {
191
+ const level = saved.thinking?.[role];
192
+ return `${saved[role] ?? UNSET[role] ?? "not set"}${saved[role] !== undefined && level !== undefined ? ` · ${level}` : ""}`;
193
+ };
194
+ const rows = [...ROLES.map((role) => `${role}: ${shown(role)}`), `${JUDGE}: ${judge ?? (judge === null ? "not set" : "not installed")}`];
145
195
  const row = await ctx.ui.select("Router: which role? (Esc when done)", rows);
146
196
  if (row === undefined) break;
147
197
  const role = ROLES[rows.indexOf(row)];
@@ -153,15 +203,22 @@ export default function piRouter(pi: ExtensionAPI): void {
153
203
  const picked = await ctx.ui.select(`${role} ${ROLE_HELP[role]}`, [...(unset === undefined ? [] : [unset]), ...models]);
154
204
  if (picked === undefined) continue;
155
205
  try {
156
- setRole(role, picked === unset ? undefined : picked);
206
+ const ref = picked === unset ? undefined : picked;
207
+ setRole(role, ref);
208
+ if (ref !== undefined) await chooseThinking(ctx, role, ref);
157
209
  } catch (error) {
158
210
  ctx.ui.notify((error as Error).message, "error");
159
211
  }
160
212
  }
161
- if (isComplete(saved)) ctx.ui.notify(`Choose ${VIRTUAL_PROVIDER}/${VIRTUAL_ID} in /model to use these.`, "info");
162
- else ctx.ui.notify("The router needs both plan and build before router/auto appears.", "warning");
213
+ if (!isComplete(saved)) ctx.ui.notify("router/auto needs both plan and build before it can answer.", "warning");
214
+ else if (refOf(ctx.model) !== `${VIRTUAL_PROVIDER}/${VIRTUAL_ID}`) ctx.ui.notify(`Choose ${VIRTUAL_PROVIDER}/${VIRTUAL_ID} in /model to use these.`, "info");
163
215
  }
164
216
 
217
+ // /suite opens this screen too, without knowing how it works.
218
+ pi.events.on(SETTINGS_PROBE, (data) => {
219
+ (data as { open?: (ctx: ExtensionContext) => Promise<void> }).open = chooseRoles;
220
+ });
221
+
165
222
  pi.registerCommand("router", {
166
223
  description: "Choose which model does what for router/auto",
167
224
  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 home = input.subagent ? (config.subagent ?? config.build) : config.build;
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 { model: home };
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
- const moves = overloaded && config.fallback !== undefined && config.fallback !== input.failed.model;
56
- return { model: moves ? (config.fallback as string) : input.failed.model, ...changed };
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 { model: input.previous, ...changed };
67
+ return { ...forRole("fallback", input.previous), ...changed };
62
68
  }
63
69
 
64
- if (input.subagent) return { model: home };
65
- const model = input.mode === "plan" || state?.phase === "plan" ? config.plan : config.build;
66
- return { model, ...changed };
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 Hüseyin Sönmez
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,17 @@
1
+ # pi-subagent
2
+
3
+ Part of [pi-suite](../../README.md). Turn it off with `/suite off subagent`.
4
+
5
+ One tool, `agent`, that hands a task to a fresh copy of Pi and gives back its final answer as text. Wide searches, heavy reading and independent reviews happen in their context rather than yours. There's nothing to set up, and no agents to define.
6
+
7
+ | `type` | What it can do |
8
+ | --- | --- |
9
+ | `explore` (the default) | Only `read`, `grep`, `find` and `ls`. Also allowed in `plan` and `read-only` |
10
+ | `general` | Everything, under your permission rules. Blocked in `plan` and `read-only` |
11
+
12
+ - **Parallel:** several calls in one message run at once.
13
+ - **Model:** it uses the session's model and reasoning. With `router/auto`, that's the `subagent` role in `/router`, so a cheaper model can do the reading.
14
+ - **Permissions:** a child's asks come to your session, and `auto` carries over to it.
15
+ - **Limits:** a child can't start its own agents, its answer is cut at 30,000 characters, it isn't saved as a session, and it starts as fast as Pi does. For long work in the background, use [pi-board](../board/README.md).
16
+
17
+ If you have `pi-subagents` installed, remove it, or the model has two tools for the same job.
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@huseynsnmz/pi-subagent",
3
+ "version": "0.1.0",
4
+ "description": "One simple tool for handing a task to a fresh agent.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": true,
8
+ "scripts": {
9
+ "check": "tsc --noEmit",
10
+ "test": "node --test test/*.test.ts"
11
+ },
12
+ "peerDependencies": {
13
+ "@earendil-works/pi-coding-agent": "*",
14
+ "typebox": "*"
15
+ },
16
+ "devDependencies": {
17
+ "@earendil-works/pi-coding-agent": "1.5.0",
18
+ "@types/node": "26.6.6",
19
+ "typescript": "7.0.2",
20
+ "typebox": "1.4.3"
21
+ }
22
+ }