@piagent/platform 1.43.9 → 1.44.2

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 (82) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/docs/command-reference-vietnamese.md +8 -2
  3. package/docs/company-dev-commands.md +2 -0
  4. package/docs/distribution-standard.md +6 -6
  5. package/docs/harness-conformance.md +14 -0
  6. package/docs/operator-manual-vietnamese.md +7 -7
  7. package/docs/publishing-for-teams.md +3 -0
  8. package/docs/release-install-policy.md +4 -2
  9. package/docs/team-onboarding.md +2 -2
  10. package/docs-site/architecture.html +1 -1
  11. package/docs-site/assets/search-en.json +1 -1
  12. package/docs-site/assets/search-vi.json +1 -1
  13. package/docs-site/benchmark.html +1 -1
  14. package/docs-site/commands.html +1 -1
  15. package/docs-site/context-engine.html +1 -1
  16. package/docs-site/ecosystem.html +1 -1
  17. package/docs-site/en/architecture.html +1 -1
  18. package/docs-site/en/benchmark.html +1 -1
  19. package/docs-site/en/commands.html +1 -1
  20. package/docs-site/en/context-engine.html +1 -1
  21. package/docs-site/en/ecosystem.html +1 -1
  22. package/docs-site/en/index.html +15 -15
  23. package/docs-site/en/mcp-approval.html +1 -1
  24. package/docs-site/en/mcp-auth.html +1 -1
  25. package/docs-site/en/mcp-servers.html +1 -1
  26. package/docs-site/en/mcp.html +1 -1
  27. package/docs-site/en/permissions.html +1 -1
  28. package/docs-site/en/profiles.html +1 -1
  29. package/docs-site/en/quickstart.html +1 -1
  30. package/docs-site/en/runtime.html +1 -1
  31. package/docs-site/en/scope.html +1 -1
  32. package/docs-site/en/security.html +1 -1
  33. package/docs-site/en/team.html +1 -1
  34. package/docs-site/en/update.html +3 -3
  35. package/docs-site/en/whats-new.html +79 -45
  36. package/docs-site/en/windows.html +1 -1
  37. package/docs-site/en/workflows.html +1 -1
  38. package/docs-site/index.html +15 -15
  39. package/docs-site/mcp-approval.html +1 -1
  40. package/docs-site/mcp-auth.html +1 -1
  41. package/docs-site/mcp-servers.html +1 -1
  42. package/docs-site/mcp.html +1 -1
  43. package/docs-site/permissions.html +1 -1
  44. package/docs-site/profiles.html +1 -1
  45. package/docs-site/quickstart.html +1 -1
  46. package/docs-site/releases.json +108 -0
  47. package/docs-site/runtime.html +1 -1
  48. package/docs-site/scope.html +1 -1
  49. package/docs-site/security.html +1 -1
  50. package/docs-site/team.html +1 -1
  51. package/docs-site/update.html +3 -3
  52. package/docs-site/whats-new.html +79 -45
  53. package/docs-site/windows.html +1 -1
  54. package/docs-site/workflows.html +1 -1
  55. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  56. package/package.json +4 -1
  57. package/packages/piagent-core/extensions/piagent-guard.ts +3 -0
  58. package/packages/piagent-core/managed/helper-roles.mjs +24 -6
  59. package/packages/piagent-core/managed/pi-host.mjs +60 -1
  60. package/packages/piagent-core/managed/resource-loader.mjs +3 -2
  61. package/packages/piagent-core/managed/risk-lanes.mjs +38 -0
  62. package/packages/piagent-core/managed/session.mjs +6 -5
  63. package/packages/piagent-core/managed/workflow.mjs +24 -4
  64. package/packages/piagent-core/package.json +1 -1
  65. package/packages/piagent-core/runtime/resources/plugin-skills.mjs +33 -4
  66. package/packages/piagent-core/runtime/terminal/terminal-style.d.mts +7 -0
  67. package/packages/piagent-core/runtime/terminal/terminal-style.mjs +99 -0
  68. package/packages/piagent-core/themes/piagent-contrast.json +78 -0
  69. package/packages/piagent-core/themes/piagent-focus.json +78 -0
  70. package/packages/piagent-core/themes/piagent-ocean.json +78 -0
  71. package/packages/piagent-core/themes/piagent-paper.json +78 -0
  72. package/packages/piagent-webui/gateway/pi-host.ts +13 -1
  73. package/scripts/company-terminal.mjs +6 -2
  74. package/scripts/install-global.sh +13 -1
  75. package/scripts/pi-host.mjs +109 -0
  76. package/scripts/piagent-cli.mjs +17 -6
  77. package/scripts/piagent-dashboard.mjs +2 -2
  78. package/scripts/runtime-advisory-exceptions.json +40 -0
  79. package/scripts/setup.sh +19 -1
  80. package/scripts/skill-sources.mjs +188 -0
  81. package/scripts/uninstall-global.sh +18 -1
  82. package/scripts/update-global.mjs +13 -5
@@ -9,6 +9,7 @@ import { execFile } from 'node:child_process';
9
9
  import { pathToFileURL } from 'node:url';
10
10
 
11
11
  import { registerTerminalUpdateOffer } from '../packages/piagent-core/runtime/update/terminal-update-offer.mjs';
12
+ import { registerStyleCommand } from '../packages/piagent-core/runtime/terminal/terminal-style.mjs';
12
13
  import { parseFailure } from '../packages/piagent-core/runtime/managed-failure.mjs';
13
14
  import { checkpointsOf } from '../packages/piagent-core/managed/checkpoints.mjs';
14
15
  import { bypassAllowed, effectivePermission } from '../packages/piagent-core/managed/permission.mjs';
@@ -59,7 +60,7 @@ export function memoryResultLines(text, expanded = false) {
59
60
  const branchOf = (cwd) => new Promise((resolve) => execFile('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeout: 3000 },
60
61
  (error, out) => resolve(error ? null : String(out).trim() || null)));
61
62
  export const HELP_TEXT = [
62
- 'Cuộc trò chuyện công ty: /undo hoàn tác file lượt trước đã sửa · /bypass on|full|off|status quyền của cuộc trò chuyện · /fast on|off|status (khi Harness cho phép) · /skill:<tên> hoặc /<lệnh> của bạn và của project.',
63
+ 'Cuộc trò chuyện công ty: /undo hoàn tác file lượt trước đã sửa · /bypass on|full|off|status quyền của cuộc trò chuyện · /fast on|off|status (khi Harness cho phép) · /style giao diện Terminal · /skill:<tên> hoặc /<lệnh> của bạn và của project.',
63
64
  'Pi: /session thông tin cuộc trò chuyện · /resume mở cuộc trò chuyện công ty khác · /new cuộc mới · /compact nén ngữ cảnh · /tree, /fork nhánh · /name đặt tên · /copy, /export · /hotkeys toàn bộ phím tắt · /quit thoát.',
64
65
  'Phím: ! <lệnh> chạy shell · Esc dừng lượt đang chạy · Ctrl+C xoá ô nhập, hai lần để thoát · Ctrl+O xem thêm.',
65
66
  'Mở lại sau: piagent studio --project <thư mục>, rồi /resume. Đừng mở cuộc trò chuyện công ty bằng pi cá nhân.',
@@ -112,7 +113,7 @@ export function companyCommandText(name, summary = {}) {
112
113
  case 'login': case 'logout': return 'Cuộc trò chuyện công ty dùng tài khoản AI của team qua Agent Watch: không cần /login hay /logout. Tài khoản và API key cá nhân chỉ dùng trong pi cá nhân.';
113
114
  case 'model': case 'scoped-models': return `Model do Harness của team quyết định${main}; Terminal không chọn model. ${ADMIN}`;
114
115
  case 'thinking': return `Mức suy luận do Harness của team quyết định${main}. ${ADMIN}`;
115
- case 'settings': return `Cài đặt của cuộc trò chuyện công ty (compaction, retry…) do Harness quyết định. Phím tắt: /hotkeys. ${ADMIN}`;
116
+ case 'settings': return `Cài đặt của cuộc trò chuyện công ty (compaction, retry…) do Harness quyết định. Giao diện Terminal: /style. Phím tắt: /hotkeys. ${ADMIN}`;
116
117
  default: return null;
117
118
  }
118
119
  }
@@ -288,6 +289,9 @@ export function companyTerminalExtension({ managed, packageRoot, sdkRoot, bindin
288
289
  registerTerminalUpdateOffer(pi, { installed, packageRoot, restart: 'piagent studio',
289
290
  afterUpdate: () => bindingChanged() ? 'Bản mới đổi trình khởi chạy công ty: Agent Watch tự cập nhật cấu hình (cần Agent Watch đang chạy); piagent studio chờ việc này khi mở lại.' : null });
290
291
 
292
+ // The member's pick of look (/style); /settings is not offered here.
293
+ registerStyleCommand(pi, { remember: true });
294
+
291
295
  // Ask first or Bypass, kept with the conversation like the dashboard's switch.
292
296
  const showPermission = (ctx) => { const mode = managed() ? effectivePermission(managed()) : null; ctx.ui?.setStatus?.(PERMISSION_STATUS, mode && mode !== 'workspace-write' ? PERMISSION_NAME[mode] : undefined); };
293
297
  pi.on('session_start', async (_event, ctx) => showPermission(ctx));
@@ -701,7 +701,19 @@ else
701
701
  if [[ "$pi_version_output" =~ ([0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?) ]]; then
702
702
  current_pi_version="${BASH_REMATCH[1]}"
703
703
  fi
704
- if [[ "$current_pi_version" != "$EXPECTED_PI_VERSION" ]]; then
704
+ pi_host_plan="$(node --input-type=module -e 'const { hostPlan } = await import(process.argv[1]); process.stdout.write(hostPlan(process.argv[2] || null));' "$PLATFORM_ROOT/packages/piagent-core/managed/pi-host.mjs" "$current_pi_version")"
705
+ if [[ "$pi_host_plan" == "private" ]]; then
706
+ # The member's own, newer pi stays: it loads the Piagent package as any Pi
707
+ # package, and company sessions and `piagent pi` run the private pinned host.
708
+ echo "NOTE: your pi $current_pi_version stays as it is; Piagent is qualified on Pi $EXPECTED_PI_VERSION." >&2
709
+ pi_host_args=(ensure --force)
710
+ [[ "$DRY_RUN" == true ]] && pi_host_args+=(--dry-run)
711
+ if ! node "$PLATFORM_ROOT/scripts/pi-host.mjs" "${pi_host_args[@]}" >&2; then
712
+ echo "FAIL: could not install the private Pi $EXPECTED_PI_VERSION." >&2
713
+ exit 1
714
+ fi
715
+ echo "NOTE: run \`piagent pi\` for the qualified release; \`pi\` ($current_pi_version) loads Piagent too, unqualified." >&2
716
+ elif [[ "$current_pi_version" != "$EXPECTED_PI_VERSION" ]]; then
705
717
  echo "FAIL: Pi Coding Agent $EXPECTED_PI_VERSION is required; found ${current_pi_version:-${pi_version_output:-unknown}}." >&2
706
718
  echo "Install the supported host with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent@$EXPECTED_PI_VERSION" >&2
707
719
  exit 1
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // The Pi host Piagent runs on, when the member keeps a Pi of their own.
3
+ //
4
+ // Piagent pins one Pi release (package.json peerDependencies). A member who
5
+ // already uses Pi may run a newer one: setup used to replace it with the
6
+ // pinned release. Now theirs stays as it is, and the pinned release is
7
+ // installed privately (packages/piagent-core/managed/pi-host.mjs); company
8
+ // sessions and the dashboard run it, and `piagent pi` opens it for personal use.
9
+ //
10
+ // pi-host.mjs status [--json] the pinned host, the `pi` on PATH, the private copy
11
+ // pi-host.mjs ensure [--dry-run] install the private copy when PATH has another Pi
12
+ // pi-host.mjs root the host root Piagent runs (empty when none)
13
+ // pi-host.mjs run [-- args] run Pi on the pinned host
14
+ import { spawn, spawnSync } from 'node:child_process';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { PI_HOST_VERSION, hostCli, hostPlan, installPrivateHost, privateHostRoot } from '../packages/piagent-core/managed/pi-host.mjs';
19
+
20
+ const HOST_NAME = '@earendil-works/pi-coding-agent';
21
+
22
+ function usage() {
23
+ return `Usage:
24
+ piagent pi [pi arguments] Run Pi ${PI_HOST_VERSION} (the release Piagent is qualified on)
25
+ piagent pi-host status [--json] Show the pinned host, your own pi and the private copy
26
+ piagent pi-host ensure [--dry-run]
27
+ Install the pinned host privately when your pi is another release
28
+ `;
29
+ }
30
+
31
+ // The `pi` on PATH: its package root and version, or nulls.
32
+ export function pathHost(env = process.env, { probe = true } = {}) {
33
+ for (const dir of (env.PATH ?? '').split(path.delimiter)) {
34
+ if (!path.isAbsolute(dir)) continue;
35
+ let root;
36
+ try { root = path.dirname(fs.realpathSync(path.join(dir, process.platform === 'win32' ? 'pi.cmd' : 'pi'))); } catch { continue; }
37
+ for (let depth = 0; depth < 4; depth += 1, root = path.dirname(root)) {
38
+ try {
39
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
40
+ if (manifest.name === HOST_NAME) return { root, version: manifest.version ?? null };
41
+ } catch { /* keep walking up */ }
42
+ }
43
+ }
44
+ if (!probe) return { root: null, version: null };
45
+ // A `pi` that is not an npm package (a standalone build): ask it.
46
+ const output = spawnSync('pi', ['--version'], { encoding: 'utf8', timeout: 5_000, shell: process.platform === 'win32' });
47
+ const version = output.status === 0 ? /(\d+\.\d+\.\d+(?:[+-][0-9A-Za-z.-]+)?)/.exec(output.stdout)?.[1] ?? null : null;
48
+ return { root: null, version };
49
+ }
50
+
51
+ // The host Piagent runs: the `pi` on PATH when it is the pinned release, else
52
+ // the private copy, else (not installed yet) whatever PATH has.
53
+ export function activeHostRoot({ env = process.env, home } = {}) {
54
+ const own = pathHost(env, { probe: false });
55
+ if (own.root && own.version === PI_HOST_VERSION) return own.root;
56
+ return privateHostRoot(PI_HOST_VERSION, home) ?? own.root;
57
+ }
58
+
59
+ export function status({ env = process.env, home } = {}) {
60
+ const own = pathHost(env), privateRoot = privateHostRoot(PI_HOST_VERSION, home);
61
+ return { pinned: PI_HOST_VERSION, own: own.version, ownRoot: own.root, privateRoot, plan: hostPlan(own.version), active: activeHostRoot({ env, home }) };
62
+ }
63
+
64
+ function describe(report) {
65
+ const lines = [`Piagent runs on Pi ${report.pinned}.`];
66
+ if (!report.own) lines.push('No `pi` on PATH.');
67
+ else if (report.plan === 'current') lines.push(`Your pi is ${report.own}: the same release, used as it is.`);
68
+ else if (report.plan === 'private') lines.push(`Your pi is ${report.own}: kept as it is. Company sessions and \`piagent pi\` use a private Pi ${report.pinned}.`);
69
+ else lines.push(`Your pi is ${report.own}: older than ${report.pinned}; piagent-setup updates it.`);
70
+ lines.push(report.privateRoot ? `Private copy: ${report.privateRoot}` : 'Private copy: not installed');
71
+ return lines.join('\n');
72
+ }
73
+
74
+ async function main(argv) {
75
+ const [command, ...rest] = argv;
76
+ if ([undefined, 'help', '--help', '-h'].includes(command)) { process.stdout.write(usage()); return 0; }
77
+ if (command === 'status') {
78
+ const report = status();
79
+ process.stdout.write(rest.includes('--json') ? `${JSON.stringify(report, null, 2)}\n` : `${describe(report)}\n`);
80
+ return 0;
81
+ }
82
+ if (command === 'root') { process.stdout.write(`${activeHostRoot() ?? ''}\n`); return 0; }
83
+ if (command === 'ensure') {
84
+ const report = status(), dryRun = rest.includes('--dry-run');
85
+ if (report.plan !== 'private' && !rest.includes('--force')) { process.stdout.write(`${describe(report)}\n`); return 0; }
86
+ try {
87
+ const root = installPrivateHost({ dryRun, log: (line) => process.stdout.write(`${line}\n`) });
88
+ process.stdout.write(`${dryRun ? 'DRY RUN: ' : ''}Pi ${PI_HOST_VERSION} for Piagent: ${root}\n`);
89
+ if (report.own) process.stdout.write(`Your pi ${report.own} is unchanged.\n`);
90
+ return 0;
91
+ } catch (error) { process.stderr.write(`FAIL: ${error.message}\n`); return 1; }
92
+ }
93
+ if (command === 'run') {
94
+ const args = rest[0] === '--' ? rest.slice(1) : rest;
95
+ const root = activeHostRoot();
96
+ if (!root || JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version !== PI_HOST_VERSION) {
97
+ process.stderr.write(`FAIL: Pi ${PI_HOST_VERSION} is not installed for Piagent. Run: piagent pi-host ensure --force\n`);
98
+ return 1;
99
+ }
100
+ const child = spawn(process.execPath, [hostCli(root), ...args], { stdio: 'inherit' });
101
+ for (const signal of ['SIGTERM', 'SIGHUP']) process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
102
+ process.on('SIGINT', () => {});
103
+ return await new Promise((resolve) => child.once('exit', (code, signal) => resolve(signal ? 1 : code ?? 1)));
104
+ }
105
+ process.stderr.write(usage());
106
+ return 2;
107
+ }
108
+
109
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main(process.argv.slice(2));
@@ -26,7 +26,9 @@ const scriptByCommand = {
26
26
  "piagent-context": "scripts/context-engine.mjs",
27
27
  "piagent-explain": "scripts/explain-command.mjs",
28
28
  "piagent-webui": "scripts/piagent-webui-launcher.mjs",
29
- "piagent-dashboard": "scripts/piagent-dashboard.mjs"
29
+ "piagent-dashboard": "scripts/piagent-dashboard.mjs",
30
+ "piagent-pi-host": "scripts/pi-host.mjs",
31
+ "piagent-skills": "scripts/skill-sources.mjs"
30
32
  };
31
33
 
32
34
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -42,6 +44,10 @@ if (invokedAs === "piagent") {
42
44
  } else if (subcommand === "dashboard") {
43
45
  script = "scripts/piagent-dashboard.mjs";
44
46
  forwardedArgs = forwardedArgs.slice(1);
47
+ } else if (subcommand === "pi") {
48
+ // Pi itself, on the release Piagent is qualified on, whatever `pi` the member keeps.
49
+ script = "scripts/pi-host.mjs";
50
+ forwardedArgs = ["run", "--", ...forwardedArgs.slice(1)];
45
51
  } else if (subcommand === "explain") {
46
52
  script = "scripts/explain-command.mjs";
47
53
  forwardedArgs = forwardedArgs.slice(1);
@@ -64,6 +70,9 @@ if (invokedAs === "piagent") {
64
70
  console.log("Commands:");
65
71
  console.log(" dashboard Open and manage the local session hub");
66
72
  console.log(" studio Open the managed company agent imported by Agent Watch");
73
+ console.log(" pi Run Pi on the release Piagent is qualified on (your own pi stays as it is)");
74
+ console.log(" pi-host Show or install the Pi host Piagent runs");
75
+ console.log(" skills Add, list or remove skill sources such as mattpocock/skills");
67
76
  console.log(" explain Say why the guard would allow or block a shell command");
68
77
  console.log(" approve-verification Preview or approve an independent verification plan");
69
78
  console.log(" select-verification Preview reusable contracts for exact task criteria");
@@ -95,8 +104,10 @@ function benchmarkSourceRoot() {
95
104
  process.exit(1);
96
105
  }
97
106
 
98
- // The Pi host this member runs: the `pi` found on PATH, as its package root.
99
- function piHost() {
107
+ // The Pi host this member runs: the `pi` on PATH when it is the pinned release,
108
+ // else Piagent's private copy (a member keeping a newer Pi of their own).
109
+ async function piHost() {
110
+ try { return (await import("./pi-host.mjs")).activeHostRoot(); } catch { /* the `pi` on PATH, below */ }
100
111
  for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
101
112
  if (!path.isAbsolute(dir)) continue;
102
113
  let root;
@@ -113,7 +124,7 @@ function piHost() {
113
124
  // entrypoint, the Node running it and the Pi host. Source checkouts never
114
125
  // register, and a failure here never blocks the command. Asking for help or the
115
126
  // version changes nothing in the operator's agent directory.
116
- function recordRuntime() {
127
+ async function recordRuntime() {
117
128
  try {
118
129
  if (forwardedArgs.some((value) => ["--help", "-h", "--version", "-v"].includes(value))) return;
119
130
  if (!packageRoot.split(path.sep).includes("node_modules")) return;
@@ -129,7 +140,7 @@ function recordRuntime() {
129
140
  version: JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version,
130
141
  entrypoint: fs.realpathSync(path.join(packageRoot, "scripts", "piagent-studio.mjs")),
131
142
  node: fs.realpathSync(process.execPath),
132
- pi_sdk_root: piHost()
143
+ pi_sdk_root: await piHost()
133
144
  }, null, 2)}\n`;
134
145
  const file = path.join(agentDir, "piagent-runtime.json");
135
146
  try { if (fs.readFileSync(file, "utf8") === body) return; } catch { /* first run */ }
@@ -138,7 +149,7 @@ function recordRuntime() {
138
149
  fs.renameSync(temporary, file);
139
150
  } catch { /* optional: Agent Watch falls back to known install locations */ }
140
151
  }
141
- recordRuntime();
152
+ await recordRuntime();
142
153
 
143
154
  const sourceRoot = invokedAs === "piagent-benchmark" ? benchmarkSourceRoot() : packageRoot;
144
155
  const target = path.join(sourceRoot, script);
@@ -7,7 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
 
8
8
  import { requestGatewayControl } from "../packages/piagent-webui/gateway/control-socket.ts";
9
9
  import { browserCommand } from "../packages/piagent-core/runtime/host/browser-opener.mjs";
10
- import { installedPiHostRoot } from "../packages/piagent-webui/gateway/pi-host.ts";
10
+ import { pinnedPiHostRoot } from "../packages/piagent-webui/gateway/pi-host.ts";
11
11
  import { gatewayProfileState, profileRef, readGatewayDescriptor, readOrCreateCatalogKey,
12
12
  removeGatewayDescriptor } from "../packages/piagent-webui/gateway/profile-state.ts";
13
13
  import { startPiagentGateway } from "../packages/piagent-webui/gateway/gateway-service.ts";
@@ -266,7 +266,7 @@ async function main() {
266
266
  const key = readOrCreateCatalogKey(state), descriptor = readGatewayDescriptor(state);
267
267
  let actual = null;
268
268
  try {
269
- const hostRoot = installedPiHostRoot();
269
+ const hostRoot = pinnedPiHostRoot(expectedPiVersion);
270
270
  actual = JSON.parse(fs.readFileSync(path.join(hostRoot, "package.json"), "utf8")).version;
271
271
  } catch { /* reported as a failed check below */ }
272
272
  let health = null;
@@ -24,6 +24,46 @@
24
24
  "decided": "2026-10-24",
25
25
  "until": "2026-11-07",
26
26
  "reason": "Subresource Integrity check bypassed with a non-lowercase algorithm token; Pi does not fetch with integrity metadata. Pinned by the Pi host as above; fixed in undici 8.12.2."
27
+ },
28
+ {
29
+ "id": "GHSA-4w7w-5xrc-9h26",
30
+ "package": "undici",
31
+ "node": "node_modules/undici",
32
+ "decided": "2026-10-28",
33
+ "until": "2026-11-07",
34
+ "reason": "Cache poisoning and cross-user disclosure through cross-origin redirects in undici's cache; Pi does not share an HTTP cache between users. Pinned by the Pi host as above (8.10.2 in 1.3.0 and 1.4.0); fixed in undici 8.12.2."
35
+ },
36
+ {
37
+ "id": "GHSA-2m5v-pch6-74hx",
38
+ "package": "undici",
39
+ "node": "node_modules/undici",
40
+ "decided": "2026-10-28",
41
+ "until": "2026-11-07",
42
+ "reason": "TLS certificate validation bypassed when an Agent drops its tls options; affects callers that pass custom tls settings to an undici Agent. Pinned by the Pi host as above; fixed in undici 8.12.2. Owner accepted on 2026-10-28 to keep releases going; revisit as soon as a Pi host carries undici >= 8.12.2."
43
+ },
44
+ {
45
+ "id": "GHSA-qrxm-fqhp-jwvm",
46
+ "package": "undici",
47
+ "node": "node_modules/undici",
48
+ "decided": "2026-10-28",
49
+ "until": "2026-11-07",
50
+ "reason": "Denial of service through a TLS 1.2 certificate-changing renegotiation by a hostile server; no data exposure. Pinned by the Pi host as above; fixed in undici 8.12.2."
51
+ },
52
+ {
53
+ "id": "GHSA-9fm3-97px-rjf6",
54
+ "package": "undici",
55
+ "node": "node_modules/undici",
56
+ "decided": "2026-10-28",
57
+ "until": "2026-11-07",
58
+ "reason": "Denial of service from unbounded automatic PONG buffering in the WebSocket client fed by a hostile server; no data exposure. Pinned by the Pi host as above; fixed in undici 8.12.2."
59
+ },
60
+ {
61
+ "id": "GHSA-55wc-3pg8-jfw6",
62
+ "package": "undici",
63
+ "node": "node_modules/undici",
64
+ "decided": "2026-10-28",
65
+ "until": "2026-11-07",
66
+ "reason": "Denial of service from an unhandled exception in the decompress interceptor; no data exposure. Pinned by the Pi host as above; fixed in undici 8.12.2."
27
67
  }
28
68
  ]
29
69
  }
package/scripts/setup.sh CHANGED
@@ -80,6 +80,7 @@ CONFIGURE_MODEL_SCOPE=true
80
80
  MODEL_SCOPE_PRESET="full"
81
81
  DEFAULT_MODEL="openai-codex/gpt-6-sol:high"
82
82
  AUTO_INSTALL_PI=true
83
+ PI_HOST_PRIVATE=false
83
84
  FORCE_PROFILE=false
84
85
  FORCE_SETTINGS=false
85
86
  SKIP_AGENTS=false
@@ -347,6 +348,19 @@ ensure_pi_cli() {
347
348
  if [[ "$current_version" == "$expected_pi_version" ]]; then
348
349
  return
349
350
  fi
351
+ # A newer pi is the member's own: it stays, and the pinned host is
352
+ # installed privately beside it (scripts/pi-host.mjs).
353
+ if [[ -n "$current_version" ]] && [[ "$(node -e 'const [a,b]=process.argv.slice(1).map(v=>v.split(/[+-]/)[0].split(".").map(Number)); for (let i=0;i<3;i++) if (a[i]!==b[i]) { process.stdout.write(a[i]>b[i]?"newer":"older"); process.exit(0); } process.stdout.write("same");' "$current_version" "$expected_pi_version")" == "newer" ]]; then
354
+ echo "Pi CLI $current_version is your own and stays as it is; Piagent runs on a private Pi $expected_pi_version."
355
+ local ensure_args=(ensure --force)
356
+ [[ "$DRY_RUN" == true ]] && ensure_args+=(--dry-run)
357
+ if ! node "$PLATFORM_ROOT/scripts/pi-host.mjs" "${ensure_args[@]}"; then
358
+ echo "FAIL: could not install the private Pi $expected_pi_version." >&2
359
+ exit 1
360
+ fi
361
+ PI_HOST_PRIVATE=true
362
+ return
363
+ fi
350
364
  if [[ "$AUTO_INSTALL_PI" == false ]]; then
351
365
  echo "FAIL: Pi Coding Agent $expected_pi_version is required; found ${current_version:-${current_output:-unknown}}. Remove --no-install-pi or upgrade it manually." >&2
352
366
  exit 1
@@ -490,7 +504,11 @@ echo "Next:"
490
504
  if [[ "$DO_PROJECT" == true ]]; then
491
505
  echo " cd \"$PROJECT_PATH\""
492
506
  fi
493
- echo " pi"
507
+ if [[ "$PI_HOST_PRIVATE" == true ]]; then
508
+ echo " piagent pi # Pi $(node -p 'require(process.argv[1]).peerDependencies["@earendil-works/pi-coding-agent"]' "$PLATFORM_ROOT/package.json"), the release Piagent is qualified on; your own pi also loads Piagent"
509
+ else
510
+ echo " pi"
511
+ fi
494
512
  echo " /login # first time only"
495
513
  if [[ "$DO_PROJECT" == true ]]; then
496
514
  echo " /model # or Ctrl+L: select provider/model from Pi selector"
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // Skill repositories a member adds to Piagent, such as mattpocock/skills.
3
+ //
4
+ // A source is a public GitHub repository of Agent Skills (folders with a
5
+ // SKILL.md). `add` keeps it at one commit under ~/.piagent/skill-sources, so
6
+ // what the agent reads changes only on `update`; the index lists it
7
+ // (~/.piagent/skill-sources.json), and plugin-skills.mjs offers its skills to
8
+ // personal Pi, the company Terminal and the dashboard, as it does a Claude
9
+ // plugin's. A repository with a plugin manifest (.claude-plugin/plugin.json)
10
+ // loads only the skills the manifest publishes. Skills are instructions: a
11
+ // script they bundle runs only through the session's own tools and approvals.
12
+ //
13
+ // piagent skills add <owner/repo> [--ref <branch|tag|commit>]
14
+ // piagent skills list [--json]
15
+ // piagent skills update [<owner/repo>]
16
+ // piagent skills remove <owner/repo>
17
+ import { spawnSync } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { skillSourcePlugin, skillSourcesIndex, skillSourcesRoot } from '../packages/piagent-core/runtime/resources/plugin-skills.mjs';
23
+
24
+ // Short names for sources the team recommends.
25
+ export const KNOWN_SOURCES = { mattpocock: 'mattpocock/skills', 'matt-pocock': 'mattpocock/skills' };
26
+
27
+ function usage() {
28
+ return `Usage:
29
+ piagent skills add <owner/repo> [--ref <branch|tag|commit>]
30
+ Add a GitHub skill repository, kept at one commit
31
+ (for example: piagent skills add mattpocock/skills)
32
+ piagent skills list [--json] Skill sources and the skills each one adds
33
+ piagent skills update [<owner/repo>]
34
+ Move a source (or all) to the newest commit of its ref
35
+ piagent skills remove <owner/repo>
36
+ Stop reading a source and delete its copy
37
+
38
+ Skills from a source appear in Pi as /skill:<name>, in the company Terminal and
39
+ in the dashboard, after the next /reload or new conversation.
40
+ `;
41
+ }
42
+
43
+ // owner/repo from what the member typed: owner/repo, a github.com URL, or a known name.
44
+ export function parseRepository(value) {
45
+ const typed = KNOWN_SOURCES[String(value ?? '').trim().toLowerCase()] ?? String(value ?? '').trim();
46
+ const match = /^(?:https:\/\/github\.com\/|git:github\.com\/|github\.com\/)?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/.exec(typed);
47
+ if (!match || match[2] === '.' || match[2] === '..') return null;
48
+ return `${match[1]}/${match[2]}`;
49
+ }
50
+
51
+ const validRef = (ref) => typeof ref === 'string' && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(ref) && !ref.includes('..');
52
+
53
+ export function readIndex(home = os.homedir()) {
54
+ try {
55
+ const value = JSON.parse(fs.readFileSync(skillSourcesIndex(home), 'utf8'));
56
+ return { schema_version: 1, sources: Array.isArray(value?.sources) ? value.sources : [] };
57
+ } catch { return { schema_version: 1, sources: [] }; }
58
+ }
59
+
60
+ function writeIndex(index, home) {
61
+ const file = skillSourcesIndex(home);
62
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
63
+ const temporary = `${file}.${process.pid}.tmp`;
64
+ fs.writeFileSync(temporary, `${JSON.stringify(index, null, 2)}\n`, { mode: 0o600 });
65
+ fs.renameSync(temporary, file);
66
+ }
67
+
68
+ function git(args, options = {}) {
69
+ const result = spawnSync('git', args, { encoding: 'utf8', timeout: 120_000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, ...options });
70
+ if (result.error) throw new Error(`git could not start: ${result.error.message}`);
71
+ if (result.status !== 0) throw new Error((result.stderr || result.stdout || `git ${args[0]} failed`).trim().split('\n').at(-1));
72
+ return result.stdout.trim();
73
+ }
74
+
75
+ // The repository at `ref` (default branch when none), copied without its
76
+ // history into <root>/github.com/<owner>/<repo>/<commit>. Returns the entry.
77
+ export function fetchSource(repo, { ref, home = os.homedir(), runGit = git } = {}) {
78
+ const url = `https://github.com/${repo}.git`, root = skillSourcesRoot(home);
79
+ const parent = path.join(root, 'github.com', ...repo.split('/'));
80
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
81
+ const staging = fs.mkdtempSync(path.join(parent, '.incoming-'));
82
+ try {
83
+ const checkout = path.join(staging, 'checkout');
84
+ if (ref && /^[0-9a-f]{40}$/i.test(ref)) {
85
+ fs.mkdirSync(checkout);
86
+ runGit(['init', '-q', checkout]);
87
+ runGit(['-C', checkout, 'fetch', '-q', '--depth', '1', url, ref]);
88
+ runGit(['-C', checkout, 'checkout', '-q', 'FETCH_HEAD']);
89
+ } else {
90
+ runGit(['clone', '-q', '--depth', '1', ...(ref ? ['--branch', ref] : []), url, checkout]);
91
+ }
92
+ const commit = runGit(['-C', checkout, 'rev-parse', 'HEAD']);
93
+ if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error('could not read the commit');
94
+ fs.rmSync(path.join(checkout, '.git'), { recursive: true, force: true });
95
+ const target = path.join(parent, commit);
96
+ if (!fs.existsSync(target)) fs.renameSync(checkout, target);
97
+ return { repo, ref: ref ?? null, commit, path: path.relative(root, target), added_at: new Date().toISOString() };
98
+ } finally { fs.rmSync(staging, { recursive: true, force: true }); }
99
+ }
100
+
101
+ // The skills a source adds, as the agent will see them.
102
+ export function sourceSkills(entry, home = os.homedir()) {
103
+ const found = skillSourcePlugin(path.join(skillSourcesRoot(home), entry.path), entry.repo);
104
+ if (!found) return [];
105
+ const names = [];
106
+ const walk = (dir, depth) => {
107
+ if (depth > 4 || names.length >= 400) return;
108
+ if (fs.existsSync(path.join(dir, 'SKILL.md'))) { names.push(path.basename(dir)); return; }
109
+ let entries = [];
110
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
111
+ for (const child of entries) if (child.isDirectory() && !child.name.startsWith('.') && child.name !== 'node_modules') walk(path.join(dir, child.name), depth + 1);
112
+ };
113
+ for (const dir of found.skills) walk(dir, 0);
114
+ return [...new Set(names)].sort();
115
+ }
116
+
117
+ function removeCopy(entry, home) {
118
+ const root = skillSourcesRoot(home), dir = path.resolve(root, entry.path);
119
+ if (dir.startsWith(root + path.sep)) fs.rmSync(dir, { recursive: true, force: true });
120
+ }
121
+
122
+ function add(repo, { ref, home }) {
123
+ const index = readIndex(home), existing = index.sources.find((item) => item.repo === repo);
124
+ const entry = fetchSource(repo, { ref: ref ?? existing?.ref ?? undefined, home });
125
+ const skills = sourceSkills(entry, home);
126
+ if (!skills.length) {
127
+ removeCopy(entry, home);
128
+ throw new Error(`${repo} has no skill (no SKILL.md under the folders it publishes)`);
129
+ }
130
+ if (existing && existing.path !== entry.path) removeCopy(existing, home);
131
+ index.sources = [...index.sources.filter((item) => item.repo !== repo), { ...entry, name: repo }];
132
+ writeIndex(index, home);
133
+ return { entry, skills, replaced: existing?.commit ?? null };
134
+ }
135
+
136
+ function main(argv, home = os.homedir()) {
137
+ const [command, ...rest] = argv;
138
+ const flag = (name) => { const at = rest.indexOf(name); return at >= 0 ? rest[at + 1] : undefined; };
139
+ const positional = rest.filter((value, at) => !value.startsWith('--') && !(at > 0 && rest[at - 1] === '--ref'));
140
+ if ([undefined, 'help', '--help', '-h'].includes(command)) { process.stdout.write(usage()); return 0; }
141
+ try {
142
+ if (command === 'list') {
143
+ const sources = readIndex(home).sources.map((entry) => ({ ...entry, skills: sourceSkills(entry, home) }));
144
+ if (rest.includes('--json')) { process.stdout.write(`${JSON.stringify({ sources }, null, 2)}\n`); return 0; }
145
+ if (!sources.length) { process.stdout.write('No skill source yet. Add one: piagent skills add mattpocock/skills\n'); return 0; }
146
+ for (const entry of sources) process.stdout.write(`${entry.repo} @ ${entry.commit.slice(0, 12)}${entry.ref ? ` (${entry.ref})` : ''}: ${entry.skills.length} skill(s)\n ${entry.skills.join(', ')}\n`);
147
+ return 0;
148
+ }
149
+ if (command === 'add') {
150
+ const repo = parseRepository(positional[0]), ref = flag('--ref');
151
+ if (!repo) throw new Error('name a GitHub repository as owner/repo, for example mattpocock/skills');
152
+ if (ref !== undefined && !validRef(ref)) throw new Error('--ref needs a branch, tag or commit');
153
+ const { entry, skills, replaced } = add(repo, { ref, home });
154
+ process.stdout.write(`${replaced && replaced !== entry.commit ? 'Updated' : 'Added'} ${repo} @ ${entry.commit.slice(0, 12)}: ${skills.length} skill(s)\n ${skills.join(', ')}\n`);
155
+ process.stdout.write('Use them as /skill:<name> after /reload in Pi, or in a new company conversation.\n');
156
+ return 0;
157
+ }
158
+ if (command === 'update') {
159
+ const only = positional[0] ? parseRepository(positional[0]) : null;
160
+ if (positional[0] && !only) throw new Error('name a GitHub repository as owner/repo');
161
+ const targets = readIndex(home).sources.filter((entry) => !only || entry.repo === only);
162
+ if (!targets.length) throw new Error(only ? `${only} is not a skill source` : 'no skill source to update');
163
+ for (const entry of targets) {
164
+ const { entry: next, skills } = add(entry.repo, { ref: entry.ref ?? undefined, home });
165
+ process.stdout.write(`${entry.repo}: ${entry.commit.slice(0, 12)} -> ${next.commit.slice(0, 12)} (${skills.length} skill(s))\n`);
166
+ }
167
+ return 0;
168
+ }
169
+ if (command === 'remove') {
170
+ const repo = parseRepository(positional[0]);
171
+ if (!repo) throw new Error('name a GitHub repository as owner/repo');
172
+ const index = readIndex(home), entry = index.sources.find((item) => item.repo === repo);
173
+ if (!entry) throw new Error(`${repo} is not a skill source`);
174
+ removeCopy(entry, home);
175
+ index.sources = index.sources.filter((item) => item.repo !== repo);
176
+ writeIndex(index, home);
177
+ process.stdout.write(`Removed ${repo}.\n`);
178
+ return 0;
179
+ }
180
+ } catch (error) {
181
+ process.stderr.write(`FAIL: ${error.message}\n`);
182
+ return 1;
183
+ }
184
+ process.stderr.write(usage());
185
+ return 2;
186
+ }
187
+
188
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2), os.homedir());
@@ -212,7 +212,24 @@ fi
212
212
 
213
213
  if [[ "$WITH_HOST" == true ]]; then
214
214
  echo
215
- if command -v npm >/dev/null 2>&1; then
215
+ # Piagent's private host (kept beside a member's own, newer pi) is Piagent's to remove.
216
+ PRIVATE_HOSTS="${HOME}/.piagent/pi-host"
217
+ if [[ -d "$PRIVATE_HOSTS" ]]; then
218
+ echo "Removing Piagent's private Pi host:"
219
+ run_cmd rm -rf "$PRIVATE_HOSTS"
220
+ fi
221
+ # A pi newer than the pinned release is the member's own: it stays.
222
+ own_pi_version=""
223
+ if command -v pi >/dev/null 2>&1 && [[ "$(pi --version 2>/dev/null || true)" =~ ([0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?) ]]; then
224
+ own_pi_version="${BASH_REMATCH[1]}"
225
+ fi
226
+ own_pi_plan=""
227
+ if [[ -n "$own_pi_version" ]]; then
228
+ own_pi_plan="$(node --input-type=module -e 'const { hostPlan } = await import(process.argv[1]); process.stdout.write(hostPlan(process.argv[2]));' "$ROOT/packages/piagent-core/managed/pi-host.mjs" "$own_pi_version" 2>/dev/null || true)"
229
+ fi
230
+ if [[ "$own_pi_plan" == "private" ]]; then
231
+ echo "Pi $own_pi_version is your own (newer than the release Piagent pins): left in place."
232
+ elif command -v npm >/dev/null 2>&1; then
216
233
  echo "Uninstalling Pi host:"
217
234
  HOST_PREFIX="$(npm_prefix_override)"
218
235
  if [[ -n "$HOST_PREFIX" ]]; then
@@ -22,6 +22,7 @@ import path from "node:path";
22
22
  import { spawnSync } from "node:child_process";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { inspect as inspectPersonalPi } from "./personal-pi-config.mjs";
25
+ import { hostPlan, installPrivateHost, privateHostRoot } from "../packages/piagent-core/managed/pi-host.mjs";
25
26
 
26
27
  const HELPER_PACKAGE = "@piagent/platform";
27
28
  const HOST_PACKAGE = "@earendil-works/pi-coding-agent";
@@ -426,11 +427,15 @@ function main() {
426
427
 
427
428
  // npm exec is temporary even when its version already matches the target.
428
429
  const helperNeedsChange = options.force || currentHelper !== targetHelper || isNpxCacheRoot(platformRoot);
429
- const hostNeedsChange = options.host && (options.force || currentHost !== targetHost);
430
+ // A newer pi is the member's own: kept, with the target host installed privately.
431
+ const ownHost = hostPlan(currentHost, targetHost) === "private";
432
+ const hostNeedsChange = options.host && (ownHost ? !privateHostRoot(targetHost) : options.force || currentHost !== targetHost);
430
433
 
431
434
  console.log("Pi Agent Platform update");
432
435
  console.log(` helper: ${currentHelper} -> ${targetHelper}${helperNeedsChange ? "" : " (already current)"}`);
433
- console.log(` host: ${currentHost ?? "not installed"} -> ${targetHost}${hostNeedsChange ? "" : " (already current)"}`);
436
+ console.log(ownHost
437
+ ? ` host: your pi ${currentHost} stays; Piagent's private Pi -> ${targetHost}${hostNeedsChange ? "" : " (already current)"}`
438
+ : ` host: ${currentHost ?? "not installed"} -> ${targetHost}${hostNeedsChange ? "" : " (already current)"}`);
434
439
  // The Pi package can lag the helper (installed from an older tag), and
435
440
  // personal Pi can hold conflicting Piagent or old company entries.
436
441
  const personal = inspectPersonalPi(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"));
@@ -473,7 +478,7 @@ function main() {
473
478
  // here rather than trusted from before the move.
474
479
  let effectiveHost = currentHost;
475
480
  let effectiveHostNeedsChange = hostNeedsChange;
476
- if (npmTarget.bin) {
481
+ if (npmTarget.bin && !ownHost) {
477
482
  const activeHost = installedHostVersion();
478
483
  if (activeHost !== currentHost) {
479
484
  effectiveHost = activeHost;
@@ -484,14 +489,17 @@ function main() {
484
489
  }
485
490
 
486
491
  // Refuse an impossible package installation before replacing either global.
487
- if (options.package && !options.host && effectiveHost !== targetHost) {
492
+ if (options.package && !options.host && effectiveHost !== targetHost && !(ownHost && privateHostRoot(targetHost))) {
488
493
  fail(`--no-host cannot install this Pi package: active Pi is ${effectiveHost ?? "missing"}, but v${targetHelper} requires ${targetHost}. Rerun without --no-host.`);
489
494
  }
490
495
 
491
496
  // Host first: piagent-install refuses to run against a host that does not match
492
497
  // the version its package.json pins, so a helper installed ahead of the host
493
498
  // would fail on its very next step.
494
- if (effectiveHostNeedsChange) {
499
+ if (effectiveHostNeedsChange && ownHost) {
500
+ try { installPrivateHost({ version: targetHost, dryRun: options.dryRun, log: (line) => console.log(line.startsWith("DRY RUN") ? line : `run: ${line}`) }); }
501
+ catch { fail(`could not install the private ${HOST_PACKAGE}@${targetHost}`); }
502
+ } else if (effectiveHostNeedsChange) {
495
503
  const args = npmInstallArgs(`${HOST_PACKAGE}@${targetHost}`, npmTarget);
496
504
  describeStep(options.dryRun, "npm", args);
497
505
  if (!options.dryRun && !run("npm", args).ok) fail(`could not install ${HOST_PACKAGE}@${targetHost}`);