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.
- package/README.md +5 -1
- package/overlay/overlay.json +118 -0
- package/package.json +15 -6
- package/pstack/skills/architect/SKILL.md +1 -1
- package/pstack/skills/arena/SKILL.md +1 -1
- package/pstack/skills/how/SKILL.md +1 -1
- package/pstack/skills/interrogate/SKILL.md +1 -1
- package/pstack/skills/poteto-help/SKILL.md +1 -1
- package/pstack/skills/poteto-mode/SKILL.md +4 -4
- package/pstack/skills/poteto-mode/brief.md +4 -4
- package/pstack/skills/poteto-mode/playbooks/autopilot-full.md +1 -1
- package/pstack/skills/poteto-mode/playbooks/orchestrate.md +1 -1
- package/pstack/skills/reflect/SKILL.md +1 -1
- package/pstack/skills/setup-pstack/SKILL.md +3 -3
- package/pstack/skills/statechart/scripts/run.ts +1 -1
- package/pstack/skills/swarm/SKILL.md +1 -1
- package/pstack/skills/why/SKILL.md +1 -1
- package/src/bridge/accounts.ts +2 -2
- package/src/bridge/ask.ts +2 -2
- package/src/bridge/bash-timeout.ts +1 -1
- package/src/bridge/config.ts +10 -2
- package/src/bridge/plan.ts +1 -1
- package/src/bridge/resume.ts +2 -2
- package/src/bridge/state.ts +2 -2
- package/src/bridge/steer-judge.ts +2 -2
- package/src/bridge/steer-transcript.ts +1 -1
- package/src/bridge/steer.ts +1 -1
- package/src/bridge/t3-json.ts +2 -2
- package/src/bridge/tasks.ts +2 -2
- package/src/bridge/thread.ts +1 -1
- package/src/bridge/transcript.ts +2 -2
- package/src/pstack/brief.ts +1 -1
- package/src/pstack/child.ts +50 -75
- package/src/pstack/models.ts +2 -2
- package/src/pstack/overlay.ts +251 -0
- package/src/pstack/resources.ts +16 -2
- package/src/pstack/results.ts +1 -1
- package/src/pstack/sandbox.ts +253 -0
- package/src/pstack/session.ts +4 -24
- package/tstack/LICENSE +21 -0
- package/tstack/README.md +53 -0
- package/tstack/skills/babysit-pr/SKILL.md +117 -0
- package/tstack/skills/babysit-pr/scripts/pr-wait +556 -0
- package/tstack/skills/council/SKILL.md +56 -0
- package/tstack/skills/file-pr/SKILL.md +50 -0
- package/tstack/skills/full-send/SKILL.md +123 -0
- package/tstack/skills/full-send/agents/openai.yaml +4 -0
- package/tstack/skills/full-stop/SKILL.md +47 -0
- package/tstack/skills/full-stop/agents/openai.yaml +4 -0
- package/tstack/skills/leaving-pr-comments/SKILL.md +44 -0
- package/tstack/skills/leaving-pr-comments/agents/openai.yaml +4 -0
- package/tstack/skills/modernize/SKILL.md +102 -0
- package/tstack/skills/modernize/agents/openai.yaml +4 -0
- package/tstack/skills/sol-loop/SKILL.md +119 -0
- package/tstack/skills/takeover/SKILL.md +42 -0
- package/tstack.ts +21 -0
package/src/bridge/t3-json.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Type } from "
|
|
2
|
-
import type { Static, TSchema } from "
|
|
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({
|
package/src/bridge/tasks.ts
CHANGED
|
@@ -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,
|
package/src/bridge/thread.ts
CHANGED
package/src/bridge/transcript.ts
CHANGED
|
@@ -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
|
|
package/src/pstack/brief.ts
CHANGED
package/src/pstack/child.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
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
|
|
23
|
-
// sandbox around every shell command) and only to its
|
|
24
|
-
// edit), its time and disk limits, and a report line
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 =
|
|
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 ||
|
|
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
|
|
package/src/pstack/models.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/pstack/resources.ts
CHANGED
|
@@ -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
|
|
21
|
+
const upstreamRoot = path.join(extensionDir, "pstack");
|
|
22
|
+
|
|
23
|
+
const pstackRoot = materializePstack(upstreamRoot, overlayDir(extensionDir));
|
|
13
24
|
|
|
14
|
-
|
|
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 =>
|
package/src/pstack/results.ts
CHANGED