@piagent/platform 1.24.0 → 1.26.0

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 (88) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/company-dev-commands.md +68 -0
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/harness-conformance.md +154 -0
  8. package/docs/languages.json +2 -2
  9. package/docs/managed-local-acceptance.md +38 -0
  10. package/docs/operator-manual-vietnamese.md +7 -7
  11. package/docs/publishing-for-teams.md +2 -0
  12. package/docs/release-install-policy.md +2 -2
  13. package/docs/team-onboarding.md +2 -2
  14. package/docs-site/architecture.html +1 -1
  15. package/docs-site/benchmark.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/en/index.html +4 -4
  18. package/docs-site/content/en/whats-new.html +12 -0
  19. package/docs-site/content/index.html +4 -4
  20. package/docs-site/content/whats-new.html +12 -0
  21. package/docs-site/context-engine.html +1 -1
  22. package/docs-site/ecosystem.html +1 -1
  23. package/docs-site/en/architecture.html +1 -1
  24. package/docs-site/en/benchmark.html +1 -1
  25. package/docs-site/en/commands.html +1 -1
  26. package/docs-site/en/context-engine.html +1 -1
  27. package/docs-site/en/ecosystem.html +1 -1
  28. package/docs-site/en/index.html +5 -5
  29. package/docs-site/en/mcp-approval.html +1 -1
  30. package/docs-site/en/mcp-auth.html +1 -1
  31. package/docs-site/en/mcp-servers.html +1 -1
  32. package/docs-site/en/mcp.html +1 -1
  33. package/docs-site/en/permissions.html +1 -1
  34. package/docs-site/en/profiles.html +1 -1
  35. package/docs-site/en/quickstart.html +1 -1
  36. package/docs-site/en/runtime.html +1 -1
  37. package/docs-site/en/scope.html +1 -1
  38. package/docs-site/en/security.html +1 -1
  39. package/docs-site/en/team.html +1 -1
  40. package/docs-site/en/whats-new.html +13 -1
  41. package/docs-site/en/windows.html +1 -1
  42. package/docs-site/en/workflows.html +1 -1
  43. package/docs-site/index.html +5 -5
  44. package/docs-site/mcp-approval.html +1 -1
  45. package/docs-site/mcp-auth.html +1 -1
  46. package/docs-site/mcp-servers.html +1 -1
  47. package/docs-site/mcp.html +1 -1
  48. package/docs-site/permissions.html +1 -1
  49. package/docs-site/profiles.html +1 -1
  50. package/docs-site/quickstart.html +1 -1
  51. package/docs-site/runtime.html +1 -1
  52. package/docs-site/scope.html +1 -1
  53. package/docs-site/security.html +1 -1
  54. package/docs-site/team.html +1 -1
  55. package/docs-site/whats-new.html +13 -1
  56. package/docs-site/windows.html +1 -1
  57. package/docs-site/workflows.html +1 -1
  58. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  59. package/package.json +1 -1
  60. package/packages/piagent-core/managed/background-process.mjs +101 -0
  61. package/packages/piagent-core/managed/checkpoints.mjs +69 -0
  62. package/packages/piagent-core/managed/command-tools.mjs +92 -21
  63. package/packages/piagent-core/managed/container-engine.mjs +40 -0
  64. package/packages/piagent-core/managed/continuation.mjs +8 -0
  65. package/packages/piagent-core/managed/doctor.mjs +4 -4
  66. package/packages/piagent-core/managed/host-command.mjs +67 -0
  67. package/packages/piagent-core/managed/language-environment.mjs +8 -2
  68. package/packages/piagent-core/managed/pi-host.mjs +19 -0
  69. package/packages/piagent-core/managed/repository-operation.mjs +39 -17
  70. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +22 -9
  71. package/packages/piagent-core/managed/session.mjs +3 -3
  72. package/packages/piagent-core/managed/tool-boundary.mjs +102 -59
  73. package/packages/piagent-core/managed/tool-worker.mjs +3 -1
  74. package/packages/piagent-core/managed/ts-diagnostics-server.mjs +102 -0
  75. package/packages/piagent-core/managed/ts-diagnostics.mjs +59 -0
  76. package/packages/piagent-core/managed/workflow.mjs +18 -6
  77. package/packages/piagent-core/package.json +1 -1
  78. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-GDGT_YLS.js → MarkdownMessage-DcFq8SRy.js} +1 -1
  79. package/packages/piagent-webui/dist/client/assets/index-B4PFrM4y.js +232 -0
  80. package/packages/piagent-webui/dist/client/index.html +1 -1
  81. package/packages/piagent-webui/gateway/managed-gateway.mjs +2 -1
  82. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +2 -2
  83. package/packages/piagent-webui/server/transcript-projection.ts +9 -3
  84. package/packages/piagent-webui/shared/process-copy.ts +10 -0
  85. package/schemas/piagent-webui/transcript-v1.schema.json +13 -1
  86. package/scripts/company-terminal.mjs +12 -0
  87. package/scripts/piagent-studio.mjs +20 -7
  88. package/packages/piagent-webui/dist/client/assets/index-BmXL8XrW.js +0 -231
@@ -30,7 +30,7 @@
30
30
  <span>Pi Agent Platform</span>
31
31
  </a>
32
32
  <div class="top-actions">
33
- <span class="pill"><span class="pill-dot"></span>v1.24.0 docs · VI</span>
33
+ <span class="pill"><span class="pill-dot"></span>v1.26.0 docs · VI</span>
34
34
  <nav class="language-switch" aria-label="Ngôn ngữ">
35
35
  <a class="language-option current" href="/workflows" hreflang="vi" lang="vi" aria-current="true">VI</a>
36
36
  <a class="language-option" href="/en/workflows" hreflang="en" lang="en">EN</a>
@@ -256,8 +256,8 @@
256
256
  },
257
257
  {
258
258
  "path": "tests",
259
- "fileCount": 539,
260
- "sha256": "3d6f3ab397c5b5e9d2709c5b22e56bbd1dd93537e2f52146b26239fe7a3cd2b7"
259
+ "fileCount": 547,
260
+ "sha256": "845f7c2abbea50453be655a8c88710ca49a8f62f2e58d0a02e29ab5c7319f92e"
261
261
  }
262
262
  ],
263
263
  "custodianComparisonRules": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/platform",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { redactedText } from '../runtime/managed-failure.mjs';
4
+
5
+ // Processes the agent keeps running across turns: a dev server, a watcher, a
6
+ // local service the tests need. A bash command stops after 10 minutes and what
7
+ // it leaves running stops when the turn ends; these run in the same sandbox,
8
+ // without network, until the agent stops them, the conversation closes or
9
+ // they reach their lifetime.
10
+ export const MAX_PROCESSES = 4;
11
+ export const LIFETIME_MS = 4 * 60 * 60 * 1000;
12
+ const BUFFER = 64 * 1024, MAX_WAIT_SECONDS = 60;
13
+
14
+ export class BackgroundProcesses {
15
+ #entries = new Map(); #next = 1;
16
+ constructor(boundary, { lifetime = LIFETIME_MS, poll = 200 } = {}) { Object.assign(this, { boundary, lifetime, poll }); }
17
+ running() { return [...this.#entries.values()].filter(entry => entry.status === 'running'); }
18
+ async start(command, name) {
19
+ if (this.running().length >= MAX_PROCESSES) throw new Error(`managed-process-limit: ${MAX_PROCESSES} background processes already run in this conversation (${this.running().map(e => e.id).join(', ')}). Stop one with stop_process first.`);
20
+ const key = randomUUID(), id = `p${this.#next++}`;
21
+ const { child, isolated } = await this.boundary.spawnBackground(command, key);
22
+ const entry = { id, key, name: String(name || command).slice(0, 80), command, child, status: 'running', code: null, text: '', dropped: 0, total: 0, read: 0, started: Date.now(), isolated };
23
+ const append = chunk => {
24
+ entry.text += chunk.toString('utf8'); entry.total += chunk.length;
25
+ if (entry.text.length > BUFFER) { entry.dropped += entry.text.length - BUFFER; entry.text = entry.text.slice(-BUFFER); }
26
+ };
27
+ child.stdout.on('data', append); child.stderr.on('data', append);
28
+ child.on('close', code => { if (entry.status === 'running') { entry.status = 'exited'; entry.code = code; } clearTimeout(entry.timer); });
29
+ child.on('error', () => { entry.status = 'exited'; entry.code = -1; });
30
+ entry.timer = setTimeout(() => this.stop(id, 'its 4-hour lifetime ended').catch(() => {}), this.lifetime); entry.timer.unref?.();
31
+ this.#entries.set(id, entry);
32
+ return entry;
33
+ }
34
+ get(id) {
35
+ const entry = this.#entries.get(String(id));
36
+ if (!entry) throw new Error(`managed-process-unknown: no background process ${id} in this conversation${this.#entries.size ? ` (known: ${[...this.#entries.keys()].join(', ')})` : ''}.`);
37
+ return entry;
38
+ }
39
+ // New output since the last read; waits up to `waitSeconds` for `until` to
40
+ // appear in it (a server's "ready" line) or for the process to end.
41
+ async output(id, { waitSeconds = 0, until, signal } = {}) {
42
+ const entry = this.get(id), deadline = Date.now() + Math.min(Math.max(Number(waitSeconds) || 0, 0), MAX_WAIT_SECONDS) * 1000;
43
+ const fresh = () => entry.text.slice(Math.max(0, entry.read - entry.dropped));
44
+ while (Date.now() < deadline && entry.status === 'running' && !signal?.aborted && !(until && fresh().includes(until)))
45
+ await new Promise(resolve => setTimeout(resolve, this.poll));
46
+ const skipped = Math.max(0, entry.dropped - entry.read), text = fresh();
47
+ entry.read = entry.dropped + entry.text.length;
48
+ return { entry, text: redactedText((skipped ? `[… ${skipped} characters not read …]\n` : '') + text), matched: until ? text.includes(until) : null };
49
+ }
50
+ async stop(id, reason = 'the agent stopped it') {
51
+ const entry = this.get(id);
52
+ if (entry.status !== 'running') return entry;
53
+ entry.status = 'stopped'; entry.reason = reason; clearTimeout(entry.timer);
54
+ // Its process group, what it started (found by parent, since ps cannot
55
+ // read the environment of Apple's own binaries), then its marker.
56
+ const tree = descendants(entry.child.pid);
57
+ for (const pid of [-entry.child.pid, ...tree]) try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
58
+ await this.boundary.stopBackground(entry.key);
59
+ for (const pid of tree) try { process.kill(pid, 'SIGKILL'); } catch { /* stopped */ }
60
+ return entry;
61
+ }
62
+ async dispose() { await Promise.all(this.running().map(entry => this.stop(entry.id, 'the conversation closed'))); }
63
+ }
64
+
65
+ function descendants(root) {
66
+ let rows = [];
67
+ try { rows = execFileSync('/bin/ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8', timeout: 5000 }).trim().split('\n').map(row => row.trim().split(/\s+/).map(Number)); } catch { return []; }
68
+ const out = [], queue = [root];
69
+ while (queue.length) { const parent = queue.shift(); for (const [pid, ppid] of rows) if (ppid === parent && !out.includes(pid)) { out.push(pid); queue.push(pid); } }
70
+ return out;
71
+ }
72
+
73
+ const describe = entry => `${entry.id} (${entry.name}): ${entry.status === 'running' ? `running for ${Math.round((Date.now() - entry.started) / 1000)} s`
74
+ : entry.status === 'exited' ? `exited with code ${entry.code}` : `stopped (${entry.reason})`}`;
75
+ const result = text => ({ content: [{ type: 'text', text }], details: {} });
76
+
77
+ export function processTools(self) {
78
+ if (process.platform !== 'darwin') return [];
79
+ // The boundary closes with the conversation and stops them then.
80
+ const manager = () => (self.background ??= self.boundary.background = new BackgroundProcesses(self.boundary));
81
+ return [
82
+ { name: 'start_process', label: 'Start background process',
83
+ description: 'Start a long-running command in the background and keep it running across turns: a dev server (npm run dev), a file watcher, a local service the tests call. It runs in the same sandbox as bash, without network, and keeps running until stop_process, the end of the conversation or 4 hours. Read its output with process_output (wait for a line such as "ready" there). Do not use it for tests or builds: run those with bash. At most 4 at once.',
84
+ parameters: { type: 'object', properties: { command: { type: 'string', minLength: 1, maxLength: 4000 }, name: { type: 'string', minLength: 1, maxLength: 80 } }, required: ['command'], additionalProperties: false },
85
+ execute: async (_id, args) => {
86
+ const entry = await manager().start(args.command, args.name);
87
+ return result(`Started ${entry.id} (${entry.name}). It keeps running across turns; read its output with process_output and stop it with stop_process.${entry.isolated ? ' A local proxy listens on this Mac, so other localhost servers are out of its reach.' : ''}`);
88
+ } },
89
+ { name: 'process_output', label: 'Background process output',
90
+ description: 'Read what a background process started with start_process printed since the last read, and whether it still runs. wait_seconds (up to 60) waits for more output; with until, it returns as soon as that text appears (a server\'s ready line).',
91
+ parameters: { type: 'object', properties: { id: { type: 'string', minLength: 1, maxLength: 20 }, wait_seconds: { type: 'number', minimum: 0, maximum: MAX_WAIT_SECONDS }, until: { type: 'string', minLength: 1, maxLength: 200 } }, required: ['id'], additionalProperties: false },
92
+ execute: async (_id, args, signal) => {
93
+ const { entry, text, matched } = await manager().output(args.id, { waitSeconds: args.wait_seconds, until: args.until, signal });
94
+ return result(`${describe(entry)}${matched === false ? `; "${args.until}" has not appeared yet` : ''}\n${text || '(no new output)'}`);
95
+ } },
96
+ { name: 'stop_process', label: 'Stop background process',
97
+ description: 'Stop a background process started with start_process, with everything it started.',
98
+ parameters: { type: 'object', properties: { id: { type: 'string', minLength: 1, maxLength: 20 } }, required: ['id'], additionalProperties: false },
99
+ execute: async (_id, args) => result(describe(await manager().stop(args.id))) },
100
+ ];
101
+ }
@@ -0,0 +1,69 @@
1
+ // Turn checkpoints and /undo for company conversations in a git repository.
2
+ // Before a run first changes files, the working tree (tracked and untracked,
3
+ // .gitignore honoured) is written as a git tree through a private index; when
4
+ // the run ends, the tree after it. /undo puts back the files the last turn
5
+ // changed, and refuses when one of them changed again since (the member's own
6
+ // edit is never overwritten). The member's index, HEAD and commits are not
7
+ // touched: a commit made in the turn stays. The private index is copied with
8
+ // its time kept, so git still re-reads a file changed within the same second
9
+ // at the same size. Commands run in the command sandbox.
10
+ const SNAPSHOT = `set -e; git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo NOREPO; exit 0; }
11
+ top=$(git rev-parse --show-toplevel); cd "$top"; idx="$TMPDIR/piagent-checkpoint-$$"
12
+ real=$(git rev-parse --git-path index); if [ -f "$real" ]; then cp -p "$real" "$idx"; fi
13
+ GIT_INDEX_FILE="$idx" git add -A >/dev/null 2>&1; tree=$(GIT_INDEX_FILE="$idx" git write-tree); rm -f "$idx"; echo "TREE $tree"`;
14
+ const b64 = list => Buffer.from(list.map(p => p + '\0').join(''), 'utf8').toString('base64');
15
+
16
+ export class TurnCheckpoints {
17
+ #stack = [];
18
+ constructor(managed) { this.managed = managed; }
19
+ async #bash(command) {
20
+ const result = await this.managed.boundary.invoke('bash', { command, timeout: 120 });
21
+ return (result.content ?? []).map(part => part.text ?? '').join('');
22
+ }
23
+ async #tree() {
24
+ const out = await this.#bash(SNAPSHOT);
25
+ return out.match(/^TREE ([0-9a-f]{40,64})$/m)?.[1] ?? null;
26
+ }
27
+ // Before the first change of a run; once per run.
28
+ async take(run = this.managed.run) {
29
+ if (!run || run.checkpoint !== undefined) return;
30
+ run.checkpoint = null;
31
+ try { const before = await this.#tree(); if (before) { run.checkpoint = { before, after: null, at: Date.now() }; } } catch { /* no checkpoint: /undo says so */ }
32
+ }
33
+ // When the run ends: the tree after it, kept when the run changed something.
34
+ async finish(run = this.managed.run) {
35
+ const point = run?.checkpoint;
36
+ if (!point) return;
37
+ try { point.after = await this.#tree(); } catch { return; }
38
+ if (point.after && point.after !== point.before) this.#stack.push(point);
39
+ }
40
+ async #changed(from, to) {
41
+ const out = await this.#bash(`top=$(git rev-parse --show-toplevel) && cd "$top" && git diff --no-renames --name-status -z ${from} ${to}`);
42
+ const parts = out.split('\0').filter(Boolean), files = [];
43
+ for (let i = 0; i + 1 < parts.length; i += 2) files.push({ status: parts[i].trim(), path: parts[i + 1] });
44
+ return files;
45
+ }
46
+ // Puts back what the last turn changed: { result, restored, removed, files }
47
+ // for the WebUI note (phase "undo") and `text`, the sentence for the member.
48
+ async undo() {
49
+ const answer = (undo, text) => ({ ...undo, text });
50
+ if (this.managed.activePrompt) return answer({ result: 'busy', restored: 0, removed: 0, files: [] }, 'Đang có một lượt chạy: chờ nó xong (hoặc dừng nó) rồi /undo.');
51
+ const point = this.#stack.at(-1);
52
+ if (!point) return answer({ result: 'none', restored: 0, removed: 0, files: [] }, 'Không có lượt nào để hoàn tác trong cuộc trò chuyện này (chỉ áp dụng cho project là git repository; checkpoint có từ khi mở cuộc trò chuyện).');
53
+ const now = await this.#tree();
54
+ if (!now) return answer({ result: 'failed', restored: 0, removed: 0, files: [] }, 'Không đọc được trạng thái hiện tại của project; không hoàn tác.');
55
+ const turn = await this.#changed(point.before, point.after), since = new Set((await this.#changed(point.after, now)).map(f => f.path));
56
+ const touched = turn.filter(f => since.has(f.path)).map(f => f.path);
57
+ if (touched.length) return answer({ result: 'refused', restored: 0, removed: 0, files: touched.slice(0, 5) },
58
+ `Không hoàn tác: ${touched.length} file lượt trước đã sửa lại thay đổi sau đó (${touched.slice(0, 5).join(', ')}${touched.length > 5 ? ', …' : ''}). Hoàn tác sẽ ghi đè thay đổi đó.`);
59
+ const restore = turn.filter(f => f.status !== 'A').map(f => f.path), remove = turn.filter(f => f.status === 'A').map(f => f.path);
60
+ const steps = ['set -e', 'top=$(git rev-parse --show-toplevel); cd "$top"; idx="$TMPDIR/piagent-undo-$$"'];
61
+ if (restore.length) steps.push(`GIT_INDEX_FILE="$idx" git read-tree ${point.before}`, `printf %s ${b64(restore)} | base64 --decode | GIT_INDEX_FILE="$idx" git checkout-index -f -z --stdin`, 'rm -f "$idx"');
62
+ if (remove.length) steps.push(`printf %s ${b64(remove)} | base64 --decode | xargs -0 rm -f --`);
63
+ await this.#bash(steps.join('\n'));
64
+ this.#stack.pop();
65
+ return answer({ result: 'done', restored: restore.length, removed: remove.length, files: [...restore, ...remove].slice(0, 5), more: this.#stack.length > 0 },
66
+ `Đã hoàn tác lượt trước: ${restore.length} file trả về như trước lượt đó, ${remove.length} file lượt đó tạo đã xoá.${this.#stack.length ? ' /undo lần nữa để hoàn tác lượt trước nữa.' : ''} Commit tạo trong lượt đó (nếu có) vẫn giữ nguyên.`);
67
+ }
68
+ }
69
+ export const checkpointsOf = managed => (managed.checkpoints ??= new TurnCheckpoints(managed));
@@ -1,17 +1,23 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import { networkConfirmation } from './permission.mjs';
3
+ import { engineAlive, engineLauncher } from './container-engine.mjs';
4
+ import { MEMBER_ONLY, memberOnlyReason, runHostCommand } from './host-command.mjs';
5
+ import { processTools } from './background-process.mjs';
6
+ import { checkpointsOf } from './checkpoints.mjs';
2
7
 
3
8
  // Commands that leave the plain sandbox, each after the member approves that
4
- // exact command: run_with_network (internet, a local server or browser) and,
5
- // when this machine has a Docker engine, run_with_docker (the same plus the
6
- // engine's socket). Bypass runs both without asking (owner, 2026-10-09),
7
- // except what must be confirmed for any command (bypass-policy.mjs: pushes,
8
- // deploys, registry logins, deletes known only at run time).
9
+ // exact command: run_with_network (internet, a local server or browser),
10
+ // run_with_docker when this machine has Docker (the same plus the engine's
11
+ // socket) and run_on_host (on the member's machine, outside the sandbox).
12
+ // Bypass runs the first two without asking (owner, 2026-10-09), except what
13
+ // must be confirmed for any command (bypass-policy.mjs: pushes, deploys,
14
+ // registry logins, deletes known only at run time); run_on_host always asks.
9
15
  const PARAMETERS = { type: 'object', properties: { command: { type: 'string', minLength: 1, maxLength: 4000 }, reason: { type: 'string', minLength: 1, maxLength: 300 }, timeout: { type: 'number', minimum: 1, maximum: 1800 } }, required: ['command', 'reason'], additionalProperties: false };
10
16
 
11
17
  const KINDS = {
12
18
  network: {
13
19
  name: 'run_with_network', label: 'Run with network', scope: 'network-command-once', provider: 'network', riskClass: 'medium',
14
- description: 'Run one shell command that needs the internet beyond the package registries bash already reaches (a download site or registry the package proxy refuses), or that starts a local server or a browser (end-to-end tests with Playwright\'s Chromium against a server on 127.0.0.1), after the user approves that exact command. Normal bash has no network and cannot listen on a port. Credentials (.npmrc, SSH keys, Keychain) stay unavailable, so private registries and git push are not possible here.',
20
+ description: 'Run one shell command that needs the internet beyond the package registries bash already reaches (a download site or registry the package proxy refuses), or that starts a local server or a browser (end-to-end tests with Playwright\'s Chromium against a server on 127.0.0.1), after the user approves that exact command. Normal bash has no internet; on macOS it reaches servers on this Mac and runs Playwright\'s Chromium against them, except while a local proxy listens. Credentials (.npmrc, SSH keys, Keychain) stay unavailable, so private registries and git push are not possible here.',
15
21
  allow: 'Run this exact command once, with internet access; a server it starts accepts connections while it runs.',
16
22
  title: 'Chạy lệnh có internet', allowText: 'chạy đúng lệnh này một lần, có internet; server nó mở nhận kết nối trong lúc chạy.',
17
23
  confirm: (self, command) => networkConfirmation(self, command),
@@ -23,34 +29,99 @@ const KINDS = {
23
29
  title: 'Chạy lệnh dùng Docker', allowText: 'chạy đúng lệnh này một lần, có internet và Docker; container nó tạo có thể đọc thư mục Docker được chia sẻ trên máy và có thể tiếp tục chạy.',
24
30
  confirm: (self, command) => networkConfirmation(self, command),
25
31
  },
32
+ host: {
33
+ name: 'run_on_host', label: 'Run on this machine', scope: 'host-command-once', provider: 'host', riskClass: 'high',
34
+ description: 'Run one shell command on the member\'s machine, outside the sandbox, as the member, after they approve that exact command. Use it when the sandbox blocks what the task needs and no other tool covers it: install a runtime or tool the project needs (brew install openjdk@21, brew install node@22, sdk install java…), git pull or git push with the member\'s sign-in, a private registry, a file outside the project. It has the member\'s files, Keychain and sign-ins, so ask only for what the task needs and say why in reason. No input can be typed: commands that need the member\'s password (sudo, a .pkg installer) or an interactive sign-in are refused; ask the member with ask_user for those.',
35
+ allow: 'Run this exact command once on this machine, outside the sandbox, as you: it can read and change your files and use your sign-ins.',
36
+ title: 'Chạy lệnh trên máy (ngoài sandbox)', allowText: 'chạy đúng lệnh này một lần trên máy bạn, ngoài sandbox, bằng quyền của bạn: lệnh đọc/sửa được file và dùng được tài khoản đã đăng nhập trên máy.',
37
+ // Bypass does not cover it: the member approves each command (with what
38
+ // Bypass would still have asked about, when there is something).
39
+ confirm: async (self, command) => await networkConfirmation(self, command) ?? 'ask',
40
+ },
26
41
  };
27
42
 
43
+ // The engine answers, looked up again when it did not (started since).
44
+ async function engineReady(boundary) {
45
+ if (boundary.engine && await engineAlive(boundary.engine.socket)) return true;
46
+ return boundary.refreshEnvironment() && engineAlive(boundary.engine.socket);
47
+ }
48
+ const DOCKER_STOPPED = 'managed-docker-unreachable: Docker engine trên máy không chạy. Gọi start_docker để xin member duyệt mở Docker, rồi chạy lại đúng lệnh này bằng run_with_docker.';
49
+
50
+ // One approval of the member (the dashboard card, else the Terminal).
51
+ async function approve(self, id, signal, ctx, action, { title, body }) {
52
+ const { piApprovalBroker } = await import('../runtime/inspection/approval-broker.ts');
53
+ const decision = await piApprovalBroker.request({ cwd: self.cwd, rawSessionId: self.session.sessionManager.getSessionId(), toolCallId: id,
54
+ action: { kind: 'external-provider-action', preconditionClass: 'runtime-only', targetPaths: [self.cwd], urlOrigin: null, ...action },
55
+ terminalConfirm: () => ctx?.ui?.confirm?.(title, body) ?? Promise.resolve(false),
56
+ unavailableFallback: 'terminal-confirm', recheck: () => !signal?.aborted && Boolean(self.grant) });
57
+ if (!decision.allowed || !decision.consume() || signal?.aborted) throw Error('managed-operation-denied');
58
+ }
59
+
28
60
  function commandTool(self, kind) {
29
61
  const spec = KINDS[kind];
30
62
  return { name: spec.name, label: spec.label, description: spec.description, parameters: PARAMETERS,
31
63
  execute: async (id, args, signal, onUpdate, ctx) => {
64
+ if (kind === 'docker' && !await engineReady(self.boundary)) throw Error(DOCKER_STOPPED);
65
+ const memberOnly = kind === 'host' ? memberOnlyReason(args.command) : null;
66
+ if (memberOnly) throw Error(MEMBER_ONLY(memberOnly));
32
67
  const confirm = await spec.confirm(self, args.command);
33
- const decision = confirm === null ? { allowed: true, consume: () => true } : await (await import('../runtime/inspection/approval-broker.ts')).piApprovalBroker.request({ cwd: self.cwd, rawSessionId: self.session.sessionManager.getSessionId(), toolCallId: id,
34
- action: { kind: 'external-provider-action', preconditionClass: 'runtime-only', toolName: spec.name, rawAction: { command: args.command }, commandPreview: String(args.command),
35
- targetPaths: [self.cwd], provider: spec.provider, urlOrigin: null, requestedScope: spec.scope, reason: (confirm === 'ask' ? String(args.reason) : `Bypass still asks: ${confirm}. ${args.reason}`).slice(0, 300), riskClass: spec.riskClass,
36
- allowConsequence: spec.allow, denyConsequence: 'The command does not run; the agent is told you declined.' },
37
- terminalConfirm: () => ctx?.ui?.confirm?.(spec.title, `${args.command}\n\nLý do: ${args.reason}${confirm === 'ask' ? '' : `\nBypass vẫn hỏi: ${confirm}`}\n\nĐồng ý: ${spec.allowText}\nTừ chối: lệnh không chạy, agent được báo bạn đã từ chối.`) ?? Promise.resolve(false),
38
- unavailableFallback: 'terminal-confirm', recheck: () => !signal?.aborted && Boolean(self.grant) });
39
- if (!decision.allowed || !decision.consume() || signal?.aborted) throw Error('managed-operation-denied');
68
+ if (confirm !== null) await approve(self, id, signal, ctx, { toolName: spec.name, rawAction: { command: args.command }, commandPreview: String(args.command),
69
+ provider: spec.provider, requestedScope: spec.scope, reason: (confirm === 'ask' ? String(args.reason) : `Bypass still asks: ${confirm}. ${args.reason}`).slice(0, 300), riskClass: spec.riskClass,
70
+ allowConsequence: spec.allow, denyConsequence: 'The command does not run; the agent is told you declined.' },
71
+ { title: spec.title, body: `${args.command}\n\nLý do: ${args.reason}${confirm === 'ask' ? '' : `\nBypass vẫn hỏi: ${confirm}`}\n\nĐồng ý: ${spec.allowText}\nTừ chối: lệnh không chạy, agent được báo bạn đã từ chối.` });
40
72
  let ok = false;
73
+ if (self.run) await checkpointsOf(self).take();
41
74
  const before = await self.changedFiles();
42
- try { const result = await self.boundary.invoke('bash', { command: args.command, ...(args.timeout ? { timeout: args.timeout } : {}) }, signal, onUpdate, ctx?.model, kind === 'docker' ? { docker: true } : { network: true }); ok = !result?.isError; return result; }
43
- finally { await self.claimChanges(before); await self.run?.afterTool('bash', args, ok, () => self.digest()); await self.refreshReview(); }
75
+ try {
76
+ const result = kind === 'host' ? await runHostCommand({ command: args.command, cwd: self.cwd, userHome: self.boundary.userHome, timeout: args.timeout, signal, onUpdate })
77
+ : await self.boundary.invoke('bash', { command: args.command, ...(args.timeout ? { timeout: args.timeout } : {}) }, signal, onUpdate, ctx?.model, kind === 'docker' ? { docker: true } : { network: true });
78
+ ok = !result?.isError; return result;
79
+ } finally {
80
+ // What the command installed (a JDK, Node) is there for the next one.
81
+ if (kind === 'host') self.boundary.refreshEnvironment();
82
+ await self.claimChanges(before); await self.run?.afterTool('bash', args, ok, () => self.digest()); await self.refreshReview();
83
+ }
84
+ } };
85
+ }
86
+
87
+ // start_docker: the engine the member installed, started after they approve
88
+ // (Bypass starts it without asking: local and reversible), then waited for.
89
+ const textResult = text => ({ content: [{ type: 'text', text }], details: {} });
90
+ function startDockerTool(self, { wait = 180_000, every = 2000, launcher: found = engineLauncher, spawnImpl = spawn } = {}) {
91
+ return { name: 'start_docker', label: 'Start Docker',
92
+ description: 'Start the Docker engine installed on this machine (Docker Desktop, OrbStack, Colima, rootless Docker) after the member approves, and wait until it answers; run_with_docker works right after, in this conversation. Use it when a command reports that Docker is not running.',
93
+ parameters: { type: 'object', properties: { reason: { type: 'string', minLength: 1, maxLength: 300 } }, required: ['reason'], additionalProperties: false },
94
+ execute: async (id, args, signal, _onUpdate, ctx) => {
95
+ if (await engineReady(self.boundary)) return textResult('The Docker engine is running: use run_with_docker.');
96
+ const launcher = found({ userHome: self.boundary.userHome });
97
+ if (!launcher) throw Error(self.boundary.dockerCli ? MEMBER_ONLY("starting this machine's Docker service needs the member's password")
98
+ : 'managed-docker-not-installed: Máy chưa cài Docker. Hỏi member bằng ask_user: cài Docker Desktop hoặc OrbStack (trình cài cần mật khẩu nên member tự cài; máy có Homebrew thì có thể xin run_on_host "brew install colima docker"), hay chạy check theo cách khác.');
99
+ const preview = `${launcher.command} ${launcher.args.join(' ')}`;
100
+ if (self.permission !== 'trusted-full-access') await approve(self, id, signal, ctx, { toolName: 'start_docker', rawAction: { launcher: launcher.label }, commandPreview: preview,
101
+ provider: 'docker', requestedScope: 'start-docker-once', reason: String(args.reason).slice(0, 300), riskClass: 'medium',
102
+ allowConsequence: `Start ${launcher.label} on this machine; it keeps running after the conversation.`, denyConsequence: 'Docker is not started; the agent is told you declined.' },
103
+ { title: `Mở ${launcher.label}`, body: `${preview}\n\nLý do: ${args.reason}\n\nĐồng ý: mở ${launcher.label} trên máy; nó vẫn chạy sau cuộc trò chuyện.\nTừ chối: không mở Docker, agent được báo bạn đã từ chối.` });
104
+ try { spawnImpl(launcher.command, launcher.args, { detached: true, stdio: 'ignore', env: { HOME: self.boundary.userHome, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', USER: process.env.USER ?? '', LANG: 'en_US.UTF-8' } }).on('error', () => {}).unref(); } catch { /* waited for below */ }
105
+ for (const until = Date.now() + wait; Date.now() < until;) {
106
+ if (signal?.aborted) throw Error('managed-tool-cancelled');
107
+ await new Promise(resolve => setTimeout(resolve, every));
108
+ if (await engineReady(self.boundary)) return textResult(`${launcher.label} is running. Run the Docker command again with run_with_docker.`);
109
+ }
110
+ throw Error(`managed-docker-start-timeout: ${launcher.label} đã được mở nhưng engine chưa trả lời sau ${Math.round(wait / 60000)} phút (lần đầu mở app có thể chờ member bấm đồng ý điều khoản hoặc đăng nhập trong cửa sổ Docker). Hỏi member bằng ask_user, kèm lựa chọn "Docker đã chạy, thử lại" và "Bỏ qua bước này".`);
44
111
  } };
45
112
  }
46
113
 
47
- export function commandTools(self) {
48
- return [commandTool(self, 'network'), ...(self.boundary.engine ? [commandTool(self, 'docker')] : [])];
114
+ export function commandTools(self, options) {
115
+ const docker = self.boundary.engine || self.boundary.dockerCli;
116
+ return [commandTool(self, 'network'), ...(docker ? [commandTool(self, 'docker')] : []), startDockerTool(self, options), commandTool(self, 'host'), ...processTools(self)];
49
117
  }
50
118
 
51
- // What the system prompt says about services: Docker when the machine has an
52
- // engine (run_with_docker), else the member starts them.
119
+ // What the system prompt says about services and what the sandbox blocks: it
120
+ // is read for every request, so a Docker started since shows up.
121
+ const HOST_PROMPT = ' When the sandbox blocks something the task needs (a missing runtime such as a JDK or a Node version, the member\'s sign-in for git pull or git push or a private registry, a file outside the project), do not stop and do not tell the member to run it: request run_on_host with the exact command; it runs on the member\'s machine once they approve. Only what needs the member themselves (their password for sudo or an installer, an interactive sign-in, a window to click) goes to ask_user, with the exact steps and the options to continue or skip; after their answer, check and carry on.';
53
122
  export function servicesPrompt(boundary) {
54
- if (boundary.engine) return ' Docker is available through run_with_docker (the member approves each command): a database or service for tests (docker compose up -d, then the tests through bash or run_with_network), docker build, or Testcontainers tests. Stop what you started when done unless the member wants it kept. The iOS Simulator is not available in the sandbox: ask the member to run those tests.';
55
- return ' Docker is not running on this machine and the iOS Simulator is not available in the sandbox: ask the member to start those services (or Docker, after which a new conversation has run_with_docker) or run those tests.';
123
+ const simulator = ' The iOS Simulator is not available in the sandbox: ask the member with ask_user to run those tests.';
124
+ if (boundary.engine) return ' Docker is available through run_with_docker (the member approves each command): a database or service for tests (docker compose up -d, then the tests through bash or run_with_network), docker build, or Testcontainers tests. If the engine stops answering, start_docker asks the member to start it. Stop what you started when done unless the member wants it kept.' + simulator + HOST_PROMPT;
125
+ if (boundary.dockerCli) return ' Docker is installed but not running: when the task needs it, call start_docker (the member approves), then run_with_docker.' + simulator + HOST_PROMPT;
126
+ return ' Docker is not installed on this machine: when the task needs it, ask the member with ask_user (install it, or check another way).' + simulator + HOST_PROMPT;
56
127
  }
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import net from 'node:net';
2
3
  import path from 'node:path';
3
4
 
4
5
  // The Docker engine a company command may use once the member approves it
@@ -66,3 +67,42 @@ export function engineEnvironment(engine, home) {
66
67
  ...(process.platform === 'darwin' ? { TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: '/var/run/docker.sock' } : {}),
67
68
  };
68
69
  }
70
+
71
+ // The Docker CLI on this machine, engine running or not.
72
+ export const dockerCli = (paths = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/Applications/Docker.app/Contents/Resources/bin']) =>
73
+ paths.map(dir => real(path.join(dir, 'docker'))).find(Boolean) ?? null;
74
+
75
+ // Whether the engine behind a socket answers (Docker Desktop leaves its
76
+ // socket file in place when it quits).
77
+ export function engineAlive(socket, timeoutMs = 2000) {
78
+ return new Promise(resolve => {
79
+ let reply = '';
80
+ const done = value => { clearTimeout(timer); connection.destroy(); resolve(value); };
81
+ const connection = net.createConnection(socket, () => connection.write('GET /_ping HTTP/1.0\r\nHost: docker\r\n\r\n'));
82
+ const timer = setTimeout(() => done(false), timeoutMs);
83
+ connection.on('data', chunk => { reply += chunk; if (/\r\n\r\nOK/.test(reply)) done(true); });
84
+ connection.on('error', () => done(false)); connection.on('end', () => done(/\r\n\r\nOK/.test(reply)));
85
+ });
86
+ }
87
+
88
+ // How start_docker starts an engine the member installed: the app (Docker
89
+ // Desktop, OrbStack, Rancher Desktop) or Colima on macOS, the user service on
90
+ // Linux. Null when none is installed, or only the system service is (that
91
+ // needs the member's password).
92
+ export function engineLauncher({ userHome, platform = process.platform, exists = fs.existsSync } = {}) {
93
+ if (platform === 'darwin') {
94
+ for (const app of ['Docker', 'OrbStack', 'Rancher Desktop']) {
95
+ const bundle = [`/Applications/${app}.app`, path.join(userHome, `Applications/${app}.app`)].find(exists);
96
+ if (bundle) return { label: app === 'Docker' ? 'Docker Desktop' : app, command: '/usr/bin/open', args: ['-g', '-a', bundle] };
97
+ }
98
+ const colima = ['/opt/homebrew/bin/colima', '/usr/local/bin/colima'].find(exists);
99
+ return colima ? { label: 'Colima', command: colima, args: ['start'] } : null;
100
+ }
101
+ // WSL2: Docker Desktop is a Windows app that serves this distribution.
102
+ const desktop = '/mnt/c/Program Files/Docker/Docker/Docker Desktop.exe';
103
+ if (exists('/proc/sys/fs/binfmt_misc/WSLInterop') && exists(desktop))
104
+ return { label: 'Docker Desktop', command: '/mnt/c/Windows/System32/cmd.exe', args: ['/c', 'start', '""', 'C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe'] };
105
+ const units = [path.join(userHome, '.config/systemd/user/docker.service'), '/usr/lib/systemd/user/docker.service', '/usr/lib/systemd/user/podman.socket'];
106
+ const unit = units.find(exists);
107
+ return unit ? { label: unit.endsWith('podman.socket') ? 'Podman' : 'Docker (rootless)', command: '/usr/bin/systemctl', args: ['--user', 'start', path.basename(unit)] } : null;
108
+ }
@@ -1,3 +1,4 @@
1
+ import { checkpointsOf } from './checkpoints.mjs';
1
2
  import { unfinished, planText, currentPlan, workflowPolicy } from './workflow.mjs';
2
3
  import { failureCode } from '../runtime/managed-failure.mjs';
3
4
 
@@ -77,6 +78,13 @@ const fresh = (self, manager, text) => {
77
78
  };
78
79
 
79
80
  export async function memberTurn(self, { manager, prompt, runRound }, text, deliver) {
81
+ // /undo from the WebUI (the Terminal has its own command): the last turn's
82
+ // files go back, said in a note, with no model request (checkpoints.mjs).
83
+ if (/^\s*\/undo\s*$/.test(text)) {
84
+ const { text: content, ...undo } = await checkpointsOf(self).undo();
85
+ await self.session.sendCustomMessage({ customType: 'agent-watch-process', content, display: true, details: { phase: 'undo', undo } }, { triggerTurn: false });
86
+ return;
87
+ }
80
88
  let continuation = fresh(self, manager, text), result, waiting = null, ended = null;
81
89
  self.stopRequested = false; self.queued = [];
82
90
  try {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
- import { ManagedToolBoundary } from './tool-boundary.mjs';
5
+ import { ManagedToolBoundary, PI_HOST_VERSION, hostPackage } from './tool-boundary.mjs';
6
6
  import { ensureSearchTools } from './toolchain.mjs';
7
7
  import { nativeManagedModel, piProvider } from './native-catalog.mjs';
8
8
  import { fetchPublicPage } from './web-fetch.mjs';
@@ -28,9 +28,9 @@ export async function runDoctor({ sdkRoot, origin, broker, network = true }) {
28
28
  catch (error) { results.push({ label, required, ok: false, detail: first(error?.message ?? error) }); }
29
29
  };
30
30
  const sdk = fs.realpathSync(sdkRoot);
31
- await check('Pi host 0.87.1', true, () => {
31
+ await check(`Pi host ${PI_HOST_VERSION}`, true, () => {
32
32
  const version = JSON.parse(fs.readFileSync(path.join(sdk, 'package.json'), 'utf8')).version;
33
- if (version !== '0.87.1') throw Error(`found ${version}; company sessions need 0.87.1`);
33
+ if (version !== PI_HOST_VERSION) throw Error(`found ${version}; company sessions need ${PI_HOST_VERSION}`);
34
34
  return sdk;
35
35
  });
36
36
  await check('search tools (ripgrep, fd)', true, async () => { await ensureSearchTools(sdk); return 'ready'; });
@@ -115,7 +115,7 @@ export async function runDoctor({ sdkRoot, origin, broker, network = true }) {
115
115
  return `key "${manifest.key_label ?? manifest.key_id}"`;
116
116
  });
117
117
  if (manifest) await check('this Piagent knows every harness model', true, async () => {
118
- const api = await import(pathToFileURL(path.join(sdk, 'dist/index.js'))), ai = await import(pathToFileURL(path.join(sdk, 'node_modules/@earendil-works/pi-ai/dist/index.js')));
118
+ const api = await import(pathToFileURL(path.join(sdk, 'dist/index.js'))), ai = await import(pathToFileURL(path.join(hostPackage(sdk, '@earendil-works/pi-ai'), 'dist/index.js')));
119
119
  const runtime = await api.ModelRuntime.create({ credentials: new ai.InMemoryCredentialStore(), modelsPath: null, allowModelNetwork: false, refreshOnCreate: false });
120
120
  const roles = Object.entries(manifest.harness.configuration ?? {}).filter(([, role]) => role?.model_ids?.length);
121
121
  for (const [role, value] of roles) for (const id of value.model_ids) {
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import { spawn } from 'node:child_process';
4
+ import { redactedText } from '../runtime/managed-failure.mjs';
5
+
6
+ // Commands the member approved to run on their machine, outside the sandbox
7
+ // (run_on_host, start_docker): installing a runtime the project needs, git
8
+ // with the member's own sign-in, a file outside the project (owner,
9
+ // 2026-10-13: once the member approves, it runs; only what needs the member
10
+ // themselves goes back to them).
11
+
12
+ // What only the member can do: type their password, sign in interactively.
13
+ const PASSWORD = new Set(['sudo', 'doas', 'su', 'pkexec', 'passwd']);
14
+ const SIGN_IN = [/^gh auth (login|refresh)\b/, /^(npm|pnpm) (login|adduser)\b/, /^yarn npm login\b/, /^docker login\b(?!.*--password-stdin)/, /^az login\b/,
15
+ /^gcloud auth (login|application-default login)\b/, /^aws (configure\s*$|sso login\b)/, /^(firebase|vercel|wrangler|heroku|netlify) login\b/];
16
+ const WRAPPERS = new Set(['env', 'command', 'exec', 'time', 'nohup']);
17
+ export function memberOnlyReason(command) {
18
+ for (const part of String(command).split(/&&|\|\||;|\||\n/)) {
19
+ const words = part.trim().split(/\s+/).filter(Boolean);
20
+ while (words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) || WRAPPERS.has(words[0]))) words.shift();
21
+ const name = words[0]?.split('/').pop(), rest = words.join(' ');
22
+ if (PASSWORD.has(name)) return `${name} needs the member's password`;
23
+ if (name === 'installer' || name === 'softwareupdate') return `${name} needs the member's password`;
24
+ if (SIGN_IN.some(rule => rule.test(rest))) return 'an interactive sign-in needs the member';
25
+ }
26
+ return null;
27
+ }
28
+ // What a command printed when it stopped to ask for a password or a terminal.
29
+ export const NEEDS_MEMBER = /sudo: a (terminal|password) is required|no tty present|askpass|Password:\s*$|must be run from a terminal|Inappropriate ioctl for device/im;
30
+
31
+ export const MEMBER_ONLY = reason => `managed-host-member-only: ${reason}. Lệnh này member phải tự làm (nhập mật khẩu máy hoặc đăng nhập tương tác). Dùng ask_user: đưa đúng lệnh hoặc các bước, nói vì sao cần, kèm lựa chọn "Tôi đã làm xong" và "Bỏ qua bước này"; member trả lời xong thì kiểm tra lại và làm tiếp.`;
32
+
33
+ const TAIL = 50 * 1024;
34
+ const tail = text => text.length > TAIL ? `[… ${text.length - TAIL} characters before …]\n${text.slice(-TAIL)}` : text;
35
+ const shell = () => process.platform === 'darwin' ? '/bin/zsh' : fs.existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh';
36
+
37
+ // Runs one command as the member, in a login shell (Homebrew, SDKMAN… on
38
+ // PATH as in their Terminal). Nothing of Piagent's own environment (Studio
39
+ // tokens) is passed; no input, so a prompt ends the command instead of hanging.
40
+ export function runHostCommand({ command, cwd, userHome = os.homedir(), timeout = 600, signal, onUpdate, spawnImpl = spawn }) {
41
+ return new Promise((resolve, reject) => {
42
+ if (signal?.aborted) return reject(Error('Command aborted'));
43
+ const login = shell(), user = os.userInfo().username;
44
+ const child = spawnImpl(login, ['-lc', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
45
+ env: { HOME: userHome, USER: user, LOGNAME: user, SHELL: login, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8', TERM: 'dumb',
46
+ GIT_TERMINAL_PROMPT: '0', NONINTERACTIVE: '1', HOMEBREW_NO_ENV_HINTS: '1', DEBIAN_FRONTEND: 'noninteractive' } });
47
+ let output = '', ended = null, last = 0;
48
+ const kill = why => { ended ??= why; try { process.kill(-child.pid, 'SIGTERM'); } catch {} setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 2000).unref(); };
49
+ const seconds = Math.min(Math.max(Number(timeout) || 600, 1), 1800);
50
+ const timer = setTimeout(() => kill(`Command timed out after ${seconds} seconds`), seconds * 1000); timer.unref();
51
+ const abort = () => kill('Command aborted'); signal?.addEventListener('abort', abort, { once: true });
52
+ const take = chunk => {
53
+ output += chunk; if (output.length > 4 * TAIL) output = output.slice(-2 * TAIL);
54
+ if (onUpdate && Date.now() - last > 250) { last = Date.now(); onUpdate({ content: [{ type: 'text', text: redactedText(tail(output)) }], details: { host: true } }); }
55
+ };
56
+ child.stdout.on('data', take); child.stderr.on('data', take);
57
+ child.on('error', () => { ended ??= 'managed-host-command-failed: the shell did not start'; });
58
+ child.on('close', code => {
59
+ clearTimeout(timer); signal?.removeEventListener('abort', abort);
60
+ const text = redactedText(tail(output.trimEnd()));
61
+ if (ended) return reject(Error(`${text}${text ? '\n\n' : ''}${ended}`));
62
+ if (code === 0) return resolve({ content: [{ type: 'text', text: text || '(no output)' }], details: { host: true, exitCode: 0 } });
63
+ const member = NEEDS_MEMBER.test(output) ? `\n\n${MEMBER_ONLY('the command stopped to ask for a password or a terminal')}` : '';
64
+ reject(Error(`${text}${member}\n\nCommand exited with code ${code}`));
65
+ });
66
+ });
67
+ }
@@ -43,8 +43,14 @@ function javaHome(userHome) {
43
43
  }
44
44
  try {
45
45
  const home = execFileSync('/usr/libexec/java_home', [], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: { HOME: userHome, PATH: '/usr/bin:/bin' } }).trim();
46
- return existing(home);
47
- } catch { return null; }
46
+ if (existing(home)) return existing(home);
47
+ } catch { /* none registered with macOS */ }
48
+ // A JDK macOS does not list: Homebrew's openjdk formulas (keg-only, what
49
+ // run_on_host installs without a password), SDKMAN's current Java.
50
+ const kegs = ['/opt/homebrew/opt', '/usr/local/opt'].flatMap(dir => {
51
+ try { return fs.readdirSync(dir).filter(name => /^openjdk(@\d+)?$/.test(name)).map(name => path.join(dir, name, 'libexec/openjdk.jdk/Contents/Home')); } catch { return []; }
52
+ }).sort((a, b) => (/openjdk\//.test(b) - /openjdk\//.test(a)) || Number(/@(\d+)/.exec(b)?.[1] ?? 0) - Number(/@(\d+)/.exec(a)?.[1] ?? 0));
53
+ return [...kegs, path.join(userHome, '.sdkman/candidates/java/current')].map(existing).find(Boolean) ?? null;
48
54
  }
49
55
 
50
56
  // SwiftPM and xcodebuild start sandbox-exec for package manifests and
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // The Pi host company sessions are qualified on: the package's peer
5
+ // dependency, which piagent-setup installs and CI tests. One place, so moving
6
+ // to a new host is one change (scripts/verify-release-identity.mjs keeps its
7
+ // own copy on purpose, to catch a pin moved by accident).
8
+ export const PI_HOST_VERSION = JSON.parse(fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')).peerDependencies['@earendil-works/pi-coding-agent'];
9
+
10
+ // A package the host depends on (@earendil-works/pi-ai), found the way Node
11
+ // finds it from the host: in its own node_modules (0.87.1 shipped a
12
+ // shrinkwrap that nested it there), else in a parent's (1.x hoists it).
13
+ export function hostPackage(sdk, name) {
14
+ for (let dir = sdk; ; dir = path.dirname(dir)) {
15
+ const candidate = path.join(dir, 'node_modules', ...name.split('/'));
16
+ if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
17
+ if (path.dirname(dir) === dir) throw new Error(`managed-sdk-dependency-missing: ${name}`);
18
+ }
19
+ }