@piagent/platform 1.4.0 → 1.5.1

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 (132) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +12 -2
  3. package/README.vi.md +47 -0
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/en/architecture.md +13 -0
  7. package/docs/languages.json +20 -4
  8. package/docs/mcp-and-tools.md +112 -1
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +42 -0
  11. package/docs/release-install-policy.md +21 -8
  12. package/docs/runtime-harness-standard.md +1 -1
  13. package/docs/security-threat-model.md +1 -1
  14. package/docs/task-implementation-contract.md +11 -4
  15. package/docs/team-onboarding.md +2 -2
  16. package/docs/usage-observability.md +70 -0
  17. package/docs/vi/architecture.md +13 -0
  18. package/docs-site/architecture.html +5 -1
  19. package/docs-site/benchmark.html +5 -1
  20. package/docs-site/commands.html +5 -1
  21. package/docs-site/content/en/index.html +49 -0
  22. package/docs-site/content/en/runtime.html +13 -0
  23. package/docs-site/content/index.html +49 -0
  24. package/docs-site/content/runtime.html +28 -0
  25. package/docs-site/context-engine.html +5 -1
  26. package/docs-site/en/architecture.html +5 -1
  27. package/docs-site/en/benchmark.html +5 -1
  28. package/docs-site/en/commands.html +5 -1
  29. package/docs-site/en/context-engine.html +5 -1
  30. package/docs-site/en/index.html +54 -1
  31. package/docs-site/en/mcp-approval.html +5 -1
  32. package/docs-site/en/mcp-auth.html +5 -1
  33. package/docs-site/en/mcp-servers.html +5 -1
  34. package/docs-site/en/mcp.html +5 -1
  35. package/docs-site/en/permissions.html +5 -1
  36. package/docs-site/en/profiles.html +5 -1
  37. package/docs-site/en/quickstart.html +5 -1
  38. package/docs-site/en/runtime.html +18 -1
  39. package/docs-site/en/scope.html +5 -1
  40. package/docs-site/en/security.html +5 -1
  41. package/docs-site/en/team.html +5 -1
  42. package/docs-site/en/whats-new.html +5 -1
  43. package/docs-site/en/workflows.html +5 -1
  44. package/docs-site/index.html +54 -1
  45. package/docs-site/mcp-approval.html +5 -1
  46. package/docs-site/mcp-auth.html +5 -1
  47. package/docs-site/mcp-servers.html +5 -1
  48. package/docs-site/mcp.html +5 -1
  49. package/docs-site/permissions.html +5 -1
  50. package/docs-site/profiles.html +5 -1
  51. package/docs-site/quickstart.html +5 -1
  52. package/docs-site/runtime.html +33 -1
  53. package/docs-site/scope.html +5 -1
  54. package/docs-site/security.html +5 -1
  55. package/docs-site/team.html +5 -1
  56. package/docs-site/whats-new.html +5 -1
  57. package/docs-site/workflows.html +5 -1
  58. package/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
  59. package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
  60. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
  61. package/package.json +14 -2
  62. package/packages/piagent-core/README.md +3 -2
  63. package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
  64. package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
  65. package/packages/piagent-core/extensions/document-intake.ts +6 -3
  66. package/packages/piagent-core/extensions/piagent-guard.ts +20 -237
  67. package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
  68. package/packages/piagent-core/extensions/shell-reach.ts +250 -0
  69. package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
  70. package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
  71. package/packages/piagent-core/package.json +1 -1
  72. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
  73. package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
  74. package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
  75. package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
  76. package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
  77. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
  78. package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
  79. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
  80. package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
  81. package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
  82. package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
  83. package/packages/piagent-core/runtime/session/token-economics.js +34 -0
  84. package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
  85. package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
  86. package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
  87. package/packages/piagent-core/security/sensitive-data.js +7 -0
  88. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-Uouhx1QO.js +29 -0
  89. package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
  90. package/packages/piagent-webui/dist/client/assets/index-MBZq8XLV.js +174 -0
  91. package/packages/piagent-webui/dist/client/index.html +2 -2
  92. package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
  93. package/packages/piagent-webui/extension/piagent-webui.ts +19 -6
  94. package/packages/piagent-webui/extension/same-session-bridge.ts +12 -12
  95. package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
  96. package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
  97. package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
  98. package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
  99. package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
  100. package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
  101. package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
  102. package/packages/piagent-webui/gateway/session-inspection-registry.ts +67 -12
  103. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +24 -11
  104. package/packages/piagent-webui/package.json +3 -1
  105. package/packages/piagent-webui/server/core-inspection-provider.ts +52 -15
  106. package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
  107. package/packages/piagent-webui/server/loopback-server.ts +63 -25
  108. package/packages/piagent-webui/server/read-model-provider.ts +2 -0
  109. package/packages/piagent-webui/server/read-only-router.ts +6 -0
  110. package/packages/piagent-webui/server/transcript-projection.ts +58 -2
  111. package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
  112. package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
  113. package/schemas/piagent-webui/catalog-v1.json +9 -0
  114. package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
  115. package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
  116. package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
  117. package/scripts/build-docs-site.mjs +21 -1
  118. package/scripts/explain-command.mjs +240 -0
  119. package/scripts/install-global.sh +104 -3
  120. package/scripts/mcp-manage.mjs +17 -4
  121. package/scripts/pi-session-stats.sh +10 -2
  122. package/scripts/piagent-cli.mjs +14 -1
  123. package/scripts/piagent-dashboard.mjs +55 -2
  124. package/scripts/setup.sh +69 -33
  125. package/scripts/traffic-snapshot.mjs +214 -0
  126. package/scripts/typescript-loader.mjs +20 -4
  127. package/scripts/uninstall-global.sh +53 -2
  128. package/scripts/update-global.mjs +33 -1
  129. package/scripts/verify-local.sh +20 -1
  130. package/scripts/verify-translation-pairing.mjs +121 -0
  131. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
  132. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
@@ -0,0 +1,276 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { redactSensitiveText } from "../../extensions/redaction-core.js";
6
+ import {
7
+ DOCUMENT_EXTENSIONS,
8
+ extractDocument,
9
+ resolveDocumentPath,
10
+ resolveDocumentRoots,
11
+ type DocumentRoot
12
+ } from "../../extensions/document-intake.ts";
13
+
14
+ // Opening documents in the browser.
15
+ //
16
+ // The tool `piagent_document_read` already answers "read this path" for the
17
+ // model. This answers "what is there to read, and show me one" for the operator,
18
+ // and it is deliberately built on the same three functions: the roots come from
19
+ // `resolveDocumentRoots`, every path is decided by `resolveDocumentPath`, and the
20
+ // bytes are turned into text by `extractDocument`. A second implementation of
21
+ // containment or of the extension gate would be a second thing to keep correct,
22
+ // and the one that drifted would be this one, because it is the one nobody runs
23
+ // from a terminal.
24
+ //
25
+ // Two rules the listing adds on top:
26
+ //
27
+ // A name is not an authority. The browser never sends a path — it sends an
28
+ // opaque ref, and the ref is resolved by re-listing and matching. A path that
29
+ // stopped being listable between the listing and the read is simply not found,
30
+ // which is the same answer as a path that was never listed.
31
+ //
32
+ // The walk does not follow links. Containment is decided on the canonical path
33
+ // at read time, so a link out of a root could never be *read*, but following one
34
+ // during the walk would still put its target's name in front of the operator.
35
+
36
+ export const DOCUMENT_WORKSPACE_LIMITS = Object.freeze({
37
+ documents: 2000,
38
+ // A shared budget spent in root order lets a large project consume all of it
39
+ // and leave a granted directory — the one the operator added on purpose —
40
+ // showing nothing at all. Each root gets its own slice instead, with a floor
41
+ // so a small grant is never squeezed to nothing by a big one.
42
+ documentsPerRootFloor: 250,
43
+ depth: 8,
44
+ entriesPerDirectory: 4096,
45
+ text: 400_000
46
+ });
47
+
48
+ // Directories that are large, uninteresting, and present in almost every
49
+ // project. Walking them costs seconds and returns vendored copies of documents
50
+ // the operator did not write.
51
+ const SKIPPED_DIRECTORIES = new Set([
52
+ "node_modules", ".git", ".hg", ".svn", "dist", "build", "out", "coverage",
53
+ ".next", ".nuxt", ".turbo", ".cache", ".venv", "venv", "__pycache__",
54
+ "target", "vendor", ".gradle", ".idea", ".tmp", ".playwright"
55
+ ]);
56
+
57
+ const EXTENSIONS = new Set(DOCUMENT_EXTENSIONS);
58
+
59
+ export type DocumentWorkspaceEntry = {
60
+ documentRef: string;
61
+ rootRef: string;
62
+ name: string;
63
+ relativePath: string;
64
+ extension: string;
65
+ sizeBytes: number;
66
+ modifiedAt: string;
67
+ };
68
+ export type DocumentWorkspaceRoot = { rootRef: string; path: string; source: DocumentRoot["source"]; documentCount: number };
69
+ export type DocumentWorkspaceListing = {
70
+ schemaVersion: 1;
71
+ version: "piagent-webui-document-workspace-v1";
72
+ messageType: "listing";
73
+ generatedAt: string;
74
+ state: "ready" | "unavailable";
75
+ roots: DocumentWorkspaceRoot[];
76
+ documents: DocumentWorkspaceEntry[];
77
+ truncated: boolean;
78
+ reasonCode: string | null;
79
+ };
80
+ export type DocumentWorkspaceDocument = {
81
+ schemaVersion: 1;
82
+ version: "piagent-webui-document-workspace-v1";
83
+ messageType: "document";
84
+ generatedAt: string;
85
+ documentRef: string;
86
+ state: "ready" | "unavailable";
87
+ // Null wherever the document could not be identified at all. A document that
88
+ // was found but could not be read still names itself, so the operator sees
89
+ // which file the refusal is about.
90
+ name: string | null;
91
+ relativePath: string | null;
92
+ rootRef: string | null;
93
+ format: "text" | "docx" | "pdf" | null;
94
+ text: string | null;
95
+ sizeBytes: number | null;
96
+ truncated: boolean;
97
+ redacted: boolean;
98
+ reasonCode: string | null;
99
+ };
100
+
101
+ export type DocumentWorkspaceInput = {
102
+ cwd: string;
103
+ profileRoots?: unknown;
104
+ environmentRoots?: string | undefined;
105
+ home?: string | undefined;
106
+ isProtectedPath?: (candidate: string) => boolean;
107
+ now?: () => Date;
108
+ };
109
+
110
+ function ref(prefix: string, value: string): string {
111
+ return `${prefix}.${createHash("sha256").update(value).digest("hex")}`;
112
+ }
113
+
114
+ // A control character in a name would sit inside a JSON string safely enough,
115
+ // but this text is also rendered, copied and logged. Stripping is cheaper than
116
+ // auditing every place it lands.
117
+ function displayName(value: string): string {
118
+ return value.replace(/[\u0000-\u001F\u007F]/g, " ").trim();
119
+ }
120
+
121
+ function timestamp(value: Date): string {
122
+ return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString();
123
+ }
124
+
125
+ function extensionOf(name: string): string | undefined {
126
+ const extension = path.extname(name).toLowerCase().replace(/^\./, "");
127
+ return EXTENSIONS.has(extension) ? extension : undefined;
128
+ }
129
+
130
+ // Only regular files and real directories are considered, and both are judged by
131
+ // lstat so a link is seen as a link rather than as whatever it points at.
132
+ function walkRoot(root: DocumentRoot, budget: { remaining: number }, isProtected: (relative: string, absolute: string) => boolean,
133
+ paths: Map<string, string>): DocumentWorkspaceEntry[] {
134
+ const rootRef = ref("document-root", root.path), found: DocumentWorkspaceEntry[] = [];
135
+ const queue: Array<{ directory: string; depth: number }> = [{ directory: root.path, depth: 0 }];
136
+ while (queue.length > 0 && budget.remaining > 0) {
137
+ const current = queue.shift()!;
138
+ let entries: fs.Dirent[];
139
+ try { entries = fs.readdirSync(current.directory, { withFileTypes: true }); }
140
+ catch { continue; }
141
+ // A directory with more entries than this is a data directory, not a place
142
+ // an operator keeps documents, and reading all of it is what makes a listing
143
+ // take seconds.
144
+ if (entries.length > DOCUMENT_WORKSPACE_LIMITS.entriesPerDirectory) continue;
145
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
146
+ if (budget.remaining <= 0) break;
147
+ // A Dirent already reports a link as a link, so the isFile and isDirectory
148
+ // tests below reject one on their own. This says so out loud, because a
149
+ // later refactor to stat-based checks would silently start following them.
150
+ if (entry.isSymbolicLink()) continue;
151
+ const absolute = path.join(current.directory, entry.name);
152
+ const relative = path.relative(root.path, absolute).split(path.sep).join("/");
153
+ if (entry.isDirectory()) {
154
+ if (entry.name.startsWith(".") || SKIPPED_DIRECTORIES.has(entry.name)) continue;
155
+ if (current.depth + 1 > DOCUMENT_WORKSPACE_LIMITS.depth) continue;
156
+ if (isProtected(relative, absolute)) continue;
157
+ queue.push({ directory: absolute, depth: current.depth + 1 });
158
+ continue;
159
+ }
160
+ if (!entry.isFile()) continue;
161
+ const extension = extensionOf(entry.name);
162
+ if (!extension || isProtected(relative, absolute)) continue;
163
+ let stat: fs.Stats;
164
+ try { stat = fs.statSync(absolute); }
165
+ catch { continue; }
166
+ if (!stat.isFile() || stat.size <= 0) continue;
167
+ budget.remaining -= 1;
168
+ const documentRef = ref("document", absolute);
169
+ // The projected name is stripped for display. Resolution must run on the
170
+ // path as it exists on disk, so the exact bytes are kept beside the ref
171
+ // and never rebuilt from what the browser was shown.
172
+ paths.set(documentRef, absolute);
173
+ found.push({ documentRef, rootRef, name: displayName(entry.name),
174
+ relativePath: displayName(relative), extension, sizeBytes: stat.size, modifiedAt: timestamp(stat.mtime) });
175
+ }
176
+ }
177
+ return found;
178
+ }
179
+
180
+ function protectedPredicate(input: DocumentWorkspaceInput): (relative: string, absolute: string) => boolean {
181
+ const custom = input.isProtectedPath;
182
+ if (!custom) return () => false;
183
+ // Protected patterns are project-relative and anchored at the first segment, so
184
+ // an absolute candidate only ever matches a `**/`-prefixed one. Checking a
185
+ // single form would let every anchored entry through, the same way the document
186
+ // tool checks both.
187
+ return (relative, absolute) => custom(absolute) || custom(relative);
188
+ }
189
+
190
+ // The listing and the raw paths behind it, produced together. Only the listing
191
+ // crosses to the browser; the paths stay here so a read never has to rebuild one
192
+ // from projected text.
193
+ function collectDocuments(input: DocumentWorkspaceInput): { listing: DocumentWorkspaceListing; roots: DocumentRoot[]; paths: Map<string, string> } {
194
+ const now = (input.now ?? (() => new Date()))();
195
+ const base = { schemaVersion: 1 as const, version: "piagent-webui-document-workspace-v1" as const,
196
+ messageType: "listing" as const, generatedAt: timestamp(now) };
197
+ const empty = (reasonCode: string) => ({ listing: { ...base, state: "unavailable" as const, roots: [], documents: [],
198
+ truncated: false, reasonCode }, roots: [] as DocumentRoot[], paths: new Map<string, string>() });
199
+ let roots: DocumentRoot[];
200
+ try {
201
+ roots = resolveDocumentRoots({ cwd: input.cwd, profileRoots: input.profileRoots,
202
+ environmentRoots: input.environmentRoots, home: input.home });
203
+ } catch { return empty("document-roots-unavailable"); }
204
+ if (roots.length === 0) return empty("no-readable-root");
205
+
206
+ const isProtected = protectedPredicate(input);
207
+ const perRoot = Math.max(DOCUMENT_WORKSPACE_LIMITS.documentsPerRootFloor,
208
+ Math.floor(DOCUMENT_WORKSPACE_LIMITS.documents / roots.length));
209
+ const paths = new Map<string, string>();
210
+ const documents: DocumentWorkspaceEntry[] = [], projected: DocumentWorkspaceRoot[] = [];
211
+ let truncated = false;
212
+ for (const root of roots) {
213
+ const budget = { remaining: Math.min(perRoot, Math.max(0, DOCUMENT_WORKSPACE_LIMITS.documents - documents.length)) };
214
+ const found = walkRoot(root, budget, isProtected, paths);
215
+ truncated ||= budget.remaining <= 0;
216
+ projected.push({ rootRef: ref("document-root", root.path), path: displayName(root.path), source: root.source, documentCount: found.length });
217
+ documents.push(...found);
218
+ }
219
+ return { listing: { ...base, state: "ready", roots: projected, documents, truncated, reasonCode: null }, roots, paths };
220
+ }
221
+
222
+ export function projectDocumentWorkspaceListing(input: DocumentWorkspaceInput): DocumentWorkspaceListing {
223
+ return collectDocuments(input).listing;
224
+ }
225
+
226
+ export function projectDocumentWorkspaceDocument(input: DocumentWorkspaceInput & { documentRef: string }): DocumentWorkspaceDocument {
227
+ const now = (input.now ?? (() => new Date()))();
228
+ const base = { schemaVersion: 1 as const, version: "piagent-webui-document-workspace-v1" as const,
229
+ messageType: "document" as const, generatedAt: timestamp(now), documentRef: input.documentRef };
230
+ const unavailable = (reasonCode: string): DocumentWorkspaceDocument => ({ ...base, state: "unavailable", name: null, relativePath: null,
231
+ rootRef: null, format: null, text: null, sizeBytes: null, truncated: false, redacted: false, reasonCode });
232
+
233
+ // The ref is matched against a listing taken now, so opening a document proves
234
+ // it is still listable rather than trusting a ref the browser has been holding.
235
+ const { listing, roots, paths } = collectDocuments(input);
236
+ if (listing.state !== "ready") return unavailable(listing.reasonCode ?? "document-roots-unavailable");
237
+ const entry = listing.documents.find((candidate) => candidate.documentRef === input.documentRef);
238
+ const absolute = paths.get(input.documentRef);
239
+ if (!entry || !absolute) return unavailable("document-not-listed");
240
+
241
+ // Resolution runs against the same roots the walk used, so the extension gate,
242
+ // the size cap, the symlink canonicalisation and the containment check are the
243
+ // ones the document tool applies — including the identity that extractDocument
244
+ // re-proves when it opens the file.
245
+ const resolved = resolveDocumentPath(absolute, roots, { cwd: input.cwd, home: input.home });
246
+ if (resolved.status === "error") return unavailable("document-unavailable");
247
+ // The walk above already dropped protected documents, so a protected ref fails
248
+ // to match a listing entry before it reaches here. Both of the next two checks
249
+ // are the invariant stated rather than assumed: they hold the guarantee in
250
+ // place if the listing filter is ever loosened or the ref scheme changes.
251
+ const isProtected = protectedPredicate(input);
252
+ const relative = path.relative(resolved.root.path, resolved.absolutePath).split(path.sep).join("/");
253
+ if (isProtected(relative, resolved.absolutePath)) return unavailable("document-protected");
254
+ // Roots are canonical and the walk never traverses a link, so the listed path
255
+ // is already the canonical one and this equality holds by construction. It is
256
+ // re-derived anyway: the cost is a hash, and what it protects is the rule that
257
+ // the browser can only ever name a document the listing chose to expose.
258
+ if (ref("document", resolved.absolutePath) !== input.documentRef) return unavailable("document-changed");
259
+
260
+ const extracted = extractDocument(resolved);
261
+ if (extracted.status === "error") {
262
+ return { ...unavailable(extracted.reason.includes("needs OCR") ? "document-needs-ocr"
263
+ : extracted.reason.includes("pdftotext") ? "document-converter-unavailable" : "document-unreadable"),
264
+ name: entry.name, relativePath: entry.relativePath, rootRef: entry.rootRef };
265
+ }
266
+ // The same redaction the document tool applies before the model sees a file.
267
+ // The operator can open the original in an editor; what crosses this boundary
268
+ // is a projection, and projections in this read model do not carry secrets.
269
+ const safe = redactSensitiveText(extracted.text);
270
+ const capped = safe.text.length > DOCUMENT_WORKSPACE_LIMITS.text
271
+ ? { text: safe.text.slice(0, DOCUMENT_WORKSPACE_LIMITS.text), truncated: true }
272
+ : { text: safe.text, truncated: false };
273
+ return { ...base, state: "ready", name: entry.name, relativePath: entry.relativePath, rootRef: entry.rootRef,
274
+ format: extracted.kind, text: capped.text, sizeBytes: entry.sizeBytes,
275
+ truncated: extracted.truncated || capped.truncated, redacted: safe.redacted, reasonCode: null };
276
+ }
@@ -13,19 +13,34 @@ function readRequiredJsonObject(file: string): Record<string, unknown> {
13
13
  return value as Record<string, unknown>;
14
14
  }
15
15
 
16
- export function runtimeProtectedPaths(packageRoot: string, cwd: string): string[] {
17
- const policy = readRequiredJsonObject(path.join(packageRoot, "packages", "piagent-core", "policies", "base-policy.json"));
16
+ // A project profile is operator-supplied and may be missing, malformed, a
17
+ // symlink, or enormous. Every reader of it needs the same refusal, so both
18
+ // callers below go through this one.
19
+ function safeProjectProfile(packageRoot: string, cwd: string): Record<string, unknown> {
18
20
  const profileFile = path.join(cwd, ".pi", "piagent-profile.json");
19
- let profile: Record<string, unknown> = {};
20
21
  try {
21
22
  const stat = fs.lstatSync(profileFile);
22
23
  if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 1_048_576) {
23
- profile = readProjectProfileDocument(packageRoot, profileFile).profile ?? {};
24
+ return readProjectProfileDocument(packageRoot, profileFile).profile ?? {};
24
25
  }
25
26
  } catch {
26
27
  // Missing or unsafe project profiles never remove the platform policy.
27
28
  }
28
- return effectiveProtectedPaths(policy, profile).readProtectedPaths;
29
+ return {};
30
+ }
31
+
32
+ export function runtimeProtectedPaths(packageRoot: string, cwd: string): string[] {
33
+ const policy = readRequiredJsonObject(path.join(packageRoot, "packages", "piagent-core", "policies", "base-policy.json"));
34
+ return effectiveProtectedPaths(policy, safeProjectProfile(packageRoot, cwd)).readProtectedPaths;
35
+ }
36
+
37
+ // Directories the operator granted for reading documents outside the project.
38
+ // Returned raw: resolveDocumentRoots is what decides which of them exist and
39
+ // canonicalises them, and duplicating that judgement here would let the two
40
+ // answers drift.
41
+ export function runtimeDocumentReadRoots(packageRoot: string, cwd: string): string[] {
42
+ const granted = safeProjectProfile(packageRoot, cwd).additionalReadRoots;
43
+ return Array.isArray(granted) ? granted.filter((item): item is string => typeof item === "string") : [];
29
44
  }
30
45
 
31
46
  export function runtimeConnectionDefinitions(cwd: string): {
@@ -0,0 +1,20 @@
1
+ // The identity and revision shape a Pi session is addressed by.
2
+ //
3
+ // Both the in-session bridge and the Gateway have to speak it: the bridge
4
+ // because it owns one live session, the Gateway because it synthesises the same
5
+ // shape from an inspection snapshot for the many sessions it drives. The layer
6
+ // rules let extension and gateway each import server, and neither import the
7
+ // other, so this is the one place both can agree on.
8
+
9
+ type NullableRef = string | null;
10
+
11
+ export type BridgeIdentity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string | null;
12
+ taskRunId: string | null; agentOperationId: string | null; toolCallId: null };
13
+
14
+ export type BridgeRevisions = { runtimeRevision: string; taskRevision: NullableRef; controlRevision: NullableRef;
15
+ workspaceRevision: NullableRef; indexRevision: NullableRef; approvalRevision: NullableRef;
16
+ sessionOptionRevision: NullableRef; queueRevision: NullableRef };
17
+
18
+ export type BridgeSnapshot = { state: "unbound" | "ready" | "replacement-pending" | "shutdown";
19
+ identity: BridgeIdentity | null; revisions: BridgeRevisions | null; liveness: "idle" | "running" | "unknown";
20
+ taskState: "none" | "active" | "pause-requested" | "paused" | "terminal" | "unknown"; eventSequence: number };
@@ -7,6 +7,9 @@ import type { HelperRequest, HelperRole } from "./role-policy.ts";
7
7
  import { validateHelperRequest } from "./role-policy.ts";
8
8
 
9
9
  export const OWNED_WORK_BUDGET_VERSION = "owned-work-budget-v1" as const;
10
+ // `maxRepairPasses` is declared for the repair lane, which reserves no helper
11
+ // role here, so this mechanism never reads it. It is named rather than removed
12
+ // because the repair lane is bounded elsewhere; nothing in this file enforces it.
10
13
  export type OwnedWorkCeilings = Readonly<{ maxConcurrentHelpers: number; maxTotalHelpers: number; maxScoutPasses: number; maxPlannerPasses: number; maxReviewPasses: number; maxOracleCalls: number; maxRepairPasses: number; maxWriters: number }>;
11
14
  export const DEFAULT_OWNED_WORK_CEILINGS: OwnedWorkCeilings = Object.freeze({ maxConcurrentHelpers: 2, maxTotalHelpers: 3, maxScoutPasses: 1, maxPlannerPasses: 1, maxReviewPasses: 1, maxOracleCalls: 1, maxRepairPasses: 1, maxWriters: 1 });
12
15
  export const AUTOMATIC_OWNED_WORK_CEILINGS: OwnedWorkCeilings = Object.freeze({ ...DEFAULT_OWNED_WORK_CEILINGS, maxConcurrentHelpers: 1, maxTotalHelpers: 1 });
@@ -26,7 +29,27 @@ function statePath(cwd: string, taskRunId: string): string { return path.join(cw
26
29
  function lockPath(cwd: string, taskRunId: string): string { return `${statePath(cwd, taskRunId)}.lock`; }
27
30
  function empty(request: HelperRequest, now: string): BudgetState { return { version: OWNED_WORK_BUDGET_VERSION, taskId: request.taskId, taskRunId: request.taskRunId, terminal: false, reservations: [], updatedAt: now }; }
28
31
  function active(state: BudgetState): OwnedWorkReservation[] { return state.reservations.filter((item) => item.status === "active"); }
29
- function roleLimit(role: HelperRole): number { return role === "scout" || role === "retriever" || role === "researcher" ? 1 : role === "planner" ? 1 : role === "reviewer" ? 1 : role === "oracle" ? 1 : role === "worker" ? 1 : 0; }
32
+ // The per-role ceiling, read from the ceilings the caller passed rather than
33
+ // from constants written here. These limits were real but fixed at one apiece,
34
+ // so `OwnedWorkCeilings` advertised six numbers that nothing ever read: a
35
+ // profile could raise `maxScoutPasses` and be silently ignored, while the two
36
+ // neighbouring fields in the same object did take effect. Half-honoured
37
+ // configuration is worse than none, because the half that works teaches the
38
+ // reader to trust the half that does not.
39
+ //
40
+ // Every default is one, so this changes no behaviour under the shipped
41
+ // ceilings; it makes the object mean what it says.
42
+ function roleLimit(role: HelperRole, ceilings: OwnedWorkCeilings): number {
43
+ switch (role) {
44
+ case "scout": case "retriever": case "researcher": return ceilings.maxScoutPasses;
45
+ case "planner": return ceilings.maxPlannerPasses;
46
+ case "reviewer": return ceilings.maxReviewPasses;
47
+ case "oracle": return ceilings.maxOracleCalls;
48
+ case "worker": return ceilings.maxWriters;
49
+ // An unknown role reserves nothing rather than defaulting to a limit.
50
+ default: return 0;
51
+ }
52
+ }
30
53
 
31
54
  function read(cwd: string, request: HelperRequest, now: string): BudgetState {
32
55
  const target = statePath(cwd, request.taskRunId); if (!fs.existsSync(target)) return empty(request, now);
@@ -136,7 +159,7 @@ export class OwnedWorkBudgetController {
136
159
  const duplicate = state.reservations.find((item) => item.deduplicationKey === request.deduplicationKey && ["active", "succeeded"].includes(item.status));
137
160
  if (duplicate) return { decision: "duplicate", reason: "equivalent-helper-already-owned", reservationId: duplicate.id, recoveredOrphans };
138
161
  if (active(state).length >= ceilings.maxConcurrentHelpers || state.reservations.length >= ceilings.maxTotalHelpers) return { decision: "blocked", reason: "helper-budget-exhausted", reservationId: null, recoveredOrphans };
139
- if (state.reservations.filter((item) => item.role === request.role).length >= roleLimit(request.role)) return { decision: "blocked", reason: `${request.role}-ceiling-reached`, reservationId: null, recoveredOrphans };
162
+ if (state.reservations.filter((item) => item.role === request.role).length >= roleLimit(request.role, ceilings)) return { decision: "blocked", reason: `${request.role}-ceiling-reached`, reservationId: null, recoveredOrphans };
140
163
  if (request.authority === "single-writer" && active(state).some((item) => item.authority === "single-writer")) return { decision: "blocked", reason: "single-writer-already-owned", reservationId: null, recoveredOrphans };
141
164
  const id = digest(`${request.taskRunId}:${request.deduplicationKey}:${state.reservations.length}`).slice(0, 32);
142
165
  state.reservations.push({ id, deduplicationKey: request.deduplicationKey, role: request.role, authority: request.authority, status: "active", reservedAt: now, expiresAt: new Date(Date.parse(now) + request.ceilings.timeSeconds * 1000).toISOString(), completedAt: null, usageRef: null });
@@ -0,0 +1,139 @@
1
+ // What to do about a refusal.
2
+ //
3
+ // A blocked tool call is the only conversation most operators ever have with
4
+ // this policy, and for most of them it was one-sided: the guard states 74
5
+ // distinct refusals and three of them said what to do next. The rest ended at
6
+ // "no", which leaves the reader guessing at a rule they cannot see.
7
+ //
8
+ // Remedies are attached in one place rather than at the 29 sites that build a
9
+ // refusal, for the reason this codebase keeps arriving at: a rule applied at
10
+ // every call site is a rule that will be missed at the next one. Every decision
11
+ // leaves through `registerToolCallHook`, so it is enough to answer there, and a
12
+ // refusal added tomorrow is answered without anyone remembering to.
13
+
14
+ // Matched against the refusal text in order; the first match wins, so put the
15
+ // narrower phrase above the broader one it would otherwise be swallowed by.
16
+ const REMEDIES: Array<{ match: RegExp; remedy: string }> = [
17
+ {
18
+ // `grep glob targeting` and `find pattern targeting` are the same refusal
19
+ // reached through a different tool, and matching only the shell wording left
20
+ // two thirds of this family answering nothing.
21
+ match: /glob can target protected path|glob targeting protected path|pattern targeting protected path/i,
22
+ remedy: "Name the exact file instead of a pattern: a pattern is refused when any name it can match is protected."
23
+ + " `piagent explain '<command>'` shows which name it matched."
24
+ },
25
+ {
26
+ match: /builds a filename this guard cannot resolve/i,
27
+ // This refusal already carries its own guidance, so it only gains the
28
+ // pointer to the tool that shows the whole chain.
29
+ remedy: "`piagent explain '<command>'` shows every step that ran and what each one saw."
30
+ },
31
+ {
32
+ match: /(touches|resolves to) protected path|access to protected path|write to protected path/i,
33
+ remedy: "Use an approved `piagent-context` projection if one is available. Direct access requires the operator"
34
+ + " to change the protected-path policy; task scope alone does not override it."
35
+ + " `piagent explain '<command>'` shows which pattern matched."
36
+ },
37
+ {
38
+ match: /write to read-only path/i,
39
+ remedy: "The active profile marks this path read-only. Change `readOnlyPaths` in `.pi/piagent-profile.json`"
40
+ + " if the project really should allow writes here, then reapply the profile."
41
+ },
42
+ {
43
+ match: /outside resolved filesystem scope/i,
44
+ remedy: "Work inside the project, or widen the filesystem scope in the active profile and reapply it."
45
+ },
46
+ {
47
+ match: /cannot mutate paths outside its declared scope|semantic repair must stay inside its declared task scope/i,
48
+ remedy: "Start a new attempt with `piagent_task_start` listing this path in the scope; a running task cannot widen its own."
49
+ },
50
+ {
51
+ match: /Task Implementation Contract is required/i,
52
+ remedy: "Call `piagent_task_start` once with explicit project-relative scope, then retry."
53
+ },
54
+ {
55
+ match: /Capability lock is (missing|unreadable)|does not match what this project agreed to/i,
56
+ remedy: "Reapply the project profile to rebuild the lock: `piagent-init <project> --force-profile`."
57
+ },
58
+ {
59
+ match: /runtime changed during this session/i,
60
+ remedy: "Restart the Pi session. The lock is re-verified and re-pinned at session start, never mid-session."
61
+ },
62
+ {
63
+ match: /Permission profile read-only blocked/i,
64
+ remedy: "This session is read-only. Change the access level in Settings, or start it with a writable permission profile."
65
+ },
66
+ {
67
+ match: /Tool (is not registered|registry is disabled|registry blocked)/i,
68
+ remedy: "Enable the tool group for this profile, or use a tool the profile already allows."
69
+ },
70
+ {
71
+ match: /lifecycle control blocks tool start|resume is blocked|could not enter an audited repair phase/i,
72
+ remedy: "Resolve the task lifecycle first — resume, repair, or close the current attempt — then retry."
73
+ },
74
+ {
75
+ match: /path traverses symbolic link|path resolves outside the project|path component cannot be inspected/i,
76
+ remedy: "Use the real path inside the project. A link that leaves the project is refused rather than followed."
77
+ },
78
+ {
79
+ match: /requires confirmation|requires explicit operator approval/i,
80
+ remedy: "Approve it in the session when prompted; this is a decision the operator makes, not a defect."
81
+ },
82
+ {
83
+ // The whole session is refused here, so this is the refusal most in need of
84
+ // a next step -- and the one the narrower MCP wording used to miss.
85
+ match: /Blocked every tool call/i,
86
+ remedy: "Every tool call is refused while a repository config can reach an ungated server."
87
+ + " Run `piagent-mcp doctor` to see which config stopped the session, then approve or remove those servers."
88
+ },
89
+ {
90
+ match: /MCP (proxy|shell carrier|command|server)/i,
91
+ remedy: "Check the server with `piagent-mcp doctor`, and approve it explicitly if the project should reach it."
92
+ },
93
+ {
94
+ match: /is read-only; .*cannot mutate|read-only inspection allowlist/i,
95
+ remedy: "This task was started read-only. Start a source-change task with `piagent_task_start` when the work needs to write."
96
+ },
97
+ {
98
+ match: /opaque shell mutation whose write target is not statically bounded/i,
99
+ remedy: "Use the edit, write, or apply-patch tools so the target is named, instead of a shell command that computes it."
100
+ },
101
+ {
102
+ match: /Context budget blocked editing large file/i,
103
+ remedy: "Edit a narrower range of the file, or split the change, so the edit stays inside the context budget."
104
+ },
105
+ {
106
+ match: /Approval became stale/i,
107
+ remedy: "Ask for the action again; an approval is bound to the state it was granted for and does not survive a change to it."
108
+ },
109
+ {
110
+ match: /Missing capability|Capability validation failed/i,
111
+ remedy: "Add the pack that provides it to the project profile, then reapply the profile."
112
+ },
113
+ {
114
+ match: /nesting exceeds inspection depth|unsafe scope pattern/i,
115
+ remedy: "Flatten the tool input or narrow the scope pattern: anything the guard cannot finish inspecting is refused rather than guessed at."
116
+ },
117
+ {
118
+ match: /start a new attempt or a fresh task/i,
119
+ remedy: "Start the next attempt with `piagent_task_start`; a closed attempt cannot keep mutating the project."
120
+ }
121
+ ];
122
+
123
+ // Kept out of the appended text when the refusal already ends by saying what to
124
+ // do, so a reader is never given the same instruction twice in one line.
125
+ const ALREADY_GUIDED = /Write the path out|Reapply the project profile|Restart the session|then retry\.$|Start the task first/i;
126
+
127
+ export function remedyForReason(reason: string): string | undefined {
128
+ if (typeof reason !== "string" || reason.trim().length === 0) return undefined;
129
+ const found = REMEDIES.find((entry) => entry.match.test(reason));
130
+ if (!found) return undefined;
131
+ if (ALREADY_GUIDED.test(reason) && !/glob can target|cannot resolve/i.test(reason)) return undefined;
132
+ return found.remedy;
133
+ }
134
+
135
+ export function withRemedy<T extends { block: true; reason: string }>(decision: T): T {
136
+ const remedy = remedyForReason(decision.reason);
137
+ if (!remedy || decision.reason.includes(remedy)) return decision;
138
+ return { ...decision, reason: `${decision.reason.replace(/\s+$/, "")} → ${remedy}` };
139
+ }
@@ -44,10 +44,11 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
44
44
  const taskStartTool = {
45
45
  name: "piagent_task_start",
46
46
  label: "Piagent Task Start",
47
- description: "Create a Task Implementation Contract for the current project before editing.",
48
- promptSnippet: "Start a governed implementation task and persist the task contract.",
47
+ description: "Create a Task Implementation Contract before governed project inspection, command execution, or source changes.",
48
+ promptSnippet: "Start a governed project task and persist the task contract.",
49
49
  promptGuidelines: [
50
50
  "Call this exactly once before source edits in a project managed by Pi Agent Platform.",
51
+ "Use source-change for project command execution, including tests, builds, lint, typecheck, verification, and package dry-runs, even when the operator requires source files to remain unchanged. Read-only intentionally blocks shell execution.",
51
52
  "Do not call context, status, policy, evidence-recording, trace, or gate tools first; runtime hooks provide those checks automatically.",
52
53
  "Use tiny for a bounded low-risk change, normal for ordinary multi-file work, and high-risk for security, data, release, migration, or external-impact work.",
53
54
  "Every scope entry must be a project-relative path or glob such as src/file.ts, src/**, or test/**; never put prose in scope.",
@@ -58,7 +59,9 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
58
59
  taskId: Type.Optional(Type.String({ minLength: 1 })),
59
60
  summary: Type.String({ minLength: 10 }),
60
61
  riskLane: StringEnum(["tiny", "normal", "high-risk"] as const),
61
- changeMode: Type.Optional(StringEnum(["source-change", "read-only"] as const)),
62
+ changeMode: Type.Optional(StringEnum(["source-change", "read-only"] as const, {
63
+ description: "Use source-change for project command execution as well as edits; use read-only only for inspection that needs no shell execution."
64
+ })),
62
65
  maxAttempts: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
63
66
  expectedOutput: Type.String({ minLength: 10 }),
64
67
  acceptanceCriteria: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 12 }),
@@ -103,15 +106,8 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
103
106
  const migrationRetry = active?.taskId === taskId
104
107
  && active?.workingTreeDigestMigration?.status === "new-attempt-required";
105
108
  const authorityRetry = active?.taskId === taskId && activeAuthorityReplacement?.required === true;
106
- if (active && !migrationRetry && !authorityRetry) {
109
+ if (active?.trace.outcome === "pending" && !migrationRetry && !authorityRetry) {
107
110
  if (active.taskId === taskId) {
108
- if (active.trace.outcome !== "pending") {
109
- return {
110
- content: [{ type: "text", text: `Session already belongs to terminal task ${active.taskId} (${active.trace.outcome}). Start a fresh Pi session for a retry or new task.` }],
111
- details: active,
112
- isError: true
113
- };
114
- }
115
111
  const manifest = repositoryFileManifest(ctx.cwd);
116
112
  const currentScopeResolution = resolveTaskScopePatterns(active.scope, manifest);
117
113
  const proposedScopeResolution = Array.isArray(params.scope)
@@ -151,7 +147,7 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
151
147
  };
152
148
  }
153
149
  return {
154
- content: [{ type: "text", text: `Session already belongs to task ${active.taskId} (${active.taskRunId}, ${active.trace.outcome}). Use one Pi session per task and start a fresh session before another task.` }],
150
+ content: [{ type: "text", text: `Session already has pending task ${active.taskId} (${active.taskRunId}). Complete or stop it before starting another task in this conversation.` }],
155
151
  details: active,
156
152
  isError: true
157
153
  };
@@ -428,7 +424,7 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
428
424
  const started = await taskStartTool.execute(
429
425
  `runtime-intake-${ctx.sessionManager.getSessionId()}`,
430
426
  {
431
- taskId: hasOperatorSessionName(sessionName) ? sessionName : summary,
427
+ taskId: active && active.trace.outcome !== "pending" ? summary : hasOperatorSessionName(sessionName) ? sessionName : summary,
432
428
  summary,
433
429
  riskLane: automaticTaskRiskLane(prompt),
434
430
  intakeMode: "runtime",
@@ -8,7 +8,7 @@ export function registerWorkflowCommands(pi: ExtensionAPI, deps: Record<string,
8
8
  buildUsageSnapshot, commandArgs, emitProfileStatus, emitProfileTechStatus, emitRuntimeMessage,
9
9
  fs, loadProfileFromContext, prefixCompletions, projectContextFilePath, projectProfilePath,
10
10
  registerRuntimeCommand, resolveMemorySettings, runProfileTechWizard, selectRuntimeAction, sendWorkflowFollowUp,
11
- shortTaskLabel, techStackPath
11
+ freshRequestParts, shortTaskLabel, techStackPath
12
12
  } = deps;
13
13
  type WorkflowCommandName = "task" | "scout" | "be-to-fe" | "discuss" | "plan" | "review" | "platform-improve" | "commit" | "pr" | "onboard";
14
14
 
@@ -377,17 +377,18 @@ export function registerWorkflowCommands(pi: ExtensionAPI, deps: Record<string,
377
377
  });
378
378
 
379
379
  async function startFreshWorkflow(workflow: "task" | "scout" | "be-to-fe", args: string, ctx: any) {
380
- const request = String(args ?? "").trim();
380
+ const parsed = freshRequestParts(String(args ?? ""));
381
+ const request = parsed.request;
381
382
  if (!request) {
382
383
  ctx.ui.notify(`Usage: /fresh ${workflow} <request>`, "warning");
383
384
  return;
384
385
  }
385
386
 
386
- const label = shortTaskLabel(request);
387
+ const label = parsed.sessionTitle ?? shortTaskLabel(request);
387
388
  const command = `/${workflow} ${request}`;
388
389
  const result = await ctx.newSession({
389
390
  withSession: async (nextCtx) => {
390
- pi.setSessionName(`pi:${workflow}:${label}`);
391
+ pi.setSessionName(`pi:${label}`);
391
392
  await nextCtx.sendUserMessage(command);
392
393
  }
393
394
  });