@piagent/platform 1.44.4 → 1.46.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/CHANGELOG.md +39 -0
- package/README.md +5 -3
- package/README.vi.md +5 -3
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/assets/search-en.json +1 -1
- package/docs-site/assets/search-vi.json +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +15 -15
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/update.html +3 -3
- package/docs-site/en/whats-new.html +58 -35
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +15 -15
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/releases.json +75 -0
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/update.html +3 -3
- package/docs-site/whats-new.html +58 -35
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/benchmark/benchmark-bootstrap.js +3 -1
- package/packages/piagent-core/extensions/agent-home.d.ts +4 -0
- package/packages/piagent-core/extensions/agent-home.js +52 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +5 -1
- package/packages/piagent-core/extensions/working-tree-snapshot-worker.mjs +9 -0
- package/packages/piagent-core/extensions/workspace-evidence-roots.js +28 -2
- package/packages/piagent-core/mcp/mcp-config-layers.js +4 -1
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/input-hook.ts +17 -3
- package/packages/piagent-core/runtime/input/chat-images.ts +66 -10
- package/packages/piagent-core/runtime/input/file-arguments.ts +102 -0
- package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +2 -6
- package/packages/piagent-core/runtime/inspection/working-tree-off-thread.ts +52 -0
- package/packages/piagent-core/runtime/solver/runtime-features.ts +15 -3
- package/packages/piagent-webui/extension/piagent-webui.ts +2 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +4 -1
- package/packages/piagent-webui/gateway/pi-host.ts +2 -2
- package/packages/piagent-webui/ownership/profile-state.ts +10 -1
- package/packages/piagent-webui/server/benchmark-release-monitor.ts +2 -1
- package/scripts/benchmark-runner-support.mjs +2 -1
- package/scripts/dashboard-update-job.mjs +4 -2
- package/scripts/install-global.sh +113 -5
- package/scripts/model-scope-prune.mjs +2 -1
- package/scripts/personal-pi-config.mjs +2 -1
- package/scripts/personal-pi-restore.mjs +184 -0
- package/scripts/pi-usage-history.mjs +2 -1
- package/scripts/piagent-cli.mjs +49 -9
- package/scripts/piagent-dashboard.mjs +5 -3
- package/scripts/setup.sh +2 -6
- package/scripts/team-doctor.sh +11 -5
- package/scripts/uninstall-global.sh +17 -2
- package/scripts/update-global.mjs +5 -2
|
@@ -29,6 +29,25 @@ type ResolvedChatImage =
|
|
|
29
29
|
| { status: "ok"; absolutePath: string; bytes: Buffer; mimeType: string }
|
|
30
30
|
| { status: "error"; reason: string };
|
|
31
31
|
|
|
32
|
+
export type ChatPathAccess =
|
|
33
|
+
| { status: "ok"; canonicalPath: string; stats: fs.Stats }
|
|
34
|
+
| { status: "error"; reason: string };
|
|
35
|
+
|
|
36
|
+
// Pi saves a pasted clipboard image as <tmpdir>/pi-clipboard-<uuid>.<ext> and
|
|
37
|
+
// inserts that path into the prompt. The operator created it by pasting, so it
|
|
38
|
+
// counts as granted even though the temporary directory is not.
|
|
39
|
+
const PI_CLIPBOARD_IMAGE = /^pi-clipboard-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:png|jpe?g|gif|webp|bmp)$/i;
|
|
40
|
+
|
|
41
|
+
function clipboardImageRoot(canonicalPath: string): { path: string; source: string } | undefined {
|
|
42
|
+
if (!PI_CLIPBOARD_IMAGE.test(path.basename(canonicalPath))) return undefined;
|
|
43
|
+
try {
|
|
44
|
+
const temporary = fs.realpathSync.native(os.tmpdir());
|
|
45
|
+
return path.dirname(canonicalPath) === temporary ? { path: temporary, source: "clipboard" } : undefined;
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
function escapeRegExp(input: string): string {
|
|
33
52
|
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
34
53
|
}
|
|
@@ -108,12 +127,31 @@ function normalizeImagePathCandidate(candidate: string, cwd: string, options: {
|
|
|
108
127
|
}
|
|
109
128
|
const ext = path.extname(raw).toLowerCase().replace(/^\./, "");
|
|
110
129
|
if (!IMAGE_EXTENSIONS.includes(ext as typeof IMAGE_EXTENSIONS[number])) return undefined;
|
|
111
|
-
|
|
130
|
+
const resolved = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
|
|
131
|
+
// A file dropped on a terminal arrives shell-escaped: "Screenshot\ 1.png".
|
|
132
|
+
if (!WINDOWS && /\\[ ()'&]/.test(resolved) && !fs.existsSync(resolved)) {
|
|
133
|
+
const unescaped = resolved.replace(/\\([ ()'&])/g, "$1");
|
|
134
|
+
if (fs.existsSync(unescaped)) return unescaped;
|
|
135
|
+
}
|
|
136
|
+
return resolved;
|
|
112
137
|
}
|
|
113
138
|
|
|
114
|
-
|
|
139
|
+
// Pi's own @file arguments arrive as <file name="..."> blocks with their images
|
|
140
|
+
// already attached; scanning them again would attach each image twice.
|
|
141
|
+
const FILE_ARGUMENT_TAG = /<file name="[^"\n]*">/g;
|
|
142
|
+
|
|
143
|
+
export function extractLocalImagePathCandidates(input: string, cwd: string): string[] {
|
|
144
|
+
const text = input.replace(FILE_ARGUMENT_TAG, "");
|
|
115
145
|
const candidates = new Set<string>();
|
|
116
146
|
const imageExt = "(?:png|jpe?g|gif|webp|bmp)";
|
|
147
|
+
|
|
148
|
+
// Pi's @ completion inserts @path, or @"path" when the path has spaces.
|
|
149
|
+
const mention = new RegExp(`(?:^|\\s)@(?:"([^"\\n]+\\.${imageExt})"|([^\\s"'<>@]+\\.${imageExt}))(?=$|[\\s,;:!?)])`, "gi");
|
|
150
|
+
for (const match of text.matchAll(mention)) {
|
|
151
|
+
const normalized = normalizeImagePathCandidate(match[1] ?? match[2], cwd);
|
|
152
|
+
if (normalized) candidates.add(normalized);
|
|
153
|
+
}
|
|
154
|
+
|
|
117
155
|
const wholeTextPath = normalizeImagePathCandidate(text, cwd, { allowBareRelative: false });
|
|
118
156
|
if (wholeTextPath) candidates.add(wholeTextPath);
|
|
119
157
|
|
|
@@ -149,11 +187,12 @@ function pathContainedBy(root: string, candidate: string): boolean {
|
|
|
149
187
|
&& !path.isAbsolute(relative);
|
|
150
188
|
}
|
|
151
189
|
|
|
152
|
-
|
|
190
|
+
/** Whether the operator's policy lets a chat message carry this file at all. */
|
|
191
|
+
export function checkChatPathAccess(
|
|
153
192
|
requestedPath: string,
|
|
154
193
|
cwd: string,
|
|
155
194
|
access: ChatImageAccessPolicy
|
|
156
|
-
):
|
|
195
|
+
): ChatPathAccess {
|
|
157
196
|
const lexicalRequestedPath = path.isAbsolute(requestedPath)
|
|
158
197
|
? path.normalize(requestedPath)
|
|
159
198
|
: path.resolve(cwd, requestedPath);
|
|
@@ -170,17 +209,16 @@ export function readChatImage(
|
|
|
170
209
|
return { status: "error", reason: "file does not exist or cannot be inspected" };
|
|
171
210
|
}
|
|
172
211
|
if (!inspected.isFile()) return { status: "error", reason: "not a regular file" };
|
|
173
|
-
|
|
174
|
-
|
|
212
|
+
|
|
213
|
+
const root = access.roots.find((candidate) => pathContainedBy(candidate.path, canonicalPath))
|
|
214
|
+
?? clipboardImageRoot(canonicalPath);
|
|
215
|
+
if (!root) {
|
|
175
216
|
return {
|
|
176
217
|
status: "error",
|
|
177
|
-
reason:
|
|
218
|
+
reason: "outside the project; grant its folder with profile additionalReadRoots or PIAGENT_ADDITIONAL_READ_ROOTS"
|
|
178
219
|
};
|
|
179
220
|
}
|
|
180
221
|
|
|
181
|
-
const root = access.roots.find((candidate) => pathContainedBy(candidate.path, canonicalPath));
|
|
182
|
-
if (!root) return { status: "error", reason: "outside the project and every granted additionalReadRoots directory" };
|
|
183
|
-
|
|
184
222
|
const rootRelative = path.relative(root.path, canonicalPath).split(path.sep).join("/") || ".";
|
|
185
223
|
const projectRoot = access.roots.find((candidate) => candidate.source === "project");
|
|
186
224
|
let canonicalRequestedLocation = requestedPath;
|
|
@@ -216,6 +254,24 @@ export function readChatImage(
|
|
|
216
254
|
reason: `outside the resolved filesystem read scope (${access.filesystemRead.join(", ")})`
|
|
217
255
|
};
|
|
218
256
|
}
|
|
257
|
+
return { status: "ok", canonicalPath, stats: inspected };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function readChatImage(
|
|
261
|
+
requestedPath: string,
|
|
262
|
+
cwd: string,
|
|
263
|
+
access: ChatImageAccessPolicy
|
|
264
|
+
): ResolvedChatImage {
|
|
265
|
+
const checked = checkChatPathAccess(requestedPath, cwd, access);
|
|
266
|
+
if (checked.status === "error") return checked;
|
|
267
|
+
const { canonicalPath, stats: inspected } = checked;
|
|
268
|
+
if (inspected.size <= 0) return { status: "error", reason: "empty file" };
|
|
269
|
+
if (inspected.size > MAX_CHAT_IMAGE_BYTES) {
|
|
270
|
+
return {
|
|
271
|
+
status: "error",
|
|
272
|
+
reason: `image is ${formatCount(inspected.size)} bytes > ${formatCount(MAX_CHAT_IMAGE_BYTES)} byte limit`
|
|
273
|
+
};
|
|
274
|
+
}
|
|
219
275
|
|
|
220
276
|
access.onImageInspected?.(canonicalPath);
|
|
221
277
|
const openFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { checkChatPathAccess, supportedChatImageMimeType } from "./chat-images.ts";
|
|
5
|
+
import type { ChatImageAccessPolicy } from "./chat-images.ts";
|
|
6
|
+
|
|
7
|
+
// `pi @notes.md @screen.png "…"` reads each file before any extension runs and
|
|
8
|
+
// sends it as one <file name="/abs/path">…</file> block per argument, images
|
|
9
|
+
// attached in argument order. Pi applies no read policy, so `pi @.env` would
|
|
10
|
+
// hand a secret to the model. This holds those blocks to the same rule as a
|
|
11
|
+
// pasted image path: inside a granted root, not protected, within read scope.
|
|
12
|
+
|
|
13
|
+
export type FileArgumentReview = {
|
|
14
|
+
text: string;
|
|
15
|
+
images: unknown[];
|
|
16
|
+
withheld: Array<{ path: string; reason: string }>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type FileBlock = { path: string; start: number; end: number; image: boolean; attachesImage: boolean };
|
|
20
|
+
|
|
21
|
+
const BLOCK_OPEN = /<file name="([^"\n]+)">/g;
|
|
22
|
+
|
|
23
|
+
function stripBom(text: string): string {
|
|
24
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Walk the contiguous run of blocks Pi wrote. Each block's end is found from
|
|
28
|
+
// the file itself, so a file whose text contains "</file>" cannot end it early.
|
|
29
|
+
// undefined: a block no longer matches its file, and the run cannot be trusted.
|
|
30
|
+
function parseFileBlocks(text: string): FileBlock[] | undefined {
|
|
31
|
+
BLOCK_OPEN.lastIndex = 0;
|
|
32
|
+
const first = BLOCK_OPEN.exec(text);
|
|
33
|
+
if (!first) return [];
|
|
34
|
+
const blocks: FileBlock[] = [];
|
|
35
|
+
let cursor = first.index;
|
|
36
|
+
while (text.startsWith("<file name=\"", cursor)) {
|
|
37
|
+
BLOCK_OPEN.lastIndex = cursor;
|
|
38
|
+
const open = BLOCK_OPEN.exec(text);
|
|
39
|
+
if (!open || open.index !== cursor || !path.isAbsolute(open[1])) break;
|
|
40
|
+
const filePath = open[1];
|
|
41
|
+
const bodyStart = cursor + open[0].length;
|
|
42
|
+
let bytes: Buffer;
|
|
43
|
+
try { bytes = fs.readFileSync(filePath); } catch { return undefined; }
|
|
44
|
+
if (supportedChatImageMimeType(bytes) || !text.startsWith("\n", bodyStart)) {
|
|
45
|
+
// An image block holds nothing, or a one-line note from Pi.
|
|
46
|
+
const close = text.indexOf("</file>\n", bodyStart);
|
|
47
|
+
if (close < 0) return undefined;
|
|
48
|
+
const note = text.slice(bodyStart, close);
|
|
49
|
+
blocks.push({ path: filePath, start: cursor, end: close + 8, image: true,
|
|
50
|
+
attachesImage: !note.startsWith("[Image omitted") });
|
|
51
|
+
cursor = close + 8;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const expected = `\n${stripBom(bytes.toString("utf8"))}\n</file>\n`;
|
|
55
|
+
if (!text.startsWith(expected, bodyStart)) return undefined;
|
|
56
|
+
blocks.push({ path: filePath, start: cursor, end: bodyStart + expected.length, image: false, attachesImage: false });
|
|
57
|
+
cursor = bodyStart + expected.length;
|
|
58
|
+
}
|
|
59
|
+
return blocks;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Withhold Pi @file blocks the read policy does not allow; undefined when there is nothing to change. */
|
|
63
|
+
export function reviewFileArguments(
|
|
64
|
+
text: string,
|
|
65
|
+
existingImages: unknown[] | undefined,
|
|
66
|
+
cwd: string,
|
|
67
|
+
resolveAccess: () => ChatImageAccessPolicy
|
|
68
|
+
): FileArgumentReview | undefined {
|
|
69
|
+
if (!text.includes("<file name=\"")) return undefined;
|
|
70
|
+
const images = Array.isArray(existingImages) ? existingImages : [];
|
|
71
|
+
const blocks = parseFileBlocks(text);
|
|
72
|
+
if (blocks === undefined) {
|
|
73
|
+
return { text: "", images: [], withheld: [{ path: "(@file arguments)", reason: "a file changed while Pi was reading it" }] };
|
|
74
|
+
}
|
|
75
|
+
if (blocks.length === 0) return undefined;
|
|
76
|
+
const access = resolveAccess();
|
|
77
|
+
const withheld: FileArgumentReview["withheld"] = [];
|
|
78
|
+
const droppedImages = new Set<number>();
|
|
79
|
+
const imageBlocks = blocks.filter((block) => block.attachesImage);
|
|
80
|
+
// Pi attaches only its file images to the first message; any other count
|
|
81
|
+
// means the order cannot be matched, so a withheld image drops them all.
|
|
82
|
+
const imagesMatch = imageBlocks.length === images.length;
|
|
83
|
+
let nextText = "";
|
|
84
|
+
let cursor = 0;
|
|
85
|
+
for (const block of blocks) {
|
|
86
|
+
const checked = checkChatPathAccess(block.path, cwd, access);
|
|
87
|
+
if (checked.status === "ok") continue;
|
|
88
|
+
withheld.push({ path: block.path, reason: checked.reason });
|
|
89
|
+
nextText += text.slice(cursor, block.start)
|
|
90
|
+
+ `<file name="${block.path}">[Withheld by Piagent: ${checked.reason}]</file>\n`;
|
|
91
|
+
cursor = block.end;
|
|
92
|
+
if (!block.attachesImage) continue;
|
|
93
|
+
if (!imagesMatch) images.forEach((_, index) => droppedImages.add(index));
|
|
94
|
+
else droppedImages.add(imageBlocks.indexOf(block));
|
|
95
|
+
}
|
|
96
|
+
if (withheld.length === 0) return undefined;
|
|
97
|
+
return {
|
|
98
|
+
text: nextText + text.slice(cursor),
|
|
99
|
+
images: images.filter((_, index) => !droppedImages.has(index)),
|
|
100
|
+
withheld
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import type { TaskContract } from "../../extensions/guard-types.ts";
|
|
4
4
|
import { matchesProtectedPath } from "../../extensions/policy-core.js";
|
|
5
5
|
import { redactSensitiveText } from "../../extensions/redaction-core.js";
|
|
6
|
-
import {
|
|
6
|
+
import { workingTreeSnapshotOffThread } from "./working-tree-off-thread.ts";
|
|
7
7
|
import { workingTreeEvidenceDigest, workingTreeSnapshotUsesCurrentAlgorithm } from "../../extensions/working-tree-digest.js";
|
|
8
8
|
import { inspectTaskContinuationBudget } from "../recovery/continuation-budget.ts";
|
|
9
9
|
import { handoffIdentityMatchesTask, readHandoffProjection } from "../recovery/handoff-projection.ts";
|
|
@@ -365,11 +365,7 @@ export async function buildWebUiInspectionProjection(input: {
|
|
|
365
365
|
const identity: WebUiIdentity = { projectRef: webUiProjectRef(input.cwd), runtimeInstanceId: input.runtimeInstanceId ?? WEBUI_RUNTIME_INSTANCE_REF,
|
|
366
366
|
sessionRef: webUiSessionRef(input.sessionId), taskId: input.task?.taskId ?? null, taskRunId: input.task?.taskRunId ?? null, agentOperationId: null, toolCallId: null };
|
|
367
367
|
const effectiveProtectedPaths = input.protectedPaths ?? input.task?.protectedPaths ?? [];
|
|
368
|
-
|
|
369
|
-
const currentSnapshot = workingTreeSnapshot(input.cwd, {
|
|
370
|
-
gitTimeoutMs: 8_000,
|
|
371
|
-
isProtectedProjectPath: (projectPath: string) => Boolean(matchesProtectedPath(projectPath, effectiveProtectedPaths))
|
|
372
|
-
}) as Record<string, string>;
|
|
368
|
+
const currentSnapshot = await workingTreeSnapshotOffThread(input.cwd, { gitTimeoutMs: 8_000, protectedPaths: effectiveProtectedPaths });
|
|
373
369
|
const taskRevision = input.task ? webUiTaskRevision(input.task) : null;
|
|
374
370
|
const views = await collectSourceChangeViews({ cwd: input.cwd, identity, generatedAt, taskRevision,
|
|
375
371
|
isProtectedPath: (_root, repoPath) => Boolean(matchesProtectedPath(repoPath, effectiveProtectedPaths)) });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
|
|
3
|
+
import { matchesProtectedPath } from "../../extensions/policy-core.js";
|
|
4
|
+
import { workingTreeSnapshot } from "../../extensions/task-state.js";
|
|
5
|
+
|
|
6
|
+
type Snapshot = Record<string, string>;
|
|
7
|
+
type Options = { gitTimeoutMs: number; protectedPaths: string[] };
|
|
8
|
+
|
|
9
|
+
// The inspection hashes every changed and loose file. On the main thread that
|
|
10
|
+
// froze the terminal for seconds after each answer in a folder of
|
|
11
|
+
// repositories, twice (turn end, then agent settled). A worker keeps the
|
|
12
|
+
// terminal drawing; refreshes that overlap share one scan.
|
|
13
|
+
const inFlight = new Map<string, Promise<Snapshot>>();
|
|
14
|
+
|
|
15
|
+
function inline(cwd: string, options: Options): Snapshot {
|
|
16
|
+
return workingTreeSnapshot(cwd, {
|
|
17
|
+
gitTimeoutMs: options.gitTimeoutMs,
|
|
18
|
+
isProtectedProjectPath: (projectPath: string) => Boolean(matchesProtectedPath(projectPath, options.protectedPaths))
|
|
19
|
+
}) as Snapshot;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function scan(cwd: string, options: Options): Promise<Snapshot> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
let worker: Worker;
|
|
25
|
+
try {
|
|
26
|
+
worker = new Worker(new URL("../../extensions/working-tree-snapshot-worker.mjs", import.meta.url),
|
|
27
|
+
{ workerData: { cwd, ...options } });
|
|
28
|
+
} catch {
|
|
29
|
+
resolve(inline(cwd, options));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let settled = false;
|
|
33
|
+
const fallback = () => {
|
|
34
|
+
if (settled) return; settled = true;
|
|
35
|
+
try { resolve(inline(cwd, options)); } catch (error) { reject(error); }
|
|
36
|
+
};
|
|
37
|
+
worker.once("message", (snapshot: Snapshot) => {
|
|
38
|
+
if (settled) return; settled = true; resolve(snapshot); void worker.terminate();
|
|
39
|
+
});
|
|
40
|
+
worker.once("error", fallback);
|
|
41
|
+
worker.once("exit", fallback);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function workingTreeSnapshotOffThread(cwd: string, options: Options): Promise<Snapshot> {
|
|
46
|
+
const key = JSON.stringify([cwd, options.gitTimeoutMs, options.protectedPaths]);
|
|
47
|
+
const running = inFlight.get(key);
|
|
48
|
+
if (running) return running;
|
|
49
|
+
const started = scan(cwd, options).finally(() => inFlight.delete(key));
|
|
50
|
+
inFlight.set(key, started);
|
|
51
|
+
return started;
|
|
52
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { gitEvidenceRootDetails, workingTreeHasChanges } from "../../extensions/workspace-evidence-roots.js";
|
|
4
4
|
import type { ProjectProfile, TaskContract } from "../../extensions/guard-types.ts";
|
|
5
5
|
import type { RuntimeModelSnapshot } from "../model/runtime-snapshot.ts";
|
|
6
6
|
import { runtimeModelSnapshotDigest } from "../model/runtime-snapshot.ts";
|
|
@@ -8,6 +8,19 @@ import type { TaskFeatureInput } from "./task-features.ts";
|
|
|
8
8
|
import type { SolverShadowEvaluation } from "./solver-shadow.ts";
|
|
9
9
|
import { SolverShadowRuntime } from "./solver-shadow.ts";
|
|
10
10
|
|
|
11
|
+
// Finding the repositories under a parent folder runs Git once per child and
|
|
12
|
+
// blocks the terminal; they rarely change between two prompts.
|
|
13
|
+
const ROOTS_TTL_MS = 30_000;
|
|
14
|
+
const rootDetails = new Map<string, { at: number; details: ReturnType<typeof gitEvidenceRootDetails> }>();
|
|
15
|
+
function gitShape(cwd: string): { gitReady: boolean; dirtyTree: boolean } {
|
|
16
|
+
let cached = rootDetails.get(cwd);
|
|
17
|
+
if (!cached || Date.now() - cached.at > ROOTS_TTL_MS) {
|
|
18
|
+
try { cached = { at: Date.now(), details: gitEvidenceRootDetails(cwd) }; } catch { return { gitReady: false, dirtyTree: true }; }
|
|
19
|
+
rootDetails.set(cwd, cached);
|
|
20
|
+
}
|
|
21
|
+
return { gitReady: cached.details.roots.length > 0, dirtyTree: workingTreeHasChanges(cwd, cached.details) };
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
export function runtimeTaskFeatureInput(options: {
|
|
12
25
|
request: string;
|
|
13
26
|
ctx: ExtensionContext;
|
|
@@ -29,8 +42,7 @@ export function runtimeTaskFeatureInput(options: {
|
|
|
29
42
|
request,
|
|
30
43
|
profileMode: profile.mode ?? null,
|
|
31
44
|
projectShape: roles,
|
|
32
|
-
|
|
33
|
-
dirtyTree: Object.keys(workingTreeSnapshot(ctx.cwd) as Record<string, string>).length > 0,
|
|
45
|
+
...gitShape(ctx.cwd),
|
|
34
46
|
verifierReady: profileVerifier || taskVerifier,
|
|
35
47
|
contextPressure,
|
|
36
48
|
activeTaskState: !activeTask ? "none" : activeTask.trace.outcome === "pending" ? "pending" : "terminal",
|
|
@@ -34,6 +34,7 @@ import { VSCodeHandoff } from "./vscode-handoff.ts";
|
|
|
34
34
|
import { browserCommand } from "../../piagent-core/runtime/host/browser-opener.mjs";
|
|
35
35
|
import { TerminalSessionAdapter } from "./terminal-session-adapter.ts";
|
|
36
36
|
import { isGatewayRuntimeContext } from "../ownership/gateway-runtime-context.ts";
|
|
37
|
+
import { dormantInPersonalPi } from "../../piagent-core/extensions/agent-home.js";
|
|
37
38
|
|
|
38
39
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
39
40
|
const PLATFORM_ROOT = path.resolve(PACKAGE_ROOT, "../..");
|
|
@@ -183,6 +184,7 @@ function readMethod(provider: CoreInspectionProvider, queue: HeldMessageQueue, s
|
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
export default function piagentWebUiExtension(pi: ExtensionAPI): void {
|
|
187
|
+
if (dormantInPersonalPi()) return;
|
|
186
188
|
const runtimeInstanceId = createWebUiExtensionRuntimeInstanceRef();
|
|
187
189
|
let terminalAdapter: TerminalSessionAdapter | null = null;
|
|
188
190
|
const managers = new Map<string, Manager>();
|
|
@@ -53,6 +53,7 @@ import { CompanyRelay, type CompanyConnector } from "./company-relay.ts";
|
|
|
53
53
|
import { ProviderAuthBroker } from "./provider-auth-broker.ts";
|
|
54
54
|
import { McpAuthBroker } from "./mcp-auth-broker.ts";
|
|
55
55
|
import { AgentStatusFiles } from "./agent-status-files.ts";
|
|
56
|
+
import { agentWatchControlDir, piagentAgentDir } from "../../piagent-core/extensions/agent-home.js";
|
|
56
57
|
import type { ScopedBrokerRouter, RuntimeFactory } from "./session-runtime-factory.ts";
|
|
57
58
|
import { UiPreferencesStore } from "./ui-preferences-store.ts";
|
|
58
59
|
|
|
@@ -249,7 +250,9 @@ export async function startPiagentGateway(options: {
|
|
|
249
250
|
// Agent Watch reads the member's ~/.pi/agent/status. The dashboard's
|
|
250
251
|
// agentDir is that folder; the company Gateway's is its private session
|
|
251
252
|
// folder under Agent Watch, so it writes to the member's folder instead.
|
|
252
|
-
|
|
253
|
+
// Piagent's own home serves the member's dashboard; a test or benchmark home keeps its status beside it.
|
|
254
|
+
const watchRoot = options.managed || state.agentDir === piagentAgentDir() ? agentWatchControlDir() : state.agentDir;
|
|
255
|
+
statusFiles = new AgentStatusFiles(path.join(watchRoot, "status", options.managed ? "company" : "dashboard"), async () => { const catalog = await readCatalog(); if (catalog.state !== "ready") throw new Error("catalog-not-ready"); return catalog; }, () => Boolean(options.managed)); statusFiles.start();
|
|
253
256
|
const projectFolder = async (projectRef: string) => projects.resolve(projectRef)
|
|
254
257
|
?? (await runtimes!.listSessions()).find((info) => typeof info.cwd === "string" && projectRefForCwd(key, info.cwd) === projectRef)?.cwd
|
|
255
258
|
?? (relay ? (await relay.folders()).find((folder) => projectRefForCwd(key, folder) === projectRef) : undefined);
|
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { privateHostRoot } from "../../piagent-core/managed/pi-host.mjs";
|
|
8
|
+
import { agentWatchControlDir } from "../../piagent-core/extensions/agent-home.js";
|
|
8
9
|
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
|
|
@@ -58,8 +59,7 @@ export function installedPiHostRoot(): string {
|
|
|
58
59
|
const home = os.homedir(), hostPackage = path.join("lib", "node_modules", "@earendil-works", "pi-coding-agent");
|
|
59
60
|
let recorded: string | null = null;
|
|
60
61
|
try {
|
|
61
|
-
|
|
62
|
-
recorded = JSON.parse(fs.readFileSync(path.join(agentDir, "piagent-runtime.json"), "utf8")).pi_sdk_root ?? null;
|
|
62
|
+
recorded = JSON.parse(fs.readFileSync(path.join(agentWatchControlDir(home), "piagent-runtime.json"), "utf8")).pi_sdk_root ?? null;
|
|
63
63
|
} catch { /* no record yet */ }
|
|
64
64
|
// npm's default Windows prefixes (%APPDATA%\npm, or beside node.exe) have no lib folder.
|
|
65
65
|
const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
@@ -47,8 +47,17 @@ function shortControlSocket(agentDir: string): string {
|
|
|
47
47
|
return path.join(socketRoot, `${digest}.sock`);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Piagent's own Pi home (~/.piagent/agent), never the member's personal pi.
|
|
51
|
+
// Same rule as piagent-core/extensions/agent-home.js, which this layer cannot import.
|
|
52
|
+
function defaultAgentDir(): string {
|
|
53
|
+
const own = process.env.PIAGENT_AGENT_DIR?.trim(), configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
54
|
+
if (own) return own;
|
|
55
|
+
if (configured && path.resolve(configured) !== path.resolve(os.homedir(), ".pi", "agent")) return configured;
|
|
56
|
+
return path.join(os.homedir(), ".piagent", "agent");
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
export function gatewayProfileState(agentDirInput?: string): GatewayProfileState {
|
|
51
|
-
const agentDir = path.resolve(agentDirInput ||
|
|
60
|
+
const agentDir = path.resolve(agentDirInput || defaultAgentDir());
|
|
52
61
|
fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
|
53
62
|
const agentStat = fs.lstatSync(agentDir);
|
|
54
63
|
if (!agentStat.isDirectory() || agentStat.isSymbolicLink()
|
|
@@ -10,6 +10,7 @@ import { assertBenchmarkLedgerBinding, inspectBenchmarkLedger, validateBenchmark
|
|
|
10
10
|
import { completedBenchmarkRecord } from "../../piagent-core/benchmark/benchmark-record-validation.js";
|
|
11
11
|
import { benchmarkGitEnvironment } from "../../piagent-core/benchmark/benchmark-runtime.js";
|
|
12
12
|
import { redactSensitiveText } from "../../piagent-core/extensions/redaction-core.js";
|
|
13
|
+
import { piagentAgentDir } from "../../piagent-core/extensions/agent-home.js";
|
|
13
14
|
|
|
14
15
|
const MAX_RUNS = 20, MAX_DIRECTORIES = 100, MAX_DIRECTORY_ENTRIES = 5_000, MAX_JSON_BYTES = 32 * 1024 * 1024;
|
|
15
16
|
type Identity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string | null; taskRunId: string | null;
|
|
@@ -210,7 +211,7 @@ function releaseReadiness(cwd: string, file: string, repo: ReturnType<typeof rep
|
|
|
210
211
|
export function projectBenchmarkReleaseMonitor(input: { cwd: string; identity: Identity; generatedAt?: string; benchmarkRoot?: string; releaseReportPath?: string }) {
|
|
211
212
|
const generatedAt = input.generatedAt ?? new Date().toISOString();
|
|
212
213
|
try {
|
|
213
|
-
const repo = repository(input.cwd), root = input.benchmarkRoot ?? path.join(
|
|
214
|
+
const repo = repository(input.cwd), root = input.benchmarkRoot ?? path.join(piagentAgentDir(), "benchmarks", "piagent");
|
|
214
215
|
const releasePath = input.releaseReportPath ?? path.join(input.cwd, "plans", "codex-first-product", "evidence", "p7-local-readiness", "report.json");
|
|
215
216
|
const benchmark = benchmarkRuns(input.cwd, root, repo), release = releaseReadiness(input.cwd, releasePath, repo);
|
|
216
217
|
const revision = `release-monitor.${createHash("sha256").update(JSON.stringify({ benchmark, release })).digest("hex")}`;
|
|
@@ -14,6 +14,7 @@ import { acquireBenchmarkRunLock } from "../packages/piagent-core/benchmark/benc
|
|
|
14
14
|
import { recoverBenchmarkObservationCheckpoints } from "../packages/piagent-core/benchmark/benchmark-resume-recovery.js";
|
|
15
15
|
import { benchmarkSurfaceLabel } from "../packages/piagent-core/benchmark/benchmark-core.js";
|
|
16
16
|
import { codexModelName, codexThinkingEffort } from "../packages/piagent-core/benchmark/benchmark-codex.js";
|
|
17
|
+
import { piagentAgentDir } from "../packages/piagent-core/extensions/agent-home.js";
|
|
17
18
|
|
|
18
19
|
export function fail(message, code = 2) {
|
|
19
20
|
const error = new Error(message);
|
|
@@ -96,7 +97,7 @@ export function registeredBenchmarkCustodyRoot(runRoot) {
|
|
|
96
97
|
|
|
97
98
|
export function defaultOutputRoot(bootstrapMetadata) {
|
|
98
99
|
if (bootstrapMetadata?.defaultOutputRoot) return bootstrapMetadata.defaultOutputRoot;
|
|
99
|
-
const agentRoot =
|
|
100
|
+
const agentRoot = piagentAgentDir();
|
|
100
101
|
return path.join(agentRoot, "benchmarks", "piagent");
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -59,8 +59,10 @@ if (installed !== to) finish("failed", { reason: "update-version-mismatch", ...(
|
|
|
59
59
|
// the launcher needs the binding imported again in Agent Watch.
|
|
60
60
|
let bindingChanged = false;
|
|
61
61
|
try {
|
|
62
|
-
|
|
63
|
-
const
|
|
62
|
+
// Agent Watch keeps the binding in the personal Pi folder; a test or custom home may hold its own.
|
|
63
|
+
const homes = [process.env.PI_CODING_AGENT_DIR?.trim(), path.join(home, ".pi", "agent")].filter(Boolean);
|
|
64
|
+
const file = homes.map((dir) => path.join(dir, "agent-watch-managed.json")).find((candidate) => fs.existsSync(candidate));
|
|
65
|
+
const binding = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
64
66
|
if (typeof binding?.entrypoint === "string" && typeof binding?.entrypoint_sha256 === "string")
|
|
65
67
|
bindingChanged = crypto.createHash("sha256").update(fs.readFileSync(binding.entrypoint)).digest("hex") !== binding.entrypoint_sha256;
|
|
66
68
|
} catch { /* no company binding on this machine */ }
|