pi-t3-bridge 0.8.1 → 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,9 @@
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
+
3
7
  ## 0.8.1, 6 November 2026
4
8
 
5
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.
package/README.md CHANGED
@@ -230,7 +230,7 @@ To choose your own models, run the `setup-pstack` skill, which writes a model sh
230
230
 
231
231
  ### Limits for delegated children
232
232
 
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.
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.
234
234
 
235
235
  ```mermaid
236
236
  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.2",
4
4
  "description": "Pi extension adapter for t3-pstack inside T3 Code.",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -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,
@@ -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;