@huseynsnmz/pi-suite 0.2.4 → 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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.5]
4
+
5
+ ### Security
6
+
7
+ - `auto`: a path Pi rewrites before writing, such as `@.git/hooks/pre-commit`, `@/etc/x` or `file:///etc/x`, went past the project check. These now go to the judge.
8
+ - `auto`: `.GIT` and `.Github` on a case-insensitive disk went past the guarded folder check.
9
+ - The `cd` rules matched `cd <project>/../x`, paths the shell works out later, and symlinks out of the project. They now ask. Projects that already have the rules get the new ones at the next start.
10
+ - An agent started in the default mode, not its parent's, so a parent in `normal` could have a child in `auto`. It now takes its parent's mode. From `plan` it's `read-only`, and from `yolo` it's `normal`.
11
+ - A tool named `agent` from another package no longer skips the judge and your review.
12
+ - `/review-permissions` refuses `cd*`, which also matches `cdk deploy`.
13
+
14
+ ### Fixes
15
+
16
+ - A `cd` to a folder that isn't there could run somewhere else: `cd build && rm -rf *` in a folder with no `build` went to the project's top `build`. It now fails as in a shell.
17
+ - The bash folder is kept across `/reload` and `/resume`, and follows moves in `/tree`.
18
+ - With no `bash` rules and a catch-all of `allow`, adding the `cd` rules made every command ask. `/review-permissions` had the same problem. Rules now start from your catch-all.
19
+ - `cd` rules work in projects with a space in the path.
20
+ - `/mastermind` no longer marks sessions as reviewed when the answer was cut off or can't be read, and a retry keeps the proposals it didn't repeat.
21
+ - Non-English text in an agent's answer can't be garbled where it's split across chunks.
22
+
3
23
  ## [0.2.4]
4
24
 
5
25
  ### Features
package/README.md CHANGED
@@ -25,7 +25,7 @@ There's no setting per project, so a repository can't choose where your code goe
25
25
  Requires Pi 1.5 or newer.
26
26
 
27
27
  ```bash
28
- pi install npm:@huseynsnmz/pi-suite@0.2.4
28
+ pi install npm:@huseynsnmz/pi-suite@0.2.5
29
29
  ```
30
30
 
31
31
  If you installed any of these separately, remove them first:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huseynsnmz/pi-suite",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "One Pi package for all of it: modes and permissions, a framed screen, an agent board, memory, web tools with a separate page reader, and a model router. Turn off what you don't want with /suite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@ Requires Pi 1.5 or newer.
26
26
  1. Install it:
27
27
 
28
28
  ```bash
29
- pi install npm:@huseynsnmz/pi-suite@0.2.4
29
+ pi install npm:@huseynsnmz/pi-suite@0.2.5
30
30
  ```
31
31
 
32
32
  2. Restart Pi, and press `Left` twice on an empty prompt, or run `/agent-board`.
@@ -19,7 +19,7 @@ Requires Pi 1.5 or newer.
19
19
  2. Install it:
20
20
 
21
21
  ```bash
22
- pi install npm:@huseynsnmz/pi-suite@0.2.4
22
+ pi install npm:@huseynsnmz/pi-suite@0.2.5
23
23
  ```
24
24
 
25
25
  3. Restart Pi. The start card says `π-frame`.
@@ -9,7 +9,7 @@ Long-term memory for [Pi](https://pi.dev). It reviews your past conversations, k
9
9
  Requires Pi 1.5 or newer, on Node 22.18 or newer.
10
10
 
11
11
  ```bash
12
- pi install npm:@huseynsnmz/pi-suite@0.2.4
12
+ pi install npm:@huseynsnmz/pi-suite@0.2.5
13
13
  ```
14
14
 
15
15
  ## Use
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { randomBytes } from "node:crypto";
6
6
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import { COMPACT_SYSTEM, compactPrompt, GLOBAL, parseProposals, PLAYBOOK_CHARS, readPlaybook, REVIEW_SYSTEM, reviewPrompt, writePlaybook } from "./playbook.ts";
7
+ import { COMPACT_SYSTEM, compactPrompt, GLOBAL, parseProposals, PLAYBOOK_CHARS, readPlaybook, readProposals, REVIEW_SYSTEM, reviewPrompt, writePlaybook } from "./playbook.ts";
8
8
  import { nextBatch } from "./review.ts";
9
9
  import { hasSecret } from "./secrets.ts";
10
10
  import type { MemoryStore } from "./store.ts";
@@ -31,16 +31,18 @@ async function ask(ctx: ExtensionContext, chosen: Mastermind, systemPrompt: stri
31
31
  ).result();
32
32
  // Not the provider's message, as some repeat part of the request, and that's your conversations.
33
33
  if (response.stopReason === "error" || response.stopReason === "aborted") throw new Error(`${chosen.model} failed (${response.stopReason})`);
34
+ if (response.stopReason === "length") throw new Error(`${chosen.model} ran out of room before it finished, so nothing was marked as reviewed. Try a lower reasoning level in /router.`);
34
35
  return response.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n");
35
36
  }
36
37
 
37
38
  /** The model has one chance to fit the limit. After that it's yours to trim in the editor. */
38
39
  async function propose(ctx: ExtensionContext, chosen: Mastermind, system: string, text: string, allowed: ReadonlySet<string>): Promise<Map<string, string>> {
39
- const first = parseProposals(await ask(ctx, chosen, system, text), allowed);
40
+ const first = readProposals(await ask(ctx, chosen, system, text), allowed);
40
41
  if (![...first.values()].some((doc) => doc.length > PLAYBOOK_CHARS)) return first;
41
42
  const again = `${text}\n\nYour last answer went over the limit of ${PLAYBOOK_CHARS} characters:\n\n${[...first].map(([scope, doc]) => `<playbook scope="${scope}">${doc}</playbook>`).join("\n")}\n\nAnswer again within the limit.`;
42
43
  const second = parseProposals(await ask(ctx, chosen, system, again), allowed);
43
- return second.size > 0 ? second : first;
44
+ // It may answer again for only the ones that were too long.
45
+ return new Map([...first, ...second]);
44
46
  }
45
47
 
46
48
  function why(text: string): string | undefined {
@@ -82,6 +82,13 @@ export function compactPrompt(playbooks: Map<string, string>): string {
82
82
  }
83
83
 
84
84
  /** What the model proposes, by scope. Only scopes it was given can come back, so it can't write to any other folder. */
85
+ /** A reply that's neither rules nor NONE was cut off or went wrong, and must not count as having nothing to say. */
86
+ export function readProposals(reply: string, allowed: ReadonlySet<string>): Map<string, string> {
87
+ const proposals = parseProposals(reply, allowed);
88
+ if (proposals.size === 0 && !/\bNONE\W*$/.test(reply)) throw new Error("Its answer couldn't be read, so nothing was marked as reviewed. Try again.");
89
+ return proposals;
90
+ }
91
+
85
92
  export function parseProposals(reply: string, allowed: ReadonlySet<string>): Map<string, string> {
86
93
  const proposals = new Map<string, string>();
87
94
  for (const match of reply.matchAll(/<playbook scope="([^"]*)">([\s\S]*?)<\/playbook>/g)) {
@@ -23,7 +23,7 @@ Requires Pi 1.5 or newer.
23
23
  1. Install it:
24
24
 
25
25
  ```bash
26
- pi install npm:@huseynsnmz/pi-suite@0.2.4
26
+ pi install npm:@huseynsnmz/pi-suite@0.2.5
27
27
  ```
28
28
 
29
29
  2. Move pi-suite above `@gotgenes/pi-permission-system`, because `pi install` adds packages last. Otherwise, in `plan` and `read-only` you may be asked to approve an action that is then blocked anyway. It stays blocked either way, and the extension warns you at startup. Skip this step if you don't use that package.
@@ -122,9 +122,9 @@ In `auto`, `edit` and `write` on files in the project skip the judge, so you are
122
122
 
123
123
  `/review-permissions` also reads the judge's decisions from the review log, and says why `auto` keeps asking.
124
124
 
125
- **Moving around:** bash keeps its folder, as in Claude Code. A `cd` at the start of a command carries over to later commands while it stays in the project, and the command's result tells the model so. A move outside runs once, and then the result says `Shell cwd was reset`. Later commands run as `cd <folder> && <command>`, which the permission system follows, so paths are checked from the right place. A `cd` it can't follow, such as `cd "$DIR"`, works for that command only. `read`, `edit` and `write` paths don't move.
125
+ **Moving around:** bash keeps its folder, as in Claude Code. A `cd` at the start of a command carries over to later commands while it stays in the project, and the command's result tells the model so. A move outside runs once, and then the result says `Shell cwd was reset`. The folder is saved in the session, so it's still there after `/reload` or `/resume`, and moving in `/tree` goes back to the folder that point was in. A folder that isn't there fails as it would in a shell, unless the model is repeating the move it just made. Later commands run as `cd <folder> && <command>`, which the permission system follows, so paths are checked from the right place. A `cd` it can't follow, such as `cd "$DIR"`, works for that command only. `read`, `edit` and `write` paths don't move.
126
126
 
127
- The permission system judges each part of a chain, so with `"*": "ask"`, every `cd x && go test` would ask about the `cd`. The first time you start Pi in a project, pi-modes allows `cd` inside that project in your bash rules, keeps a backup, and tells you. The rest of the command is still checked, and so is a `cd` anywhere else or one it can't read, such as `cd "$DIR"`. A blanket `"cd *": "allow"` is not the same: the permission system doesn't follow a new file's name into the folder a `cd` reached, so `cd /elsewhere && touch new` would go unchecked. It won't overwrite a rule of your own, and if you remove the rules, they stay removed. If the permission system loaded before pi-suite, restart Pi once for them to apply. Without a permission judge, add `"agent": "allow"` too, or you'll be asked about each `agent` call.
127
+ The permission system judges each part of a chain, so with `"*": "ask"`, every `cd x && go test` would ask about the `cd`. The first time you start Pi in a project, pi-modes allows `cd` inside that project in your bash rules, keeps a backup, and tells you. The rest of the command is still checked, and so is a `cd` anywhere else, one it can't read, such as `cd "$DIR"`, or one that leaves through `..` or a symlink. A blanket `"cd *": "allow"` is not the same: the permission system doesn't follow a new file's name into the folder a `cd` reached, so `cd /elsewhere && touch new` would go unchecked. It won't overwrite a rule of your own, and if you remove the rules, they stay removed. If the permission system loaded before pi-suite, restart Pi once for them to apply. Without a permission judge, add `"agent": "allow"` too, or you'll be asked about each `agent` call.
128
128
 
129
129
  Without a classifier, `auto` behaves like `normal`, and the extension warns you when you switch to it.
130
130
 
@@ -9,10 +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 { addCdRules, registerWorkdir } from "./workdir.ts";
13
- import { clearGate, hasPermissionSystem, installJudgeGate, JUDGE_NAMES, judgeGated, noteEditPaths, registerYoloLink, setGateCwd, setGateMode, YOLO_LINK } from "./judge-gate.ts";
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";
14
14
  import { readSection, writeSection } from "../../../src/settings.ts";
15
- 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";
16
16
 
17
17
  const STATUS_KEY = "pi-modes";
18
18
  const DENY_STATUS_KEY = "pi-modes-deny";
@@ -108,7 +108,7 @@ function restoredMode(ctx: ExtensionContext): Mode {
108
108
  const saved: unknown = (entry.data as { mode?: unknown } | undefined)?.mode;
109
109
  if (isMode(saved)) mode = saved;
110
110
  }
111
- if (mode === undefined && process.env.PI_MODES_START === "auto") return "auto"; // An agent started in auto.
111
+ if (mode === undefined && process.env.PI_SUBAGENT_CHILD === "1") return startingMode(process.env.PI_MODES_START); // Its parent's say, not the default.
112
112
  return mode === undefined ? startingMode(readConfig().defaultMode) : resumedMode(mode);
113
113
  }
114
114
 
@@ -119,7 +119,9 @@ export default function piModes(pi: ExtensionAPI): void {
119
119
  const project = realpathSync(process.cwd());
120
120
  const cdFolders = readConfig().cdRuleFolders;
121
121
  const handled = Array.isArray(cdFolders) ? (cdFolders as string[]) : [];
122
- const cdRule = process.env.PI_SUBAGENT_CHILD === "1" || handled.includes(project) ? { handled: false } : addCdRules(getAgentDir(), project);
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);
123
125
  if (cdRule.handled) writeSection(getAgentDir(), "modes", { ...readConfig(), cdRuleFolders: [...handled, project] });
124
126
  let cdNotice = cdRule.notice;
125
127
 
@@ -148,6 +150,8 @@ export default function piModes(pi: ExtensionAPI): void {
148
150
  if (next !== "yolo") denyOff = false;
149
151
  setGateMode(ctx.sessionManager.getSessionId(), next);
150
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));
151
155
  let others = withoutAliases(pi.getActiveTools().filter((name) => name !== SUBMIT_PLAN_TOOL), replacedTools);
152
156
  replacedTools = [];
153
157
  if (denyOff) ({ active: others, replaced: replacedTools } = withAliases(others));
@@ -39,6 +39,8 @@ interface SessionState {
39
39
  mode: Mode;
40
40
  judgeGated: boolean;
41
41
  cwd?: string;
42
+ /** Whether the `agent` tool is pi-suite's own. Another package's could do anything. */
43
+ agentIsOurs?: boolean;
42
44
  /** Where the model's latest edits and writes go, by call id, as an ask doesn't say. */
43
45
  editPaths?: Map<string, string>;
44
46
  }
@@ -46,6 +48,12 @@ interface SessionState {
46
48
  /** Inside a project, but they run code or change what Pi does, so the judge still looks. */
47
49
  const GUARDED = new Set([".git", ".pi", ".claude", ".husky", ".github", ".vscode", ".envrc"]);
48
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
+
49
57
  /** The path with symlinks followed, as far as it exists. Undefined for a link that leads nowhere. */
50
58
  function realTarget(path: string): string | undefined {
51
59
  if (existsSync(path)) return realpathSync(path);
@@ -63,19 +71,20 @@ function realTarget(path: string): string | undefined {
63
71
  /** An edit or write to an ordinary file in the project: what `auto` lets through without asking anyone. */
64
72
  export function isProjectEdit(details: unknown, cwd: string | undefined): boolean {
65
73
  const { toolName, path } = (details ?? {}) as { toolName?: unknown; path?: unknown };
66
- if ((toolName !== "edit" && toolName !== "write") || typeof path !== "string" || cwd === undefined) return false;
74
+ if ((toolName !== "edit" && toolName !== "write") || typeof path !== "string" || cwd === undefined || REWRITTEN_BY_PI.test(path)) return false;
67
75
  const expanded = path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
68
76
  const target = realTarget(resolve(cwd, expanded));
69
77
  const root = realTarget(cwd);
70
78
  if (target === undefined || root === undefined) return false;
71
79
  const inside = relative(root, target);
72
80
  if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return false;
73
- return !inside.split(sep).some((part) => GUARDED.has(part));
81
+ // Lowercase, as macOS finds `.GIT` at `.git` by default.
82
+ return !inside.split(sep).some((part) => GUARDED.has(part.toLowerCase()));
74
83
  }
75
84
 
76
85
  /** It does nothing by itself: what the agent then does is still checked. */
77
- function needsNoAsk(details: unknown): boolean {
78
- return (details as { toolName?: unknown } | undefined)?.toolName === AGENT_TOOL;
86
+ function needsNoAsk(state: SessionState, details: unknown): boolean {
87
+ return state.agentIsOurs === true && (details as { toolName?: unknown } | undefined)?.toolName === AGENT_TOOL;
79
88
  }
80
89
 
81
90
  function autoAllows(state: SessionState, details: unknown, log: unknown): boolean {
@@ -113,7 +122,7 @@ function gateService(store: Store, sessionId: string, service: ServiceLike): voi
113
122
  stateFor(store, sessionId).judgeGated = true;
114
123
  return register(name, async (details, query, log) => {
115
124
  const state = stateFor(store, sessionId);
116
- if (needsNoAsk(details)) return { kind: "allow" };
125
+ if (needsNoAsk(state, details)) return { kind: "allow" };
117
126
  if (state.mode !== "auto") return { kind: "defer" };
118
127
  return autoAllows(state, details, log) ? { kind: "allow" } : authorize(details, query, log);
119
128
  });
@@ -146,7 +155,7 @@ export function registerYoloLink(sessionId: string, store: Store = globalThis as
146
155
  if (typeof service?.registerAuthorizer !== "function") return undefined;
147
156
  return service.registerAuthorizer(YOLO_LINK, async (details, _query, log) => {
148
157
  const state = stateFor(store, sessionId);
149
- return needsNoAsk(details) || state.mode === "yolo" || autoAllows(state, details, log) ? { kind: "allow" } : { kind: "defer" };
158
+ return needsNoAsk(state, details) || state.mode === "yolo" || autoAllows(state, details, log) ? { kind: "allow" } : { kind: "defer" };
150
159
  });
151
160
  }
152
161
 
@@ -175,6 +184,10 @@ export function setGateCwd(sessionId: string, cwd: string, store: Store = global
175
184
  stateFor(store, sessionId).cwd = cwd;
176
185
  }
177
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
+
178
191
  export function judgeGated(sessionId: string, store: Store = globalThis as unknown as Store): boolean {
179
192
  return stateFor(store, sessionId).judgeGated;
180
193
  }
@@ -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
- permission.bash = withBashAllows(permission.bash, bash);
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 {
@@ -6,14 +6,18 @@
6
6
  */
7
7
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
8
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";
9
+ import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { applyChoices, checkChoices } from "./review/apply.mjs";
12
12
  import { ruleFor } from "./review/collect.mjs";
13
13
 
14
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@%+=:,./-]+$/;
15
19
  /** 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("'", "'\\''")}'`);
20
+ const quoted = (text: string) => (PLAIN.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`);
17
21
 
18
22
  function folderAt(path: string): string | undefined {
19
23
  try {
@@ -23,24 +27,41 @@ function folderAt(path: string): string | undefined {
23
27
  }
24
28
  }
25
29
 
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. */
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
+ */
27
34
  export function cdTarget(root: string, here: string | undefined, target: string): string | undefined {
28
35
  if (target === "-") return undefined;
29
36
  const path = target === "~" || target.startsWith("~/") ? join(homedir(), target.slice(1)) : target;
30
37
  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;
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
+ });
36
56
  }
37
57
 
38
58
  /** The command to really run, and the folder it moves to if it starts with a cd. */
39
59
  export function planCommand(root: string, here: string | undefined, command: string): { command: string; move?: string } {
40
60
  const match = LEADING_CD.exec(command);
41
61
  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}` };
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}` };
44
65
  }
45
66
 
46
67
  export function isInside(root: string, folder: string): boolean {
@@ -50,7 +71,30 @@ export function isInside(root: string, folder: string): boolean {
50
71
 
51
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. */
52
73
  export function cdRules(root: string): string[] {
53
- return [`cd ${root}`, `cd ${root}/*`];
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
+ }
54
98
  }
55
99
 
56
100
  /**
@@ -62,25 +106,37 @@ export function addCdRules(agentDir: string, root: string): { handled: boolean;
62
106
  const path = join(agentDir, "extensions", "pi-permission-system", "config.json");
63
107
  if (root === "/" || !existsSync(path)) return { handled: false };
64
108
  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");
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";
67
113
  if (!asks) return { handled: true };
68
114
  const rules = cdRules(root);
69
115
  if (checkChoices(agentDir, { bash: rules }).length > 0) return { handled: true }; // A path a rule can't hold.
70
- applyChoices(agentDir, { bash: rules });
116
+ applyChoices(agentDir, { bash: rules, guards: cdGuards(root) });
71
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.` };
72
118
  } catch {
73
119
  return { handled: false }; // Not ours to fix. It's tried again next start.
74
120
  }
75
121
  }
76
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
+
77
131
  export function registerWorkdir(pi: ExtensionAPI) {
78
132
  let here: string | undefined;
79
133
  const moves = new Map<string, string>();
80
- pi.on("session_start", () => {
81
- here = undefined;
134
+ const restore = (_event: unknown, ctx: ExtensionContext) => {
135
+ here = savedFolder(realpathSync(ctx.cwd), ctx.sessionManager.getBranch());
82
136
  moves.clear();
83
- });
137
+ };
138
+ pi.on("session_start", restore);
139
+ pi.on("session_tree", restore);
84
140
  pi.on("tool_result", (event, ctx) => {
85
141
  const move = moves.get(event.toolCallId);
86
142
  if (move === undefined) return undefined;
@@ -88,7 +144,9 @@ export function registerWorkdir(pi: ExtensionAPI) {
88
144
  if (event.isError) return undefined;
89
145
  const root = realpathSync(ctx.cwd);
90
146
  const stays = isInside(root, move);
91
- here = stays && move !== root ? move : undefined;
147
+ const next = stays && move !== root ? move : undefined;
148
+ if (next !== here) pi.appendEntry(WORKDIR_ENTRY, { folder: next ?? null });
149
+ here = next;
92
150
  // Told here rather than in the system prompt, which would cost every request and replace the subscription part's prompt.
93
151
  const note = stays ? `Bash stays in ${move} for later commands, so don't repeat the cd.` : `Shell cwd was reset to ${root}`;
94
152
  return { content: [...event.content, { type: "text" as const, text: `\n${note}` }] };
@@ -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.4
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`.
@@ -19,9 +19,15 @@ export function childArgs({ type, model, thinking }: ChildOptions): string[] {
19
19
  return ["-p", "--no-session", ...(model === undefined ? [] : ["--model", model]), ...(thinking === undefined ? [] : ["--thinking", thinking]), ...(type === "explore" ? ["--tools", READ_ONLY_TOOLS.join(",")] : [])];
20
20
  }
21
21
 
22
- /** How the permission system and pi-router know this is a child, and pi-modes knows auto carries over. */
22
+ /** The mode a child runs in. Plan's only way out is a person, so its explore agents just read, and yolo has to be asked for. */
23
+ function childMode(mode: string): string {
24
+ if (mode === "plan") return "read-only";
25
+ return mode === "yolo" ? "normal" : mode;
26
+ }
27
+
28
+ /** How the permission system and pi-router know this is a child, and pi-modes knows its mode. */
23
29
  export function childEnv(base: NodeJS.ProcessEnv, parentSession: string, mode: string | undefined): NodeJS.ProcessEnv {
24
- return { ...base, PI_SUBAGENT_CHILD: "1", PI_SUBAGENT_PARENT_SESSION: parentSession, ...(mode === "auto" ? { PI_MODES_START: "auto" } : {}) };
30
+ return { ...base, PI_SUBAGENT_CHILD: "1", PI_SUBAGENT_PARENT_SESSION: parentSession, ...(mode === undefined ? {} : { PI_MODES_START: childMode(mode) }) };
25
31
  }
26
32
 
27
33
  export function trimAnswer(answer: string): string {
@@ -29,14 +35,14 @@ export function trimAnswer(answer: string): string {
29
35
  return text.length > ANSWER_CHARS ? `${text.slice(0, ANSWER_CHARS)}\n[The answer was cut at ${ANSWER_CHARS} characters.]` : text;
30
36
  }
31
37
 
32
- function runChild(cwd: string, args: string[], env: NodeJS.ProcessEnv, prompt: string, signal: AbortSignal | undefined): Promise<string> {
38
+ export function runChild(cwd: string, command: string[], env: NodeJS.ProcessEnv, prompt: string, signal: AbortSignal | undefined): Promise<string> {
33
39
  return new Promise((resolve, reject) => {
34
- // Pi may run from a compiled binary, with no script to name.
35
- const script = process.argv[1];
36
- const command = script !== undefined && existsSync(script) ? [script, ...args] : args;
37
40
  const child = spawn(process.execPath, command, { cwd, env, stdio: ["pipe", "pipe", "pipe"], signal });
38
41
  let out = "";
39
42
  let err = "";
43
+ // A character can be split across two chunks.
44
+ child.stdout.setEncoding("utf8");
45
+ child.stderr.setEncoding("utf8");
40
46
  child.stdout.on("data", (data) => (out += data));
41
47
  child.stderr.on("data", (data) => (err = (err + data).slice(-ERROR_CHARS)));
42
48
  child.stdin.on("error", () => {}); // It can exit before reading. Its exit code says why.
@@ -69,7 +75,10 @@ export default function piSubagent(pi: ExtensionAPI): void {
69
75
  pi.events.emit("pi-modes:mode", probe);
70
76
  const model = ctx.model === undefined ? undefined : `${ctx.model.provider}/${ctx.model.id}`;
71
77
  const args = childArgs({ type: params.type ?? "explore", model, thinking: pi.getThinkingLevel() });
72
- const answer = await runChild(ctx.cwd, args, childEnv(process.env, ctx.sessionManager.getSessionId(), probe.mode), params.prompt, signal);
78
+ // Pi may run from a compiled binary, with no script to name.
79
+ const script = process.argv[1];
80
+ const command = script !== undefined && existsSync(script) ? [script, ...args] : args;
81
+ const answer = await runChild(ctx.cwd, command, childEnv(process.env, ctx.sessionManager.getSessionId(), probe.mode), params.prompt, signal);
73
82
  return { content: [{ type: "text" as const, text: answer }], details: {} };
74
83
  },
75
84
  });
@@ -9,7 +9,7 @@ A web page can hold instructions aimed at the AI reading it. So a separate model
9
9
  ## Install
10
10
 
11
11
  ```bash
12
- pi install npm:@huseynsnmz/pi-suite@0.2.4
12
+ pi install npm:@huseynsnmz/pi-suite@0.2.5
13
13
  ```
14
14
 
15
15
  `web_fetch` works straight away. It reads pages with the session's model, unless you set `readerModel` or use [pi-router](https://github.com/huseynsnmz/pi-suite/tree/main/packages/router), which can choose the reader. `web_search` stays off until you choose where searches go.