pi-t3-bridge 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/pstack/skills/poteto-mode/brief.md +3 -0
- package/src/bridge/state.ts +1 -0
- package/src/bridge/steer.ts +39 -21
- package/src/pstack/brief.ts +20 -3
- package/src/pstack/child.ts +24 -4
- package/src/pstack/sandbox.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Change log
|
|
2
2
|
|
|
3
|
+
## 0.8.3, 6 November 2026
|
|
4
|
+
|
|
5
|
+
Version 0.8.3 hardens the child sandbox from a large orchestration run. A bullet under "Files you may write" may now list several paths; every token with a slash or a glob counts. The disk limit no longer counts `node_modules`, which pnpm fills from the store, so a 500 MB limit no longer fails on a pnpm monorepo install. On Linux the child runs in its own PID namespace and cannot see or signal host processes, and anything a command starts in the background ends with that command. The child env now sets `TMPDIR`. Chrome, Electron and headless shells need `--no-sandbox` inside the sandbox, because their own namespace sandbox cannot nest under bwrap, and the sandbox already confines them; this is now documented, along with `PI_CODING_AGENT_DIR` for running `pi` inside a child.
|
|
6
|
+
|
|
3
7
|
## 0.8.2, 6 November 2026
|
|
4
8
|
|
|
5
9
|
Version 0.8.2 closes a hole in the child sandbox. A delegated child whose brief missed a section or a header field ran with no sandbox and no limits, because the bridge treated it as the user's own session. Such a child could rewrite the parent's git hooks. Now every tool in that child is blocked, and the block lists what the brief is missing, so the parent can resend a complete one. A top-level session is never blocked, even when its first message starts with `# Task:`.
|
package/README.md
CHANGED
|
@@ -31,6 +31,8 @@ A delegated child runs its shell commands in a sandbox, which is `sandbox-exec`
|
|
|
31
31
|
|
|
32
32
|
Inside the sandbox a child can write only to its workdir, its git directory, the tool caches, the pnpm store, and the system temp folder. The git hooks and git config stay read-only, because the parent runs them later outside the sandbox. Each child gets its own `XDG_RUNTIME_DIR` in the temp folder, where pnpm takes its store lock. The child also gets `pnpm_config_store_dir` set to the store that `pnpm store path` names for the workdir outside the sandbox, so it shares the parent's store. Under `bwrap`, pnpm copies packages from the store, because Linux refuses hardlinks across the sandbox's mounts. Children skip install scripts (`npm_config_ignore_scripts` and `pnpm_config_ignore_scripts` are `true`), so a root `prepare` such as `lefthook install` does not run. With this setting npm also skips the pre and post scripts of `npm run` and `npm test`, while pnpm still runs them. Steps that only a postinstall runs must run through the build or typecheck the brief names.
|
|
33
33
|
|
|
34
|
+
The child also gets `TMPDIR` set. On Linux the child cannot see or signal processes outside its own commands, so a stray `pkill` cannot reach a daemon on the host, and anything a command starts in the background dies when that command ends. Chrome, Electron and headless shells need `--no-sandbox` inside the sandbox, because their own namespace sandbox cannot nest under bwrap; the sandbox already confines them. A `pi` run inside the sandbox needs `PI_CODING_AGENT_DIR` pointing at a writable temp copy of `~/.pi/agent`, because the real one is read-only. The disk limit in a brief ignores `node_modules`, which pnpm fills from the store.
|
|
35
|
+
|
|
34
36
|
Native Windows and WSL1 have no sandbox. Where no sandbox can run, the bridge blocks a child's shell commands, unless you set `childSandbox` to `"off"` in the settings file.
|
|
35
37
|
|
|
36
38
|
## Install
|
|
@@ -150,7 +152,9 @@ on(run about to stop)
|
|
|
150
152
|
ask the classifier how the run ended
|
|
151
153
|
if the classifier is down or takes more than 5 seconds
|
|
152
154
|
let the run stop
|
|
153
|
-
if the agent gave up partway
|
|
155
|
+
if the agent gave up partway
|
|
156
|
+
if a stop note was already sent for the current prompt
|
|
157
|
+
log the check as capped in steer.jsonl and let the run stop
|
|
154
158
|
add a note
|
|
155
159
|
continue for one more turn
|
|
156
160
|
|
package/package.json
CHANGED
|
@@ -61,6 +61,9 @@ ISSUES or BLOCKED: one line each with file:line and the command that shows it
|
|
|
61
61
|
The child's own pstack extension reads this brief from its first message and enforces it, whatever the model does:
|
|
62
62
|
|
|
63
63
|
- Every shell command runs in a sandbox (`sandbox-exec` on macOS, `bwrap` on Linux) that can write only to the workdir, its git directory, the tool caches, the pnpm store and the system temp folder. Git hooks and git config stay read-only.
|
|
64
|
+
- All seven sections of this template are required. A delegated child whose brief misses one gets every tool blocked, so the parent must resend a complete brief.
|
|
65
|
+
- In "Files you may write", a bullet may list several paths. Every token with a slash or a glob counts, as in `- packages/a/** and packages/b/c.ts`.
|
|
66
|
+
- The disk limit ignores `node_modules`, which pnpm fills from the store, but build output still counts.
|
|
64
67
|
- Install scripts are skipped, so `pnpm install` or `npm install` runs no `prepare` or `postinstall`. Don't ask for `prepare` to pass. If a postinstall generates something, such as `next typegen`, name the build or typecheck that runs it in the acceptance command. npm also skips `pre` and `post` scripts of `npm run` and `npm test`, so name those too, as in `npm run prebuild && npm test`.
|
|
65
68
|
- `write` and `edit` only touch the listed files and `.scratch/`.
|
|
66
69
|
- Shell timeouts stop at the time limit. After it, or past the disk limit, every tool is refused and the child can only report.
|
package/src/bridge/state.ts
CHANGED
|
@@ -121,6 +121,7 @@ export const appendLedger = (
|
|
|
121
121
|
// One line per steer check, beside the ledger. It holds the verdict and never
|
|
122
122
|
// transcript text. A log that cannot be written must not break a session.
|
|
123
123
|
export const appendSteerLog = (line: {
|
|
124
|
+
readonly capped?: boolean;
|
|
124
125
|
readonly error?: string;
|
|
125
126
|
readonly fired?: readonly string[];
|
|
126
127
|
readonly kind: string;
|
package/src/bridge/steer.ts
CHANGED
|
@@ -37,8 +37,13 @@ import { isDelegatedChild } from "./thread.ts";
|
|
|
37
37
|
// github.com/dbreunig/pi-mast-steer), which only adds notes and never
|
|
38
38
|
// continues a stopped run.
|
|
39
39
|
|
|
40
|
-
// Per user prompt, so a model that keeps quitting cannot loop.
|
|
41
|
-
|
|
40
|
+
// Per user prompt, so a model that keeps quitting cannot loop. A model that
|
|
41
|
+
// stops again after a stop nudge has already reconsidered, and every second
|
|
42
|
+
// stop nudge in the user's sessions was wrong. A reply cut off at the output
|
|
43
|
+
// limit can need more than one continuation.
|
|
44
|
+
const NUDGE_LIMITS = { length: 2, stop: 1 } as const;
|
|
45
|
+
|
|
46
|
+
type NudgeGroup = keyof typeof NUDGE_LIMITS;
|
|
42
47
|
|
|
43
48
|
const UNAWARE_OF_TERMINATION = "1.5";
|
|
44
49
|
|
|
@@ -79,17 +84,28 @@ const nudge = (
|
|
|
79
84
|
],
|
|
80
85
|
});
|
|
81
86
|
|
|
82
|
-
|
|
87
|
+
const noteDetails = compile(obj({ kind: opt(str()) }, { open: true }));
|
|
88
|
+
|
|
89
|
+
const groupOf = (entry: SessionEntry): NudgeGroup | undefined => {
|
|
90
|
+
if (entry.type !== "custom_message" || entry.customType !== STEER_NOTE) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return noteDetails.check(entry.details) && entry.details.kind === "length"
|
|
95
|
+
? "length"
|
|
96
|
+
: "stop";
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Counted from the branch, so the limit holds across a reload. A nudge is a
|
|
83
100
|
// custom message, not a user message, so it does not reset the count.
|
|
84
|
-
const
|
|
101
|
+
const atLimit = (ctx: ExtensionContext, group: NudgeGroup): boolean => {
|
|
85
102
|
const branch = ctx.sessionManager.getBranch();
|
|
86
103
|
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
(entry) =>
|
|
91
|
-
|
|
92
|
-
).length;
|
|
104
|
+
return (
|
|
105
|
+
branch
|
|
106
|
+
.slice(branch.findLastIndex(isUser) + 1)
|
|
107
|
+
.filter((entry) => groupOf(entry) === group).length >= NUDGE_LIMITS[group]
|
|
108
|
+
);
|
|
93
109
|
};
|
|
94
110
|
|
|
95
111
|
// The newest note this feature added in the prompt, of either kind. MAST 1.5
|
|
@@ -441,23 +457,21 @@ export const registerSteer = (pi: ExtensionAPI): void => {
|
|
|
441
457
|
return;
|
|
442
458
|
}
|
|
443
459
|
|
|
444
|
-
if (nudgesThisPrompt(ctx) >= MAX_NUDGES) {
|
|
445
|
-
tell(ctx, "steer: limit reached", "info");
|
|
446
|
-
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
460
|
const base = {
|
|
451
461
|
mode,
|
|
452
462
|
model: last.model,
|
|
453
463
|
session: ctx.sessionManager.getSessionId(),
|
|
454
464
|
};
|
|
455
465
|
|
|
456
|
-
// Shadow mode only judges and logs, so it never continues a run.
|
|
466
|
+
// Shadow mode only judges and logs, so it never continues a run. A nudge
|
|
467
|
+
// the limit holds back is logged as capped and not shown.
|
|
457
468
|
if (last.stopReason === "length") {
|
|
458
|
-
|
|
469
|
+
const capped = mode === "on" && atLimit(ctx, "length");
|
|
470
|
+
const nudged = mode === "on" && !capped;
|
|
471
|
+
|
|
472
|
+
appendSteerLog({ ...base, capped, kind: "length", nudged });
|
|
459
473
|
|
|
460
|
-
return
|
|
474
|
+
return nudged ? nudge(event, "length") : undefined;
|
|
461
475
|
}
|
|
462
476
|
|
|
463
477
|
const input =
|
|
@@ -488,9 +502,13 @@ export const registerSteer = (pi: ExtensionAPI): void => {
|
|
|
488
502
|
}
|
|
489
503
|
|
|
490
504
|
const chosen = chooseNudge(ctx, verdict);
|
|
491
|
-
const nudged = mode === "on" && chosen !== undefined;
|
|
492
505
|
|
|
493
|
-
|
|
506
|
+
const capped =
|
|
507
|
+
mode === "on" && chosen !== undefined && atLimit(ctx, "stop");
|
|
508
|
+
|
|
509
|
+
const nudged = mode === "on" && chosen !== undefined && !capped;
|
|
510
|
+
|
|
511
|
+
appendSteerLog({ ...base, capped, kind: "stop", nudged, verdict });
|
|
494
512
|
|
|
495
513
|
return nudged ? nudge(event, chosen.kind, chosen.probability) : undefined;
|
|
496
514
|
});
|
package/src/pstack/brief.ts
CHANGED
|
@@ -64,9 +64,26 @@ const listItems = (section: string | undefined): string[] =>
|
|
|
64
64
|
return [];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// A bullet may list several paths. Every token with a slash or a glob
|
|
68
|
+
// counts, so "a/** and b/c.ts" writes both. A bullet with no such
|
|
69
|
+
// token keeps only its first word, which stays the old behavior for
|
|
70
|
+
// plain file names.
|
|
71
|
+
const tokens = item
|
|
72
|
+
.replaceAll("`", "")
|
|
73
|
+
.trim()
|
|
74
|
+
.split(/[\s,]+/u)
|
|
75
|
+
.map((token) =>
|
|
76
|
+
token
|
|
77
|
+
.replace(/^[([{\u201C']+/u, "")
|
|
78
|
+
.replace(/[.,;)\]}\u201D']+$/u, "")
|
|
79
|
+
)
|
|
80
|
+
.filter((token) => token !== "");
|
|
81
|
+
|
|
82
|
+
const paths = tokens.filter(
|
|
83
|
+
(token) => token.includes("/") || token.includes("*")
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return paths.length > 0 ? paths : [tokens[0] ?? ""];
|
|
70
87
|
})
|
|
71
88
|
.filter((item) => item !== "");
|
|
72
89
|
|
package/src/pstack/child.ts
CHANGED
|
@@ -93,13 +93,33 @@ export const allowedFile = (brief: Brief, relative: string): boolean =>
|
|
|
93
93
|
relative.startsWith(SCRATCH) ||
|
|
94
94
|
brief.files.some((file) => globToRegExp(file).test(relative));
|
|
95
95
|
|
|
96
|
+
const quote = (text: string): string => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
97
|
+
|
|
96
98
|
const duKb = async (target: string): Promise<number | undefined> => {
|
|
97
99
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
100
|
+
// node_modules is left out: pnpm fills it from the store, so its size is
|
|
101
|
+
// bounded by the lockfile and says nothing about runaway usage.
|
|
102
|
+
const { stdout } = await run(
|
|
103
|
+
"/bin/bash",
|
|
104
|
+
[
|
|
105
|
+
"-c",
|
|
106
|
+
`du -sk ${quote(target)} && find ${quote(target)} -name node_modules -prune -type d -exec du -sk {} +`,
|
|
107
|
+
],
|
|
108
|
+
{ timeout: DU_TIMEOUT_MS }
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const lines = stdout.trim().split("\n");
|
|
112
|
+
const total = Number(lines[0]?.split(/\s/u)[0]);
|
|
113
|
+
|
|
114
|
+
if (!Number.isFinite(total)) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const modules = lines
|
|
119
|
+
.slice(1)
|
|
120
|
+
.reduce((sum, line) => sum + Number(line.split(/\s/u)[0]), 0);
|
|
101
121
|
|
|
102
|
-
return
|
|
122
|
+
return total >= modules ? total - modules : total;
|
|
103
123
|
} catch {
|
|
104
124
|
return undefined;
|
|
105
125
|
}
|
package/src/pstack/sandbox.ts
CHANGED
|
@@ -158,6 +158,11 @@ const bwrapArgs = (
|
|
|
158
158
|
"--dev-bind",
|
|
159
159
|
"/dev",
|
|
160
160
|
"/dev",
|
|
161
|
+
// A child cannot see or signal processes outside its own commands, so a
|
|
162
|
+
// stray pkill from a child cannot reach a daemon on the host. What a
|
|
163
|
+
// command starts in the background dies when that command ends.
|
|
164
|
+
"--die-with-parent",
|
|
165
|
+
"--unshare-pid",
|
|
161
166
|
"--proc",
|
|
162
167
|
"/proc",
|
|
163
168
|
...existing(writableRoots(workdir, gitDir, store)).flatMap((root) =>
|
|
@@ -269,7 +274,7 @@ export const chooseSandbox = async (
|
|
|
269
274
|
// and later read pnpm_config_*. npm also skips pre and post scripts of
|
|
270
275
|
// `npm run` then.
|
|
271
276
|
const exportEnv = (env: ChildEnv, command: string): string =>
|
|
272
|
-
`export XDG_RUNTIME_DIR=${quote(env.runtimeDir)}${env.pnpmStore === undefined ? "" : ` pnpm_config_store_dir=${quote(env.pnpmStore)}`} npm_config_ignore_scripts=true pnpm_config_ignore_scripts=true; ${command}`;
|
|
277
|
+
`export XDG_RUNTIME_DIR=${quote(env.runtimeDir)} TMPDIR=${quote(tmpdir())}${env.pnpmStore === undefined ? "" : ` pnpm_config_store_dir=${quote(env.pnpmStore)}`} npm_config_ignore_scripts=true pnpm_config_ignore_scripts=true; ${command}`;
|
|
273
278
|
|
|
274
279
|
// Undefined when no sandbox runs: the caller must not run the command.
|
|
275
280
|
export const wrap = (
|