@piagent/platform 1.44.3 → 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.
Files changed (91) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/en/windows.md +2 -0
  7. package/docs/herdr-workflow.md +13 -1
  8. package/docs/languages.json +4 -4
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +2 -0
  11. package/docs/quickstart-vietnamese.md +1 -1
  12. package/docs/release-install-policy.md +2 -2
  13. package/docs/team-onboarding.md +2 -2
  14. package/docs/vi/windows.md +2 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/assets/search-en.json +1 -1
  17. package/docs-site/assets/search-vi.json +1 -1
  18. package/docs-site/benchmark.html +1 -1
  19. package/docs-site/commands.html +1 -1
  20. package/docs-site/content/en/quickstart.html +1 -1
  21. package/docs-site/content/quickstart.html +1 -1
  22. package/docs-site/context-engine.html +1 -1
  23. package/docs-site/ecosystem.html +1 -1
  24. package/docs-site/en/architecture.html +1 -1
  25. package/docs-site/en/benchmark.html +1 -1
  26. package/docs-site/en/commands.html +1 -1
  27. package/docs-site/en/context-engine.html +1 -1
  28. package/docs-site/en/ecosystem.html +1 -1
  29. package/docs-site/en/index.html +15 -15
  30. package/docs-site/en/mcp-approval.html +1 -1
  31. package/docs-site/en/mcp-auth.html +1 -1
  32. package/docs-site/en/mcp-servers.html +1 -1
  33. package/docs-site/en/mcp.html +1 -1
  34. package/docs-site/en/permissions.html +1 -1
  35. package/docs-site/en/profiles.html +1 -1
  36. package/docs-site/en/quickstart.html +2 -2
  37. package/docs-site/en/runtime.html +1 -1
  38. package/docs-site/en/scope.html +1 -1
  39. package/docs-site/en/security.html +1 -1
  40. package/docs-site/en/team.html +1 -1
  41. package/docs-site/en/update.html +3 -3
  42. package/docs-site/en/whats-new.html +54 -32
  43. package/docs-site/en/windows.html +1 -1
  44. package/docs-site/en/workflows.html +1 -1
  45. package/docs-site/index.html +15 -15
  46. package/docs-site/mcp-approval.html +1 -1
  47. package/docs-site/mcp-auth.html +1 -1
  48. package/docs-site/mcp-servers.html +1 -1
  49. package/docs-site/mcp.html +1 -1
  50. package/docs-site/permissions.html +1 -1
  51. package/docs-site/profiles.html +1 -1
  52. package/docs-site/quickstart.html +2 -2
  53. package/docs-site/releases.json +67 -0
  54. package/docs-site/runtime.html +1 -1
  55. package/docs-site/scope.html +1 -1
  56. package/docs-site/security.html +1 -1
  57. package/docs-site/team.html +1 -1
  58. package/docs-site/update.html +3 -3
  59. package/docs-site/whats-new.html +54 -32
  60. package/docs-site/windows.html +1 -1
  61. package/docs-site/workflows.html +1 -1
  62. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  63. package/package.json +1 -1
  64. package/packages/piagent-core/extensions/company-session-guard.ts +4 -2
  65. package/packages/piagent-core/extensions/working-tree-snapshot-worker.mjs +9 -0
  66. package/packages/piagent-core/extensions/workspace-evidence-roots.js +28 -2
  67. package/packages/piagent-core/managed/herdr-reporter.mjs +85 -0
  68. package/packages/piagent-core/managed/store.d.mts +1 -0
  69. package/packages/piagent-core/managed/store.mjs +13 -0
  70. package/packages/piagent-core/package.json +1 -1
  71. package/packages/piagent-core/runtime/hooks/input-hook.ts +17 -3
  72. package/packages/piagent-core/runtime/input/chat-images.ts +66 -10
  73. package/packages/piagent-core/runtime/input/file-arguments.ts +102 -0
  74. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +2 -6
  75. package/packages/piagent-core/runtime/inspection/working-tree-off-thread.ts +52 -0
  76. package/packages/piagent-core/runtime/solver/runtime-features.ts +15 -3
  77. package/packages/piagent-webui/dist/client/assets/{App-DcaqK-_s.js → App-CiwY9VpU.js} +1 -1
  78. package/packages/piagent-webui/dist/client/assets/{GatewayApp-5DVdNIyr.js → GatewayApp-DrNJW3v_.js} +3 -3
  79. package/packages/piagent-webui/dist/client/assets/{InspectionApp-CBPHxuqj.js → InspectionApp-BBiwSaV0.js} +1 -1
  80. package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-DmD9Qt4f.js → SessionAgentWorkspace-DwyPebIX.js} +1 -1
  81. package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-CK5ujmH3.js → SessionInspectorDrawer-CpVzaf_G.js} +2 -2
  82. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-C-tO5iXn.js +3 -0
  83. package/packages/piagent-webui/dist/client/assets/{SettingsPage-Dda8GW2a.js → SettingsPage-Ca-qRSZa.js} +1 -1
  84. package/packages/piagent-webui/dist/client/assets/{TerminalPanel-X2389XHl.js → TerminalPanel-B8j_r6-y.js} +1 -1
  85. package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-Dt3dS_QG.js → WorkspaceCodeEditor-BDTPvq-0.js} +1 -1
  86. package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-CBHwY4BN.js → WorkspaceExplorer-DVsNU0eu.js} +3 -3
  87. package/packages/piagent-webui/dist/client/assets/{index-BMQo9OEq.js → index-uhJMXxqD.js} +2 -2
  88. package/packages/piagent-webui/dist/client/index.html +1 -1
  89. package/packages/piagent-webui/shared/company-copy.ts +1 -0
  90. package/scripts/piagent-studio.mjs +16 -6
  91. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BnorH-l_.js +0 -3
@@ -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
- return path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
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
- export function extractLocalImagePathCandidates(text: string, cwd: string): string[] {
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
- export function readChatImage(
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
- ): ResolvedChatImage {
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
- if (inspected.size <= 0) return { status: "error", reason: "empty file" };
174
- if (inspected.size > MAX_CHAT_IMAGE_BYTES) {
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: `image is ${formatCount(inspected.size)} bytes > ${formatCount(MAX_CHAT_IMAGE_BYTES)} byte limit`
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 { workingTreeSnapshot } from "../../extensions/task-state.js";
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
- // Synchronous: the Gateway answers nobody while it runs (task-state.js).
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 { hasGitEvidenceRoot, workingTreeSnapshot } from "../../extensions/task-state.js";
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
- gitReady: hasGitEvidenceRoot(ctx.cwd),
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",