@piagent/platform 1.44.4 → 1.45.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 +20 -0
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +1 -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 +9 -9
- 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 +30 -19
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +9 -9
- 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 +36 -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 +30 -19
- 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/extensions/working-tree-snapshot-worker.mjs +9 -0
- package/packages/piagent-core/extensions/workspace-evidence-roots.js +28 -2
- 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/package.json
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { matchesProtectedPath } from './policy-core.js';
|
|
3
|
+
import { workingTreeSnapshot } from './task-state.js';
|
|
4
|
+
// Hash the working tree away from the thread that draws the terminal.
|
|
5
|
+
const { cwd, gitTimeoutMs, protectedPaths } = workerData;
|
|
6
|
+
parentPort.postMessage(workingTreeSnapshot(cwd, {
|
|
7
|
+
gitTimeoutMs,
|
|
8
|
+
isProtectedProjectPath: (projectPath) => Boolean(matchesProtectedPath(projectPath, protectedPaths))
|
|
9
|
+
}));
|
|
@@ -104,11 +104,17 @@ function directChildGitEvidenceRootDetails(cwd) {
|
|
|
104
104
|
complete = false;
|
|
105
105
|
continue;
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
// One Git process per child: a parent of thirty repositories ran sixty.
|
|
108
|
+
let inside, topLevel;
|
|
109
|
+
try {
|
|
110
|
+
[inside, topLevel] = gitOutput(child, ["rev-parse", "--is-inside-work-tree", "--show-toplevel"]).trim().split("\n");
|
|
111
|
+
} catch {
|
|
112
|
+
inside = "false";
|
|
113
|
+
}
|
|
114
|
+
if (inside !== "true" || !topLevel) {
|
|
108
115
|
complete = false;
|
|
109
116
|
continue;
|
|
110
117
|
}
|
|
111
|
-
const topLevel = gitOutput(child, ["rev-parse", "--show-toplevel"]).trim();
|
|
112
118
|
if (fs.realpathSync(topLevel) !== fs.realpathSync(child)) {
|
|
113
119
|
complete = false;
|
|
114
120
|
continue;
|
|
@@ -143,6 +149,26 @@ export function gitEvidenceRoots(cwd) {
|
|
|
143
149
|
return gitEvidenceRootDetails(cwd).roots;
|
|
144
150
|
}
|
|
145
151
|
|
|
152
|
+
// Whether the working tree differs from HEAD, without reading any file: the
|
|
153
|
+
// same answer as a non-empty workingTreeSnapshot, which hashes every changed
|
|
154
|
+
// and loose file and blocked the terminal for seconds on each prompt in a
|
|
155
|
+
// folder of repositories. Unknown counts as changed, as it does there.
|
|
156
|
+
export function workingTreeHasChanges(cwd, details = gitEvidenceRootDetails(cwd)) {
|
|
157
|
+
if (!details.complete) return true;
|
|
158
|
+
for (const root of details.roots) {
|
|
159
|
+
try {
|
|
160
|
+
const status = gitOutput(root.cwd, ["status", "--porcelain", "-z", "--untracked-files=normal",
|
|
161
|
+
"--", ".", ":(exclude).pi/piagent-state"]);
|
|
162
|
+
if (status) return true;
|
|
163
|
+
} catch {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (details.roots.length === 0 || details.roots.some((root) => root.prefix === "")) return false;
|
|
168
|
+
const loose = nonGitWorkspaceFileDetails(cwd, details.roots.map((root) => root.prefix), 1);
|
|
169
|
+
return loose.files.length > 0 || !loose.complete;
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
function workspaceEvidenceFileLimit(value) {
|
|
147
173
|
return Number.isInteger(value)
|
|
148
174
|
? Math.max(1, Math.min(WORKSPACE_EVIDENCE_MAX_FILES, value))
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
1
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
|
|
3
5
|
import { classifyContextTask } from "../../extensions/context-engine.js";
|
|
@@ -5,6 +7,7 @@ import { matchesProtectedPath } from "../../extensions/policy-core.js";
|
|
|
5
7
|
import type { TaskContract } from "../../extensions/guard-types.js";
|
|
6
8
|
import { attachLocalImagesFromText, extractLocalImagePathCandidates } from "../input/chat-images.ts";
|
|
7
9
|
import type { ChatImageAccessPolicy } from "../input/chat-images.ts";
|
|
10
|
+
import { reviewFileArguments } from "../input/file-arguments.ts";
|
|
8
11
|
import type { AuthorityResumeDecision } from "../policy/authority-resume-policy.ts";
|
|
9
12
|
import { registerAdaptiveContextGovernor } from "../session/adaptive-context-governor.ts";
|
|
10
13
|
import { toolGroupsForPrompt, PIAGENT_TOOL_NAMES, PIAGENT_TOOL_ORDER, PIAGENT_TOOL_GROUPS } from "../tools/tool-groups.ts";
|
|
@@ -74,10 +77,21 @@ export function registerInputHook(pi: ExtensionAPI, dependencies: InputHookDepen
|
|
|
74
77
|
.filter((group) => !["intake", "task", "recovery"].includes(group)));
|
|
75
78
|
dependencies.telemetry(ctx, { event: "user_input", turnId: turn.turnId, source: event.source,
|
|
76
79
|
promptHash: signal.promptHash, promptChars: text.length, inputMode: "freeform" });
|
|
77
|
-
const
|
|
80
|
+
const reviewed = reviewFileArguments(text, event.images, ctx.cwd, () => dependencies.imageAccess(ctx));
|
|
81
|
+
if (reviewed && !reviewed.text) {
|
|
82
|
+
ctx.ui.notify("Not sent: a file changed while Pi was reading the @file arguments. Run the command again.", "error");
|
|
83
|
+
return { action: "handled" };
|
|
84
|
+
}
|
|
85
|
+
if (reviewed) ctx.ui.notify(`Withheld ${reviewed.withheld.map((item) => `${path.basename(item.path)} (${item.reason})`).join(", ")}`, "warning");
|
|
86
|
+
const nextText = reviewed?.text ?? text;
|
|
87
|
+
const nextImages = reviewed ? reviewed.images as typeof event.images : event.images;
|
|
88
|
+
const attachment = attachLocalImagesFromText(nextText, nextImages, ctx.cwd, () => dependencies.imageAccess(ctx));
|
|
89
|
+
if (attachment?.skipped.length) {
|
|
90
|
+
ctx.ui.notify(`Skipped ${attachment.skipped.map((item) => `${path.basename(item.path)} (${item.reason})`).join(", ")}`, "warning");
|
|
91
|
+
}
|
|
78
92
|
if (attachment?.attached.length) return { action: "transform", text: attachment.text,
|
|
79
|
-
images: [...(
|
|
80
|
-
if (
|
|
93
|
+
images: [...(nextImages ?? []), ...attachment.images] };
|
|
94
|
+
if (reviewed) return { action: "transform", text: nextText, images: nextImages };
|
|
81
95
|
return { action: "continue" };
|
|
82
96
|
});
|
|
83
97
|
}
|
|
@@ -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",
|