pi-t3-bridge 0.8.0 → 0.8.1

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,11 @@
1
1
  # Change log
2
2
 
3
+ ## 0.8.1, 6 November 2026
4
+
5
+ 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.
6
+
7
+ 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.
8
+
3
9
  ## 0.8.0, 5 November 2026
4
10
 
5
11
  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
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.1",
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`.
@@ -297,7 +297,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
297
297
  state.gitDir = undefined;
298
298
  }
299
299
 
300
- state.sandbox = await chooseSandbox(state.gitDir);
300
+ state.sandbox = await chooseSandbox(ctx.cwd, state.gitDir);
301
301
 
302
302
  state.baselineKb = await duKb(ctx.cwd);
303
303
 
@@ -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: {