pi-t3-bridge 0.2.0 → 0.4.0

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.
Files changed (56) hide show
  1. package/README.md +5 -1
  2. package/overlay/overlay.json +118 -0
  3. package/package.json +15 -6
  4. package/pstack/skills/architect/SKILL.md +1 -1
  5. package/pstack/skills/arena/SKILL.md +1 -1
  6. package/pstack/skills/how/SKILL.md +1 -1
  7. package/pstack/skills/interrogate/SKILL.md +1 -1
  8. package/pstack/skills/poteto-help/SKILL.md +1 -1
  9. package/pstack/skills/poteto-mode/SKILL.md +4 -4
  10. package/pstack/skills/poteto-mode/brief.md +4 -4
  11. package/pstack/skills/poteto-mode/playbooks/autopilot-full.md +1 -1
  12. package/pstack/skills/poteto-mode/playbooks/orchestrate.md +1 -1
  13. package/pstack/skills/reflect/SKILL.md +1 -1
  14. package/pstack/skills/setup-pstack/SKILL.md +3 -3
  15. package/pstack/skills/statechart/scripts/run.ts +1 -1
  16. package/pstack/skills/swarm/SKILL.md +1 -1
  17. package/pstack/skills/why/SKILL.md +1 -1
  18. package/src/bridge/accounts.ts +2 -2
  19. package/src/bridge/ask.ts +2 -2
  20. package/src/bridge/bash-timeout.ts +1 -1
  21. package/src/bridge/config.ts +10 -2
  22. package/src/bridge/plan.ts +1 -1
  23. package/src/bridge/resume.ts +2 -2
  24. package/src/bridge/state.ts +2 -2
  25. package/src/bridge/steer-judge.ts +2 -2
  26. package/src/bridge/steer-transcript.ts +1 -1
  27. package/src/bridge/steer.ts +1 -1
  28. package/src/bridge/t3-json.ts +2 -2
  29. package/src/bridge/tasks.ts +2 -2
  30. package/src/bridge/thread.ts +1 -1
  31. package/src/bridge/transcript.ts +2 -2
  32. package/src/pstack/brief.ts +1 -1
  33. package/src/pstack/child.ts +50 -75
  34. package/src/pstack/models.ts +2 -2
  35. package/src/pstack/overlay.ts +251 -0
  36. package/src/pstack/resources.ts +16 -2
  37. package/src/pstack/results.ts +1 -1
  38. package/src/pstack/sandbox.ts +253 -0
  39. package/src/pstack/session.ts +4 -24
  40. package/tstack/LICENSE +21 -0
  41. package/tstack/README.md +53 -0
  42. package/tstack/skills/babysit-pr/SKILL.md +117 -0
  43. package/tstack/skills/babysit-pr/scripts/pr-wait +556 -0
  44. package/tstack/skills/council/SKILL.md +56 -0
  45. package/tstack/skills/file-pr/SKILL.md +50 -0
  46. package/tstack/skills/full-send/SKILL.md +123 -0
  47. package/tstack/skills/full-send/agents/openai.yaml +4 -0
  48. package/tstack/skills/full-stop/SKILL.md +47 -0
  49. package/tstack/skills/full-stop/agents/openai.yaml +4 -0
  50. package/tstack/skills/leaving-pr-comments/SKILL.md +44 -0
  51. package/tstack/skills/leaving-pr-comments/agents/openai.yaml +4 -0
  52. package/tstack/skills/modernize/SKILL.md +102 -0
  53. package/tstack/skills/modernize/agents/openai.yaml +4 -0
  54. package/tstack/skills/sol-loop/SKILL.md +119 -0
  55. package/tstack/skills/takeover/SKILL.md +42 -0
  56. package/tstack.ts +21 -0
@@ -1,5 +1,5 @@
1
- import { Type } from "@earendil-works/pi-ai";
2
- import type { Static, TSchema } from "@earendil-works/pi-ai";
1
+ import { Type } from "typebox";
2
+ import type { Static, TSchema } from "typebox";
3
3
  import { Value } from "typebox/value";
4
4
 
5
5
  const ResultEnvelopeSchema = Type.Object({
@@ -1,6 +1,6 @@
1
- import { Type } from "@earendil-works/pi-ai";
2
- import type { Static } from "@earendil-works/pi-ai";
3
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import type { Static } from "typebox";
4
4
 
5
5
  import {
6
6
  escapeHtml,
@@ -1,4 +1,4 @@
1
- import { Type } from "@earendil-works/pi-ai";
1
+ import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
 
4
4
  import { callT3Tool, insideT3 } from "./t3-http.ts";
@@ -1,10 +1,10 @@
1
- import { Type } from "@earendil-works/pi-ai";
2
- import type { Static } from "@earendil-works/pi-ai";
3
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
4
2
  import type {
5
3
  ExtensionAPI,
6
4
  ExtensionContext,
7
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+ import type { Static } from "typebox";
8
8
 
9
9
  import { registerUnlessTaken } from "./claim.ts";
10
10
 
@@ -1,5 +1,5 @@
1
- import { Type } from "@earendil-works/pi-ai";
2
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
3
  import { Value } from "typebox/value";
4
4
 
5
5
  // The child's extension reads its limits from the delegation brief
@@ -1,14 +1,14 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
2
+ import { lstatSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
 
7
- import { Type } from "@earendil-works/pi-ai";
8
7
  import type {
9
8
  ExtensionAPI,
10
9
  ExtensionContext,
11
10
  } from "@earendil-works/pi-coding-agent";
11
+ import { Type } from "typebox";
12
12
  import { Value } from "typebox/value";
13
13
 
14
14
  import { bashTimeoutSeconds } from "../bridge/bash-timeout.ts";
@@ -17,33 +17,29 @@ import { ownThreadId } from "../bridge/thread.ts";
17
17
  import { sessionBrief } from "./brief.ts";
18
18
  import type { Brief } from "./brief.ts";
19
19
  import { policyPath } from "./resources.ts";
20
+ import {
21
+ chooseSandbox,
22
+ real,
23
+ sandboxLabel,
24
+ unavailableReason,
25
+ wrap,
26
+ } from "./sandbox.ts";
27
+ import type { Sandbox } from "./sandbox.ts";
20
28
 
21
29
  // Rules a delegated child cannot talk its way around. A session whose first
22
- // message is a pstack brief gets them: writes only inside its workdir (a macOS
23
- // sandbox around every shell command) and only to its listed files (write and
24
- // edit), its time and disk limits, and a report line the code writes from
25
- // commands it runs itself, not from what the model says.
30
+ // message is a pstack brief gets them: writes only inside its workdir (a
31
+ // sandbox around every shell command, or no shell at all) and only to its
32
+ // listed files (write and edit), its time and disk limits, and a report line
33
+ // the code writes from commands it runs itself, not from what the model says.
26
34
 
27
35
  const run = promisify(execFile);
28
36
 
29
- const SANDBOX = "/usr/bin/sandbox-exec";
30
-
31
37
  const DU_TIMEOUT_MS = 15_000;
32
38
 
33
39
  const SCRATCH = ".scratch/";
34
40
 
35
41
  const WRITE_TOOLS: ReadonlySet<string> = new Set(["edit", "write"]);
36
42
 
37
- const quote = (text: string): string => `'${text.replaceAll("'", `'\\''`)}'`;
38
-
39
- const real = (target: string): string => {
40
- try {
41
- return realpathSync(target);
42
- } catch {
43
- return target;
44
- }
45
- };
46
-
47
43
  // Where a write to target lands once every symlink on its path is followed.
48
44
  // Undefined for a dangling link, whose landing spot cannot be known.
49
45
  const landing = (target: string): string | undefined => {
@@ -68,50 +64,6 @@ const landing = (target: string): string | undefined => {
68
64
  : path.join(base, path.basename(target));
69
65
  };
70
66
 
71
- // Tool caches and installs stay writable, so uv, npm and mise work.
72
- const writableRoots = (workdir: string, gitDir: string | undefined) => {
73
- const home = homedir();
74
-
75
- return [
76
- workdir,
77
- gitDir,
78
- path.join(home, ".cache"),
79
- path.join(home, "Library", "Caches"),
80
- path.join(home, ".npm"),
81
- path.join(home, ".local", "share", "uv"),
82
- path.join(home, ".local", "share", "mise"),
83
- "/private/var/folders",
84
- "/dev",
85
- ]
86
- .filter((root): root is string => root !== undefined)
87
- .flatMap((root) => [root, real(root)]);
88
- };
89
-
90
- // Hooks and config in the git dir run later, outside the sandbox, when the
91
- // parent commits or merges the child's work, so they stay read-only.
92
- const gitDenies = (gitDir: string | undefined): string[] =>
93
- gitDir === undefined
94
- ? []
95
- : [gitDir, real(gitDir)].flatMap((dir) => [
96
- `(subpath ${JSON.stringify(path.join(dir, "hooks"))})`,
97
- `(literal ${JSON.stringify(path.join(dir, "config"))})`,
98
- `(literal ${JSON.stringify(path.join(dir, "config.lock"))})`,
99
- ]);
100
-
101
- const sandboxProfile = (
102
- roots: readonly string[],
103
- denies: readonly string[]
104
- ): string =>
105
- [
106
- "(version 1)",
107
- "(allow default)",
108
- "(deny file-write*)",
109
- `(allow file-write* ${[...new Set(roots)].map((root) => `(subpath ${JSON.stringify(root)})`).join(" ")})`,
110
- ...(denies.length === 0
111
- ? []
112
- : [`(deny file-write* ${[...new Set(denies)].join(" ")})`]),
113
- ].join("");
114
-
115
67
  const globToRegExp = (glob: string): RegExp => {
116
68
  const source = glob
117
69
  .replace(/^\.\//u, "")
@@ -228,11 +180,12 @@ interface Report {
228
180
  readonly diskMb: number | undefined;
229
181
  readonly minutes: number;
230
182
  readonly outside: readonly string[];
183
+ readonly sandbox: Sandbox | undefined;
231
184
  readonly status: string;
232
185
  }
233
186
 
234
187
  const reportBlock = (report: Report): string => {
235
- const { accepted, brief, diskMb, minutes, outside } = report;
188
+ const { accepted, brief, diskMb, minutes, outside, sandbox } = report;
236
189
  const tail = accepted.tail.trim().split("\n").slice(-10).join("\n");
237
190
 
238
191
  const before =
@@ -248,6 +201,7 @@ const reportBlock = (report: Report): string => {
248
201
  `FILES: ${report.changed} changed; outside the list: ${outside.length === 0 ? "none" : outside.join(", ")}`,
249
202
  `DISK: ${diskMb === undefined ? "unknown" : `+${diskMb.toFixed(1)} MB`} in the workdir (limit ${brief.diskMb ?? "?"} MB)`,
250
203
  `TIME: ${minutes.toFixed(1)} of ${brief.minutes ?? "?"} min`,
204
+ `SANDBOX: ${sandboxLabel(sandbox)}`,
251
205
  ].join("\n");
252
206
  };
253
207
 
@@ -260,6 +214,7 @@ interface ChildState {
260
214
  gitDir: string | undefined;
261
215
  greenAtStart: boolean;
262
216
  ready: boolean;
217
+ sandbox: Sandbox | undefined;
263
218
  }
264
219
 
265
220
  export const registerChild = (pi: ExtensionAPI): void => {
@@ -272,24 +227,29 @@ export const registerChild = (pi: ExtensionAPI): void => {
272
227
  gitDir: undefined,
273
228
  greenAtStart: false,
274
229
  ready: false,
230
+ sandbox: undefined,
275
231
  };
276
232
 
277
- const sandboxed = (command: string, workdir: string): string =>
278
- existsSync(SANDBOX)
279
- ? `${SANDBOX} -p ${quote(sandboxProfile(writableRoots(workdir, state.gitDir), gitDenies(state.gitDir)))} /bin/bash -c ${quote(command)}`
280
- : command;
281
-
282
233
  const runAcceptance = async (
283
234
  command: string,
284
235
  workdir: string,
285
236
  seconds: number
286
237
  ): Promise<{ exit: number; tail: string }> => {
238
+ const wrapped = wrap(state.sandbox, command, workdir, state.gitDir);
239
+
240
+ if (wrapped === undefined) {
241
+ return {
242
+ exit: 126,
243
+ tail: `not run: no sandbox (${unavailableReason(state.sandbox)})`,
244
+ };
245
+ }
246
+
287
247
  try {
288
- const { stdout, stderr } = await run(
289
- "/bin/bash",
290
- ["-c", sandboxed(command, workdir)],
291
- { cwd: workdir, maxBuffer: 16 * 1024 * 1024, timeout: seconds * 1000 }
292
- );
248
+ const { stdout, stderr } = await run("/bin/bash", ["-c", wrapped], {
249
+ cwd: workdir,
250
+ maxBuffer: 16 * 1024 * 1024,
251
+ timeout: seconds * 1000,
252
+ });
293
253
 
294
254
  return { exit: 0, tail: `${stdout}${stderr}` };
295
255
  } catch (error) {
@@ -321,6 +281,8 @@ export const registerChild = (pi: ExtensionAPI): void => {
321
281
  state.gitDir = undefined;
322
282
  }
323
283
 
284
+ state.sandbox = await chooseSandbox(state.gitDir);
285
+
324
286
  state.baselineKb = await duKb(ctx.cwd);
325
287
 
326
288
  const redBeforeGreen = readRedBeforeGreen();
@@ -388,12 +350,21 @@ export const registerChild = (pi: ExtensionAPI): void => {
388
350
  const { input } = event;
389
351
 
390
352
  if (event.toolName === "bash" && Value.Check(BashInputSchema, input)) {
353
+ const wrapped = wrap(state.sandbox, input.command, ctx.cwd, state.gitDir);
354
+
355
+ if (wrapped === undefined) {
356
+ return {
357
+ block: true,
358
+ reason: `No sandbox can run for this shell (${unavailableReason(state.sandbox)}), so shell commands are blocked. The user can install \`bubblewrap\`, set kernel.apparmor_restrict_unprivileged_userns=0, or set "childSandbox": "off" in ~/.config/pi-t3/config.json. Use no more shell commands. Report STATUS: BLOCKED (no sandbox).`,
359
+ };
360
+ }
361
+
391
362
  const limit = Math.max(1, Math.ceil(remaining));
392
363
 
393
364
  input.timeout = Value.Check(Type.Number(), input.timeout)
394
365
  ? Math.min(input.timeout, limit)
395
366
  : limit;
396
- input.command = sandboxed(input.command, ctx.cwd);
367
+ input.command = wrapped;
397
368
 
398
369
  return;
399
370
  }
@@ -497,7 +468,10 @@ export const registerChild = (pi: ExtensionAPI): void => {
497
468
  const diskOver = state.diskOver || overLimit(diskMb, brief.diskMb);
498
469
 
499
470
  const status = reportStatus(
500
- state.greenAtStart || overTime || diskOver,
471
+ state.greenAtStart ||
472
+ overTime ||
473
+ diskOver ||
474
+ unavailableReason(state.sandbox) !== undefined,
501
475
  accepted,
502
476
  outside
503
477
  );
@@ -510,6 +484,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
510
484
  diskMb,
511
485
  minutes,
512
486
  outside,
487
+ sandbox: state.sandbox,
513
488
  status,
514
489
  });
515
490
 
@@ -1,12 +1,12 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
- import { Type } from "@earendil-works/pi-ai";
4
- import type { Static } from "@earendil-works/pi-ai";
5
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
6
4
  import type {
7
5
  ExtensionAPI,
8
6
  ExtensionToolContext,
9
7
  } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "typebox";
9
+ import type { Static } from "typebox";
10
10
  import { Value } from "typebox/value";
11
11
 
12
12
  import {
@@ -0,0 +1,251 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ statSync,
11
+ symlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import path from "node:path";
16
+
17
+ import { Type } from "@earendil-works/pi-ai";
18
+ import type { Static } from "@earendil-works/pi-ai";
19
+ import { Value } from "typebox/value";
20
+
21
+ interface Patch {
22
+ readonly find: string;
23
+ readonly replace: string;
24
+ }
25
+
26
+ const PatchSchema = Type.Object({
27
+ find: Type.String(),
28
+ replace: Type.String(),
29
+ });
30
+
31
+ const OverlayManifestSchema = Type.Object({
32
+ files: Type.Record(Type.String(), Type.Array(PatchSchema)),
33
+ global: Type.Array(PatchSchema),
34
+ shared: Type.Array(
35
+ Type.Object({
36
+ files: Type.Array(Type.String()),
37
+ patches: Type.Array(PatchSchema),
38
+ })
39
+ ),
40
+ version: Type.Number(),
41
+ });
42
+
43
+ export type OverlayManifest = Static<typeof OverlayManifestSchema>;
44
+
45
+ const TEXT_SUFFIX = ".md";
46
+ const COPIED_ENTRIES = ["skills", "agents", "models.json"] as const;
47
+
48
+ export const overlayDir = (extensionDir: string): string =>
49
+ path.join(extensionDir, "overlay");
50
+
51
+ export const loadManifest = (dir: string): OverlayManifest => {
52
+ const manifestPath = path.join(dir, "overlay.json");
53
+ const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf-8"));
54
+
55
+ if (!Value.Check(OverlayManifestSchema, parsed)) {
56
+ throw new Error(
57
+ `overlay manifest ${manifestPath} does not match the overlay schema`
58
+ );
59
+ }
60
+
61
+ return parsed;
62
+ };
63
+
64
+ const patchesFor = (
65
+ manifest: OverlayManifest,
66
+ relativePath: string
67
+ ): readonly Patch[] => {
68
+ const own = manifest.files[relativePath] ?? [];
69
+
70
+ const shared = manifest.shared
71
+ .filter((group) => group.files.includes(relativePath))
72
+ .flatMap((group) => group.patches);
73
+
74
+ return [...shared, ...own];
75
+ };
76
+
77
+ export const applyOverlay = (
78
+ manifest: OverlayManifest,
79
+ relativePath: string,
80
+ text: string
81
+ ): string => {
82
+ let result = text;
83
+
84
+ for (const patch of patchesFor(manifest, relativePath)) {
85
+ if (!result.includes(patch.find)) {
86
+ throw new Error(
87
+ `overlay patch for ${relativePath} no longer matches upstream text: ${JSON.stringify(patch.find.slice(0, 120))}. Update overlay/overlay.json for the new upstream wording.`
88
+ );
89
+ }
90
+
91
+ result = result.replaceAll(patch.find, patch.replace);
92
+ }
93
+
94
+ for (const patch of manifest.global) {
95
+ result = result.replaceAll(patch.find, patch.replace);
96
+ }
97
+
98
+ return result;
99
+ };
100
+
101
+ const walkFiles = (root: string, base = root): string[] =>
102
+ readdirSync(base, { withFileTypes: true }).flatMap((entry) => {
103
+ const full = path.join(base, entry.name);
104
+
105
+ if (entry.isDirectory()) {
106
+ return walkFiles(root, full);
107
+ }
108
+
109
+ return entry.isFile() ? [path.relative(root, full)] : [];
110
+ });
111
+
112
+ const overlayCacheRoot = (): string => {
113
+ const stateHome =
114
+ process.env.XDG_STATE_HOME?.trim() ||
115
+ path.join(homedir(), ".local", "state");
116
+
117
+ return path.join(stateHome, "pi-t3", "pstack-overlay");
118
+ };
119
+
120
+ const INPUT_ENTRIES = [
121
+ ...COPIED_ENTRIES,
122
+ "hooks/session-start-context.md",
123
+ ] as const;
124
+
125
+ const fingerprint = (
126
+ upstreamRoot: string,
127
+ manifestText: string,
128
+ extensionModules: string
129
+ ): string => {
130
+ const hash = createHash("sha256")
131
+ .update(manifestText)
132
+ .update(extensionModules);
133
+
134
+ for (const entry of INPUT_ENTRIES) {
135
+ const full = path.join(upstreamRoot, entry);
136
+
137
+ if (!existsSync(full)) {
138
+ continue;
139
+ }
140
+
141
+ const files = statSync(full).isDirectory() ? walkFiles(full) : [entry];
142
+
143
+ for (const relative of files.toSorted()) {
144
+ const filePath = statSync(full).isDirectory()
145
+ ? path.join(full, relative)
146
+ : full;
147
+
148
+ const name = statSync(full).isDirectory()
149
+ ? path.join(entry, relative)
150
+ : entry;
151
+
152
+ hash.update(name).update(readFileSync(filePath));
153
+ }
154
+ }
155
+
156
+ return hash.digest("hex").slice(0, 16);
157
+ };
158
+
159
+ // The pstack the agent reads: upstream copied out of pstack/, with the
160
+ // overlay in overlay/ applied. A copy keyed by the hash of both inputs, so
161
+ // an upstream sync or an overlay edit builds a fresh copy and a stale one
162
+ // is never served. Nothing here writes into pstack/.
163
+ export const materializePstack = (
164
+ upstreamRoot: string,
165
+ dir: string,
166
+ cacheRoot: string = overlayCacheRoot()
167
+ ): string => {
168
+ if (!existsSync(path.join(upstreamRoot, "skills"))) {
169
+ return upstreamRoot;
170
+ }
171
+
172
+ const manifestText = readFileSync(path.join(dir, "overlay.json"), "utf-8");
173
+ const manifest = loadManifest(dir);
174
+
175
+ const extensionModules = path.resolve(
176
+ path.dirname(upstreamRoot),
177
+ "node_modules"
178
+ );
179
+
180
+ const target = path.join(
181
+ cacheRoot,
182
+ fingerprint(upstreamRoot, manifestText, extensionModules)
183
+ );
184
+
185
+ if (
186
+ existsSync(path.join(target, ".complete")) &&
187
+ (!existsSync(extensionModules) ||
188
+ existsSync(path.join(target, "node_modules")))
189
+ ) {
190
+ return target;
191
+ }
192
+
193
+ mkdirSync(cacheRoot, { recursive: true });
194
+ rmSync(target, { force: true, recursive: true });
195
+
196
+ const staging = `${target}.${process.pid}.tmp`;
197
+
198
+ rmSync(staging, { force: true, recursive: true });
199
+
200
+ try {
201
+ for (const entry of COPIED_ENTRIES) {
202
+ const from = path.join(upstreamRoot, entry);
203
+
204
+ if (existsSync(from)) {
205
+ cpSync(from, path.join(staging, entry), { recursive: true });
206
+ }
207
+ }
208
+
209
+ const hookFrom = path.join(
210
+ upstreamRoot,
211
+ "hooks",
212
+ "session-start-context.md"
213
+ );
214
+
215
+ if (existsSync(hookFrom)) {
216
+ mkdirSync(path.join(staging, "hooks"), { recursive: true });
217
+ cpSync(hookFrom, path.join(staging, "hooks", "session-start-context.md"));
218
+ }
219
+
220
+ // Scripts under skills/ import packages such as commander. Inside the
221
+ // extension they resolve them from the extension's node_modules, so
222
+ // the copy links that directory in instead of copying it.
223
+ if (
224
+ existsSync(extensionModules) &&
225
+ !existsSync(path.join(staging, "node_modules"))
226
+ ) {
227
+ symlinkSync(extensionModules, path.join(staging, "node_modules"), "dir");
228
+ }
229
+
230
+ for (const relative of walkFiles(staging)) {
231
+ if (!relative.endsWith(TEXT_SUFFIX)) {
232
+ continue;
233
+ }
234
+
235
+ const filePath = path.join(staging, relative);
236
+
237
+ writeFileSync(
238
+ filePath,
239
+ applyOverlay(manifest, relative, readFileSync(filePath, "utf-8"))
240
+ );
241
+ }
242
+
243
+ writeFileSync(path.join(staging, ".complete"), "");
244
+ renameSync(staging, target);
245
+ } catch (error) {
246
+ rmSync(staging, { force: true, recursive: true });
247
+ throw error;
248
+ }
249
+
250
+ return target;
251
+ };
@@ -1,21 +1,35 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ import { materializePstack, overlayDir } from "./overlay.ts";
5
+
4
6
  export interface PstackPaths {
5
7
  readonly extensionDir: string;
6
8
  readonly pstackRoot: string;
7
9
  readonly skillsDir: string;
10
+ readonly upstreamRoot: string;
8
11
  }
9
12
 
13
+ let cached: PstackPaths | undefined;
14
+
10
15
  export const pstackPaths = (): PstackPaths => {
16
+ if (cached) {
17
+ return cached;
18
+ }
19
+
11
20
  const extensionDir = path.resolve(import.meta.dirname, "../..");
12
- const pstackRoot = path.join(extensionDir, "pstack");
21
+ const upstreamRoot = path.join(extensionDir, "pstack");
22
+
23
+ const pstackRoot = materializePstack(upstreamRoot, overlayDir(extensionDir));
13
24
 
14
- return {
25
+ cached = {
15
26
  extensionDir,
16
27
  pstackRoot,
17
28
  skillsDir: path.join(pstackRoot, "skills"),
29
+ upstreamRoot,
18
30
  };
31
+
32
+ return cached;
19
33
  };
20
34
 
21
35
  export const policyPath = (): string =>
@@ -1,5 +1,5 @@
1
- import { Type } from "@earendil-works/pi-ai";
2
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
3
  import { Value } from "typebox/value";
4
4
 
5
5
  import { readLedger } from "../bridge/state.ts";