@piagent/platform 1.25.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 (84) hide show
  1. package/CHANGELOG.md +27 -2
  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 +1 -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 +6 -0
  19. package/docs-site/content/index.html +4 -4
  20. package/docs-site/content/whats-new.html +6 -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 +7 -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 +7 -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 +5 -2
  63. package/packages/piagent-core/managed/continuation.mjs +8 -0
  64. package/packages/piagent-core/managed/doctor.mjs +4 -4
  65. package/packages/piagent-core/managed/pi-host.mjs +19 -0
  66. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +4 -2
  67. package/packages/piagent-core/managed/session.mjs +3 -3
  68. package/packages/piagent-core/managed/tool-boundary.mjs +45 -18
  69. package/packages/piagent-core/managed/tool-worker.mjs +3 -1
  70. package/packages/piagent-core/managed/ts-diagnostics-server.mjs +102 -0
  71. package/packages/piagent-core/managed/ts-diagnostics.mjs +59 -0
  72. package/packages/piagent-core/managed/workflow.mjs +9 -1
  73. package/packages/piagent-core/package.json +1 -1
  74. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-B8CsDh3B.js → MarkdownMessage-DcFq8SRy.js} +1 -1
  75. package/packages/piagent-webui/dist/client/assets/index-B4PFrM4y.js +232 -0
  76. package/packages/piagent-webui/dist/client/index.html +1 -1
  77. package/packages/piagent-webui/gateway/managed-gateway.mjs +2 -1
  78. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +2 -2
  79. package/packages/piagent-webui/server/transcript-projection.ts +9 -3
  80. package/packages/piagent-webui/shared/process-copy.ts +10 -0
  81. package/schemas/piagent-webui/transcript-v1.schema.json +13 -1
  82. package/scripts/company-terminal.mjs +12 -0
  83. package/scripts/piagent-studio.mjs +20 -7
  84. package/packages/piagent-webui/dist/client/assets/index-CiLLJu4R.js +0 -231
@@ -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));
@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { networkConfirmation } from './permission.mjs';
3
3
  import { engineAlive, engineLauncher } from './container-engine.mjs';
4
4
  import { MEMBER_ONLY, memberOnlyReason, runHostCommand } from './host-command.mjs';
5
+ import { processTools } from './background-process.mjs';
6
+ import { checkpointsOf } from './checkpoints.mjs';
5
7
 
6
8
  // Commands that leave the plain sandbox, each after the member approves that
7
9
  // exact command: run_with_network (internet, a local server or browser),
@@ -15,7 +17,7 @@ const PARAMETERS = { type: 'object', properties: { command: { type: 'string', mi
15
17
  const KINDS = {
16
18
  network: {
17
19
  name: 'run_with_network', label: 'Run with network', scope: 'network-command-once', provider: 'network', riskClass: 'medium',
18
- 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.',
19
21
  allow: 'Run this exact command once, with internet access; a server it starts accepts connections while it runs.',
20
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.',
21
23
  confirm: (self, command) => networkConfirmation(self, command),
@@ -68,6 +70,7 @@ function commandTool(self, kind) {
68
70
  allowConsequence: spec.allow, denyConsequence: 'The command does not run; the agent is told you declined.' },
69
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.` });
70
72
  let ok = false;
73
+ if (self.run) await checkpointsOf(self).take();
71
74
  const before = await self.changedFiles();
72
75
  try {
73
76
  const result = kind === 'host' ? await runHostCommand({ command: args.command, cwd: self.cwd, userHome: self.boundary.userHome, timeout: args.timeout, signal, onUpdate })
@@ -110,7 +113,7 @@ function startDockerTool(self, { wait = 180_000, every = 2000, launcher: found =
110
113
 
111
114
  export function commandTools(self, options) {
112
115
  const docker = self.boundary.engine || self.boundary.dockerCli;
113
- return [commandTool(self, 'network'), ...(docker ? [commandTool(self, 'docker')] : []), startDockerTool(self, options), commandTool(self, 'host')];
116
+ return [commandTool(self, 'network'), ...(docker ? [commandTool(self, 'docker')] : []), startDockerTool(self, options), commandTool(self, 'host'), ...processTools(self)];
114
117
  }
115
118
 
116
119
  // What the system prompt says about services and what the sandbox blocks: it
@@ -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,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
+ }
@@ -11,6 +11,8 @@ const OFFLINE = /ENOTFOUND|EAI_AGAIN|Could not resolve host|getaddrinfo|npm erro
11
11
  // name that does not resolve is the machine's network, not the sandbox.
12
12
  // `isolated`: a proxy listens on this Mac, so without approval a command does
13
13
  // not reach this Mac's own servers either (tool-boundary.mjs).
14
+ // Node's fetch reports that refusal as a bare "fetch failed" when a test
15
+ // runner prints the error without its cause (node --test on Node 22).
14
16
  // `docker`: the command ran with the Docker engine (run_with_docker);
15
17
  // `engine`: one is there for run_with_docker, but this command ran without it.
16
18
  const DOCKER_DAEMON = /Cannot connect to the Docker daemon|connect to the docker API|docker\.sock|Is the docker daemon running|Could not find a valid Docker environment/i;
@@ -18,7 +20,7 @@ const DOCKER_DAEMON = /Cannot connect to the Docker daemon|connect to the docker
18
20
  // version the project asks for).
19
21
  const TOOLCHAIN = /Unable to locate a Java Runtime|No Java runtime present|JAVA_HOME is not (?:set|defined correctly)|(?:^|\n)\S*?(?:ba|z)?sh: (?:line \d+: )?[\w.+-]+: command not found|zsh: command not found: [\w.+-]+|xcrun: error: (?:unable to find utility|invalid active developer path)|env: '?[\w.+-]+'?: No such file or directory|Unsupported class file major version|invalid target release: \d+|release version \d+ not supported|requires? (?:at least )?Java \d+|The engine "node" is incompatible|Unsupported engine/i;
20
22
  // The member's sign-in, which the sandbox does not have.
21
- const SIGN_IN = /could not read (?:Username|Password) for|terminal prompts disabled|Authentication failed for|Permission denied \(publickey|Host key verification failed|\bENEEDAUTH\b|\bE401\b|\b401 Unauthorized|requires authentication|denied: requested access to the resource is denied|pull access denied/i;
23
+ const SIGN_IN = /could not read (?:Username|Password) for|terminal prompts disabled|Authentication failed for|Permission denied \(publickey|Host key verification failed|not logged into any GitHub hosts|please run:\s+gh auth login|Could not open a connection to your authentication agent|\bENEEDAUTH\b|\bE401\b|\b401 Unauthorized|requires authentication|denied: requested access to the resource is denied|pull access denied/i;
22
24
 
23
25
  export function sandboxDiagnostic(message, { network = false, isolated = false, docker = false, engine = false } = {}) {
24
26
  const text=String(message);
@@ -26,7 +28,7 @@ export function sandboxDiagnostic(message, { network = false, isolated = false,
26
28
  if(!docker&&DOCKER_DAEMON.test(text))return (engine?'managed-docker-blocked: Lệnh bash thường không dùng được Docker trong chế độ công ty. Lệnh docker, docker compose, hoặc test cần Docker (Testcontainers) chạy qua run_with_docker để người dùng duyệt đúng lệnh đó.\n':'managed-docker-unavailable: Docker engine trên máy chưa chạy. Gọi start_docker để xin member duyệt mở Docker, rồi chạy lệnh bằng run_with_docker.\n')+text;
27
29
  if(TOOLCHAIN.test(text))return 'managed-toolchain-missing: Máy thiếu runtime/công cụ mà lệnh cần, hoặc sai version project yêu cầu. Đừng dừng ở đây: xem project cần gì (pom.xml, build.gradle, .nvmrc, .tool-versions, package.json engines…) rồi dùng run_on_host để xin member duyệt cài trên máy (ví dụ brew install openjdk@21, brew install node@22, sdk install java 21-tem), sau đó chạy lại lệnh. Nếu project build được trong Docker thì có thể đề xuất run_with_docker. Trình cài cần mật khẩu (sudo, file .pkg) thì hỏi member bằng ask_user.\n'+text;
28
30
  if(SIGN_IN.test(text))return 'managed-credentials-unavailable: Lệnh cần tài khoản đăng nhập của member (git qua HTTPS/SSH, registry riêng, image riêng), sandbox không có. git fetch dùng fetch_origin; git pull, git push, cài từ registry riêng hoặc pull image riêng thì dùng run_on_host để member duyệt chạy đúng lệnh đó trên máy bằng tài khoản của họ.\n'+text;
29
- if(isolated&&!/CONNECT tunnel failed|tunneling socket|statusCode=403|response 403/i.test(text)&&/ECONNREFUSED|EPERM|connect|Connection refused|could not connect/i.test(text))return 'managed-loopback-blocked: Máy đang chạy một proxy cục bộ, nên lệnh không có mạng cũng không được kết nối tới server trên localhost (proxy có thể đưa lệnh ra internet). Test cần server hoặc database trên localhost: dùng run_with_network để người dùng duyệt đúng lệnh đó.\n'+text;
31
+ if(isolated&&!/CONNECT tunnel failed|tunneling socket|statusCode=403|response 403/i.test(text)&&/ECONNREFUSED|EPERM|connect|Connection refused|could not connect|fetch failed/i.test(text))return 'managed-loopback-blocked: Máy đang chạy một proxy cục bộ, nên lệnh không có mạng cũng không được kết nối tới server trên localhost (proxy có thể đưa lệnh ra internet). Test cần server hoặc database trên localhost: dùng run_with_network để người dùng duyệt đúng lệnh đó.\n'+text;
30
32
  if(network&&!DENIAL.test(text)&&OFFLINE.test(text))return 'managed-network-unreachable: Lệnh đã được duyệt và chạy có mạng nhưng không tới được host (máy offline, cần VPN/proxy công ty, hoặc sai tên host). Hỏi member bằng ask_user, kèm lựa chọn "Đã kết nối mạng/VPN, chạy lại" và "Bỏ qua bước này"; member trả lời xong thì làm theo.\n'+text;
31
33
  if(!DENIAL.test(text)&&OFFLINE.test(text))return 'managed-network-blocked: Lệnh bash thường không có mạng trong chế độ công ty. Nếu lệnh thật sự cần mạng (cài package, tải dữ liệu), dùng run_with_network để người dùng duyệt đúng lệnh đó.\n'+text;
32
34
  if(!DENIAL.test(text))return text;
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { randomUUID, createHash } from 'node:crypto';
5
5
  import { pathToFileURL, fileURLToPath } from 'node:url';
6
- import { ManagedToolBoundary } from './tool-boundary.mjs';
6
+ import { ManagedToolBoundary, PI_HOST_VERSION, hostPackage } from './tool-boundary.mjs';
7
7
  import { ensureSearchTools, trustSystemCertificates } from './toolchain.mjs';
8
8
  import { inlineExtensions, managedResourceLoader, projectInstructions } from './resource-loader.mjs';
9
9
  import { fetchPublicPage, fetchLimits } from './web-fetch.mjs';
@@ -91,10 +91,10 @@ export class ManagedSession {
91
91
  // the conversation enrolls again before its next run.
92
92
  static async create({ sdkRoot, cwd, origin, broker, protectedRoots = [], agentDir, sessionManager, renewBroker = null, bindingRevision = null, terminalExtensions = [] }) {
93
93
  const sdk = fs.realpathSync(sdkRoot);
94
- if (JSON.parse(fs.readFileSync(path.join(sdk, 'package.json'))).version !== '0.87.1') throw Error('managed-sdk-version-unqualified');
94
+ if (JSON.parse(fs.readFileSync(path.join(sdk, 'package.json'))).version !== PI_HOST_VERSION) throw Error('managed-sdk-version-unqualified');
95
95
  await trustSystemCertificates();
96
96
  const api = await import(pathToFileURL(path.join(sdk, 'dist/index.js')));
97
- const ai = await import(pathToFileURL(path.join(sdk, 'node_modules/@earendil-works/pi-ai/dist/index.js')));
97
+ const ai = await import(pathToFileURL(path.join(hostPackage(sdk, '@earendil-works/pi-ai'), 'dist/index.js')));
98
98
  const manifest = usable(await patiently(() => broker.request('config'), 3));
99
99
  const self = new ManagedSession(); Object.assign(self, { sdk, cwd: fs.realpathSync(cwd), origin: safeOrigin(origin), broker, api, ai, manifest, protectedRoots, helpers: new Map(), helperCalls: new Map(), ownPaths: new Set(), listeners: new Set(), capacityWaits: new Set(), routes: new WeakMap(), grant: null,
100
100
  renewBroker, bindingRevision, bindingSeen: bindingRevision?.() ?? null, preflight: null, blocked: null });
@@ -8,6 +8,8 @@ import { createHash, randomUUID } from 'node:crypto';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { developerLicense, developerTools, managedGit, searchToolPath, userToolchains } from './toolchain.mjs';
10
10
  import { languageEnvironment } from './language-environment.mjs';
11
+ import { PI_HOST_VERSION, hostPackage } from './pi-host.mjs';
12
+ export { PI_HOST_VERSION, hostPackage };
11
13
  import { containerEngine, dockerCli, engineEnvironment } from './container-engine.mjs';
12
14
  import { DEFAULT_DOMAINS, PackageProxy, mavenSettings, memberDomains, proxyEnvironment } from './package-proxy.mjs';
13
15
  import { BWRAP, bubblewrapArgs, credentialFiles, referenceFolders } from './linux-sandbox.mjs';
@@ -205,8 +207,8 @@ export class ManagedToolBoundary {
205
207
  this.runtimeRoot = fs.realpathSync(path.dirname(workerPath));
206
208
  this.sdkPath = path.join(this.sdkRoot, 'dist/index.js');
207
209
  this.git = managedGit();
208
- if (JSON.parse(fs.readFileSync(path.join(this.sdkRoot, 'package.json'), 'utf8')).version !== '0.87.1') throw new Error('managed-sdk-version-unqualified');
209
- const workerModules = ['search-fallback.mjs', 'sandbox-diagnostic.mjs'].map(name => path.join(this.runtimeRoot, name));
210
+ if (JSON.parse(fs.readFileSync(path.join(this.sdkRoot, 'package.json'), 'utf8')).version !== PI_HOST_VERSION) throw new Error('managed-sdk-version-unqualified');
211
+ const workerModules = ['search-fallback.mjs', 'sandbox-diagnostic.mjs', 'ts-diagnostics-server.mjs'].map(name => path.join(this.runtimeRoot, name));
210
212
  this.pins = new Map([this.node, this.git, workerPath, ...workerModules, this.sdkPath].map(file => [file, hash(file)]));
211
213
  const userHome = fs.realpathSync(home ?? os.homedir());
212
214
  if ([userHome, '/', this.sdkRoot, this.runtimeRoot].includes(this.cwd)
@@ -270,7 +272,10 @@ export class ManagedToolBoundary {
270
272
  // The Docker engine, for commands the member approves (run_with_docker).
271
273
  this.engine = readOnly ? null : containerEngine({ userHome });
272
274
  this.dockerCli = readOnly ? null : this.engine?.cli ?? dockerCli();
273
- const base = this.base = { cwd: this.cwd, sdkRoot: this.sdkRoot, runtimeRoot: this.runtimeRoot, temporary: this.temporary, node: this.node,
275
+ // What the host loads: its own folder, and the node_modules its
276
+ // dependencies were hoisted to when they are not nested in it (Pi 1.x).
277
+ const ai = hostPackage(this.sdkRoot, '@earendil-works/pi-ai'), hostRoot = inside(fs.realpathSync(ai), this.sdkRoot) ? this.sdkRoot : fs.realpathSync(path.dirname(path.dirname(ai)));
278
+ const base = this.base = { cwd: this.cwd, sdkRoot: hostRoot, runtimeRoot: this.runtimeRoot, temporary: this.temporary, node: this.node,
274
279
  denied: narrowed, readOnly, repository, gitDirs, toolchains: [...toolchains.roots, ...languages.readRoots], developer: developer.roots,
275
280
  browsers: browsers ? [browsers] : [], skills, caches: languages.writeRoots, license: developerLicense() };
276
281
  if (this.linux) this.linuxBase = { ...base, toolchains: [...base.toolchains, ...base.browsers], references: this.references, empty: this.empty, engineRoots: this.engine?.readRoots ?? [] };
@@ -436,28 +441,50 @@ export class ManagedToolBoundary {
436
441
  async stopStrays() {
437
442
  // On Linux a command's processes end with it (its own PID namespace).
438
443
  if (this.#children.size || this.linux) return 0;
439
- const marker = `PIAGENT_TOOL_SCOPE=${this.scope}`, find = () => {
440
- try {
441
- return execFileSync('/bin/ps', ['-axwwE', '-o', 'pid=,command='], { encoding: 'utf8', maxBuffer: 64 << 20, timeout: 5000 }).split('\n')
442
- .filter(row => row.includes(marker)).map(row => Number(row.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid);
443
- } catch { return []; }
444
- };
445
- const strays = find();
446
- for (const pid of strays) try { process.kill(pid, 'SIGTERM'); } catch {}
447
- for (let waited = 0; strays.length && waited < 2000; waited += 100) {
448
- await new Promise(resolve => setTimeout(resolve, 100));
449
- if (!find().length) break;
450
- }
451
- for (const pid of find()) try { process.kill(pid, 'SIGKILL'); } catch {}
452
- return strays.length;
444
+ return stopMarked(`PIAGENT_TOOL_SCOPE=${this.scope}`);
445
+ }
446
+ // A process the agent keeps running across turns (background-process.mjs):
447
+ // the plain command sandbox and environment, marked as this conversation's
448
+ // background process instead of the turn's, so stopStrays leaves it.
449
+ async spawnBackground(command, key) { return this.spawnSandboxed(['/bin/bash', '-c', command], key); }
450
+ // `argv` in the plain command sandbox; stdin 'pipe' for a process that
451
+ // answers requests (ts-diagnostics.mjs).
452
+ async spawnSandboxed(argv, key, { stdin = 'ignore' } = {}) {
453
+ if (this.#closed || this.linux || !this.allowed.includes('bash') || !/^[0-9a-f-]{36}$/.test(key)) throw new Error('managed-tool-unavailable');
454
+ for (const [file, digest] of this.pins) if (hash(file) !== digest) throw new Error('managed-runtime-changed');
455
+ if (Date.now() - this.proxyCheck.at > 5000) this.proxyCheck = { at: Date.now(), listening: localProxyListening(this.proxyPorts) };
456
+ const isolated = await this.proxyCheck.listening, { PIAGENT_TOOL_SCOPE: _turn, ...env } = this.environment.offline;
457
+ const child = spawn('/usr/bin/sandbox-exec', ['-p', isolated ? this.isolatedProfile : this.profile, ...argv],
458
+ { cwd: this.cwd, detached: true, stdio: [stdin, 'pipe', 'pipe'], env: { ...env, PIAGENT_PROCESS_SCOPE: `${this.scope}:${key}` } });
459
+ return { child, isolated };
453
460
  }
461
+ stopBackground(key) { return stopMarked(`PIAGENT_PROCESS_SCOPE=${this.scope}:${key}`); }
454
462
  async dispose() {
455
463
  this.#closed = true;
456
464
  const stopping = [...this.#children].map(child => new Promise(resolve => { child.once('close', resolve); child.managedCancel(); }));
457
465
  await Promise.all(stopping); await this.#tail;
458
- await this.stopStrays();
466
+ await this.background?.dispose(); await this.stopStrays(); if (!this.linux) await stopMarked(`PIAGENT_PROCESS_SCOPE=${this.scope}:`);
459
467
  await this.proxyReady; await this.proxy?.close().catch(() => {});
460
468
  if (this.proxySocket) fs.rmSync(this.proxySocket, { force: true });
461
469
  fs.rmSync(this.temporary, { recursive: true, force: true });
462
470
  }
463
471
  }
472
+
473
+ // Stops every process whose environment carries `marker` (ps -E), then
474
+ // whatever is left after two seconds.
475
+ async function stopMarked(marker) {
476
+ const find = () => {
477
+ try {
478
+ return execFileSync('/bin/ps', ['-axwwE', '-o', 'pid=,command='], { encoding: 'utf8', maxBuffer: 64 << 20, timeout: 5000 }).split('\n')
479
+ .filter(row => row.includes(marker)).map(row => Number(row.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid);
480
+ } catch { return []; }
481
+ };
482
+ const found = find();
483
+ for (const pid of found) try { process.kill(pid, 'SIGTERM'); } catch {}
484
+ for (let waited = 0; found.length && waited < 2000; waited += 100) {
485
+ await new Promise(resolve => setTimeout(resolve, 100));
486
+ if (!find().length) break;
487
+ }
488
+ for (const pid of find()) try { process.kill(pid, 'SIGKILL'); } catch {}
489
+ return found.length;
490
+ }
@@ -43,7 +43,9 @@ input.on('line', async line => {
43
43
  : await factories[message.name](cwd, options).execute('managed-tool', message.args, controller.signal,
44
44
  update => emit({ update }), { cwd, model: message.model });
45
45
  // Successful output is returned verbatim; the sandbox note is only added to
46
- // failures (Pi tools throw on denial and on a non-zero shell exit).
46
+ // failures. Pi 0.87 throws on denial and on a non-zero shell exit; Pi 1.x
47
+ // returns an isError result with the same text instead: read as the throw.
48
+ if (result?.isError === true) throw new Error((result.content ?? []).filter(part => part.type === 'text').map(part => part.text).join('\n') || 'managed-worker-failed');
47
49
  emit({ result });
48
50
  } catch (error) { emit({ error: sandboxDiagnostic(error instanceof Error ? error.message : 'managed-worker-failed', { network, isolated, docker, engine }) }); }
49
51
  finally { if (active) { input.close(); process.stdin.destroy(); } }
@@ -0,0 +1,102 @@
1
+ // Runs inside the command sandbox (ts-diagnostics.mjs starts it): the
2
+ // project's own TypeScript reports the errors of one file, one request per
3
+ // line on stdin ({id, file}), one answer per line on stdout. TypeScript 5 and
4
+ // 6 keep a language service per tsconfig/jsconfig; TypeScript 7 (the native
5
+ // compiler, which has no such JavaScript API) checks the project with its own
6
+ // tsc, fast enough to run on each change.
7
+ import fs from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+ import { createInterface } from 'node:readline';
12
+
13
+ const [cwd] = process.argv.slice(2);
14
+ const services = new Map();
15
+ const inside = (root, file) => { const relative = path.relative(root, file); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); };
16
+ const version = file => { try { const stat = fs.statSync(file); return `${stat.mtimeMs}:${stat.size}`; } catch { return '0'; } };
17
+
18
+ // The nearest tsconfig.json, else jsconfig.json, inside the project.
19
+ function configFor(file) {
20
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
21
+ for (let dir = path.dirname(file); inside(cwd, dir); dir = path.dirname(dir)) {
22
+ if (fs.existsSync(path.join(dir, name))) return path.join(dir, name);
23
+ if (dir === cwd) break;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function serviceFor(file) {
30
+ let manifest;
31
+ try { manifest = createRequire(file).resolve('typescript/package.json'); } catch { return { reason: 'no-typescript' }; }
32
+ const config = configFor(file);
33
+ if (!config) return { reason: 'no-config' };
34
+ if (services.has(config)) return services.get(config);
35
+ const major = Number.parseInt(JSON.parse(fs.readFileSync(manifest, 'utf8')).version, 10);
36
+ if (major >= 7) {
37
+ const entry = { native: path.join(path.dirname(manifest), 'bin/tsc'), config };
38
+ services.set(config, entry); return entry;
39
+ }
40
+ const ts = createRequire(file)(path.dirname(manifest));
41
+ const parsed = ts.getParsedCommandLineOfConfigFile(config, {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} });
42
+ if (!parsed) return { reason: 'no-config' };
43
+ const extra = new Set();
44
+ const host = {
45
+ getScriptFileNames: () => [...new Set([...parsed.fileNames, ...extra])],
46
+ getScriptVersion: version,
47
+ getScriptSnapshot: name => fs.existsSync(name) ? ts.ScriptSnapshot.fromString(fs.readFileSync(name, 'utf8')) : undefined,
48
+ getCurrentDirectory: () => path.dirname(config),
49
+ getCompilationSettings: () => parsed.options,
50
+ getDefaultLibFileName: options => ts.getDefaultLibFilePath(options),
51
+ fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, readDirectory: ts.sys.readDirectory,
52
+ directoryExists: ts.sys.directoryExists, getDirectories: ts.sys.getDirectories,
53
+ };
54
+ const entry = { ts, config, extra, service: ts.createLanguageService(host, ts.createDocumentRegistry()) };
55
+ services.set(config, entry);
56
+ return entry;
57
+ }
58
+
59
+ // tsc --pretty false prints "file(line,col): error TS1234: message".
60
+ function nativeDiagnostics({ native, config }, file) {
61
+ let output;
62
+ try { output = execFileSync(process.execPath, [native, '--noEmit', '--pretty', 'false', '-p', config], { cwd: path.dirname(config), encoding: 'utf8', timeout: 30000, maxBuffer: 16 << 20, stdio: ['ignore', 'pipe', 'pipe'] }); }
63
+ catch (error) { output = String(error.stdout ?? ''); }
64
+ const found = [], elsewhere = [];
65
+ for (const line of output.split('\n')) {
66
+ const match = line.match(/^(.+?)\((\d+),(\d+)\): error TS(\d+): (.*)$/);
67
+ if (!match) continue;
68
+ const at = path.resolve(path.dirname(config), match[1]), item = { line: Number(match[2]), column: Number(match[3]), code: Number(match[4]), message: match[5].slice(0, 400) };
69
+ if (at === file) found.push(item); else elsewhere.push({ ...item, file: path.relative(cwd, at) });
70
+ }
71
+ // A syntax error anywhere stops tsc before it checks types: say so rather
72
+ // than let silence read as a clean file.
73
+ const syntax = elsewhere.filter(item => item.code >= 1000 && item.code < 2000);
74
+ return found.length || !syntax.length ? { diagnostics: found } : { diagnostics: [], uncheckedBecause: syntax.slice(0, 3) };
75
+ }
76
+
77
+ function diagnostics(file) {
78
+ const entry = serviceFor(file);
79
+ if (entry.reason) return { reason: entry.reason };
80
+ if (entry.native) return { config: path.relative(cwd, entry.config), ...nativeDiagnostics(entry, file) };
81
+ const { ts, service, extra, config } = entry;
82
+ extra.add(file);
83
+ const found = [...service.getSyntacticDiagnostics(file), ...service.getSemanticDiagnostics(file)]
84
+ .filter(d => d.category === ts.DiagnosticCategory.Error)
85
+ .map(d => {
86
+ const at = d.file && d.start !== undefined ? d.file.getLineAndCharacterOfPosition(d.start) : { line: 0, character: 0 };
87
+ return { line: at.line + 1, column: at.character + 1, code: d.code, message: ts.flattenDiagnosticMessageText(d.messageText, ' ').slice(0, 400) };
88
+ });
89
+ return { config: path.relative(cwd, config), diagnostics: found };
90
+ }
91
+
92
+ const input = createInterface({ input: process.stdin });
93
+ input.on('line', line => {
94
+ let id = null;
95
+ try {
96
+ const request = JSON.parse(line); id = request.id;
97
+ const file = path.resolve(cwd, String(request.file));
98
+ if (!inside(cwd, file)) throw new Error('outside the project');
99
+ process.stdout.write(JSON.stringify({ id, ...diagnostics(file) }) + '\n');
100
+ } catch (error) { process.stdout.write(JSON.stringify({ id, error: String(error?.message ?? error).slice(0, 300) }) + '\n'); }
101
+ });
102
+ input.on('close', () => process.exit(0));
@@ -0,0 +1,59 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // TypeScript and JavaScript errors of a file right after the agent writes or
6
+ // edits it, from the project's own TypeScript (ts-diagnostics-server.mjs, in
7
+ // the command sandbox). A file without errors adds nothing; a project without
8
+ // TypeScript or a tsconfig/jsconfig adds nothing; a slow first answer (a large
9
+ // program loading) adds nothing rather than holding the edit.
10
+ const SERVER = fileURLToPath(new URL('./ts-diagnostics-server.mjs', import.meta.url));
11
+ const EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
12
+ const SHOWN = 10, RESTARTS = 3;
13
+
14
+ export class TsDiagnostics {
15
+ #child = null; #pending = new Map(); #next = 1; #buffer = ''; #starts = 0; #off = false;
16
+ constructor(boundary, { timeout = 8000 } = {}) { Object.assign(this, { boundary, timeout }); }
17
+ applies(file) {
18
+ return !this.#off && process.platform === 'darwin' && typeof file === 'string' && EXTENSIONS.has(path.extname(file)) && !/(^|[\\/])node_modules[\\/]/.test(file);
19
+ }
20
+ async #server() {
21
+ if (this.#child) return this.#child;
22
+ if (this.#starts >= RESTARTS) { this.#off = true; return null; }
23
+ this.#starts += 1;
24
+ const { child } = await this.boundary.spawnSandboxed([this.boundary.node, SERVER, this.boundary.cwd], randomUUID(), { stdin: 'pipe' });
25
+ child.stdout.on('data', chunk => {
26
+ this.#buffer += chunk.toString('utf8');
27
+ for (let at; (at = this.#buffer.indexOf('\n')) >= 0;) {
28
+ const line = this.#buffer.slice(0, at); this.#buffer = this.#buffer.slice(at + 1);
29
+ try { const answer = JSON.parse(line); this.#pending.get(answer.id)?.(answer); } catch { /* not an answer */ }
30
+ }
31
+ });
32
+ child.stderr.on('data', () => {});
33
+ child.stdin.on('error', () => {});
34
+ child.on('close', () => { if (this.#child === child) this.#child = null; for (const resolve of this.#pending.values()) resolve(null); this.#pending.clear(); });
35
+ this.#child = child;
36
+ return child;
37
+ }
38
+ // A note for the agent, or '' when there is nothing to say.
39
+ async check(file) {
40
+ if (!this.applies(file)) return '';
41
+ try {
42
+ const child = await this.#server();
43
+ if (!child) return '';
44
+ const id = this.#next++;
45
+ const answer = await new Promise(resolve => {
46
+ const timer = setTimeout(() => { this.#pending.delete(id); resolve(null); }, this.timeout); timer.unref?.();
47
+ this.#pending.set(id, value => { clearTimeout(timer); this.#pending.delete(id); resolve(value); });
48
+ child.stdin.write(JSON.stringify({ id, file }) + '\n');
49
+ });
50
+ const found = answer?.diagnostics, name = path.relative(this.boundary.cwd, path.resolve(this.boundary.cwd, file));
51
+ if (Array.isArray(answer?.uncheckedBecause) && answer.uncheckedBecause.length)
52
+ return `\n[TypeScript, ${answer.config}] ${name} was not type-checked: tsc stops at syntax errors elsewhere in the project:\n${answer.uncheckedBecause.map(d => `${d.file}:${d.line}:${d.column} TS${d.code} ${d.message}`).join('\n')}`;
53
+ if (!Array.isArray(found) || !found.length) return '';
54
+ const lines = found.slice(0, SHOWN).map(d => `${name}:${d.line}:${d.column} TS${d.code} ${d.message}`);
55
+ return `\n[TypeScript, ${answer.config}] ${found.length} error(s) in ${name} after this change:\n${lines.join('\n')}${found.length > SHOWN ? `\n… and ${found.length - SHOWN} more` : ''}`;
56
+ } catch { return ''; }
57
+ }
58
+ dispose() { this.#off = true; try { this.#child?.stdin.end(); } catch { /* gone */ } }
59
+ }