pi-t3-bridge 0.8.1 → 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,13 @@
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
+
7
+ ## 0.8.2, 6 November 2026
8
+
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:`.
10
+
3
11
  ## 0.8.1, 6 November 2026
4
12
 
5
13
  Version 0.8.1 lets a delegated child run `pnpm install` and `git commit` in a pnpm and lefthook repo, under `bwrap` on Linux and `sandbox-exec` on macOS. Each child gets its own `XDG_RUNTIME_DIR` in the temp folder, where pnpm 12 takes its store lock. The sandbox can now write to the store that `pnpm store path` names for the workdir, and the child uses that store, so it shares the parent's store and does not build a second one in the workdir.
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
 
@@ -230,7 +234,7 @@ To choose your own models, run the `setup-pstack` skill, which writes a model sh
230
234
 
231
235
  ### Limits for delegated children
232
236
 
233
- When the first message of a session is a complete pstack brief, the extension treats the session as a delegated child and holds it to the limits in the brief. The child can't lift the limits through anything it writes.
237
+ When the first message of a session is a complete pstack brief, the extension treats the session as a delegated child and holds it to the limits in the brief. The child can't lift the limits through anything it writes. A T3-delegated child whose brief has a `# Task:` heading but misses a section or a header field gets every tool blocked, with a list of what is missing, so it never runs without the sandbox.
234
238
 
235
239
  ```mermaid
236
240
  sequenceDiagram
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-t3-bridge",
3
- "version": "0.8.1",
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
 
@@ -253,10 +270,9 @@ const dispatchable = (brief: Brief): boolean =>
253
270
  brief.diskMb !== undefined &&
254
271
  SECTIONS.every((section) => brief.sections.has(section));
255
272
 
256
- // The brief that started this session, when it is a delegated child.
257
- export const sessionBrief = (
273
+ const firstMessage = (
258
274
  ctx: ExtensionContext
259
- ): { readonly brief: Brief; readonly startedAt: number } | undefined => {
275
+ ): { readonly at: number; readonly text: string } | undefined => {
260
276
  const first = ctx.sessionManager
261
277
  .getBranch()
262
278
  .find((entry) => entry.type === "message" && entry.message.role === "user");
@@ -267,13 +283,38 @@ export const sessionBrief = (
267
283
 
268
284
  const { content } = first.message;
269
285
 
270
- const text = isString(content)
271
- ? content
272
- : content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
286
+ return {
287
+ at: Date.parse(first.timestamp),
288
+ text: isString(content)
289
+ ? content
290
+ : content
291
+ .map((part) => (part.type === "text" ? part.text : ""))
292
+ .join("\n"),
293
+ };
294
+ };
273
295
 
274
- const brief = parseBrief(text);
296
+ // The brief that started this session, when it is a delegated child.
297
+ export const sessionBrief = (
298
+ ctx: ExtensionContext
299
+ ): { readonly brief: Brief; readonly startedAt: number } | undefined => {
300
+ const first = firstMessage(ctx);
301
+ const brief = first === undefined ? undefined : parseBrief(first.text);
302
+
303
+ return first === undefined || brief === undefined || !dispatchable(brief)
304
+ ? undefined
305
+ : { brief, startedAt: first.at };
306
+ };
307
+
308
+ // What is wrong with a brief that has a `# Task:` heading but is not complete
309
+ // enough to hold a child to. Undefined when there is no such brief.
310
+ export const incompleteBrief = (
311
+ ctx: ExtensionContext,
312
+ rules: BriefRules
313
+ ): string[] | undefined => {
314
+ const first = firstMessage(ctx);
315
+ const brief = first === undefined ? undefined : parseBrief(first.text);
275
316
 
276
- return brief === undefined || !dispatchable(brief)
317
+ return first === undefined || brief === undefined || dispatchable(brief)
277
318
  ? undefined
278
- : { brief, startedAt: Date.parse(first.timestamp) };
319
+ : briefProblems(first.text, rules);
279
320
  };
@@ -22,9 +22,9 @@ import {
22
22
 
23
23
  import { bashTimeoutSeconds } from "../bridge/bash-timeout.ts";
24
24
  import { appendLedger } from "../bridge/state.ts";
25
- import { ownThreadId } from "../bridge/thread.ts";
26
- import { sessionBrief } from "./brief.ts";
27
- import type { Brief } from "./brief.ts";
25
+ import { isDelegatedChild, ownThreadId } from "../bridge/thread.ts";
26
+ import { incompleteBrief, sessionBrief } from "./brief.ts";
27
+ import type { Brief, BriefRules } from "./brief.ts";
28
28
  import { policyPath } from "./resources.ts";
29
29
  import {
30
30
  chooseSandbox,
@@ -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]);
101
113
 
102
- return Number(stdout.split(/\s/u)[0]);
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);
121
+
122
+ return total >= modules ? total - modules : total;
103
123
  } catch {
104
124
  return undefined;
105
125
  }
@@ -122,10 +142,22 @@ const isExecFailure = compile(
122
142
  )
123
143
  ).check;
124
144
 
145
+ const isLimits = compile(obj({ files: num(), minutes: num() })).check;
146
+
125
147
  const isPolicy = compile(
126
148
  obj(
127
149
  {
128
- briefs: opt(obj({ redBeforeGreen: opt(arr(str())) }, { open: true })),
150
+ briefs: opt(
151
+ obj(
152
+ {
153
+ noWrites: opt(arr(str())),
154
+ redBeforeGreen: opt(arr(str())),
155
+ sizes: opt(obj({}, { open: true })),
156
+ },
157
+ { open: true }
158
+ )
159
+ ),
160
+ jobs: opt(obj({}, { open: true })),
129
161
  },
130
162
  { open: true }
131
163
  )
@@ -139,10 +171,27 @@ const isPathInput = compile(obj({ path: str() }, { open: true })).check;
139
171
 
140
172
  const isNumber = compile(num()).check;
141
173
 
142
- const readRedBeforeGreen = (): readonly string[] => {
174
+ const readPolicy = () => {
143
175
  const policy: unknown = JSON.parse(readFileSync(policyPath(), "utf-8"));
144
176
 
145
- return isPolicy(policy) ? (policy.briefs?.redBeforeGreen ?? []) : [];
177
+ return isPolicy(policy) ? policy : {};
178
+ };
179
+
180
+ const readRedBeforeGreen = (): readonly string[] =>
181
+ readPolicy().briefs?.redBeforeGreen ?? [];
182
+
183
+ const readBriefRules = (): BriefRules => {
184
+ const { briefs, jobs } = readPolicy();
185
+
186
+ return {
187
+ jobs: Object.keys(jobs ?? {}),
188
+ noWrites: briefs?.noWrites ?? [],
189
+ sizes: Object.fromEntries(
190
+ Object.entries(briefs?.sizes ?? {}).flatMap(([size, limits]) =>
191
+ isLimits(limits) ? [[size, limits] as const] : []
192
+ )
193
+ ),
194
+ };
146
195
  };
147
196
 
148
197
  const changedFiles = async (cwd: string): Promise<string[]> => {
@@ -227,6 +276,7 @@ interface ChildState {
227
276
  diskCheck: Promise<void>;
228
277
  diskOver: boolean;
229
278
  diskQueued: boolean;
279
+ delegated: boolean | undefined;
230
280
  gitDir: string | undefined;
231
281
  greenAtStart: boolean;
232
282
  ready: boolean;
@@ -237,6 +287,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
237
287
  const state: ChildState = {
238
288
  baselineKb: undefined,
239
289
  before: undefined,
290
+ delegated: undefined,
240
291
  diskCheck: Promise.resolve(),
241
292
  diskOver: false,
242
293
  diskQueued: false,
@@ -331,7 +382,22 @@ export const registerChild = (pi: ExtensionAPI): void => {
331
382
  const started = sessionBrief(ctx);
332
383
 
333
384
  if (started === undefined) {
334
- return;
385
+ // A delegated child whose brief is incomplete would otherwise run with
386
+ // no sandbox and no limits, as if it were the user's own session.
387
+ const problems = incompleteBrief(ctx, readBriefRules());
388
+
389
+ if (problems === undefined) {
390
+ return;
391
+ }
392
+
393
+ state.delegated ??= await isDelegatedChild();
394
+
395
+ return state.delegated
396
+ ? {
397
+ block: true,
398
+ reason: `This delegated task's brief is incomplete, so it can't run in the sandbox and every tool is blocked. Use no more tools. Report STATUS: BLOCKED (incomplete brief) with these problems, so the parent can resend a complete brief:\n- ${problems.join("\n- ")}`,
399
+ }
400
+ : undefined;
335
401
  }
336
402
 
337
403
  const { brief, startedAt } = started;
@@ -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 = (