@huseynsnmz/pi-suite 0.2.3 → 0.2.5
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 +38 -0
- package/README.md +6 -4
- package/package.json +7 -2
- package/packages/board/README.md +1 -1
- package/packages/board/package.json +2 -1
- package/packages/frame/README.md +1 -1
- package/packages/memory/README.md +20 -1
- package/packages/memory/src/index.ts +60 -1
- package/packages/memory/src/mastermind.ts +112 -0
- package/packages/memory/src/playbook.ts +99 -0
- package/packages/modes/README.md +10 -2
- package/packages/modes/skills/review-permissions/SKILL.md +11 -2
- package/packages/modes/src/index.ts +31 -6
- package/packages/modes/src/judge-gate.ts +91 -7
- package/packages/modes/src/modes.ts +4 -1
- package/packages/modes/src/review/apply.mjs +11 -6
- package/packages/modes/src/review/collect.mjs +45 -2
- package/packages/modes/src/workdir.ts +163 -0
- package/packages/router/README.md +2 -1
- package/packages/router/src/config.ts +2 -1
- package/packages/router/src/index.ts +9 -0
- package/packages/subagent/LICENSE +21 -0
- package/packages/subagent/README.md +17 -0
- package/packages/subagent/package.json +22 -0
- package/packages/subagent/src/index.ts +85 -0
- package/packages/web/README.md +1 -1
- package/src/index.ts +1 -1
- package/src/settings.ts +1 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { isKeyRelease, matchesKey, Text, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
@@ -9,9 +9,10 @@ import { gitRisk } from "./git-safety.ts";
|
|
|
9
9
|
import { registerInterview } from "./interview.ts";
|
|
10
10
|
import { registerReviewTools } from "./review/tools.ts";
|
|
11
11
|
import { aliasDefinitions, DENY_OFF_PROMPT, withAliases, withoutAliases } from "./deny-off.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { addCdGuards, addCdRules, registerWorkdir } from "./workdir.ts";
|
|
13
|
+
import { clearGate, hasPermissionSystem, installJudgeGate, JUDGE_NAMES, judgeGated, noteEditPaths, registerYoloLink, setGateAgentOurs, setGateCwd, setGateMode, YOLO_LINK } from "./judge-gate.ts";
|
|
13
14
|
import { readSection, writeSection } from "../../../src/settings.ts";
|
|
14
|
-
import { decideToolCall, displayCommand, fromExpectedPackage, notReadOnly, isMode, scriptedChange, loadsAfterPermissionSystem, type Mode, MODES, modePrompt, nextMode, resumedMode, startingMode, SUBMIT_PLAN_TOOL } from "./modes.ts";
|
|
15
|
+
import { AGENT_TOOL, decideToolCall, displayCommand, fromExpectedPackage, notReadOnly, isMode, scriptedChange, loadsAfterPermissionSystem, type Mode, MODES, modePrompt, nextMode, resumedMode, startingMode, SUBMIT_PLAN_TOOL } from "./modes.ts";
|
|
15
16
|
|
|
16
17
|
const STATUS_KEY = "pi-modes";
|
|
17
18
|
const DENY_STATUS_KEY = "pi-modes-deny";
|
|
@@ -107,12 +108,23 @@ function restoredMode(ctx: ExtensionContext): Mode {
|
|
|
107
108
|
const saved: unknown = (entry.data as { mode?: unknown } | undefined)?.mode;
|
|
108
109
|
if (isMode(saved)) mode = saved;
|
|
109
110
|
}
|
|
111
|
+
if (mode === undefined && process.env.PI_SUBAGENT_CHILD === "1") return startingMode(process.env.PI_MODES_START); // Its parent's say, not the default.
|
|
110
112
|
return mode === undefined ? startingMode(readConfig().defaultMode) : resumedMode(mode);
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
export default function piModes(pi: ExtensionAPI): void {
|
|
114
116
|
installJudgeGate();
|
|
115
117
|
|
|
118
|
+
// Before the permission system reads its rules, when it loads after this.
|
|
119
|
+
const project = realpathSync(process.cwd());
|
|
120
|
+
const cdFolders = readConfig().cdRuleFolders;
|
|
121
|
+
const handled = Array.isArray(cdFolders) ? (cdFolders as string[]) : [];
|
|
122
|
+
const isChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
123
|
+
if (!isChild && handled.includes(project)) addCdGuards(getAgentDir(), project);
|
|
124
|
+
const cdRule = isChild || handled.includes(project) ? { handled: false } : addCdRules(getAgentDir(), project);
|
|
125
|
+
if (cdRule.handled) writeSection(getAgentDir(), "modes", { ...readConfig(), cdRuleFolders: [...handled, project] });
|
|
126
|
+
let cdNotice = cdRule.notice;
|
|
127
|
+
|
|
116
128
|
let mode: Mode = "normal";
|
|
117
129
|
let stopListening: (() => void) | undefined;
|
|
118
130
|
let warnedUngated = false;
|
|
@@ -129,10 +141,17 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
129
141
|
disposeYoloLink = registerYoloLink(sessionId);
|
|
130
142
|
});
|
|
131
143
|
|
|
144
|
+
pi.on("message_end", (event, ctx) => noteEditPaths(ctx.sessionManager.getSessionId(), event.message));
|
|
145
|
+
|
|
146
|
+
const bash = registerWorkdir(pi);
|
|
147
|
+
|
|
132
148
|
function apply(next: Mode, ctx: ExtensionContext): void {
|
|
133
149
|
mode = next;
|
|
134
150
|
if (next !== "yolo") denyOff = false;
|
|
135
151
|
setGateMode(ctx.sessionManager.getSessionId(), next);
|
|
152
|
+
setGateCwd(ctx.sessionManager.getSessionId(), ctx.cwd);
|
|
153
|
+
const agentTool = pi.getAllTools().find((tool) => tool.name === AGENT_TOOL);
|
|
154
|
+
setGateAgentOurs(ctx.sessionManager.getSessionId(), agentTool !== undefined && fromExpectedPackage(AGENT_TOOL, agentTool.sourceInfo.path));
|
|
136
155
|
let others = withoutAliases(pi.getActiveTools().filter((name) => name !== SUBMIT_PLAN_TOOL), replacedTools);
|
|
137
156
|
replacedTools = [];
|
|
138
157
|
if (denyOff) ({ active: others, replaced: replacedTools } = withAliases(others));
|
|
@@ -194,6 +213,8 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
194
213
|
}
|
|
195
214
|
|
|
196
215
|
pi.on("session_start", (_event, ctx) => {
|
|
216
|
+
if (cdNotice !== undefined && ctx.hasUI) ctx.ui.notify(cdNotice, "info");
|
|
217
|
+
cdNotice = undefined;
|
|
197
218
|
apply(restoredMode(ctx), ctx);
|
|
198
219
|
if (ctx.mode === "tui") {
|
|
199
220
|
if (sandboxInstalled(pi.getCommands()) && packagesLoadThisFirst()) statuses = borrowStatusReader(ctx.ui as never);
|
|
@@ -243,21 +264,25 @@ export default function piModes(pi: ExtensionAPI): void {
|
|
|
243
264
|
}
|
|
244
265
|
let decision = decideToolCall(mode, event.toolName, event.input);
|
|
245
266
|
if (decision.action === "allow" && (mode === "plan" || mode === "read-only") && event.toolName === "bash") {
|
|
246
|
-
const risk = runsGit((event.input as { command: string }).command) ? await gitRisk(ctx.cwd) : undefined;
|
|
267
|
+
const risk = runsGit((event.input as { command: string }).command) ? await gitRisk(bash.folder() ?? ctx.cwd) : undefined;
|
|
247
268
|
if (risk !== undefined) decision = notReadOnly(mode, risk);
|
|
248
269
|
}
|
|
249
270
|
if (decision.action === "allow" && (mode === "plan" || mode === "read-only")) {
|
|
250
271
|
const path = pi.getAllTools().find((tool) => tool.name === event.toolName)?.sourceInfo.path;
|
|
251
272
|
if (!fromExpectedPackage(event.toolName, path)) decision = { action: "block", reason: `${mode} mode blocks ${event.toolName} from another package.` };
|
|
252
273
|
}
|
|
253
|
-
|
|
274
|
+
// After the mode has read the command as the model wrote it, and before the permission system reads it.
|
|
275
|
+
const moveBash = () => {
|
|
276
|
+
if (event.toolName === "bash" || event.toolName === "yolo_bash") bash.apply(event.toolCallId, event.input as { command: string }, ctx.cwd);
|
|
277
|
+
};
|
|
278
|
+
if (decision.action === "allow") return moveBash();
|
|
254
279
|
if (decision.action === "block") return { block: true, reason: decision.reason };
|
|
255
280
|
|
|
256
281
|
const denied = `Plan mode blocked this command: ${decision.reason}.`;
|
|
257
282
|
if (!ctx.hasUI) return { block: true, reason: denied };
|
|
258
283
|
const command = (event.input as { command: string }).command;
|
|
259
284
|
const approved = await ctx.ui.confirm("Plan mode: run this command?", `${displayCommand(decision.reason)}\n\n${displayCommand(command)}`);
|
|
260
|
-
return approved ?
|
|
285
|
+
return approved ? moveBash() : { block: true, reason: denied };
|
|
261
286
|
});
|
|
262
287
|
|
|
263
288
|
registerReviewTools(pi);
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* that changes, `judgeGated` stays false and the extension warns.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import
|
|
16
|
+
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
19
|
+
import { AGENT_TOOL, type Mode } from "./modes.ts";
|
|
17
20
|
|
|
18
21
|
export const JUDGE_NAMES: ReadonlySet<string> = new Set(["permission-judge", "classifier"]);
|
|
19
22
|
|
|
@@ -35,6 +38,62 @@ type ServiceMap = Map<string, ServiceLike> & { [PATCHED]?: true };
|
|
|
35
38
|
interface SessionState {
|
|
36
39
|
mode: Mode;
|
|
37
40
|
judgeGated: boolean;
|
|
41
|
+
cwd?: string;
|
|
42
|
+
/** Whether the `agent` tool is pi-suite's own. Another package's could do anything. */
|
|
43
|
+
agentIsOurs?: boolean;
|
|
44
|
+
/** Where the model's latest edits and writes go, by call id, as an ask doesn't say. */
|
|
45
|
+
editPaths?: Map<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Inside a project, but they run code or change what Pi does, so the judge still looks. */
|
|
49
|
+
const GUARDED = new Set([".git", ".pi", ".claude", ".husky", ".github", ".vscode", ".envrc"]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pi's edit and write tools rewrite these before opening the file, so the path as written isn't the one written to.
|
|
53
|
+
* The judge sees them rather than this copying Pi's rules, which could drift.
|
|
54
|
+
*/
|
|
55
|
+
const REWRITTEN_BY_PI = /^@|^file:|[\u00A0\u2000-\u200A\u202F\u205F\u3000]/;
|
|
56
|
+
|
|
57
|
+
/** The path with symlinks followed, as far as it exists. Undefined for a link that leads nowhere. */
|
|
58
|
+
function realTarget(path: string): string | undefined {
|
|
59
|
+
if (existsSync(path)) return realpathSync(path);
|
|
60
|
+
try {
|
|
61
|
+
lstatSync(path);
|
|
62
|
+
return undefined; // It's there, and can't be followed.
|
|
63
|
+
} catch {
|
|
64
|
+
const parent = dirname(path);
|
|
65
|
+
if (parent === path) return undefined;
|
|
66
|
+
const real = realTarget(parent);
|
|
67
|
+
return real === undefined ? undefined : join(real, basename(path));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** An edit or write to an ordinary file in the project: what `auto` lets through without asking anyone. */
|
|
72
|
+
export function isProjectEdit(details: unknown, cwd: string | undefined): boolean {
|
|
73
|
+
const { toolName, path } = (details ?? {}) as { toolName?: unknown; path?: unknown };
|
|
74
|
+
if ((toolName !== "edit" && toolName !== "write") || typeof path !== "string" || cwd === undefined || REWRITTEN_BY_PI.test(path)) return false;
|
|
75
|
+
const expanded = path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
76
|
+
const target = realTarget(resolve(cwd, expanded));
|
|
77
|
+
const root = realTarget(cwd);
|
|
78
|
+
if (target === undefined || root === undefined) return false;
|
|
79
|
+
const inside = relative(root, target);
|
|
80
|
+
if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return false;
|
|
81
|
+
// Lowercase, as macOS finds `.GIT` at `.git` by default.
|
|
82
|
+
return !inside.split(sep).some((part) => GUARDED.has(part.toLowerCase()));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** It does nothing by itself: what the agent then does is still checked. */
|
|
86
|
+
function needsNoAsk(state: SessionState, details: unknown): boolean {
|
|
87
|
+
return state.agentIsOurs === true && (details as { toolName?: unknown } | undefined)?.toolName === AGENT_TOOL;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function autoAllows(state: SessionState, details: unknown, log: unknown): boolean {
|
|
91
|
+
if (state.mode !== "auto") return false;
|
|
92
|
+
const { toolName, toolCallId } = (details ?? {}) as { toolName?: unknown; toolCallId?: unknown };
|
|
93
|
+
const path = typeof toolCallId === "string" ? state.editPaths?.get(toolCallId) : undefined;
|
|
94
|
+
if (!isProjectEdit({ toolName, path }, state.cwd)) return false;
|
|
95
|
+
(log as { review?: (event: string, fields: unknown) => void } | undefined)?.review?.("pi-modes.project_edit", { path });
|
|
96
|
+
return true;
|
|
38
97
|
}
|
|
39
98
|
|
|
40
99
|
type Store = Record<symbol, unknown>;
|
|
@@ -61,9 +120,12 @@ function gateService(store: Store, sessionId: string, service: ServiceLike): voi
|
|
|
61
120
|
service.registerAuthorizer = (name, authorize) => {
|
|
62
121
|
if (!JUDGE_NAMES.has(name)) return register(name, authorize);
|
|
63
122
|
stateFor(store, sessionId).judgeGated = true;
|
|
64
|
-
return register(name, async (details, query, log) =>
|
|
65
|
-
stateFor(store, sessionId)
|
|
66
|
-
|
|
123
|
+
return register(name, async (details, query, log) => {
|
|
124
|
+
const state = stateFor(store, sessionId);
|
|
125
|
+
if (needsNoAsk(state, details)) return { kind: "allow" };
|
|
126
|
+
if (state.mode !== "auto") return { kind: "defer" };
|
|
127
|
+
return autoAllows(state, details, log) ? { kind: "allow" } : authorize(details, query, log);
|
|
128
|
+
});
|
|
67
129
|
};
|
|
68
130
|
}
|
|
69
131
|
|
|
@@ -91,9 +153,10 @@ export function installJudgeGate(store: Store = globalThis as unknown as Store):
|
|
|
91
153
|
export function registerYoloLink(sessionId: string, store: Store = globalThis as unknown as Store): (() => void) | undefined {
|
|
92
154
|
const service = (store[SERVICES_KEY] as ServiceMap | undefined)?.get(sessionId);
|
|
93
155
|
if (typeof service?.registerAuthorizer !== "function") return undefined;
|
|
94
|
-
return service.registerAuthorizer(YOLO_LINK, async () =>
|
|
95
|
-
stateFor(store, sessionId)
|
|
96
|
-
|
|
156
|
+
return service.registerAuthorizer(YOLO_LINK, async (details, _query, log) => {
|
|
157
|
+
const state = stateFor(store, sessionId);
|
|
158
|
+
return needsNoAsk(state, details) || state.mode === "yolo" || autoAllows(state, details, log) ? { kind: "allow" } : { kind: "defer" };
|
|
159
|
+
});
|
|
97
160
|
}
|
|
98
161
|
|
|
99
162
|
export function hasPermissionSystem(sessionId: string, store: Store = globalThis as unknown as Store): boolean {
|
|
@@ -104,6 +167,27 @@ export function setGateMode(sessionId: string, mode: Mode, store: Store = global
|
|
|
104
167
|
stateFor(store, sessionId).mode = mode;
|
|
105
168
|
}
|
|
106
169
|
|
|
170
|
+
/** Called with each message the model finishes, which comes before any of its tools are asked about. */
|
|
171
|
+
export function noteEditPaths(sessionId: string, message: unknown, store: Store = globalThis as unknown as Store): void {
|
|
172
|
+
const { role, content } = (message ?? {}) as { role?: unknown; content?: unknown };
|
|
173
|
+
if (role !== "assistant" || !Array.isArray(content)) return;
|
|
174
|
+
const paths = new Map<string, string>();
|
|
175
|
+
for (const block of content as { type?: unknown; id?: unknown; name?: unknown; arguments?: { path?: unknown } }[]) {
|
|
176
|
+
if (block?.type === "toolCall" && (block.name === "edit" || block.name === "write") && typeof block.id === "string" && typeof block.arguments?.path === "string") {
|
|
177
|
+
paths.set(block.id, block.arguments.path);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
stateFor(store, sessionId).editPaths = paths;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function setGateCwd(sessionId: string, cwd: string, store: Store = globalThis as unknown as Store): void {
|
|
184
|
+
stateFor(store, sessionId).cwd = cwd;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function setGateAgentOurs(sessionId: string, ours: boolean, store: Store = globalThis as unknown as Store): void {
|
|
188
|
+
stateFor(store, sessionId).agentIsOurs = ours;
|
|
189
|
+
}
|
|
190
|
+
|
|
107
191
|
export function judgeGated(sessionId: string, store: Store = globalThis as unknown as Store): boolean {
|
|
108
192
|
return stateFor(store, sessionId).judgeGated;
|
|
109
193
|
}
|
|
@@ -14,10 +14,11 @@ const CODEMODE_TOOL = "codemode";
|
|
|
14
14
|
|
|
15
15
|
// pi-web's tools only read, and a separate model reads the page for them.
|
|
16
16
|
// Each tool a codemode script calls comes through this check itself, so the script needs none of its own.
|
|
17
|
+
export const AGENT_TOOL = "agent";
|
|
17
18
|
const READ_TOOLS = new Set(["read", "grep", "find", "ls", "web_fetch", "web_search", "todo", ASK_TOOL, CODEMODE_TOOL]);
|
|
18
19
|
|
|
19
20
|
/** Allowed for what pi-suite's own do. Another package's tool with the name could do anything. */
|
|
20
|
-
const SUITE_TOOLS: ReadonlySet<string> = new Set(["todo", "web_fetch", "web_search", ASK_TOOL]);
|
|
21
|
+
const SUITE_TOOLS: ReadonlySet<string> = new Set(["todo", "web_fetch", "web_search", ASK_TOOL, AGENT_TOOL]);
|
|
21
22
|
const SUITE_ENTRY = fileURLToPath(new URL("../../../src/index.ts", import.meta.url));
|
|
22
23
|
|
|
23
24
|
function realPath(path: string): string | undefined {
|
|
@@ -75,6 +76,8 @@ export function decideToolCall(mode: Mode, toolName: string, input: unknown): To
|
|
|
75
76
|
if (mode === "normal" || mode === "auto" || mode === "yolo") return ALLOW;
|
|
76
77
|
const leave = "Press Shift+Tab to change mode.";
|
|
77
78
|
if (READ_TOOLS.has(toolName)) return ALLOW;
|
|
79
|
+
// Its explore type can only read, as the child gets no other tools.
|
|
80
|
+
if (toolName === AGENT_TOOL && (input as { type?: unknown } | null)?.type !== "general") return ALLOW;
|
|
78
81
|
if (toolName === SUBMIT_PLAN_TOOL && mode === "plan") return ALLOW;
|
|
79
82
|
|
|
80
83
|
if (toolName === "bash") {
|
|
@@ -9,7 +9,7 @@ const OUTWARD = /^(?:git\s+(?:push|reset|clean|rebase|filter-branch)|gh\s+(?:pr\
|
|
|
9
9
|
* Commands with no subcommands that only read, so a trailing `*` adds just their options. Not `rg`,
|
|
10
10
|
* whose --pre runs a program, `file`, whose -C writes, or `date`, whose -s sets the clock.
|
|
11
11
|
*/
|
|
12
|
-
const READERS = new Set(["ls", "pwd", "wc", "head", "tail", "which", "grep", "tree", "du", "df", "stat", "diff"]);
|
|
12
|
+
const READERS = new Set(["cd", "ls", "pwd", "wc", "head", "tail", "which", "grep", "tree", "du", "df", "stat", "diff"]);
|
|
13
13
|
const BUILT_IN_TOOLS = new Set(["read", "grep", "find", "ls", "submit_plan"]);
|
|
14
14
|
const REFUSED_SURFACES = new Set(["bash", "path", "external_directory", "mcp", "skill", "*"]);
|
|
15
15
|
|
|
@@ -17,6 +17,7 @@ const REFUSED_SURFACES = new Set(["bash", "path", "external_directory", "mcp", "
|
|
|
17
17
|
export function refuseBash(pattern) {
|
|
18
18
|
const text = pattern.trim();
|
|
19
19
|
if (text === "" || text.startsWith("*") || text.startsWith("?")) return "it matches any command";
|
|
20
|
+
if (text === "cd*") return "it also matches cdk and other commands, so write `cd *`";
|
|
20
21
|
if (/[\n;&|`$<>()]/.test(text)) return "it holds shell syntax, and a rule matches one command";
|
|
21
22
|
const words = text.split(/\s+/);
|
|
22
23
|
const bare = words.length === 1 && text.endsWith("*") ? text.slice(0, -1) : words.length === 2 && words[1] === "*" ? words[0] : undefined;
|
|
@@ -36,15 +37,18 @@ export function refuseTool(tool) {
|
|
|
36
37
|
* Rules are order-dependent and the last match wins, so new allows go straight after the
|
|
37
38
|
* catch-all. Every existing `deny` or `ask` comes later and keeps overriding them.
|
|
38
39
|
*/
|
|
39
|
-
export function withBashAllows(rules, patterns) {
|
|
40
|
+
export function withBashAllows(rules, patterns, guards = []) {
|
|
40
41
|
const entries = Object.entries(rules ?? { "*": "ask" });
|
|
41
42
|
const fresh = patterns.filter((pattern) => !entries.some(([existing]) => existing === pattern));
|
|
42
43
|
const at = entries[0]?.[0] === "*" ? 1 : 0;
|
|
43
44
|
const next = [...entries.slice(0, at), ...fresh.map((pattern) => [pattern, "allow"]), ...entries.slice(at)];
|
|
45
|
+
// Guards ask about part of what those allows match, so they go after them, wherever they are.
|
|
46
|
+
const last = Math.max(...patterns.map((pattern) => next.findIndex(([existing]) => existing === pattern)));
|
|
47
|
+
next.splice(last + 1, 0, ...guards.filter((pattern) => !entries.some(([existing]) => existing === pattern)).map((pattern) => [pattern, "ask"]));
|
|
44
48
|
return Object.fromEntries(next);
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
export function applyRules(config, { bash = [], tools = [] }) {
|
|
51
|
+
export function applyRules(config, { bash = [], tools = [], guards = [] }) {
|
|
48
52
|
const refusals = [
|
|
49
53
|
...bash.flatMap((pattern) => {
|
|
50
54
|
const why = refuseBash(pattern);
|
|
@@ -60,7 +64,8 @@ export function applyRules(config, { bash = [], tools = [] }) {
|
|
|
60
64
|
if (bash.length > 0) {
|
|
61
65
|
if (permission.bash !== undefined && (typeof permission.bash !== "object" || permission.bash === null))
|
|
62
66
|
return { refusals: [`bash: its rule is the single word "${permission.bash}", so convert it to a map by hand`] };
|
|
63
|
-
|
|
67
|
+
// With no bash rules, bash follows the catch-all, so a new map has to start from it.
|
|
68
|
+
permission.bash = withBashAllows(permission.bash ?? { "*": permission["*"] ?? "ask" }, bash, guards);
|
|
64
69
|
}
|
|
65
70
|
for (const tool of tools) if (permission[tool] !== "deny") permission[tool] = "allow";
|
|
66
71
|
return { config: { ...config, permission }, refusals: [] };
|
|
@@ -121,13 +126,13 @@ function writeWithBackup(path, config) {
|
|
|
121
126
|
return backup;
|
|
122
127
|
}
|
|
123
128
|
|
|
124
|
-
function planChoices(agent, { bash = [], tools = [], read = [], write = [], domains = [] }) {
|
|
129
|
+
function planChoices(agent, { bash = [], tools = [], guards = [], read = [], write = [], domains = [] }) {
|
|
125
130
|
const path = join(agent, "extensions", "pi-permission-system", "config.json");
|
|
126
131
|
const sandboxPath = join(agent, "sandbox.json");
|
|
127
132
|
const wantsRules = bash.length > 0 || tools.length > 0;
|
|
128
133
|
const wantsSandbox = read.length > 0 || write.length > 0 || domains.length > 0;
|
|
129
134
|
if (wantsRules && !existsSync(path)) return { refusals: [`No config at ${path}.`] };
|
|
130
|
-
const rules = wantsRules ? applyRules(JSON.parse(readFileSync(path, "utf8")), { bash, tools }) : { refusals: [] };
|
|
135
|
+
const rules = wantsRules ? applyRules(JSON.parse(readFileSync(path, "utf8")), { bash, tools, guards }) : { refusals: [] };
|
|
131
136
|
let sandboxConfig = {};
|
|
132
137
|
if (wantsSandbox && existsSync(sandboxPath)) {
|
|
133
138
|
try {
|
|
@@ -25,6 +25,8 @@ export function ruleFor(rules, value) {
|
|
|
25
25
|
/** Two words for tools with subcommands, like `git diff*`, and a narrower rule is the reviewer's to choose. */
|
|
26
26
|
export function suggestPattern(command) {
|
|
27
27
|
const words = command.trim().split(/\s+/);
|
|
28
|
+
// `cd*` would also match `cdk deploy`. Paths are still checked against the folder it moves to.
|
|
29
|
+
if (words[0] === "cd") return "cd *";
|
|
28
30
|
const second = words[1];
|
|
29
31
|
return second !== undefined && /^[a-z][\w:-]*$/i.test(second) ? `${words[0]} ${second}*` : `${words[0]}*`;
|
|
30
32
|
}
|
|
@@ -47,6 +49,8 @@ export function collect(entries, permission = {}, history = []) {
|
|
|
47
49
|
const by = entry.decidedBy;
|
|
48
50
|
if (!PERSON.has(by?.kind) && !(by?.kind === "authorizer" && by.name !== "pi-modes")) continue;
|
|
49
51
|
const surface = entry.surface ?? entry.toolName;
|
|
52
|
+
// Auto lets project edits through by itself, so these say nothing about a rule worth adding.
|
|
53
|
+
if (by?.kind === "authorizer" && (surface === "edit" || surface === "write")) continue;
|
|
50
54
|
|
|
51
55
|
if (surface === "bash") {
|
|
52
56
|
const command = entry.executedUnit ?? entry.command;
|
|
@@ -76,6 +80,44 @@ export function collect(entries, permission = {}, history = []) {
|
|
|
76
80
|
return { candidates, skipped };
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
/**
|
|
84
|
+
* What the permission judge decided, from its entries in the log: how often, why it passed asks on,
|
|
85
|
+
* and what you then approved yourself, which is what auto could have spared you.
|
|
86
|
+
*/
|
|
87
|
+
export function collectJudge(entries) {
|
|
88
|
+
const decisions = entries.filter((entry) => entry.event === "classifier.decision");
|
|
89
|
+
const verdicts = { allow: 0, deny: 0, defer: 0 };
|
|
90
|
+
const reasons = new Map();
|
|
91
|
+
const deferred = new Map();
|
|
92
|
+
for (const entry of decisions) {
|
|
93
|
+
if (entry.verdict in verdicts) verdicts[entry.verdict] += 1;
|
|
94
|
+
if (entry.verdict !== "defer") continue;
|
|
95
|
+
const reason = entry.deferReason ?? "uncertain";
|
|
96
|
+
const group = reasons.get(reason) ?? { reason, count: 0, surfaces: new Set(), last: "" };
|
|
97
|
+
group.count += 1;
|
|
98
|
+
if (typeof entry.surface === "string") group.surfaces.add(entry.surface);
|
|
99
|
+
if (String(entry.timestamp ?? "") > group.last) group.last = String(entry.timestamp);
|
|
100
|
+
reasons.set(reason, group);
|
|
101
|
+
if (typeof entry.requestId === "string") deferred.set(entry.requestId, { reason, surface: entry.surface, value: entry.value });
|
|
102
|
+
}
|
|
103
|
+
const approved = new Map();
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
const asked = deferred.get(entry.requestId);
|
|
106
|
+
if (asked === undefined || !APPROVED.has(entry.resolution) || !PERSON.has(entry.decidedBy?.kind)) continue;
|
|
107
|
+
const value = typeof asked.value === "string" && asked.surface === "bash" && !SECRET.test(asked.value) ? suggestPattern(asked.value) : "";
|
|
108
|
+
const key = `${asked.surface} ${value} ${asked.reason}`;
|
|
109
|
+
const group = approved.get(key) ?? { surface: asked.surface, ...(value === "" ? {} : { pattern: value }), reason: asked.reason, approvals: 0 };
|
|
110
|
+
group.approvals += 1;
|
|
111
|
+
approved.set(key, group);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
decisions: decisions.length,
|
|
115
|
+
...verdicts,
|
|
116
|
+
deferReasons: [...reasons.values()].map((group) => ({ ...group, surfaces: [...group.surfaces] })).sort((a, b) => b.count - a.count),
|
|
117
|
+
deferredThenApproved: [...approved.values()].sort((a, b) => b.approvals - a.approvals).slice(0, MAX_CANDIDATES),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
79
121
|
/**
|
|
80
122
|
* Commands that ran, from one session's chat history. It doesn't say who approved them, so they
|
|
81
123
|
* count separately from approvals. Failed or blocked calls and anything run in yolo are left out.
|
|
@@ -196,7 +238,8 @@ export async function reviewData(agent) {
|
|
|
196
238
|
blocks.push(...sandboxBlocks(entries));
|
|
197
239
|
}
|
|
198
240
|
}
|
|
199
|
-
const
|
|
241
|
+
const log = logFound ? parseLog(await readFile(logPath, "utf8")) : [];
|
|
242
|
+
const result = capped(collect(log, permission, history));
|
|
200
243
|
const sandboxPath = join(agent, "sandbox.json");
|
|
201
244
|
let sandboxConfig = {};
|
|
202
245
|
try {
|
|
@@ -204,5 +247,5 @@ export async function reviewData(agent) {
|
|
|
204
247
|
} catch {
|
|
205
248
|
// Comments are allowed there. Already-allowed entries may show up, and apply won't add them twice.
|
|
206
249
|
}
|
|
207
|
-
return { logFound, ...result, sandbox: capped(collectSandbox(blocks, sandboxConfig)) };
|
|
250
|
+
return { logFound, ...result, judge: collectJudge(log), sandbox: capped(collectSandbox(blocks, sandboxConfig)) };
|
|
208
251
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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 { isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } 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
|
+
/** A cd anywhere in a chain, with a path that's plain text. */
|
|
16
|
+
const ANY_CD = /(^|&&|\|\||[;|\n({]|\b(?:then|do|else)\b)(\s*cd\s+)(?:'([^']*)'|"([^"$`\\]*)"|([^\s;&|`$<>()'"\\]+))(?=\s*(?:&&|\|\||[;|\n)}]|$))/g;
|
|
17
|
+
const WORKDIR_ENTRY = "pi-modes-workdir";
|
|
18
|
+
const PLAIN = /^[\w@%+=:,./-]+$/;
|
|
19
|
+
/** Bare when it can be, as a rule like `cd /work/app/*` only matches the path as written. */
|
|
20
|
+
const quoted = (text: string) => (PLAIN.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`);
|
|
21
|
+
|
|
22
|
+
function folderAt(path: string): string | undefined {
|
|
23
|
+
try {
|
|
24
|
+
return statSync(path).isDirectory() ? realpathSync(path) : undefined;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where a cd goes. A model often repeats a move it already made, so a path that isn't here counts if it's how the
|
|
32
|
+
* current folder ends. Nothing else is guessed: `cd build && rm -rf *` has to fail if there's no build folder here.
|
|
33
|
+
*/
|
|
34
|
+
export function cdTarget(root: string, here: string | undefined, target: string): string | undefined {
|
|
35
|
+
if (target === "-") return undefined;
|
|
36
|
+
const path = target === "~" || target.startsWith("~/") ? join(homedir(), target.slice(1)) : target;
|
|
37
|
+
const from = here ?? root;
|
|
38
|
+
const folder = folderAt(resolve(from, path));
|
|
39
|
+
if (folder !== undefined) return folder;
|
|
40
|
+
const repeat = normalize(`${path}/`).slice(0, -1);
|
|
41
|
+
const below = relative(root, from);
|
|
42
|
+
return below === repeat || below.endsWith(`${sep}${repeat}`) ? from : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A cd rule for the project also matches `cd <root>/link` and `cd <root>/../x`, which leave it. Written as where they
|
|
47
|
+
* really go, the rule no longer matches, and the permission system asks.
|
|
48
|
+
*/
|
|
49
|
+
export function realCds(root: string, command: string): string {
|
|
50
|
+
return command.replace(ANY_CD, (all, before: string, cd: string, single?: string, double?: string, bare?: string) => {
|
|
51
|
+
const path = single ?? double ?? bare ?? "";
|
|
52
|
+
if (!path.startsWith(`${root}/`)) return all;
|
|
53
|
+
const real = folderAt(path) ?? resolve(path);
|
|
54
|
+
return real === path ? all : `${before}${cd}${quoted(real)}`;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The command to really run, and the folder it moves to if it starts with a cd. */
|
|
59
|
+
export function planCommand(root: string, here: string | undefined, command: string): { command: string; move?: string } {
|
|
60
|
+
const match = LEADING_CD.exec(command);
|
|
61
|
+
const move = match === null ? undefined : cdTarget(root, here, match[1] ?? match[2] ?? match[3] ?? "");
|
|
62
|
+
if (match !== null && move !== undefined) return { command: realCds(root, `cd ${quoted(move)}${command.slice(match[0].length)}`), move };
|
|
63
|
+
const rest = realCds(root, command);
|
|
64
|
+
return { command: here === undefined ? rest : `cd ${quoted(here)} && ${rest}` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isInside(root: string, folder: string): boolean {
|
|
68
|
+
const inside = relative(root, folder);
|
|
69
|
+
return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 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. */
|
|
73
|
+
export function cdRules(root: string): string[] {
|
|
74
|
+
return [`cd ${word(root)}`, `cd ${word(root, "/*")}`];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What the rules above match but can't vouch for: a way out of the project, or text the shell turns into a path. */
|
|
78
|
+
export function cdGuards(root: string): string[] {
|
|
79
|
+
return ["..", "$", "`", "\\"].map((risky) => `cd ${word(root, `/*${risky}*`)}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The folder as bash gets it, as a rule only matches what's written. */
|
|
83
|
+
function word(root: string, tail = ""): string {
|
|
84
|
+
return PLAIN.test(root) ? `${root}${tail}` : `'${`${root}${tail}`.replaceAll("'", "'\\''")}'`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Rules added before the guards existed get them. Nothing else changes, and removed rules stay removed. */
|
|
88
|
+
export function addCdGuards(agentDir: string, root: string): void {
|
|
89
|
+
const path = join(agentDir, "extensions", "pi-permission-system", "config.json");
|
|
90
|
+
try {
|
|
91
|
+
const bash = JSON.parse(readFileSync(path, "utf8")).permission?.bash;
|
|
92
|
+
const rules = cdRules(root);
|
|
93
|
+
if (typeof bash !== "object" || bash === null || bash[rules[1] as string] !== "allow" || cdGuards(root).every((guard) => guard in bash)) return;
|
|
94
|
+
applyChoices(agentDir, { bash: rules, guards: cdGuards(root) });
|
|
95
|
+
} catch {
|
|
96
|
+
// Not ours to fix. It's tried again next start.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The permission system judges each part of a chain, so with "*" asking, every `cd x && go test` asks about the cd.
|
|
102
|
+
* Moving inside the project does nothing by itself, so it's allowed for this project, once, with a backup. A cd
|
|
103
|
+
* anywhere else still asks. Whoever removes the rules keeps them removed. Returns what to tell the user.
|
|
104
|
+
*/
|
|
105
|
+
export function addCdRules(agentDir: string, root: string): { handled: boolean; notice?: string } {
|
|
106
|
+
const path = join(agentDir, "extensions", "pi-permission-system", "config.json");
|
|
107
|
+
if (root === "/" || !existsSync(path)) return { handled: false };
|
|
108
|
+
try {
|
|
109
|
+
const permission = JSON.parse(readFileSync(path, "utf8")).permission ?? {};
|
|
110
|
+
const { bash } = permission;
|
|
111
|
+
// With no bash rules, bash follows the catch-all, which is ask when that isn't set either.
|
|
112
|
+
const asks = bash === undefined ? (permission["*"] ?? "ask") === "ask" : typeof bash === "object" && bash !== null && ruleFor(bash, `cd ${root}/x`) === "ask";
|
|
113
|
+
if (!asks) return { handled: true };
|
|
114
|
+
const rules = cdRules(root);
|
|
115
|
+
if (checkChoices(agentDir, { bash: rules }).length > 0) return { handled: true }; // A path a rule can't hold.
|
|
116
|
+
applyChoices(agentDir, { bash: rules, guards: cdGuards(root) });
|
|
117
|
+
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.` };
|
|
118
|
+
} catch {
|
|
119
|
+
return { handled: false }; // Not ours to fix. It's tried again next start.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The folder this point of the session was in, so what the model was told stays true after a reload, a resume or a move in the tree. */
|
|
124
|
+
export function savedFolder(root: string, entries: readonly { type: string; customType?: string; data?: unknown }[]): string | undefined {
|
|
125
|
+
let saved: unknown;
|
|
126
|
+
for (const entry of entries) if (entry.type === "custom" && entry.customType === WORKDIR_ENTRY) saved = (entry.data as { folder?: unknown } | undefined)?.folder;
|
|
127
|
+
if (typeof saved !== "string" || saved === root || !isInside(root, saved)) return undefined;
|
|
128
|
+
return folderAt(saved) === saved ? saved : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function registerWorkdir(pi: ExtensionAPI) {
|
|
132
|
+
let here: string | undefined;
|
|
133
|
+
const moves = new Map<string, string>();
|
|
134
|
+
const restore = (_event: unknown, ctx: ExtensionContext) => {
|
|
135
|
+
here = savedFolder(realpathSync(ctx.cwd), ctx.sessionManager.getBranch());
|
|
136
|
+
moves.clear();
|
|
137
|
+
};
|
|
138
|
+
pi.on("session_start", restore);
|
|
139
|
+
pi.on("session_tree", restore);
|
|
140
|
+
pi.on("tool_result", (event, ctx) => {
|
|
141
|
+
const move = moves.get(event.toolCallId);
|
|
142
|
+
if (move === undefined) return undefined;
|
|
143
|
+
moves.delete(event.toolCallId);
|
|
144
|
+
if (event.isError) return undefined;
|
|
145
|
+
const root = realpathSync(ctx.cwd);
|
|
146
|
+
const stays = isInside(root, move);
|
|
147
|
+
const next = stays && move !== root ? move : undefined;
|
|
148
|
+
if (next !== here) pi.appendEntry(WORKDIR_ENTRY, { folder: next ?? null });
|
|
149
|
+
here = next;
|
|
150
|
+
// Told here rather than in the system prompt, which would cost every request and replace the subscription part's prompt.
|
|
151
|
+
const note = stays ? `Bash stays in ${move} for later commands, so don't repeat the cd.` : `Shell cwd was reset to ${root}`;
|
|
152
|
+
return { content: [...event.content, { type: "text" as const, text: `\n${note}` }] };
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
folder: () => here,
|
|
156
|
+
/** Rewrites a bash call in place. */
|
|
157
|
+
apply(toolCallId: string, input: { command: string }, cwd: string): void {
|
|
158
|
+
const plan = planCommand(realpathSync(cwd), here, input.command);
|
|
159
|
+
input.command = plan.command;
|
|
160
|
+
if (plan.move !== undefined) moves.set(toolCallId, plan.move);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -9,7 +9,7 @@ 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.
|
|
12
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.5
|
|
13
13
|
```
|
|
14
14
|
|
|
15
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`.
|
|
@@ -23,6 +23,7 @@ In `/model`, `Enter` selects a model for this session and `Ctrl+S` saves it as y
|
|
|
23
23
|
| `plan` | Reads and plans, up to the first edit, and everything in `plan` mode | Required |
|
|
24
24
|
| `build` | Everything after that, and compaction summaries | Required |
|
|
25
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 |
|
|
26
27
|
| `judge` | Decides permission asks for `pi-permission-classifier` | See below |
|
|
27
28
|
| `subagent` | Runs subagents that use `router/auto` | Same as `build` |
|
|
28
29
|
| `fallback` | Takes over when a provider is overloaded or rate limited | Retries on the same model |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readSection, writeSection } from "../../../src/settings.ts";
|
|
3
3
|
|
|
4
4
|
/** The judge's model lives only in the judge's own config, so there's one answer to which model judges. */
|
|
5
|
-
export const ROLES = ["plan", "build", "reader", "subagent", "fallback"] as const;
|
|
5
|
+
export const ROLES = ["plan", "build", "reader", "subagent", "fallback", "mastermind"] as const;
|
|
6
6
|
export type Role = (typeof ROLES)[number];
|
|
7
7
|
|
|
8
8
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
@@ -16,6 +16,7 @@ export interface RouterConfig {
|
|
|
16
16
|
reader?: string;
|
|
17
17
|
subagent?: string;
|
|
18
18
|
fallback?: string;
|
|
19
|
+
mastermind?: string;
|
|
19
20
|
thinking?: RoleThinking;
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -7,6 +7,7 @@ import { chooseModel, type RouterState } from "./route.ts";
|
|
|
7
7
|
|
|
8
8
|
/** Other extensions emit these with an object, and whoever knows fills it in, whatever the load order. */
|
|
9
9
|
const READER_PROBE = "pi-router:reader-model";
|
|
10
|
+
const MASTERMIND_PROBE = "pi-router:mastermind-model";
|
|
10
11
|
const MODE_PROBE = "pi-modes:mode";
|
|
11
12
|
const SETTINGS_PROBE = "pi-suite:router-settings";
|
|
12
13
|
|
|
@@ -21,6 +22,7 @@ const ROLE_HELP: Record<Role, string> = {
|
|
|
21
22
|
reader: "reads web pages for pi-web",
|
|
22
23
|
subagent: "runs subagents that use this virtual model",
|
|
23
24
|
fallback: "takes over when a provider is overloaded",
|
|
25
|
+
mastermind: "reviews past conversations for /mastermind, and writes rules for the other models",
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
/** What a role shows, and offers to go back to, with no model of its own. */
|
|
@@ -28,6 +30,7 @@ const UNSET: Partial<Record<Role, string>> = {
|
|
|
28
30
|
reader: "same as build",
|
|
29
31
|
subagent: "same as build",
|
|
30
32
|
fallback: "none",
|
|
33
|
+
mastermind: "none",
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
function refOf(model: { provider: string; id: string } | undefined): string | undefined {
|
|
@@ -53,6 +56,12 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
53
56
|
probe.thinking = saved.thinking?.[saved.reader === undefined ? "build" : "reader"];
|
|
54
57
|
});
|
|
55
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;
|
|
63
|
+
});
|
|
64
|
+
|
|
56
65
|
function currentMode(): string | undefined {
|
|
57
66
|
const probe: { mode?: string } = {};
|
|
58
67
|
pi.events.emit(MODE_PROBE, probe);
|