pi-t3-bridge 0.8.0 → 0.8.2

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,15 @@
1
1
  # Change log
2
2
 
3
+ ## 0.8.2, 6 November 2026
4
+
5
+ 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:`.
6
+
7
+ ## 0.8.1, 6 November 2026
8
+
9
+ 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.
10
+
11
+ Children now skip install scripts, so a root `prepare` such as `lefthook install` no longer fails on the read-only git hooks. With this setting npm also skips the pre and post scripts of `npm run`, while pnpm still runs them. A brief that needs a postinstall step, such as `next typegen`, should name the build or typecheck that runs it.
12
+
3
13
  ## 0.8.0, 5 November 2026
4
14
 
5
15
  Version 0.8.0 removes the pi-amplike adapter (`amplike.ts`). The package no longer loads pi-amplike or changes its `/mode`, `/btw`, `/handoff`, and `subagent` for T3. Use T3's own `/btw`, `/side`, model picker, and `delegate_task` instead. If you set `{"source":"npm:pi-amplike","extensions":[]}` for the adapter, remove that entry from `~/.pi/agent/settings.json`.
package/README.md CHANGED
@@ -29,6 +29,8 @@ You need the following programs:
29
29
 
30
30
  A delegated child runs its shell commands in a sandbox, which is `sandbox-exec` on macOS and `bwrap` (bubblewrap) on Linux and WSL2. On Linux you need the `bubblewrap` package and unprivileged user namespaces. The Ubuntu `bubblewrap` package ships an AppArmor profile that lets `bwrap` run (checked on 26.04). If AppArmor still blocks `bwrap`, run `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`.
31
31
 
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
+
32
34
  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.
33
35
 
34
36
  ## Install
@@ -228,7 +230,7 @@ To choose your own models, run the `setup-pstack` skill, which writes a model sh
228
230
 
229
231
  ### Limits for delegated children
230
232
 
231
- 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.
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. 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.
232
234
 
233
235
  ```mermaid
234
236
  sequenceDiagram
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-t3-bridge",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Pi extension adapter for t3-pstack inside T3 Code.",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -60,7 +60,8 @@ ISSUES or BLOCKED: one line each with file:line and the command that shows it
60
60
 
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
- - Every shell command runs in a macOS sandbox that can write only to the workdir, its git directory, the tool caches and the system temp folder.
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
+ - 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`.
64
65
  - `write` and `edit` only touch the listed files and `.scratch/`.
65
66
  - Shell timeouts stop at the time limit. After it, or past the disk limit, every tool is refused and the child can only report.
66
67
  - When the child ends its turn, the extension reruns the acceptance command, `git status` and `du`, and appends a "Checked by t3-pstack" block with the real exit codes and STATUS. It also writes a ledger line to `~/.local/state/t3-pstack/ledger.jsonl`.
@@ -253,10 +253,9 @@ const dispatchable = (brief: Brief): boolean =>
253
253
  brief.diskMb !== undefined &&
254
254
  SECTIONS.every((section) => brief.sections.has(section));
255
255
 
256
- // The brief that started this session, when it is a delegated child.
257
- export const sessionBrief = (
256
+ const firstMessage = (
258
257
  ctx: ExtensionContext
259
- ): { readonly brief: Brief; readonly startedAt: number } | undefined => {
258
+ ): { readonly at: number; readonly text: string } | undefined => {
260
259
  const first = ctx.sessionManager
261
260
  .getBranch()
262
261
  .find((entry) => entry.type === "message" && entry.message.role === "user");
@@ -267,13 +266,38 @@ export const sessionBrief = (
267
266
 
268
267
  const { content } = first.message;
269
268
 
270
- const text = isString(content)
271
- ? content
272
- : content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
269
+ return {
270
+ at: Date.parse(first.timestamp),
271
+ text: isString(content)
272
+ ? content
273
+ : content
274
+ .map((part) => (part.type === "text" ? part.text : ""))
275
+ .join("\n"),
276
+ };
277
+ };
273
278
 
274
- const brief = parseBrief(text);
279
+ // The brief that started this session, when it is a delegated child.
280
+ export const sessionBrief = (
281
+ ctx: ExtensionContext
282
+ ): { readonly brief: Brief; readonly startedAt: number } | undefined => {
283
+ const first = firstMessage(ctx);
284
+ const brief = first === undefined ? undefined : parseBrief(first.text);
285
+
286
+ return first === undefined || brief === undefined || !dispatchable(brief)
287
+ ? undefined
288
+ : { brief, startedAt: first.at };
289
+ };
290
+
291
+ // What is wrong with a brief that has a `# Task:` heading but is not complete
292
+ // enough to hold a child to. Undefined when there is no such brief.
293
+ export const incompleteBrief = (
294
+ ctx: ExtensionContext,
295
+ rules: BriefRules
296
+ ): string[] | undefined => {
297
+ const first = firstMessage(ctx);
298
+ const brief = first === undefined ? undefined : parseBrief(first.text);
275
299
 
276
- return brief === undefined || !dispatchable(brief)
300
+ return first === undefined || brief === undefined || dispatchable(brief)
277
301
  ? undefined
278
- : { brief, startedAt: Date.parse(first.timestamp) };
302
+ : briefProblems(first.text, rules);
279
303
  };
@@ -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,
@@ -122,10 +122,22 @@ const isExecFailure = compile(
122
122
  )
123
123
  ).check;
124
124
 
125
+ const isLimits = compile(obj({ files: num(), minutes: num() })).check;
126
+
125
127
  const isPolicy = compile(
126
128
  obj(
127
129
  {
128
- briefs: opt(obj({ redBeforeGreen: opt(arr(str())) }, { open: true })),
130
+ briefs: opt(
131
+ obj(
132
+ {
133
+ noWrites: opt(arr(str())),
134
+ redBeforeGreen: opt(arr(str())),
135
+ sizes: opt(obj({}, { open: true })),
136
+ },
137
+ { open: true }
138
+ )
139
+ ),
140
+ jobs: opt(obj({}, { open: true })),
129
141
  },
130
142
  { open: true }
131
143
  )
@@ -139,10 +151,27 @@ const isPathInput = compile(obj({ path: str() }, { open: true })).check;
139
151
 
140
152
  const isNumber = compile(num()).check;
141
153
 
142
- const readRedBeforeGreen = (): readonly string[] => {
154
+ const readPolicy = () => {
143
155
  const policy: unknown = JSON.parse(readFileSync(policyPath(), "utf-8"));
144
156
 
145
- return isPolicy(policy) ? (policy.briefs?.redBeforeGreen ?? []) : [];
157
+ return isPolicy(policy) ? policy : {};
158
+ };
159
+
160
+ const readRedBeforeGreen = (): readonly string[] =>
161
+ readPolicy().briefs?.redBeforeGreen ?? [];
162
+
163
+ const readBriefRules = (): BriefRules => {
164
+ const { briefs, jobs } = readPolicy();
165
+
166
+ return {
167
+ jobs: Object.keys(jobs ?? {}),
168
+ noWrites: briefs?.noWrites ?? [],
169
+ sizes: Object.fromEntries(
170
+ Object.entries(briefs?.sizes ?? {}).flatMap(([size, limits]) =>
171
+ isLimits(limits) ? [[size, limits] as const] : []
172
+ )
173
+ ),
174
+ };
146
175
  };
147
176
 
148
177
  const changedFiles = async (cwd: string): Promise<string[]> => {
@@ -227,6 +256,7 @@ interface ChildState {
227
256
  diskCheck: Promise<void>;
228
257
  diskOver: boolean;
229
258
  diskQueued: boolean;
259
+ delegated: boolean | undefined;
230
260
  gitDir: string | undefined;
231
261
  greenAtStart: boolean;
232
262
  ready: boolean;
@@ -237,6 +267,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
237
267
  const state: ChildState = {
238
268
  baselineKb: undefined,
239
269
  before: undefined,
270
+ delegated: undefined,
240
271
  diskCheck: Promise.resolve(),
241
272
  diskOver: false,
242
273
  diskQueued: false,
@@ -297,7 +328,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
297
328
  state.gitDir = undefined;
298
329
  }
299
330
 
300
- state.sandbox = await chooseSandbox(state.gitDir);
331
+ state.sandbox = await chooseSandbox(ctx.cwd, state.gitDir);
301
332
 
302
333
  state.baselineKb = await duKb(ctx.cwd);
303
334
 
@@ -331,7 +362,22 @@ export const registerChild = (pi: ExtensionAPI): void => {
331
362
  const started = sessionBrief(ctx);
332
363
 
333
364
  if (started === undefined) {
334
- return;
365
+ // A delegated child whose brief is incomplete would otherwise run with
366
+ // no sandbox and no limits, as if it were the user's own session.
367
+ const problems = incompleteBrief(ctx, readBriefRules());
368
+
369
+ if (problems === undefined) {
370
+ return;
371
+ }
372
+
373
+ state.delegated ??= await isDelegatedChild();
374
+
375
+ return state.delegated
376
+ ? {
377
+ block: true,
378
+ 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- ")}`,
379
+ }
380
+ : undefined;
335
381
  }
336
382
 
337
383
  const { brief, startedAt } = started;
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, mkdirSync, realpathSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
@@ -34,10 +34,17 @@ const isProbeFailure = compile(
34
34
  )
35
35
  ).check;
36
36
 
37
+ // Settled once per child: its own XDG_RUNTIME_DIR, and the pnpm store it
38
+ // shares with the parent.
39
+ interface ChildEnv {
40
+ readonly pnpmStore: string | undefined;
41
+ readonly runtimeDir: string;
42
+ }
43
+
37
44
  export type Sandbox =
38
- | { kind: "bubblewrap"; bwrap: string }
39
- | { kind: "off" }
40
- | { kind: "sandbox-exec" }
45
+ | { kind: "bubblewrap"; bwrap: string; env: ChildEnv }
46
+ | { kind: "off"; env: ChildEnv }
47
+ | { kind: "sandbox-exec"; env: ChildEnv }
41
48
  | { kind: "unavailable"; reason: string };
42
49
 
43
50
  const quote = (text: string): string => `'${text.replaceAll("'", `'\\''`)}'`;
@@ -50,8 +57,30 @@ export const real = (target: string): string => {
50
57
  }
51
58
  };
52
59
 
53
- // Tool caches and installs stay writable, so uv, npm and mise work.
54
- const cacheRoots = (): string[] => {
60
+ // The store pnpm picks for this workdir outside the sandbox, from project and
61
+ // user config alike. Undefined when pnpm isn't installed. A store at or above
62
+ // home would open all of home, so it stays unbound.
63
+ const pnpmStore = async (workdir: string): Promise<string | undefined> => {
64
+ try {
65
+ const { stdout } = await run("pnpm", ["store", "path"], {
66
+ cwd: workdir,
67
+ timeout: PROBE_TIMEOUT_MS,
68
+ });
69
+
70
+ const store = path.dirname(stdout.trim());
71
+ const fromHome = path.relative(store, homedir());
72
+
73
+ return path.isAbsolute(store) && fromHome.startsWith("..")
74
+ ? store
75
+ : undefined;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ };
80
+
81
+ // Tool caches, installs and the pnpm store stay writable, so uv, npm, pnpm
82
+ // and mise work.
83
+ const cacheRoots = (store: string | undefined): string[] => {
55
84
  const home = homedir();
56
85
 
57
86
  return [
@@ -59,14 +88,19 @@ const cacheRoots = (): string[] => {
59
88
  path.join(home, ".npm"),
60
89
  path.join(home, ".local", "share", "uv"),
61
90
  path.join(home, ".local", "share", "mise"),
91
+ ...(store === undefined ? [] : [store]),
62
92
  ];
63
93
  };
64
94
 
65
- const writableRoots = (workdir: string, gitDir: string | undefined) =>
95
+ const writableRoots = (
96
+ workdir: string,
97
+ gitDir: string | undefined,
98
+ store: string | undefined
99
+ ) =>
66
100
  [
67
101
  workdir,
68
102
  gitDir,
69
- ...cacheRoots(),
103
+ ...cacheRoots(store),
70
104
  path.join(homedir(), "Library", "Caches"),
71
105
  "/private/var/folders",
72
106
  tmpdir(),
@@ -114,7 +148,8 @@ const bwrapArgs = (
114
148
  bwrap: string,
115
149
  command: string,
116
150
  workdir: string,
117
- gitDir: string | undefined
151
+ gitDir: string | undefined,
152
+ store: string | undefined
118
153
  ): string[] => [
119
154
  bwrap,
120
155
  "--ro-bind",
@@ -125,7 +160,7 @@ const bwrapArgs = (
125
160
  "/dev",
126
161
  "--proc",
127
162
  "/proc",
128
- ...existing(writableRoots(workdir, gitDir)).flatMap((root) =>
163
+ ...existing(writableRoots(workdir, gitDir, store)).flatMap((root) =>
129
164
  root === "/dev" ? [] : ["--bind", root, root]
130
165
  ),
131
166
  ...existing(
@@ -158,10 +193,18 @@ const probeBwrap = async (bwrap: string): Promise<string | undefined> => {
158
193
 
159
194
  // A missing root can't be bound, and a missing hooks directory could be
160
195
  // created by the child, so both are made on the host first. They are empty
161
- // directories, so git and the tools see no change.
162
- const prepareBinds = (gitDir: string | undefined): void => {
163
- for (const root of cacheRoots()) {
164
- mkdirSync(root, { recursive: true });
196
+ // directories, so git and the tools see no change. A root the user can't
197
+ // create stays unbound.
198
+ const prepareBinds = (
199
+ gitDir: string | undefined,
200
+ store: string | undefined
201
+ ): void => {
202
+ for (const root of cacheRoots(store)) {
203
+ try {
204
+ mkdirSync(root, { recursive: true });
205
+ } catch {
206
+ continue;
207
+ }
165
208
  }
166
209
 
167
210
  if (gitDir !== undefined) {
@@ -169,17 +212,23 @@ const prepareBinds = (gitDir: string | undefined): void => {
169
212
  }
170
213
  };
171
214
 
215
+ const childEnv = async (workdir: string): Promise<ChildEnv> => ({
216
+ pnpmStore: await pnpmStore(workdir),
217
+ runtimeDir: mkdtempSync(path.join(tmpdir(), "pi-t3-child-")),
218
+ });
219
+
172
220
  // Run once per child, before its first command. "childSandbox": "off" in the
173
221
  // config wins on every platform.
174
222
  export const chooseSandbox = async (
223
+ workdir: string,
175
224
  gitDir: string | undefined
176
225
  ): Promise<Sandbox> => {
177
226
  if (readConfig().childSandbox === "off") {
178
- return { kind: "off" };
227
+ return { env: await childEnv(workdir), kind: "off" };
179
228
  }
180
229
 
181
230
  if (process.platform === "darwin" && existsSync(SANDBOX)) {
182
- return { kind: "sandbox-exec" };
231
+ return { env: await childEnv(workdir), kind: "sandbox-exec" };
183
232
  }
184
233
 
185
234
  if (process.platform !== "linux") {
@@ -204,11 +253,24 @@ export const chooseSandbox = async (
204
253
  return { kind: "unavailable", reason: problem };
205
254
  }
206
255
 
207
- prepareBinds(gitDir);
256
+ const env = await childEnv(workdir);
208
257
 
209
- return { bwrap, kind: "bubblewrap" };
258
+ prepareBinds(gitDir, env.pnpmStore);
259
+
260
+ return { bwrap, env, kind: "bubblewrap" };
210
261
  };
211
262
 
263
+ // pnpm 12 takes its store lock in $XDG_RUNTIME_DIR, which the sandbox keeps
264
+ // read-only, so each child gets its own. Each bwrap bind is its own mount, and
265
+ // Linux refuses a hardlink between mounts, so pnpm would build a second store
266
+ // in the workdir. Naming the store keeps the one pnpm picked for the parent,
267
+ // and pnpm copies from it. Install scripts stay off, so a `prepare` such as `lefthook install`
268
+ // doesn't fail on the read-only hooks. npm and yarn read npm_config_*, pnpm 11
269
+ // and later read pnpm_config_*. npm also skips pre and post scripts of
270
+ // `npm run` then.
271
+ 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}`;
273
+
212
274
  // Undefined when no sandbox runs: the caller must not run the command.
213
275
  export const wrap = (
214
276
  sandbox: Sandbox | undefined,
@@ -218,17 +280,29 @@ export const wrap = (
218
280
  ): string | undefined => {
219
281
  switch (sandbox?.kind) {
220
282
  case "sandbox-exec": {
221
- return `${SANDBOX} -p ${quote(sandboxProfile(writableRoots(workdir, gitDir), gitDenies(gitDir)))} /bin/bash -c ${quote(command)}`;
283
+ return exportEnv(
284
+ sandbox.env,
285
+ `${SANDBOX} -p ${quote(sandboxProfile(writableRoots(workdir, gitDir, sandbox.env.pnpmStore), gitDenies(gitDir)))} /bin/bash -c ${quote(command)}`
286
+ );
222
287
  }
223
288
 
224
289
  case "bubblewrap": {
225
- return bwrapArgs(sandbox.bwrap, command, workdir, gitDir)
226
- .map(quote)
227
- .join(" ");
290
+ return exportEnv(
291
+ sandbox.env,
292
+ bwrapArgs(
293
+ sandbox.bwrap,
294
+ command,
295
+ workdir,
296
+ gitDir,
297
+ sandbox.env.pnpmStore
298
+ )
299
+ .map(quote)
300
+ .join(" ")
301
+ );
228
302
  }
229
303
 
230
304
  case "off": {
231
- return command;
305
+ return exportEnv(sandbox.env, command);
232
306
  }
233
307
 
234
308
  default: {