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 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 and fewer than 2 notes were sent for the current prompt
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-t3-bridge",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Pi extension adapter for t3-pstack inside T3 Code.",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -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.
@@ -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;
@@ -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
- const MAX_NUDGES = 2;
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
- // Counted from the branch, so the cap holds across a reload. A nudge is a
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 nudgesThisPrompt = (ctx: ExtensionContext): number => {
101
+ const atLimit = (ctx: ExtensionContext, group: NudgeGroup): boolean => {
85
102
  const branch = ctx.sessionManager.getBranch();
86
103
 
87
- return branch
88
- .slice(branch.findLastIndex(isUser) + 1)
89
- .filter(
90
- (entry) =>
91
- entry.type === "custom_message" && entry.customType === STEER_NOTE
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
- appendSteerLog({ ...base, kind: "length", nudged: mode === "on" });
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 mode === "on" ? nudge(event, "length") : undefined;
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
- appendSteerLog({ ...base, kind: "stop", nudged, verdict });
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
  });
@@ -64,9 +64,26 @@ const listItems = (section: string | undefined): string[] =>
64
64
  return [];
65
65
  }
66
66
 
67
- const [first = ""] = item.replaceAll("`", "").trim().split(/\s+/u);
68
-
69
- return [first.replace(/[:,]$/u, "")];
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
 
@@ -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
- const { stdout } = await run("du", ["-sk", target], {
99
- timeout: DU_TIMEOUT_MS,
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 Number(stdout.split(/\s/u)[0]);
122
+ return total >= modules ? total - modules : total;
103
123
  } catch {
104
124
  return undefined;
105
125
  }
@@ -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 = (