@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
@@ -4,7 +4,32 @@ set -euo pipefail
4
4
  usage() {
5
5
  cat <<'USAGE'
6
6
  Usage:
7
- scripts/install-global.sh [--stable|--dev|--local|--channel <stable|dev|local>] [--version <tag>] [--resolve-tag] [--package-source <source>] [--dry-run] [--with-mcp|--no-mcp] [--mcp-preset <preset>] [--with-subagents|--no-subagents] [--subagents-preset <preset>] [--with-web-access|--no-web-access] [--with-herdr] [--model-scope <preset>|--no-model-scope]
7
+ scripts/install-global.sh [--stable|--dev|--local|--channel <stable|dev|local>] [--version <tag>] [--resolve-tag] [--package-source <source>] [--dry-run] [--with-mcp|--no-mcp] [--mcp-preset <preset>] [--with-subagents|--no-subagents] [--subagents-preset <preset>] [--subagents-model-scope <scope>] [--with-web-access|--no-web-access] [--with-herdr] [--model-scope <preset>|--no-model-scope] [--default-model <model>] [-h|--help]
8
+
9
+ Options:
10
+ --channel <stable|dev|local> Package channel; --stable/--dev/--local are shorthands.
11
+ --version, --tag <tag> Pin a release tag (pair with --resolve-tag for a SHA).
12
+ --resolve-tag Resolve the tag to a commit SHA before installing.
13
+ --package-source <source> Explicit package source; overrides the channel.
14
+ --dry-run Print the planned steps without changing user config.
15
+ --with-mcp | --no-mcp Install the MCP adapter (default: install).
16
+ --mcp-preset <preset> MCP server set (default: core).
17
+ --with-subagents | --no-subagents
18
+ Install pi-subagents (default: only if already present).
19
+ --subagents-preset <minimal|safe|async|parallel>
20
+ Subagent runtime preset (default: safe).
21
+ --subagents-model-scope <none|piagent|codex|claude>
22
+ Model family the subagents run on (default: none).
23
+ --with-web-access | --no-web-access
24
+ Install pi-web-access for the researcher subagent (default: install).
25
+ --with-herdr Also configure Herdr integration.
26
+ --model-scope <full|codex|claude>
27
+ Model families to enable in Pi (default: full).
28
+ --no-model-scope Leave the user's enabledModels untouched.
29
+ --default-model <provider/model[:thinking]>
30
+ Default model written to Pi settings
31
+ (default: openai-codex/gpt-5.5:high).
32
+ -h, --help Print this help and exit.
8
33
 
9
34
  Purpose:
10
35
  Install the piagent Pi package into the current user's global Pi settings.
@@ -74,6 +99,7 @@ RESOLVE_TAG=false
74
99
  RESOLVED_PACKAGE_TAG=""
75
100
  RESOLVED_PACKAGE_COMMIT=""
76
101
  CLI_PACKAGE_SELECTOR=""
102
+ LOCAL_HELPER_PREFIX="${PIAGENT_LOCAL_HELPER_PREFIX:-}"
77
103
  PI_MCP_ADAPTER_SOURCE="npm:pi-mcp-adapter@2.15.0"
78
104
  PI_SUBAGENTS_SOURCE="npm:pi-subagents@0.38.0"
79
105
  PI_WEB_ACCESS_SOURCE="npm:pi-web-access@0.17.0"
@@ -181,6 +207,61 @@ run_cmd() {
181
207
  fi
182
208
  }
183
209
 
210
+ active_piagent_helper_prefix() {
211
+ if [[ -n "$LOCAL_HELPER_PREFIX" ]]; then
212
+ printf '%s\n' "$LOCAL_HELPER_PREFIX"
213
+ return 0
214
+ fi
215
+
216
+ local helper_command
217
+ helper_command="$(command -v piagent 2>/dev/null || true)"
218
+ [[ -n "$helper_command" ]] || return 1
219
+
220
+ node --input-type=module - "$helper_command" <<'NODE'
221
+ import fs from "node:fs";
222
+ import path from "node:path";
223
+
224
+ try {
225
+ const command = path.resolve(process.argv[2]);
226
+ if (path.basename(path.dirname(command)) === "bin") {
227
+ process.stdout.write(`${path.dirname(path.dirname(command))}\n`);
228
+ process.exit(0);
229
+ }
230
+ const target = fs.realpathSync(command);
231
+ const marker = `${path.sep}lib${path.sep}node_modules${path.sep}@piagent${path.sep}platform${path.sep}`;
232
+ const index = target.indexOf(marker);
233
+ if (index <= 0) process.exit(1);
234
+ process.stdout.write(`${target.slice(0, index)}\n`);
235
+ } catch {
236
+ process.exit(1);
237
+ }
238
+ NODE
239
+ }
240
+
241
+ sync_local_helper() {
242
+ [[ "$RESOLVED_CHANNEL_LABEL" == "local" ]] || return 0
243
+
244
+ if ! command -v npm >/dev/null 2>&1; then
245
+ echo "FAIL: npm is required to synchronize the local Piagent helper and Gateway runtime." >&2
246
+ exit 1
247
+ fi
248
+
249
+ local prefix="" used_fallback=false
250
+ prefix="$(active_piagent_helper_prefix 2>/dev/null || true)"
251
+ if [[ -z "$prefix" ]]; then
252
+ prefix="${HOME}/.pi/npm-global"
253
+ used_fallback=true
254
+ fi
255
+
256
+ echo "Synchronizing local terminal helper and Gateway runtime:"
257
+ run_cmd npm install -g --ignore-scripts --prefix "$prefix" "$PLATFORM_ROOT"
258
+
259
+ if [[ "$used_fallback" == true ]]; then
260
+ echo "NOTE: no existing Piagent helper was found; the local helper was installed under $prefix." >&2
261
+ echo "NOTE: add $prefix/bin to PATH if piagent is not found after this run." >&2
262
+ fi
263
+ }
264
+
184
265
  resolve_user_package_path() {
185
266
  local source="$1"
186
267
  local pi_agent_dir="${PI_CODING_AGENT_DIR:-"${HOME}/.pi/agent"}"
@@ -285,10 +366,29 @@ verify_npm_integrity() {
285
366
  echo "FAIL: npm is required to verify package integrity for $package_spec." >&2
286
367
  exit 1
287
368
  fi
288
- local actual
289
- actual="$(npm view "$package_spec" dist.integrity 2>/dev/null | tr -d '\r\n')"
369
+ # The status is captured rather than left to `set -e`. Declaring `local actual`
370
+ # and assigning on the next line makes the command substitution's own status the
371
+ # status of the assignment, so under `pipefail` an unreachable registry -- no
372
+ # network, a proxy, a private registry, an npm outage -- killed the script on
373
+ # this line. The `FAIL:` below was never reached and npm's stderr was already
374
+ # discarded, so the run ended with an empty screen and exit 1, after `pi install`
375
+ # had already succeeded. The operator was left half-installed with no reason
376
+ # given. (Writing `local actual="$(...)"` also avoids the exit, but by hiding the
377
+ # failure inside the empty-value branch, which then reports a digest mismatch --
378
+ # a security message for what is usually a network problem.)
379
+ local actual="" status=0
380
+ actual="$(npm view "$package_spec" dist.integrity 2>/dev/null | tr -d '\r\n')" || status=$?
381
+ if [[ "$status" -ne 0 ]]; then
382
+ echo "FAIL: could not reach the npm registry to verify $package_spec (npm exited $status)." >&2
383
+ echo " Nothing further was installed. Check network, proxy, or registry auth," >&2
384
+ echo " then re-run. To see npm's own message: npm view $package_spec dist.integrity" >&2
385
+ exit 1
386
+ fi
290
387
  if [[ -z "$actual" || "$actual" != "$expected" ]]; then
291
388
  echo "FAIL: registry integrity does not match the approved digest for $package_spec." >&2
389
+ echo " approved: $expected" >&2
390
+ echo " registry: ${actual:-<empty>}" >&2
391
+ echo " Do not proceed. The published package differs from the reviewed release." >&2
292
392
  exit 1
293
393
  fi
294
394
  }
@@ -609,6 +709,7 @@ if [[ -n "$RESOLVED_PACKAGE_COMMIT" ]]; then
609
709
  echo " resolvedCommit: $RESOLVED_PACKAGE_COMMIT"
610
710
  fi
611
711
  echo " source: $PACKAGE_SOURCE"
712
+ sync_local_helper
612
713
  echo "Removing previous Pi Agent Platform package registrations:"
613
714
  remove_existing_piagent_platform_sources
614
715
  run_cmd pi install "$PACKAGE_SOURCE"
@@ -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