@piagent/platform 1.4.0 → 1.4.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 (100) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +8 -2
  3. package/README.vi.md +27 -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 +36 -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/package.json +14 -2
  59. package/packages/piagent-core/README.md +3 -2
  60. package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
  61. package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
  62. package/packages/piagent-core/extensions/piagent-guard.ts +18 -235
  63. package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
  64. package/packages/piagent-core/extensions/shell-reach.ts +250 -0
  65. package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
  66. package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
  67. package/packages/piagent-core/package.json +1 -1
  68. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
  69. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
  70. package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
  71. package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
  72. package/packages/piagent-core/runtime/session/token-economics.js +34 -0
  73. package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
  74. package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
  75. package/packages/piagent-core/security/sensitive-data.js +7 -0
  76. package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +172 -0
  77. package/packages/piagent-webui/dist/client/index.html +1 -1
  78. package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
  79. package/packages/piagent-webui/extension/piagent-webui.ts +4 -4
  80. package/packages/piagent-webui/extension/same-session-bridge.ts +3 -3
  81. package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
  82. package/packages/piagent-webui/gateway/session-inspection-registry.ts +37 -2
  83. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +5 -1
  84. package/packages/piagent-webui/server/core-inspection-provider.ts +5 -5
  85. package/packages/piagent-webui/server/transcript-projection.ts +15 -0
  86. package/scripts/build-docs-site.mjs +21 -1
  87. package/scripts/explain-command.mjs +240 -0
  88. package/scripts/install-global.sh +104 -3
  89. package/scripts/mcp-manage.mjs +17 -4
  90. package/scripts/pi-session-stats.sh +10 -2
  91. package/scripts/piagent-cli.mjs +14 -1
  92. package/scripts/piagent-dashboard.mjs +55 -2
  93. package/scripts/setup.sh +69 -33
  94. package/scripts/traffic-snapshot.mjs +214 -0
  95. package/scripts/typescript-loader.mjs +20 -4
  96. package/scripts/uninstall-global.sh +53 -2
  97. package/scripts/update-global.mjs +33 -1
  98. package/scripts/verify-local.sh +4 -0
  99. package/scripts/verify-translation-pairing.mjs +121 -0
  100. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
@@ -0,0 +1,250 @@
1
+ // What a shell command can reach, and how to say so.
2
+ //
3
+ // These checks used to live inside the guard entrypoint, which imports the Pi
4
+ // host runtime and therefore cannot be loaded anywhere else. `piagent explain`
5
+ // needs the same answers the guard gives, and a second implementation of "does
6
+ // this command reach a protected path" is exactly the defect this project keeps
7
+ // closing: one segment, many readers, each with its own view. So the readers
8
+ // live here and both the guard and the CLI take this one.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import {
15
+ extractShellGlobCandidates,
16
+ extractShellPathCandidates,
17
+ matchesProtectedPath,
18
+ normalizePathCandidate
19
+ } from "./policy-core.js";
20
+
21
+ export function normalizeRelative(cwd: string, candidate: unknown): string | undefined {
22
+ if (typeof candidate !== "string" || candidate.trim().length === 0) return undefined;
23
+ let raw = candidate.trim();
24
+ try {
25
+ raw = decodeURIComponent(raw);
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) {
30
+ if (!raw.toLowerCase().startsWith("file://")) return undefined;
31
+ try {
32
+ raw = fileURLToPath(raw);
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
38
+ return path.relative(cwd, absolute).split(path.sep).join("/");
39
+ }
40
+
41
+ export function resolveRepositoryPathCandidate(cwd: string, candidate: string): string | undefined {
42
+ const normalized = normalizePathCandidate(candidate);
43
+ if (normalized === ".." || normalized.startsWith("../")) return undefined;
44
+
45
+ const relative = path.posix.isAbsolute(normalized)
46
+ ? path.relative(cwd, normalized).split(path.sep).join("/")
47
+ : normalized;
48
+ if (relative === ".." || relative.startsWith("../")) return undefined;
49
+
50
+ const pending = relative.split("/").filter((item) => item && item !== ".");
51
+ let current = cwd;
52
+ let resolvedDepth = 0;
53
+ for (let index = 0; index < pending.length; index += 1) {
54
+ const next = path.join(current, pending[index]);
55
+ try {
56
+ fs.lstatSync(next);
57
+ current = next;
58
+ resolvedDepth = index + 1;
59
+ } catch (error) {
60
+ const code = error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : "";
61
+ if (code !== "ENOENT") return undefined;
62
+ break;
63
+ }
64
+ }
65
+
66
+ let canonicalBase: string;
67
+ try {
68
+ canonicalBase = fs.realpathSync.native(current);
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ const canonical = path.resolve(canonicalBase, ...pending.slice(resolvedDepth));
73
+ const canonicalRoot = fs.realpathSync.native(cwd);
74
+ const canonicalRelative = path.relative(canonicalRoot, canonical).split(path.sep).join("/");
75
+ if (canonicalRelative === ".." || canonicalRelative.startsWith("../") || path.isAbsolute(canonicalRelative)) return undefined;
76
+ return canonicalRelative || ".";
77
+ }
78
+
79
+ export function findResolvedProtectedPathInCommand(
80
+ cwd: string,
81
+ command: string,
82
+ protectedPatterns: string[]
83
+ ): { candidate: string; resolved: string; pattern: string } | undefined {
84
+ for (const candidate of extractShellPathCandidates(command)) {
85
+ const relative = normalizeRelative(cwd, candidate);
86
+ if (!relative) continue;
87
+ const resolved = resolveRepositoryPathCandidate(cwd, relative);
88
+ if (!resolved || resolved === relative) continue;
89
+ const pattern = matchesProtectedPath(resolved, protectedPatterns);
90
+ if (pattern) return { candidate, resolved, pattern };
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ export function expandSimpleGlobAlternatives(pattern: string, max = 24): { values: string[]; complete: boolean } {
96
+ let results = [pattern];
97
+ let changed = true;
98
+ let complete = true;
99
+
100
+ while (changed) {
101
+ changed = false;
102
+ const expanded: string[] = [];
103
+ for (const item of results) {
104
+ const match = item.match(/\{([^{}]+)\}/);
105
+ if (!match) {
106
+ expanded.push(item);
107
+ continue;
108
+ }
109
+ changed = true;
110
+ const options = match[1].split(",").map((option) => option.trim());
111
+ for (const option of options) {
112
+ expanded.push(`${item.slice(0, match.index)}${option}${item.slice((match.index ?? 0) + match[0].length)}`);
113
+ if (expanded.length >= max) {
114
+ complete = false;
115
+ break;
116
+ }
117
+ }
118
+ if (expanded.length >= max) break;
119
+ }
120
+ results = expanded.slice(0, max);
121
+ }
122
+
123
+ return { values: results, complete };
124
+ }
125
+
126
+ export function shellGlobSegmentMatches(patternSegment: string, candidateSegment: string): boolean {
127
+ if (candidateSegment.startsWith(".") && !patternSegment.startsWith(".")) return false;
128
+ let source = "";
129
+ for (let index = 0; index < patternSegment.length; index += 1) {
130
+ const char = patternSegment[index];
131
+ if (char === "*") {
132
+ source += "[^/]*";
133
+ continue;
134
+ }
135
+ if (char === "?") {
136
+ source += "[^/]";
137
+ continue;
138
+ }
139
+ if (char === "[") {
140
+ const end = patternSegment.indexOf("]", index + 1);
141
+ const body = end > index + 1 ? patternSegment.slice(index + 1, end) : "";
142
+ if (body && /^[!^A-Za-z0-9_-]+$/.test(body)) {
143
+ const negated = body.startsWith("!") ? `^${body.slice(1)}` : body;
144
+ source += `[${negated}]`;
145
+ index = end;
146
+ continue;
147
+ }
148
+ }
149
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
150
+ }
151
+ return new RegExp(`^${source}$`, "i").test(candidateSegment);
152
+ }
153
+
154
+ export function shellGlobMatchesPath(pattern: string, candidate: string): boolean {
155
+ const patternSegments = normalizePathCandidate(pattern).split("/").filter(Boolean);
156
+ const candidateSegments = normalizePathCandidate(candidate).split("/").filter(Boolean);
157
+
158
+ function match(patternIndex: number, candidateIndex: number): boolean {
159
+ if (patternIndex === patternSegments.length) return candidateIndex === candidateSegments.length;
160
+ const patternSegment = patternSegments[patternIndex];
161
+ if (patternSegment === "**") {
162
+ if (match(patternIndex + 1, candidateIndex)) return true;
163
+ for (let next = candidateIndex; next < candidateSegments.length; next += 1) {
164
+ if (match(patternIndex + 1, next + 1)) return true;
165
+ }
166
+ return false;
167
+ }
168
+ if (candidateIndex >= candidateSegments.length) return false;
169
+ return shellGlobSegmentMatches(patternSegment, candidateSegments[candidateIndex])
170
+ && match(patternIndex + 1, candidateIndex + 1);
171
+ }
172
+
173
+ return patternSegments.length > 0 && candidateSegments.length > 0 && match(0, 0);
174
+ }
175
+
176
+ export function protectedPatternExamples(pattern: string): string[] {
177
+ const normalized = normalizePathCandidate(pattern);
178
+ if (!normalized) return [];
179
+
180
+ const examples = new Set<string>();
181
+ const add = (value: string | undefined) => {
182
+ const normalizedValue = normalizePathCandidate(value ?? "");
183
+ if (normalizedValue) examples.add(normalizedValue);
184
+ };
185
+
186
+ add(normalized);
187
+
188
+ if (normalized.endsWith("/**")) {
189
+ const base = normalized.slice(0, -3);
190
+ add(base);
191
+ add(`${base}/probe`);
192
+ }
193
+
194
+ if (normalized.startsWith("**/")) {
195
+ const tail = normalized.slice(3);
196
+ const concreteTail = tail
197
+ .replace(/\*\*/g, "nested")
198
+ .replace(/\*/g, tail.includes(".env.") ? "local" : "probe");
199
+ add(tail);
200
+ add(concreteTail);
201
+ add(`nested/${concreteTail}`);
202
+ }
203
+
204
+ const concrete = normalized
205
+ .replace(/^\*\*\//, "")
206
+ .replace(/\/\*\*$/, "/probe")
207
+ .replace(/\*\*/g, "nested")
208
+ .replace(/\*/g, normalized.includes(".env.") ? "local" : "probe");
209
+ add(concrete);
210
+
211
+ for (const example of [...examples]) {
212
+ const base = path.posix.basename(example);
213
+ if (base && base !== "probe") examples.add(base);
214
+ }
215
+
216
+ return [...examples];
217
+ }
218
+
219
+ export function shellGlobTargetsProtectedPath(
220
+ command: string,
221
+ protectedPatterns: string[]
222
+ ): { glob: string; pattern: string; example: string } | undefined {
223
+ for (const candidate of extractShellGlobCandidates(command)) {
224
+ if (!/[*?{\[]/.test(candidate)) continue;
225
+ const expanded = expandSimpleGlobAlternatives(candidate);
226
+ if (!expanded.complete) return { glob: candidate, pattern: "bounded glob expansion", example: "a protected path" };
227
+ for (const candidateGlob of expanded.values) {
228
+ for (const pattern of protectedPatterns) {
229
+ for (const example of protectedPatternExamples(pattern)) {
230
+ if (/[*?{\[\]]/.test(example)) continue;
231
+ if (
232
+ shellGlobMatchesPath(candidateGlob, example)
233
+ || shellGlobMatchesPath(`**/${candidateGlob}`, example)
234
+ || shellGlobMatchesPath(candidateGlob, path.posix.basename(example))
235
+ ) {
236
+ return { glob: candidate, pattern, example };
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ export function unresolvedExpansionReason(subject: string, words: string[]): string {
246
+ const listed = words.map((word) => `\`${word}\``).join(", ");
247
+ return `${subject} builds a filename this guard cannot resolve: ${listed}. `
248
+ + "The literal text around the expansion makes it a path, but its value is only known at run time, "
249
+ + "so it cannot be checked against the protected paths. Write the path out, or put the expansion in its own argument.";
250
+ }
@@ -37,7 +37,7 @@ export const SERVER_ACTIONS = new Set(["get", "approve", "reject", "reset", "ena
37
37
  export const TERMINAL_ONLY = new Map([
38
38
  ["add", "piagent-mcp add <name> [--scope <scope>] --url <url> | -- <command> [args...]"],
39
39
  ["remove", "piagent-mcp remove <name> --scope <scope>"],
40
- ["preset", "piagent-mcp --preset <core|popular|design> --scope <scope>"]
40
+ ["preset", "piagent-mcp --preset <core|popular|documents|google|design> --scope <scope>"]
41
41
  ]);
42
42
 
43
43
  export const HELP_LINES = [
@@ -6,7 +6,7 @@
6
6
  // Nothing here is a secret. Every credential is referenced as an environment
7
7
  // variable, so the config can be read, diffed and committed without leaking one.
8
8
 
9
- /** @typedef {{description: string, config: Record<string, unknown>}} CatalogServer */
9
+ /** @typedef {{description: string, maturity?: "stable"|"experimental", config: Record<string, unknown>}} CatalogServer */
10
10
 
11
11
  /** @type {Record<string, CatalogServer>} */
12
12
  export const CATALOG_SERVERS = {
@@ -83,6 +83,85 @@ export const CATALOG_SERVERS = {
83
83
  lifecycle: "lazy",
84
84
  directTools: false
85
85
  }
86
+ },
87
+ // Google's own remote servers, one per product, rather than a community server
88
+ // holding every scope at once: a session that only needs to read a document
89
+ // should not be one OAuth grant away from the mailbox.
90
+ //
91
+ // These carry no version pin because there is nothing local to pin -- the
92
+ // endpoint is operated by Google and changes under the same URL. That is a
93
+ // real difference from every command-based server above, where the pin is what
94
+ // stops a floating tag becoming a supply-chain decision each session.
95
+ // Scopes are declared read-only. Write access is one word longer to ask for
96
+ // and cannot be taken back once an agent has used it, so the default grant is
97
+ // the one that cannot lose anything. `clientId` is deliberately absent: every
98
+ // operator registers their own OAuth client, and a client id baked in here
99
+ // would be someone else's project answering for their consent screen.
100
+ "google-drive": {
101
+ description: "EXPERIMENTAL Developer Preview: Google Drive remote MCP server. Read-only by default.",
102
+ maturity: "experimental",
103
+ config: {
104
+ url: "https://drivemcp.googleapis.com/mcp/v1",
105
+ auth: "oauth",
106
+ oauth: { scopes: ["https://www.googleapis.com/auth/drive.readonly"] },
107
+ lifecycle: "lazy",
108
+ directTools: false
109
+ }
110
+ },
111
+ gmail: {
112
+ description: "EXPERIMENTAL Developer Preview: Gmail remote MCP server. Read-only by default.",
113
+ maturity: "experimental",
114
+ config: {
115
+ url: "https://gmailmcp.googleapis.com/mcp/v1",
116
+ auth: "oauth",
117
+ oauth: { scopes: ["https://www.googleapis.com/auth/gmail.readonly"] },
118
+ lifecycle: "lazy",
119
+ directTools: false
120
+ }
121
+ },
122
+ "google-docs": {
123
+ description: "EXPERIMENTAL Developer Preview: Google Docs remote MCP server. Read-only by default.",
124
+ maturity: "experimental",
125
+ config: {
126
+ url: "https://docsmcp.googleapis.com/mcp/v1",
127
+ auth: "oauth",
128
+ oauth: {
129
+ scopes: [
130
+ "https://www.googleapis.com/auth/documents.readonly",
131
+ "https://www.googleapis.com/auth/drive.readonly"
132
+ ]
133
+ },
134
+ lifecycle: "lazy",
135
+ directTools: false
136
+ }
137
+ },
138
+ "google-sheets": {
139
+ description: "EXPERIMENTAL Developer Preview: Google Sheets remote MCP server. Read-only by default.",
140
+ maturity: "experimental",
141
+ config: {
142
+ url: "https://sheetsmcp.googleapis.com/mcp/v1",
143
+ auth: "oauth",
144
+ oauth: {
145
+ scopes: [
146
+ "https://www.googleapis.com/auth/spreadsheets.readonly",
147
+ "https://www.googleapis.com/auth/drive.readonly"
148
+ ]
149
+ },
150
+ lifecycle: "lazy",
151
+ directTools: false
152
+ }
153
+ },
154
+ // Converts .docx, .xlsx, .pptx and .pdf to Markdown locally. Local matters:
155
+ // the alternative to this is uploading a document somewhere to read it, and a
156
+ // document worth converting is usually one worth not uploading.
157
+ markitdown: {
158
+ description: "MarkItDown MCP. Converts local docx, xlsx, pptx, pdf and html files to Markdown. Runs offline for file: URIs.",
159
+ config: {
160
+ command: "uvx",
161
+ args: ["markitdown-mcp@0.0.1a4"],
162
+ lifecycle: "lazy",
163
+ directTools: false
164
+ }
86
165
  }
87
166
  };
88
167
 
@@ -100,7 +179,33 @@ export const CATALOG_PREREQUISITES = {
100
179
  ],
101
180
  playwright: ["browsers installed on first use (npx playwright install)"],
102
181
  figma: ["Piagent approved in the Figma MCP Catalog before remote OAuth can register"],
103
- "figma-desktop": ["Figma desktop running with Dev Mode MCP enabled"]
182
+ "figma-desktop": ["Figma desktop running with Dev Mode MCP enabled"],
183
+ "google-drive": [
184
+ "a Google Cloud project with drive.googleapis.com and drivemcp.googleapis.com enabled",
185
+ "Developer Preview: no Pi OAuth end-to-end release proof exists yet",
186
+ "Google's supported client guides use an OAuth client id and secret; Pi's public-client/PKCE compatibility is unverified",
187
+ "piagent-mcp add google-drive --url https://drivemcp.googleapis.com/mcp/v1 --oauth-client-id <id> --scope user"
188
+ ],
189
+ gmail: [
190
+ "a Google Cloud project with gmail.googleapis.com and gmailmcp.googleapis.com enabled",
191
+ "Developer Preview: no Pi OAuth end-to-end release proof exists yet",
192
+ "Google's supported client guides use an OAuth client id and secret; Pi's public-client/PKCE compatibility is unverified",
193
+ "read the warning in docs/mcp-and-tools.md first: mail is untrusted input reaching an agent that can act"
194
+ ],
195
+ "google-docs": [
196
+ "a Google Cloud project with docs.googleapis.com and docsmcp.googleapis.com enabled",
197
+ "Developer Preview: no Pi OAuth end-to-end release proof exists yet",
198
+ "Google's supported client guides use an OAuth client id and secret; Pi's public-client/PKCE compatibility is unverified"
199
+ ],
200
+ "google-sheets": [
201
+ "a Google Cloud project with sheets.googleapis.com and sheetsmcp.googleapis.com enabled",
202
+ "Developer Preview: no Pi OAuth end-to-end release proof exists yet",
203
+ "Google's supported client guides use an OAuth client id and secret; Pi's public-client/PKCE compatibility is unverified"
204
+ ],
205
+ markitdown: [
206
+ "uv on PATH (https://docs.astral.sh/uv); the first run downloads a Python runtime and roughly 120 MB of dependencies",
207
+ "note: markitdown-mcp is published as an alpha, so pin changes deliberately"
208
+ ]
104
209
  };
105
210
 
106
211
  // Variables a pinned server references but works without. Context7 answers
@@ -113,6 +218,11 @@ export const CATALOG_OPTIONAL_ENV = {
113
218
  context7: ["CONTEXT7_API_KEY"]
114
219
  };
115
220
 
221
+ // `core` is what an install writes when nobody names a preset, and `popular` is
222
+ // what somebody picks without reading the list. Neither may reach a mailbox or a
223
+ // Drive: an install that quietly grants those is a decision the operator never
224
+ // made. The Google servers are therefore only in presets somebody has to type,
225
+ // and in `all`, which means what it says.
116
226
  /** @type {Record<string, string[]>} */
117
227
  export const CATALOG_PRESETS = {
118
228
  minimal: [],
@@ -122,9 +232,24 @@ export const CATALOG_PRESETS = {
122
232
  design: ["figma-desktop"],
123
233
  "design-local": ["figma-desktop"],
124
234
  web: ["context7", "chrome-devtools", "playwright"],
235
+ // The generic documents preset stays local. Google Workspace is Developer
236
+ // Preview and must be selected through an explicitly named Google preset.
237
+ documents: ["markitdown"],
238
+ // One preset per product, because that is why Google ships four servers
239
+ // instead of one. Bundling them back together would mean reading a
240
+ // spreadsheet costs a grant over Drive and every document in it.
241
+ "google-drive": ["google-drive"],
242
+ "google-docs": ["google-docs"],
243
+ "google-sheets": ["google-sheets"],
244
+ "google-mail": ["gmail"],
245
+ google: ["google-drive", "google-docs", "google-sheets"],
246
+ "google-all": ["google-drive", "gmail", "google-docs", "google-sheets"],
125
247
  core: ["context7", "chrome-devtools", "github"],
126
248
  popular: ["context7", "chrome-devtools", "playwright", "github", "figma-desktop"],
127
- all: ["context7", "chrome-devtools", "playwright", "github", "figma-desktop", "figma"]
249
+ all: [
250
+ "context7", "chrome-devtools", "playwright", "github", "figma-desktop", "figma",
251
+ "markitdown", "google-drive", "gmail", "google-docs", "google-sheets"
252
+ ]
128
253
  };
129
254
 
130
255
  /** @param {string} name @returns {boolean} */
@@ -136,7 +261,7 @@ export function isCatalogPreset(name) {
136
261
  * The servers a preset installs, with their definitions resolved. Throws on an
137
262
  * unknown preset so a typo is refused before anything is written.
138
263
  * @param {string} name
139
- * @returns {{name: string, config: Record<string, unknown>, requires: string[]}[]}
264
+ * @returns {{name: string, maturity: "stable"|"experimental", config: Record<string, unknown>, requires: string[]}[]}
140
265
  */
141
266
  export function resolvePreset(name) {
142
267
  if (!isCatalogPreset(name)) {
@@ -145,6 +270,11 @@ export function resolvePreset(name) {
145
270
  return CATALOG_PRESETS[name].map((serverName) => {
146
271
  const server = CATALOG_SERVERS[serverName];
147
272
  if (!server) throw new Error(`preset ${name} references missing server ${serverName}`);
148
- return { name: serverName, config: server.config, requires: CATALOG_PREREQUISITES[serverName] ?? [] };
273
+ return {
274
+ name: serverName,
275
+ maturity: server.maturity ?? "stable",
276
+ config: server.config,
277
+ requires: CATALOG_PREREQUISITES[serverName] ?? []
278
+ };
149
279
  });
150
280
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  import { toolResultFingerprint } from "../../extensions/context-engine.js";
5
+ import { withRemedy } from "../policy/block-remedy.ts";
5
6
  import { observeTrajectorySync } from "../trajectory/trajectory-observability.ts";
6
7
  import type { TrajectorySyncResult } from "../trajectory/trajectory-runtime.ts";
7
8
 
@@ -75,7 +76,13 @@ export function registerToolCallHook(pi: ExtensionAPI, dependencies: ToolCallHoo
75
76
  : dependencies.reviewBudgetDecision?.(resolvedEvent, ctx);
76
77
  const startDecision = !(decision && typeof decision === "object" && decision.block) ? dependencies.beforeStart?.(resolvedEvent, ctx) : undefined;
77
78
  const finalDecision = startDecision && typeof startDecision === "object" && startDecision.block ? startDecision : decision;
78
- const blockedDecision = finalDecision && typeof finalDecision === "object" ? finalDecision : undefined;
79
+ // Every refusal leaves through here, so this is where it is answered. A
80
+ // remedy attached at the 29 sites that build one is a remedy the next site
81
+ // will forget.
82
+ const answered = finalDecision && typeof finalDecision === "object" && finalDecision.block
83
+ ? withRemedy(finalDecision)
84
+ : finalDecision;
85
+ const blockedDecision = answered && typeof answered === "object" ? answered : undefined;
79
86
  record(ctx, {
80
87
  activityId: `decision:${toolCallId}`,
81
88
  event: "tool_decision",
@@ -91,6 +98,6 @@ export function registerToolCallHook(pi: ExtensionAPI, dependencies: ToolCallHoo
91
98
  observeTrajectorySync(ctx, dependencies.afterAuthorized?.(resolvedEvent, ctx, { toolCallId }), dependencies.telemetry);
92
99
  }
93
100
  dependencies.afterDecision?.(resolvedEvent, ctx, { toolCallId, allowed: !blockedDecision?.block, reason: blockedDecision?.reason });
94
- return finalDecision;
101
+ return answered;
95
102
  });
96
103
  }
@@ -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 });