@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
@@ -536,7 +536,7 @@ function commandPreset(args) {
536
536
  presets: CATALOG_PRESETS,
537
537
  servers: Object.fromEntries(Object.entries(CATALOG_SERVERS).map(([name, server]) => [
538
538
  name,
539
- { description: server.description, requires: CATALOG_PREREQUISITES[name] ?? [] }
539
+ { description: server.description, maturity: server.maturity ?? "stable", requires: CATALOG_PREREQUISITES[name] ?? [] }
540
540
  ]))
541
541
  }, null, 2)}\n`);
542
542
  return 0;
@@ -563,7 +563,9 @@ function commandPreset(args) {
563
563
  const added = [];
564
564
  const kept = [];
565
565
  const replaced = [];
566
- for (const server of resolvePreset(presetName)) {
566
+ const resolvedPreset = resolvePreset(presetName);
567
+ const experimental = resolvedPreset.filter((server) => server.maturity === "experimental").map((server) => server.name);
568
+ for (const server of resolvedPreset) {
567
569
  if (Object.hasOwn(config.mcpServers, server.name)) {
568
570
  if (!replace) {
569
571
  kept.push(server.name);
@@ -585,6 +587,7 @@ function commandPreset(args) {
585
587
  added,
586
588
  kept,
587
589
  replaced,
590
+ experimental,
588
591
  serverCount: Object.keys(config.mcpServers).length
589
592
  };
590
593
 
@@ -594,11 +597,21 @@ function commandPreset(args) {
594
597
  return 0;
595
598
  }
596
599
 
600
+ // Warn before the write. Naming a Google preset is explicit, but it is not
601
+ // evidence that the operator knows the endpoint is pre-GA or that Pi OAuth
602
+ // has not passed a credentialed release gate yet.
603
+ if (experimental.length > 0) {
604
+ process.stderr.write(
605
+ `\nEXPERIMENTAL: ${experimental.join(", ")} use Google Workspace Developer Preview endpoints. `
606
+ + "They are not part of the default presets and have no Pi OAuth end-to-end release proof yet.\n"
607
+ );
608
+ }
609
+
597
610
  writeMcpConfig(file, config);
598
611
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
599
612
 
600
- // stdout stays machine-readable; the operator note goes to stderr.
601
- const lines = resolvePreset(presetName).flatMap((server) => server.requires.map((item) => ` ${server.name}: ${item}`));
613
+ // stdout stays machine-readable; operator notes go to stderr.
614
+ const lines = resolvedPreset.flatMap((server) => server.requires.map((item) => ` ${server.name}: ${item}`));
602
615
  if (lines.length > 0) {
603
616
  process.stderr.write("\nServer definitions are written. Each still needs this before it can connect:\n");
604
617
  for (const line of lines) process.stderr.write(`${line}\n`);
@@ -108,8 +108,13 @@ RPC_OUTPUT="$(
108
108
  | pi -ne --mode rpc --session "$SESSION_FILE" --offline --approve
109
109
  )"
110
110
 
111
- node --input-type=module - "$RPC_OUTPUT" <<'NODE'
111
+ node --input-type=module - "$RPC_OUTPUT" "$SCRIPT_DIR/../packages/piagent-core/runtime/session/token-economics.js" <<'NODE'
112
+ import { pathToFileURL } from "node:url";
113
+
112
114
  const input = process.argv[2] ?? "";
115
+ // The token arithmetic lives in a module both this command and its test can
116
+ // read, so the terminal cannot drift from the definition the benchmark uses.
117
+ const { sessionTokenEconomics, formatTokenEconomics } = await import(pathToFileURL(process.argv[3]).href);
113
118
 
114
119
  const line = input
115
120
  .split(/\n+/)
@@ -126,6 +131,7 @@ const input = process.argv[2] ?? "";
126
131
  const data = response.data;
127
132
  const tokens = data.tokens ?? {};
128
133
  const context = data.contextUsage ?? {};
134
+ const economics = sessionTokenEconomics(tokens);
129
135
  const number = (value) => value === null || value === undefined ? "unknown" : Math.round(Number(value)).toLocaleString("en-US");
130
136
  const money = (value) => value === null || value === undefined ? "unknown" : `$${Number(value).toFixed(6)}`;
131
137
  const percent = (value) => value === null || value === undefined ? "unknown" : `${Number(value).toFixed(1)}%`;
@@ -144,7 +150,8 @@ const input = process.argv[2] ?? "";
144
150
  output: tokens.output ?? null,
145
151
  cacheRead: tokens.cacheRead ?? null,
146
152
  cacheWrite: tokens.cacheWrite ?? null,
147
- total: tokens.total ?? null
153
+ total: tokens.total ?? null,
154
+ ...economics
148
155
  },
149
156
  cost: data.cost ?? null,
150
157
  contextUsage: context.tokens === undefined ? null : {
@@ -154,6 +161,7 @@ const input = process.argv[2] ?? "";
154
161
  },
155
162
  summary: {
156
163
  tokens: `${number(tokens.total)} total (${number(tokens.input)} input, ${number(tokens.output)} output, ${number(tokens.cacheRead)} cache read, ${number(tokens.cacheWrite)} cache write)`,
164
+ ...formatTokenEconomics(economics, number, percent),
157
165
  cost: money(data.cost),
158
166
  context: context.tokens === undefined ? "unknown" : `${number(context.tokens)} / ${number(context.contextWindow)} (${percent(context.percent)})`
159
167
  }
@@ -24,6 +24,7 @@ const scriptByCommand = {
24
24
  "piagent-import-instructions": "scripts/import-agent-instructions.mjs",
25
25
  "piagent-auto": "scripts/pi-auto.sh",
26
26
  "piagent-context": "scripts/context-engine.mjs",
27
+ "piagent-explain": "scripts/explain-command.mjs",
27
28
  "piagent-webui": "scripts/piagent-webui-launcher.mjs",
28
29
  "piagent-dashboard": "scripts/piagent-dashboard.mjs"
29
30
  };
@@ -38,8 +39,20 @@ if (invokedAs === "piagent") {
38
39
  if (subcommand === "dashboard") {
39
40
  script = "scripts/piagent-dashboard.mjs";
40
41
  forwardedArgs = forwardedArgs.slice(1);
42
+ } else if (subcommand === "explain") {
43
+ script = "scripts/explain-command.mjs";
44
+ forwardedArgs = forwardedArgs.slice(1);
41
45
  } else if ([undefined, "help", "--help", "-h"].includes(subcommand)) {
42
- console.log("Usage: piagent dashboard [open|status|stop|restart|doctor] [--no-open] [--json]");
46
+ // Subcommands are listed, their flags are not. Restating them here meant a
47
+ // second copy that drifts: this line still advertised the dashboard without
48
+ // `--repair` after that flag shipped. Each command states its own surface.
49
+ console.log("Usage: piagent <command> [options]");
50
+ console.log("");
51
+ console.log("Commands:");
52
+ console.log(" dashboard Open and manage the local session hub");
53
+ console.log(" explain Say why the guard would allow or block a shell command");
54
+ console.log("");
55
+ console.log("Run `piagent <command> --help` for that command's options.");
43
56
  process.exit(0);
44
57
  }
45
58
  }
@@ -22,6 +22,8 @@ const json = args.includes("--json");
22
22
  const repair = args.includes("--repair");
23
23
  const agentDirIndex = args.indexOf("--agent-dir");
24
24
  const agentDir = agentDirIndex >= 0 ? args[agentDirIndex + 1] : undefined;
25
+ const GATEWAY_ERROR_LOG = "gateway-stderr.log";
26
+ const GATEWAY_ERROR_MAX_BYTES = 4_000;
25
27
 
26
28
  function output(value) {
27
29
  process.stdout.write(`${json ? JSON.stringify(value) : value}\n`);
@@ -68,6 +70,42 @@ function removeOwnedSocket(target) {
68
70
  } catch { return false; }
69
71
  }
70
72
 
73
+ function gatewayErrorLog(state) {
74
+ return path.join(state.root, GATEWAY_ERROR_LOG);
75
+ }
76
+
77
+ function writeGatewayErrorLog(state, message = "") {
78
+ const target = gatewayErrorLog(state);
79
+ const descriptor = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC
80
+ | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
81
+ try {
82
+ const stat = fs.fstatSync(descriptor);
83
+ if (!stat.isFile() || typeof process.getuid === "function" && stat.uid !== process.getuid()) {
84
+ throw new Error("gateway-error-log-invalid");
85
+ }
86
+ fs.fchmodSync(descriptor, 0o600);
87
+ if (message) fs.writeFileSync(descriptor, message.slice(-GATEWAY_ERROR_MAX_BYTES));
88
+ } finally { fs.closeSync(descriptor); }
89
+ }
90
+
91
+ function readGatewayErrorLog(state) {
92
+ const target = gatewayErrorLog(state);
93
+ try {
94
+ const before = fs.lstatSync(target);
95
+ if (!before.isFile() || before.isSymbolicLink()
96
+ || typeof process.getuid === "function" && before.uid !== process.getuid()) return "";
97
+ const descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
98
+ try {
99
+ const stat = fs.fstatSync(descriptor);
100
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return "";
101
+ const length = Math.min(stat.size, GATEWAY_ERROR_MAX_BYTES);
102
+ const body = Buffer.alloc(length);
103
+ fs.readSync(descriptor, body, 0, length, Math.max(0, stat.size - length));
104
+ return body.toString("utf8");
105
+ } finally { fs.closeSync(descriptor); }
106
+ } catch { return ""; }
107
+ }
108
+
71
109
  async function control(action, timeoutMs = 1_500) {
72
110
  const state = gatewayProfileState(agentDir);
73
111
  return await requestGatewayControl(state.controlSocket, { action }, timeoutMs);
@@ -88,6 +126,8 @@ async function ensureStarted() {
88
126
  const existing = await currentLaunchUrl();
89
127
  if (existing) return existing;
90
128
  const effectiveAgentDir = path.resolve(agentDir ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"));
129
+ const state = gatewayProfileState(effectiveAgentDir);
130
+ writeGatewayErrorLog(state);
91
131
  const child = spawn(process.execPath, [
92
132
  "--disable-warning=ExperimentalWarning",
93
133
  "--import", path.join(packageRoot, "scripts", "register-typescript-loader.mjs"),
@@ -97,14 +137,23 @@ async function ensureStarted() {
97
137
  ], { cwd: process.cwd(), env: { ...process.env,
98
138
  PIAGENT_PINNED_TS_TRANSFORM_ROOT: path.join(effectiveAgentDir, "npm", "node_modules", "pi-mcp-adapter") },
99
139
  detached: true, stdio: "ignore" });
140
+ let exit = null;
141
+ child.on("exit", (code, signal) => { exit = signal ? `signal ${signal}` : `exit code ${code}`; });
100
142
  child.unref();
101
143
  const deadline = Date.now() + 12_000;
102
144
  while (Date.now() < deadline) {
103
145
  const url = await currentLaunchUrl();
104
146
  if (url) return url;
147
+ // A child that has already exited is never going to publish a URL, so
148
+ // waiting out the rest of the deadline only delays the report.
149
+ if (exit !== null) break;
105
150
  await new Promise((resolve) => setTimeout(resolve, 80));
106
151
  }
107
- throw new Error("gateway-start-timeout");
152
+ const detail = [exit && `The gateway process ended with ${exit}.`, readGatewayErrorLog(state).trim()]
153
+ .filter(Boolean).join("\n");
154
+ throw new Error(detail
155
+ ? `gateway-start-failed\n${detail}`
156
+ : "gateway-start-timeout\nThe gateway did not publish a URL within 12s and wrote nothing to stderr.");
108
157
  }
109
158
 
110
159
  async function serve() {
@@ -203,6 +252,10 @@ async function main() {
203
252
  }
204
253
 
205
254
  main().catch((error) => {
206
- process.stderr.write(`Piagent dashboard failed: ${error instanceof Error ? error.message : String(error)}\n`);
255
+ const message = `Piagent dashboard failed: ${error instanceof Error ? error.message : String(error)}\n`;
256
+ if (command === "serve") {
257
+ try { writeGatewayErrorLog(gatewayProfileState(agentDir), message); } catch { /* stderr still reports the failure. */ }
258
+ }
259
+ process.stderr.write(message);
207
260
  process.exitCode = 1;
208
261
  });
package/scripts/setup.sh CHANGED
@@ -22,7 +22,7 @@ Options:
22
22
  --project-only, --no-global Only initialize project, skip global install
23
23
  --no-project Skip project initialization
24
24
  --with-mcp / --no-mcp Install/skip Pi MCP adapter during global install (default: install)
25
- --mcp-preset <minimal|core|popular|all|docs|browser|github|design|design-local|web>
25
+ --mcp-preset <minimal|core|popular|all|docs|browser|github|design|design-local|web|documents|google|google-mail|google-all>
26
26
  Configure shared global MCP baseline when --with-mcp is enabled (default: core)
27
27
  --with-subagents / --no-subagents
28
28
  Install/skip pi-subagents during global install (default: install)
@@ -244,15 +244,11 @@ if [[ "$WITH_MCP" == false && "$MCP_PRESET_EXPLICIT" == true ]]; then
244
244
  exit 2
245
245
  fi
246
246
 
247
- resolve_package_source() {
248
- if [[ -n "$PACKAGE_SOURCE" ]]; then
249
- printf '%s\n' "$PACKAGE_SOURCE"
250
- return
251
- fi
252
-
253
- if [[ "$DO_PROJECT" == true && -n "$PROJECT_PATH" && -f "$PROJECT_PATH/.pi/settings.json" ]]; then
254
- local existing_source
255
- existing_source="$(node --input-type=module - "$PROJECT_PATH/.pi/settings.json" <<'NODE'
247
+ # The pin a project has committed, or empty. It is authority over that project
248
+ # and nothing else -- one project may not decide what the machine runs.
249
+ project_pinned_source() {
250
+ [[ "$DO_PROJECT" == true && -n "$PROJECT_PATH" && -f "$PROJECT_PATH/.pi/settings.json" ]] || return 0
251
+ node --input-type=module - "$PROJECT_PATH/.pi/settings.json" <<'NODE'
256
252
  import fs from "node:fs";
257
253
  const settings = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
258
254
  const source = Array.isArray(settings.packages)
@@ -260,30 +256,51 @@ const source = Array.isArray(settings.packages)
260
256
  : undefined;
261
257
  if (source) process.stdout.write(source);
262
258
  NODE
263
- )"
264
- if [[ -n "$existing_source" ]]; then
265
- printf '%s\n' "$existing_source"
266
- return
267
- fi
268
- fi
259
+ }
269
260
 
270
- # Running from an installed package rather than a checkout. The name and
271
- # version on disk are exactly what a teammate resolves from the registry, so
272
- # they are a portable source; the install path is not, and project settings
273
- # are meant to be committed.
274
- if [[ "$PLATFORM_ROOT" == */node_modules/* ]]; then
275
- local installed_name installed_version
276
- installed_name="$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).name ?? "");' "$PLATFORM_ROOT/package.json")"
277
- installed_version="$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).version ?? "");' "$PLATFORM_ROOT/package.json")"
278
- if [[ -n "$installed_name" && "$installed_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
279
- printf 'npm:%s@%s\n' "$installed_name" "$installed_version"
280
- return
281
- fi
261
+ # Running from an installed package rather than a checkout. The name and version
262
+ # on disk are exactly what a teammate resolves from the registry, so they are a
263
+ # portable source; the install path is not, and project settings are meant to be
264
+ # committed.
265
+ running_helper_source() {
266
+ [[ "$PLATFORM_ROOT" == */node_modules/* ]] || return 0
267
+ local installed_name installed_version
268
+ installed_name="$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).name ?? "");' "$PLATFORM_ROOT/package.json")"
269
+ installed_version="$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).version ?? "");' "$PLATFORM_ROOT/package.json")"
270
+ if [[ -n "$installed_name" && "$installed_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
271
+ printf 'npm:%s@%s' "$installed_name" "$installed_version"
282
272
  fi
273
+ }
283
274
 
275
+ local_path_source() {
284
276
  echo "WARN: No exact package source provided." >&2
285
277
  echo "WARN: using local platform path; do not commit project .pi/settings.json with this value for team rollout." >&2
286
- printf '%s\n' "$PLATFORM_ROOT"
278
+ printf '%s' "$PLATFORM_ROOT"
279
+ }
280
+
281
+ # One resolved value used to serve both consumers, and the two do not want the
282
+ # same answer. A project pin is what that project's team agreed to run; the
283
+ # global install is what every project on this machine gets. Reading the pin
284
+ # first meant the documented two-command install silently downgraded the
285
+ # machine-wide package to whatever one project happened to pin -- including
286
+ # projects the operator only meant to open once.
287
+ resolve_global_package_source() {
288
+ if [[ -n "$PACKAGE_SOURCE" ]]; then printf '%s\n' "$PACKAGE_SOURCE"; return; fi
289
+ local helper
290
+ helper="$(running_helper_source)"
291
+ if [[ -n "$helper" ]]; then printf '%s\n' "$helper"; return; fi
292
+ printf '%s\n' "$(local_path_source)"
293
+ }
294
+
295
+ resolve_project_package_source() {
296
+ if [[ -n "$PACKAGE_SOURCE" ]]; then printf '%s\n' "$PACKAGE_SOURCE"; return; fi
297
+ local pinned
298
+ pinned="$(project_pinned_source)"
299
+ if [[ -n "$pinned" ]]; then printf '%s\n' "$pinned"; return; fi
300
+ local helper
301
+ helper="$(running_helper_source)"
302
+ if [[ -n "$helper" ]]; then printf '%s\n' "$helper"; return; fi
303
+ printf '%s\n' "$(local_path_source)"
287
304
  }
288
305
 
289
306
  print_cmd() {
@@ -385,22 +402,41 @@ if [[ "$DO_PROJECT" == true ]]; then
385
402
  PROJECT_PATH="$(cd "$PROJECT_PATH" && pwd)"
386
403
  fi
387
404
 
388
- PACKAGE_SOURCE="$(resolve_package_source)"
405
+ GLOBAL_PACKAGE_SOURCE="$(resolve_global_package_source)"
406
+ PROJECT_PACKAGE_SOURCE="$(resolve_project_package_source)"
389
407
 
390
408
  echo "Pi Agent Platform setup"
391
409
  echo " platform: $PLATFORM_ROOT"
392
- echo " packageSource: $PACKAGE_SOURCE"
410
+ if [[ "$DO_GLOBAL" == true ]]; then
411
+ echo " globalPackageSource: $GLOBAL_PACKAGE_SOURCE"
412
+ fi
413
+ if [[ "$DO_PROJECT" == true ]]; then
414
+ echo " projectPackageSource: $PROJECT_PACKAGE_SOURCE"
415
+ fi
393
416
  echo " globalInstall: $DO_GLOBAL"
394
417
  echo " projectInit: $DO_PROJECT"
395
418
  if [[ "$DO_PROJECT" == true ]]; then
396
419
  echo " project: $PROJECT_PATH"
397
420
  echo " profile: $PROFILE_INPUT"
398
421
  fi
422
+
423
+ # The operator asked for both scopes and the project wants a different build
424
+ # than the one about to be installed machine-wide. Neither choice is wrong, so
425
+ # neither is overridden -- but a divergence they cannot see is how the old
426
+ # silent downgrade went unnoticed.
427
+ if [[ "$DO_GLOBAL" == true && "$DO_PROJECT" == true && "$GLOBAL_PACKAGE_SOURCE" != "$PROJECT_PACKAGE_SOURCE" ]]; then
428
+ echo
429
+ echo "NOTE: this project pins a different package source than the global install." >&2
430
+ echo "NOTE: global: $GLOBAL_PACKAGE_SOURCE" >&2
431
+ echo "NOTE: project: $PROJECT_PACKAGE_SOURCE" >&2
432
+ echo "NOTE: the project keeps its pin and the machine keeps the running helper." >&2
433
+ echo "NOTE: pass --package-source <source> to make both the same." >&2
434
+ fi
399
435
  echo
400
436
 
401
437
  if [[ "$DO_GLOBAL" == true ]]; then
402
438
  ensure_pi_cli
403
- install_args=("$PLATFORM_ROOT/scripts/install-global.sh" "--package-source" "$PACKAGE_SOURCE")
439
+ install_args=("$PLATFORM_ROOT/scripts/install-global.sh" "--package-source" "$GLOBAL_PACKAGE_SOURCE")
404
440
  # The opt-out has to be passed explicitly. The installer defaults MCP on, so
405
441
  # saying nothing here would install it against the operator's choice.
406
442
  # Subagents also needs an explicit opt-out because the installer now preserves
@@ -432,7 +468,7 @@ if [[ "$DO_GLOBAL" == true ]]; then
432
468
  fi
433
469
 
434
470
  if [[ "$DO_PROJECT" == true ]]; then
435
- init_args=("$PLATFORM_ROOT/scripts/init-project.sh" "$PROJECT_PATH" "--profile" "$PROFILE_INPUT" "--package-source" "$PACKAGE_SOURCE")
471
+ init_args=("$PLATFORM_ROOT/scripts/init-project.sh" "$PROJECT_PATH" "--profile" "$PROFILE_INPUT" "--package-source" "$PROJECT_PACKAGE_SOURCE")
436
472
  if [[ "$FORCE_PROFILE" == true ]]; then
437
473
  init_args+=("--force-profile")
438
474
  fi
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ // Archives GitHub repository traffic before GitHub discards it.
3
+ //
4
+ // The traffic API only serves a rolling 14-day window. Anything older is gone
5
+ // for good -- there is no endpoint that returns it and no way to reconstruct
6
+ // it. So the counts are merged into CSV files that keep growing, and a run that
7
+ // happens at least every 14 days loses nothing.
8
+ //
9
+ // Merge rule: a row is keyed by its day and the newest fetch wins. Today's
10
+ // bucket is still filling while the run happens, so it is always partial; the
11
+ // next run overwrites it with the settled value. Once a day leaves the window
12
+ // it stops being returned at all, and the stored row is final.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ const EXIT_USAGE = 2;
18
+ const EXIT_FORBIDDEN = 3;
19
+
20
+ const SERIES = [
21
+ { endpoint: "clones", file: "clones.csv", columns: ["date", "count", "uniques"] },
22
+ { endpoint: "views", file: "views.csv", columns: ["date", "count", "uniques"] }
23
+ ];
24
+ // Referrers and paths are a 14-day rollup, not a daily series: the API gives no
25
+ // per-day breakdown, so a row is only meaningful next to the day it was read.
26
+ const ROLLUPS = [
27
+ { endpoint: "popular/referrers", file: "referrers.csv", key: "referrer", columns: ["snapshot_date", "referrer", "count", "uniques"] },
28
+ { endpoint: "popular/paths", file: "paths.csv", key: "path", columns: ["snapshot_date", "path", "title", "count", "uniques"] }
29
+ ];
30
+
31
+ function fail(message, code = EXIT_USAGE) {
32
+ console.error(`FAIL: ${message}`);
33
+ process.exit(code);
34
+ }
35
+
36
+ export function parseArguments(argv) {
37
+ const options = { repository: "", out: "traffic", dryRun: false, today: "" };
38
+ for (let index = 0; index < argv.length; index += 1) {
39
+ const argument = argv[index];
40
+ switch (argument) {
41
+ case "--repository": case "--repo": {
42
+ options.repository = argv[index += 1] ?? "";
43
+ if (!/^[^/\s]+\/[^/\s]+$/.test(options.repository)) fail("--repository needs owner/name");
44
+ break;
45
+ }
46
+ case "--out": {
47
+ options.out = argv[index += 1] ?? "";
48
+ if (!options.out) fail("--out needs a directory");
49
+ break;
50
+ }
51
+ // The archive is written from a snapshot date, and a test cannot wait a
52
+ // day to prove the merge. Injecting it keeps the assertions exact.
53
+ case "--today": {
54
+ options.today = argv[index += 1] ?? "";
55
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(options.today)) fail("--today needs YYYY-MM-DD");
56
+ break;
57
+ }
58
+ case "--dry-run": options.dryRun = true; break;
59
+ case "-h": case "--help": options.help = true; break;
60
+ default: fail(`unknown argument: ${argument}`);
61
+ }
62
+ }
63
+ return options;
64
+ }
65
+
66
+ export function csvEscape(value) {
67
+ const text = String(value ?? "");
68
+ return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
69
+ }
70
+
71
+ export function parseCsv(text) {
72
+ const rows = [];
73
+ let row = [], field = "", quoted = false;
74
+ for (let index = 0; index < text.length; index += 1) {
75
+ const character = text[index];
76
+ if (quoted) {
77
+ if (character === '"' && text[index + 1] === '"') { field += '"'; index += 1; }
78
+ else if (character === '"') quoted = false;
79
+ else field += character;
80
+ continue;
81
+ }
82
+ if (character === '"') quoted = true;
83
+ else if (character === ",") { row.push(field); field = ""; }
84
+ else if (character === "\n") { row.push(field); rows.push(row); row = []; field = ""; }
85
+ else if (character !== "\r") field += character;
86
+ }
87
+ if (field.length || row.length) { row.push(field); rows.push(row); }
88
+ return rows.filter((entry) => entry.some((cell) => cell !== ""));
89
+ }
90
+
91
+ function readTable(file, columns) {
92
+ if (!fs.existsSync(file)) return [];
93
+ const rows = parseCsv(fs.readFileSync(file, "utf8"));
94
+ const header = rows.shift();
95
+ if (!header || header.join(",") !== columns.join(",")) {
96
+ fail(`${file} has header [${header?.join(", ") ?? "none"}], expected [${columns.join(", ")}]`);
97
+ }
98
+ return rows.map((row) => Object.fromEntries(columns.map((column, index) => [column, row[index] ?? ""])));
99
+ }
100
+
101
+ function writeTable(file, columns, rows) {
102
+ const lines = [columns.join(",")];
103
+ for (const row of rows) lines.push(columns.map((column) => csvEscape(row[column])).join(","));
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.writeFileSync(file, `${lines.join("\n")}\n`);
106
+ }
107
+
108
+ /**
109
+ * Existing rows plus fetched rows, keyed by `key`. The fetched value wins,
110
+ * because it is the newer reading of the same day. Rows outside the API window
111
+ * are untouched -- that is the whole point of the archive.
112
+ */
113
+ export function mergeByKey(existing, fetched, key) {
114
+ const merged = new Map(existing.map((row) => [row[key], row]));
115
+ for (const row of fetched) merged.set(row[key], row);
116
+ return [...merged.values()].sort((a, b) => String(a[key]).localeCompare(String(b[key])));
117
+ }
118
+
119
+ /** Rollup rows for one snapshot date, replacing any earlier run on that date. */
120
+ export function mergeRollup(existing, fetched, snapshotDate) {
121
+ const kept = existing.filter((row) => row.snapshot_date !== snapshotDate);
122
+ return [...kept, ...fetched].sort((a, b) =>
123
+ String(a.snapshot_date).localeCompare(String(b.snapshot_date))
124
+ || Number(b.count) - Number(a.count));
125
+ }
126
+
127
+ async function fetchTraffic(repository, endpoint, token) {
128
+ const url = `https://api.github.com/repos/${repository}/traffic/${endpoint}`;
129
+ const response = await fetch(url, {
130
+ headers: {
131
+ accept: "application/vnd.github+json",
132
+ authorization: `Bearer ${token}`,
133
+ "user-agent": "piagent-traffic-snapshot",
134
+ "x-github-api-version": "2022-11-28"
135
+ }
136
+ });
137
+ if (response.status === 403 || response.status === 404) {
138
+ // The distinction matters: this is the one failure an operator cannot debug
139
+ // from the status code, because the traffic API answers 403 for a token
140
+ // that is valid but lacks push access -- which the default workflow token
141
+ // may or may not have, depending on repository settings.
142
+ fail(`the token cannot read ${endpoint} for ${repository} (HTTP ${response.status}).\n`
143
+ + " Repository traffic needs push access. If this ran in GitHub Actions with the\n"
144
+ + " default GITHUB_TOKEN, create a fine-grained personal access token with the\n"
145
+ + " 'Administration: read' permission on this repository, store it as the\n"
146
+ + " TRAFFIC_TOKEN secret, and re-run. Nothing was written.", EXIT_FORBIDDEN);
147
+ }
148
+ if (!response.ok) fail(`GET ${url} returned HTTP ${response.status}`);
149
+ return response.json();
150
+ }
151
+
152
+ export async function snapshot(options, token, fetcher = fetchTraffic) {
153
+ const today = options.today || new Date().toISOString().slice(0, 10);
154
+ const summary = { repository: options.repository, snapshotDate: today, files: {} };
155
+
156
+ // Read every existing file before fetching anything. A corrupt archive is a
157
+ // reason to stop, and stopping before the network call keeps the failure
158
+ // about the file rather than about whatever the API happened to answer.
159
+ const existing = new Map();
160
+ for (const target of [...SERIES, ...ROLLUPS]) {
161
+ existing.set(target.file, readTable(path.join(options.out, target.file), target.columns));
162
+ }
163
+
164
+ for (const series of SERIES) {
165
+ const payload = await fetcher(options.repository, series.endpoint, token);
166
+ const fetched = (payload[series.endpoint] ?? []).map((entry) => ({
167
+ date: String(entry.timestamp ?? "").slice(0, 10),
168
+ count: String(entry.count ?? 0),
169
+ uniques: String(entry.uniques ?? 0)
170
+ })).filter((row) => row.date);
171
+ const file = path.join(options.out, series.file);
172
+ const merged = mergeByKey(existing.get(series.file), fetched, "date");
173
+ if (!options.dryRun) writeTable(file, series.columns, merged);
174
+ summary.files[series.file] = { fetchedDays: fetched.length, totalDays: merged.length };
175
+ }
176
+
177
+ for (const rollup of ROLLUPS) {
178
+ const payload = await fetcher(options.repository, rollup.endpoint, token);
179
+ const fetched = (Array.isArray(payload) ? payload : []).map((entry) => ({
180
+ snapshot_date: today,
181
+ [rollup.key]: String(entry[rollup.key] ?? entry.referrer ?? entry.path ?? ""),
182
+ title: String(entry.title ?? ""),
183
+ count: String(entry.count ?? 0),
184
+ uniques: String(entry.uniques ?? 0)
185
+ }));
186
+ const file = path.join(options.out, rollup.file);
187
+ const merged = mergeRollup(existing.get(rollup.file), fetched, today);
188
+ if (!options.dryRun) writeTable(file, rollup.columns, merged);
189
+ summary.files[rollup.file] = { fetchedRows: fetched.length, totalRows: merged.length };
190
+ }
191
+
192
+ return summary;
193
+ }
194
+
195
+ const invokedDirectly = process.argv[1]
196
+ && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
197
+
198
+ if (invokedDirectly) {
199
+ const options = parseArguments(process.argv.slice(2));
200
+ if (options.help) {
201
+ console.log(`Usage:
202
+ node scripts/traffic-snapshot.mjs --repository <owner/name> [--out <dir>] [--dry-run]
203
+
204
+ Archives GitHub traffic (clones, views, referrers, paths) into growing CSV files.
205
+ The API only keeps 14 days, so run this at least fortnightly or the gap is
206
+ permanent. Reads the token from TRAFFIC_TOKEN, else GITHUB_TOKEN.`);
207
+ process.exit(0);
208
+ }
209
+ if (!options.repository) fail("--repository is required");
210
+ const token = process.env.TRAFFIC_TOKEN || process.env.GITHUB_TOKEN;
211
+ if (!token) fail("set TRAFFIC_TOKEN or GITHUB_TOKEN");
212
+ const summary = await snapshot(options, token);
213
+ console.log(JSON.stringify(summary, null, 2));
214
+ }
@@ -18,11 +18,27 @@ export const PINNED_EXTERNAL_TRANSFORM = Object.freeze({
18
18
  async function pinnedTransformRoot(url) {
19
19
  const configured = process.env.PIAGENT_PINNED_TS_TRANSFORM_ROOT;
20
20
  if (!configured || process.versions.node !== PINNED_EXTERNAL_TRANSFORM.nodeVersion) return false;
21
- const root = await fs.realpath(configured), target = await fs.realpath(fileURLToPath(url));
21
+ // This asks one question: is the file being loaded inside the pinned adapter?
22
+ // A configured root that is absent, unreadable or not that adapter answers
23
+ // "no". It used to throw instead, and the throw escaped `load` on the first
24
+ // `.ts` file of any kind -- so a machine without pi-mcp-adapter installed
25
+ // could not start the gateway at all, for a reason that had nothing to do
26
+ // with the file it died on. Answering "no" falls back to `strip`, which is
27
+ // the stricter of the two modes: this can only refuse a transform, never
28
+ // grant one.
29
+ let root;
30
+ let metadata;
31
+ try {
32
+ root = await fs.realpath(configured);
33
+ metadata = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
34
+ } catch {
35
+ return false;
36
+ }
37
+ if (metadata?.name !== PINNED_EXTERNAL_TRANSFORM.packageName
38
+ || metadata?.version !== PINNED_EXTERNAL_TRANSFORM.packageVersion) return false;
39
+ const target = await fs.realpath(fileURLToPath(url));
22
40
  const relative = path.relative(root, target);
23
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return false;
24
- const metadata = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
25
- return metadata.name === PINNED_EXTERNAL_TRANSFORM.packageName && metadata.version === PINNED_EXTERNAL_TRANSFORM.packageVersion;
41
+ return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
26
42
  }
27
43
 
28
44
  export async function load(url, context, nextLoad) {