@piagent/platform 1.17.0 → 1.19.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 (98) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/catalog/claude-model-additions.json +49 -2
  5. package/docs/command-reference-vietnamese.md +2 -2
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/languages.json +2 -2
  8. package/docs/operator-manual-vietnamese.md +7 -7
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/release-install-policy.md +2 -2
  11. package/docs/team-onboarding.md +2 -2
  12. package/docs-site/architecture.html +1 -1
  13. package/docs-site/benchmark.html +1 -1
  14. package/docs-site/commands.html +1 -1
  15. package/docs-site/content/en/index.html +4 -4
  16. package/docs-site/content/en/whats-new.html +13 -0
  17. package/docs-site/content/index.html +4 -4
  18. package/docs-site/content/whats-new.html +13 -0
  19. package/docs-site/context-engine.html +1 -1
  20. package/docs-site/ecosystem.html +1 -1
  21. package/docs-site/en/architecture.html +1 -1
  22. package/docs-site/en/benchmark.html +1 -1
  23. package/docs-site/en/commands.html +1 -1
  24. package/docs-site/en/context-engine.html +1 -1
  25. package/docs-site/en/ecosystem.html +1 -1
  26. package/docs-site/en/index.html +5 -5
  27. package/docs-site/en/mcp-approval.html +1 -1
  28. package/docs-site/en/mcp-auth.html +1 -1
  29. package/docs-site/en/mcp-servers.html +1 -1
  30. package/docs-site/en/mcp.html +1 -1
  31. package/docs-site/en/permissions.html +1 -1
  32. package/docs-site/en/profiles.html +1 -1
  33. package/docs-site/en/quickstart.html +1 -1
  34. package/docs-site/en/runtime.html +1 -1
  35. package/docs-site/en/scope.html +1 -1
  36. package/docs-site/en/security.html +1 -1
  37. package/docs-site/en/team.html +1 -1
  38. package/docs-site/en/whats-new.html +14 -1
  39. package/docs-site/en/windows.html +1 -1
  40. package/docs-site/en/workflows.html +1 -1
  41. package/docs-site/index.html +5 -5
  42. package/docs-site/mcp-approval.html +1 -1
  43. package/docs-site/mcp-auth.html +1 -1
  44. package/docs-site/mcp-servers.html +1 -1
  45. package/docs-site/mcp.html +1 -1
  46. package/docs-site/permissions.html +1 -1
  47. package/docs-site/profiles.html +1 -1
  48. package/docs-site/quickstart.html +1 -1
  49. package/docs-site/runtime.html +1 -1
  50. package/docs-site/scope.html +1 -1
  51. package/docs-site/security.html +1 -1
  52. package/docs-site/team.html +1 -1
  53. package/docs-site/whats-new.html +14 -1
  54. package/docs-site/windows.html +1 -1
  55. package/docs-site/workflows.html +1 -1
  56. package/evals/private-holdout-v1/public-exposure.v2.json +3 -3
  57. package/evals/runtime-conformance-v1/runner.mjs +13 -17
  58. package/package.json +1 -1
  59. package/packages/piagent-core/managed/call-purpose.mjs +3 -2
  60. package/packages/piagent-core/managed/command-tools.mjs +59 -0
  61. package/packages/piagent-core/managed/container-engine.mjs +102 -0
  62. package/packages/piagent-core/managed/continuation.mjs +131 -0
  63. package/packages/piagent-core/managed/doctor.mjs +7 -0
  64. package/packages/piagent-core/managed/language-environment.mjs +57 -0
  65. package/packages/piagent-core/managed/linux-sandbox.mjs +3 -1
  66. package/packages/piagent-core/managed/native-catalog.mjs +14 -1
  67. package/packages/piagent-core/managed/patch-snapshot.mjs +25 -9
  68. package/packages/piagent-core/managed/repository-operation.mjs +20 -0
  69. package/packages/piagent-core/managed/request-stream.mjs +28 -0
  70. package/packages/piagent-core/managed/review-snapshot-worker.mjs +11 -4
  71. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +6 -1
  72. package/packages/piagent-core/managed/session.mjs +52 -63
  73. package/packages/piagent-core/managed/tool-boundary.mjs +21 -10
  74. package/packages/piagent-core/managed/tool-worker.mjs +3 -3
  75. package/packages/piagent-core/managed/workflow.mjs +20 -20
  76. package/packages/piagent-core/package.json +1 -1
  77. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CeQ79h30.js → MarkdownMessage-nggiOt0N.js} +1 -1
  78. package/packages/piagent-webui/dist/client/assets/{index-BNQK7rHr.js → index-CM9cs-gP.js} +16 -14
  79. package/packages/piagent-webui/dist/client/index.html +1 -1
  80. package/packages/piagent-webui/gateway/company-relay.ts +14 -2
  81. package/packages/piagent-webui/gateway/gateway-service.ts +38 -0
  82. package/packages/piagent-webui/gateway/git-branches.ts +94 -0
  83. package/packages/piagent-webui/gateway/git-repositories.ts +46 -0
  84. package/packages/piagent-webui/gateway/managed-projection.ts +9 -2
  85. package/packages/piagent-webui/gateway/project-registry.ts +10 -6
  86. package/packages/piagent-webui/gateway/session-catalog.ts +13 -6
  87. package/packages/piagent-webui/gateway/session-operation-lifecycle.ts +10 -3
  88. package/packages/piagent-webui/server/loopback-server.ts +9 -2
  89. package/packages/piagent-webui/server/member-routes.ts +41 -0
  90. package/packages/piagent-webui/server/tool-call-summary.ts +3 -1
  91. package/packages/piagent-webui/server/transcript-projection.ts +20 -4
  92. package/packages/piagent-webui/shared/process-copy.ts +3 -1
  93. package/packages/piagent-webui/shared/turn-end.ts +68 -0
  94. package/schemas/piagent-webui/session-catalog-v1.schema.json +11 -1
  95. package/schemas/piagent-webui/transcript-v1.schema.json +23 -2
  96. package/scripts/company-terminal.mjs +15 -0
  97. package/scripts/configure-model-scope.sh +1 -0
  98. package/templates/global/settings.json +2 -1
@@ -0,0 +1,131 @@
1
+ import { unfinished, planText, currentPlan, workflowPolicy } from './workflow.mjs';
2
+ import { failureCode } from '../runtime/managed-failure.mjs';
3
+
4
+ // A main agent often ends its turn between the steps of its own checklist
5
+ // ("STEP06 done, STEP07 next"), though the member asked for all of them.
6
+ // Under plan "require" the harness sends it back to the next open step, as a
7
+ // new run (as if the member had sent "continue": each round gets its own
8
+ // helpers, checks and review), until the checklist is done, the member stops
9
+ // it, a round fails, or IDLE_ROUNDS rounds in a row change nothing.
10
+ export const IDLE_ROUNDS = 2;
11
+ // A checklist holds at most 30 steps: one round per step at most.
12
+ export const MAX_ROUNDS = 30;
13
+ // How the member's message ended, shown under the last answer.
14
+ export const TURN_END_ENTRY = 'agent-watch-turn-end';
15
+ export const TURN_END_STATES = ['done', 'midway', 'failed', 'cancelled'];
16
+
17
+ const completed = plan => plan?.plan?.filter(p => p.status === 'completed').length ?? 0;
18
+ const deferred = plan => plan?.plan?.filter(p => p.status === 'deferred').length ?? 0;
19
+
20
+ export class Continuation {
21
+ constructor(policy) {
22
+ Object.assign(this, { policy, rounds: 0, idle: 0, touched: false, reason: null, startDone: 0 });
23
+ }
24
+ // A round is about to start: what its progress is measured from.
25
+ begin(plan) { this.startDone = completed(plan); }
26
+ // A round ended (its run closed). The message that starts the next round,
27
+ // or null: then `reason` says why the harness stopped sending it back.
28
+ next({ plan, planUpdated, changed, stopped, failed }) {
29
+ if (planUpdated) this.touched = true;
30
+ if (stopped) { this.reason = 'stopped'; return null; }
31
+ if (failed) { this.reason = 'failed'; return null; }
32
+ if (!this.touched || !unfinished(plan)) { this.reason = null; return null; }
33
+ if (this.policy.plan !== 'require') { this.reason = 'policy'; return null; }
34
+ // The member's own round always gets one round back; a harness round
35
+ // that neither completed a step nor changed code counts as idle.
36
+ if (this.rounds > 0) this.idle = completed(plan) > this.startDone || changed ? 0 : this.idle + 1;
37
+ if (this.idle >= IDLE_ROUNDS) { this.reason = 'idle'; return null; }
38
+ if (this.rounds >= MAX_ROUNDS) { this.reason = 'limit'; return null; }
39
+ this.rounds += 1;
40
+ const steps = plan.plan.length, done = completed(plan), open = steps - done - deferred(plan);
41
+ return { customType: 'agent-watch-process', display: true,
42
+ content: `Harness: your checklist still has ${open} open step(s) (${done}/${steps} completed):\n${planText(plan)}\nThe member asked for all of it, unless they told you to stop at this point. Continue with the next open step now and keep going until every step is completed; update the checklist with update_plan as you go. Do not stop to report progress between steps. If the member asked you to stop here (to review a plan before any change, for example) or you cannot go on without them (a decision, access, a service that is not running), ask with ask_user instead of going on. If a step is no longer needed, take it out of the checklist and say why in your final answer.`,
43
+ details: { phase: 'continue', round: this.rounds, maxRounds: MAX_ROUNDS, planOpen: open, planDone: done, planSteps: steps, ...(this.idle ? { idle: this.idle } : {}) } };
44
+ }
45
+ }
46
+
47
+ // How a member's message ended: every step done (or answered without a
48
+ // checklist), stopped with steps open (and why the harness did not go on),
49
+ // failed (which role, which kind) or stopped by the member.
50
+ export function turnEnd(continuation, plan, { stopped, failure }) {
51
+ const steps = continuation.touched ? plan?.plan?.length ?? 0 : 0, done = continuation.touched ? completed(plan) : 0;
52
+ const state = stopped ? 'cancelled' : failure ? 'failed' : continuation.touched && unfinished(plan) ? 'midway' : 'done';
53
+ const later = continuation.touched ? deferred(plan) : 0;
54
+ return { state, planSteps: steps, planDone: done, ...(later ? { planDeferred: later } : {}), rounds: continuation.rounds,
55
+ ...(state === 'midway' && continuation.reason ? { reason: continuation.reason } : {}),
56
+ ...(state === 'failed' ? { role: failure.role ?? 'main', code: String(failure.code || 'unknown').toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 64) } : {}),
57
+ at: new Date().toISOString() };
58
+ }
59
+
60
+ // One member message: its round, then the rounds the harness adds while the
61
+ // checklist has open steps. `runRound(text, deliver)` runs one Studio run and
62
+ // returns { result, preflight, planUpdated, changed, closeFailed }. A message the member
63
+ // sends while a round closes (no model runs then) becomes the next round;
64
+ // Stop ends the turn. Listeners hear "settled" once, after the last round.
65
+ // "tiếp tục", "continue", the dashboard's Continue button: the member asks
66
+ // for the open checklist, so it is this message's work even before the agent
67
+ // touches it again.
68
+ const CONTINUE = /^(tiep tuc|lam tiep|lam not|continue|go on|keep going|resume)\b/;
69
+ export function asksToContinue(text) {
70
+ const normal = String(text ?? '').normalize('NFD').replace(/\p{M}/gu, '').replace(/[đĐ]/g, 'd').toLowerCase().trim();
71
+ return normal.length <= 60 && CONTINUE.test(normal);
72
+ }
73
+ const fresh = (self, manager, text) => {
74
+ const continuation = new Continuation(workflowPolicy(self.manifest));
75
+ if (asksToContinue(text) && unfinished(currentPlan(manager))) continuation.touched = true;
76
+ return continuation;
77
+ };
78
+
79
+ export async function memberTurn(self, { manager, prompt, runRound }, text, deliver) {
80
+ let continuation = fresh(self, manager, text), result, waiting = null, ended = null;
81
+ self.stopRequested = false; self.queued = [];
82
+ try {
83
+ for (let first = true; ; first = false) {
84
+ continuation.begin(currentPlan(manager));
85
+ self.between = false;
86
+ let round;
87
+ try { round = await runRound(text, deliver); }
88
+ catch (error) { waiting?.reject(error); waiting = null; throw error; }
89
+ if (first) result = round.result; else waiting?.resolve(round.result);
90
+ waiting = null;
91
+ const last = self.session.messages.findLast(m => m.role === 'assistant');
92
+ const stopped = self.stopRequested || last?.stopReason === 'aborted';
93
+ const failure = round.preflight || last?.stopReason === 'error' ? { role: 'main', code: failureCode(last?.errorMessage ?? '') }
94
+ : round.closeFailed ? { role: 'main', code: round.closeFailed } : null;
95
+ const plan = currentPlan(manager), message = continuation.next({ plan, planUpdated: round.planUpdated, changed: round.changed, stopped, failed: !!failure });
96
+ if (self.queued.length) {
97
+ // The member wrote while the round closed: theirs is the next round,
98
+ // a new request (Stop before it applied to the round before).
99
+ waiting = self.queued.shift(); self.stopRequested = false;
100
+ const next = waiting; text = next.text;
101
+ deliver = () => prompt(next.text, { ...next.options, expandPromptTemplates: false });
102
+ continuation = fresh(self, manager, next.text);
103
+ } else if (message) {
104
+ text = message.content;
105
+ deliver = () => { self.nextPurpose = 'continue'; return self.session.sendCustomMessage(message, { triggerTurn: true }); };
106
+ } else { ended = turnEnd(continuation, plan, { stopped, failure }); break; }
107
+ }
108
+ } finally {
109
+ self.between = false;
110
+ for (const queued of self.queued.splice(0)) queued.reject(Error('managed-turn-stopped'));
111
+ // How the member's message ended: an entry of the conversation (not a
112
+ // message: the model never reads it), shown under the last answer, and
113
+ // told to listeners (the company Terminal).
114
+ if (ended) {
115
+ try { manager.appendCustomEntry(TURN_END_ENTRY, ended); } catch { /* the answer stands */ }
116
+ self.notify({ type: 'managed_turn_end', end: ended, text: turnEndText(ended) });
117
+ }
118
+ self.activePrompt = false;
119
+ self.notify({ type: 'agent_settled' });
120
+ }
121
+ return result;
122
+ }
123
+
124
+ // The plain text of a turn's end (the Terminal shows it; the WebUI reads details).
125
+ export function turnEndText(end) {
126
+ if (end.state === 'cancelled') return 'Turn stopped by the member.';
127
+ if (end.state === 'failed') return `Turn failed: ${end.role} agent, ${end.code}. Send "continue" to go on.`;
128
+ if (end.state === 'midway') return `Turn ended with ${end.planSteps - end.planDone - (end.planDeferred ?? 0)} of ${end.planSteps} checklist step(s) open${end.reason === 'idle' ? `: the agent stopped ${IDLE_ROUNDS} times in a row without progress` : end.reason === 'limit' ? `: ${MAX_ROUNDS} rounds reached` : ''}. Send "continue" to go on.`;
129
+ if (end.planDeferred) return `Done: ${end.planDone} of ${end.planSteps} checklist steps completed, ${end.planDeferred} left for later.`;
130
+ return end.planSteps ? `Done: all ${end.planSteps} checklist steps completed.` : 'Done.';
131
+ }
@@ -86,6 +86,13 @@ export async function runDoctor({ sdkRoot, origin, broker, network = true }) {
86
86
  try { return await bash('node loopback.mjs'); }
87
87
  catch (error) { throw Error(/loopback-blocked/.test(String(error?.message)) ? 'a local proxy listens on this Mac: tests that use localhost need run_with_network' : first(String(error?.message))); }
88
88
  });
89
+ // run_with_docker: the engine answers through its socket, and only there.
90
+ await check('Docker for approved commands (run_with_docker)', false, async () => {
91
+ if (!boundary.engine) throw Error('no Docker engine running (Docker Desktop, Colima, OrbStack…): start it, then open a new conversation');
92
+ let refused = false; try { await bash('docker version --format "{{.Server.Version}}"'); } catch { refused = true; }
93
+ if (!refused) throw Error('a plain command reached the Docker engine');
94
+ return `engine ${await bash('docker version --format "{{.Server.Version}}"', { docker: true })}`;
95
+ });
89
96
  await check('package caches kept for the project', false, async () => { await bash('mkdir -p "$npm_config_cache" && touch "$npm_config_cache/.piagent-doctor"'); return boundary.packageCache; });
90
97
  // The languages installed on this Mac, each started once in the sandbox.
91
98
  // (macOS ships stubs for some, such as javac without a JDK: a probe decides.)
@@ -10,6 +10,10 @@ import { execFileSync } from 'node:child_process';
10
10
  // network (run_with_network), later runs work offline;
11
11
  // - the member's own dependency caches as read-only seeds, where the tool can
12
12
  // layer one (Go, Gradle, Maven), so what is already downloaded is reused;
13
+ // - the distributions the Maven Wrapper (./mvnw) and the Gradle Wrapper
14
+ // (./gradlew) already unpacked on this Mac, linked read-only into the
15
+ // project's cache, so they run without asking for network; one they have
16
+ // to download stays in the cache;
13
17
  // - runtimes installed in places the sandbox otherwise keeps closed (JDKs
14
18
  // under ~/Library/Java, the Android SDK, the system gem folder, MacPorts);
15
19
  // - SwiftPM and xcodebuild, which cannot start their own sandbox inside this
@@ -62,6 +66,50 @@ case "$(basename "$0"):$1" in
62
66
  esac
63
67
  `;
64
68
 
69
+ // Each <name>/<hash> distribution folder of the member's Maven Wrapper as a
70
+ // link in the project's cache; one already there (linked, or downloaded by an
71
+ // approved command) is kept.
72
+ function linkDistributions(source, target) {
73
+ let names = [];
74
+ try { names = fs.readdirSync(source, { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { return; }
75
+ for (const name of names) {
76
+ let hashes = [];
77
+ try { hashes = fs.readdirSync(path.join(source, name.name), { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { continue; }
78
+ for (const hash of hashes) {
79
+ const link = path.join(target, name.name, hash.name);
80
+ try { fs.lstatSync(link); continue; } catch { /* not there yet */ }
81
+ try { fs.mkdirSync(path.dirname(link), { recursive: true, mode: 0o700 }); fs.symlinkSync(path.join(source, name.name, hash.name), link); } catch { /* best effort */ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // The Gradle Wrapper locks <hash>/<zip>.lck and checks <zip>.ok before it
87
+ // uses a distribution, so each <name>/<hash> is a folder of the cache with
88
+ // the marker copied and the unpacked distribution linked.
89
+ function linkGradleDistributions(source, target) {
90
+ let names = [];
91
+ try { names = fs.readdirSync(source, { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { return; }
92
+ for (const name of names) {
93
+ let hashes = [];
94
+ try { hashes = fs.readdirSync(path.join(source, name.name), { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { continue; }
95
+ for (const hash of hashes) {
96
+ const from = path.join(source, name.name, hash.name), to = path.join(target, name.name, hash.name);
97
+ let entries = [];
98
+ try { entries = fs.readdirSync(from, { withFileTypes: true }); } catch { continue; }
99
+ if (!entries.some(entry => entry.name.endsWith('.ok'))) continue; // not fully unpacked
100
+ try { fs.mkdirSync(to, { recursive: true, mode: 0o700 }); } catch { continue; }
101
+ for (const entry of entries) {
102
+ const link = path.join(to, entry.name);
103
+ try { fs.lstatSync(link); continue; } catch { /* not there yet */ }
104
+ try {
105
+ if (entry.isDirectory()) fs.symlinkSync(path.join(from, entry.name), link);
106
+ else if (entry.name.endsWith('.ok')) fs.writeFileSync(link, '', { mode: 0o600 });
107
+ } catch { /* best effort */ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+
65
113
  export function languageEnvironment({ userHome, repositoryTop, home }) {
66
114
  const linux = process.platform === 'linux';
67
115
  const root = linux ? path.join(process.env.XDG_CACHE_HOME || path.join(userHome, '.cache'), 'piagent/sandbox') : path.join(userHome, 'Library/Caches/Piagent/sandbox');
@@ -80,11 +128,19 @@ export function languageEnvironment({ userHome, repositoryTop, home }) {
80
128
  // .NET restores over TLS through the macOS Security framework, closed
81
129
  // here: packages the member restored are read from their own folder.
82
130
  nuget: existing(path.join(userHome, '.nuget/packages')),
131
+ mavenWrapper: existing(path.join(userHome, '.m2/wrapper/dists')),
132
+ gradleWrapper: existing(path.join(userHome, '.gradle/wrapper/dists')),
83
133
  };
84
134
  const java = javaHome(userHome), android = existing(path.join(userHome, linux ? 'Android/Sdk' : 'Library/Android/sdk'));
85
135
  const readRoots = [...new Set([...Object.values(seeds), java, android, existing(path.join(userHome, 'Library/Java/JavaVirtualMachines')),
86
136
  existing('/Library/Ruby'), existing('/opt/local')].filter(Boolean))];
87
137
 
138
+ // ./mvnw looks for its distribution in MAVEN_USER_HOME/wrapper/dists/<name>/<hash>
139
+ // (the sandbox's HOME is an empty folder per conversation).
140
+ if (seeds.mavenWrapper) linkDistributions(seeds.mavenWrapper, at('m2/wrapper/dists'));
141
+ // ./gradlew: GRADLE_USER_HOME/wrapper/dists/<name>/<hash>.
142
+ if (seeds.gradleWrapper) linkGradleDistributions(seeds.gradleWrapper, at('gradle/wrapper/dists'));
143
+
88
144
  const bin = path.join(home, '.piagent/bin');
89
145
  fs.mkdirSync(bin, { recursive: true, mode: 0o700 });
90
146
  // (Only macOS: SwiftPM on Linux starts no sandbox of its own.)
@@ -102,6 +158,7 @@ export function languageEnvironment({ userHome, repositoryTop, home }) {
102
158
  CARGO_HOME: at('cargo'), GRADLE_USER_HOME: at('gradle'), NUGET_PACKAGES: at('nuget'), COMPOSER_CACHE_DIR: at('composer'),
103
159
  PUB_CACHE: at('pub'), GEM_HOME: at('gem'), BUNDLE_PATH: at('bundle'), CP_HOME_DIR: at('cocoapods'), PIAGENT_DERIVED_DATA: at('DerivedData'),
104
160
  DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1',
161
+ MAVEN_USER_HOME: at('m2'),
105
162
  MAVEN_OPTS: [`-Dmaven.repo.local=${at('m2/repository')}`, ...(seeds.maven ? [`-Dmaven.repo.local.tail=${seeds.maven}`] : [])].join(' '),
106
163
  ...(seeds.gradle ? { GRADLE_RO_DEP_CACHE: seeds.gradle } : {}),
107
164
  ...(seeds.nuget ? { NUGET_FALLBACK_PACKAGES: seeds.nuget } : {}),
@@ -52,7 +52,7 @@ export function credentialFiles(roots, { limit = SCAN_ENTRIES } = {}) {
52
52
  }
53
53
 
54
54
  const depth = file => file.split('/').length;
55
- export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, node, readOnly = false, repository = [], gitDirs = [], toolchains = [], skills = [], caches = [], references = [], network = false, credentials = [] }) {
55
+ export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, node, readOnly = false, repository = [], gitDirs = [], toolchains = [], skills = [], caches = [], references = [], network = false, credentials = [], docker = null }) {
56
56
  const exists = file => { try { fs.statSync(file); return true; } catch { return false; } };
57
57
  const readable = [...new Set([sdkRoot, runtimeRoot, path.dirname(node), ...repository, ...toolchains, ...skills, ...references, ...(readOnly ? [cwd, ...gitDirs] : [])])].filter(exists);
58
58
  const writable = [...new Set([...caches, ...(readOnly ? [] : [cwd, ...gitDirs])])].filter(exists);
@@ -66,6 +66,8 @@ export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, no
66
66
  '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--bind', temporary, temporary,
67
67
  ...mounts.flatMap(([kind, dir]) => [kind, dir, dir]),
68
68
  ...credentials.filter(item => exists(item.path)).flatMap(item => item.directory ? ['--tmpfs', item.path] : ['--ro-bind', empty, item.path]),
69
+ // An approved Docker command reaches the engine's socket (run_with_docker).
70
+ ...(docker && exists(docker) ? ['--bind', docker, docker] : []),
69
71
  '--remount-ro', '/', '--chdir', cwd,
70
72
  ];
71
73
  }
@@ -12,6 +12,19 @@ const sonnet55 = {
12
12
  compat: { forceAdaptiveThinking: true, supportsTemperature: false, supportsStrictTools: true, supportsMidConvoEffort: true },
13
13
  promptCache: { short: 300, long: 3600 },
14
14
  };
15
+ // Claude Haiku 5.5, released 2026-10-07, checked 2026-10-08: 1M context,
16
+ // 128k output, adaptive thinking with effort; $0.10/$0.50 per 1M, cache read
17
+ // $0.01, 5m cache write $0.125, all five times above 100k input tokens.
18
+ // https://platform.claude.com/docs/en/models/haiku-5-5/overview
19
+ const haiku55 = {
20
+ id: 'claude-haiku-5-5', provider: 'anthropic', api: 'anthropic-messages',
21
+ name: 'Claude Haiku 5.5', reasoning: true, input: ['text', 'image'],
22
+ contextWindow: 1000000, maxTokens: 128000,
23
+ cost: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125, tiers: [{ inputTokensAbove: 100000, input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 }] },
24
+ thinkingLevelMap: { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' },
25
+ compat: { forceAdaptiveThinking: true, supportsTemperature: false, supportsStrictTools: true, supportsMidConvoEffort: true },
26
+ promptCache: { short: 300, long: 3600 },
27
+ };
15
28
  // GPT-6.1 Sol through Codex, checked 2026-10-01: Codex keeps the 272k window
16
29
  // of its pricing tier (the API model has 1.05M); 128k output; efforts low to
17
30
  // max, no none/minimal; $2/$10 per 1M, cached $0.10, doubled above 272k.
@@ -27,7 +40,7 @@ const sol61 = {
27
40
  compat: { supportsOpenAIGrammarTools: true, supportsAdditionalTools: true, supportsToolSearch: true, supportsMidConvoSystemMessages: true },
28
41
  inputLimits: { images: { resize: { maxWidth: 2000, maxHeight: 2000, maxBytes: 4718592, jpegQuality: 80 } } },
29
42
  };
30
- const GAPS = { anthropic: [sonnet55], 'openai-codex': [sol61] };
43
+ const GAPS = { anthropic: [sonnet55, haiku55], 'openai-codex': [sol61] };
31
44
  // Studio's providers: the Claude and Codex subscriptions, or an API-key
32
45
  // vendor (DeepSeek, Kimi, GLM, MiMo, Qwen, OpenCode, Grok) whose id is Pi's
33
46
  // own provider id; Studio runs vendors as OpenAI Chat Completions.
@@ -10,14 +10,18 @@ const MAX_SNAPSHOT = 16 * 1024 * 1024;
10
10
 
11
11
  // The worker runs inside the tool boundary; what it prints is one short line.
12
12
  // `scope`: only these paths (relative to the project) make the patch.
13
- async function worker(boundary, mode, scope = null) {
13
+ // `base`: the commit the patch starts from (the turn's starting HEAD), so a
14
+ // commit made during the turn stays part of its change; none: HEAD.
15
+ const BASE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
16
+ async function worker(boundary, mode, scope = null, base = null) {
14
17
  let scopeName = null;
15
18
  if (scope) {
16
19
  scopeName = `scope-${randomUUID()}.json`;
17
20
  fs.writeFileSync(path.join(boundary.temporary, scopeName), JSON.stringify([...scope]), { mode: 0o600, flag: 'wx' });
18
21
  }
19
22
  let result;
20
- try { result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} ${mode}${scopeName ? ' ' + scopeName : ''}`, timeout: 60 }); }
23
+ const from = typeof base === 'string' && BASE.test(base) ? ' ' + base : '';
24
+ try { result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} ${mode}${scopeName ? ' ' + scopeName : ''}${from}`, timeout: 60 }); }
21
25
  finally { if (scopeName) fs.rmSync(path.join(boundary.temporary, scopeName), { force: true }); }
22
26
  const text = textContent(result).trim();
23
27
  if (result.details?.truncation?.truncated || text.includes('\n')) throw Error(/review-(patch|file|untracked)-limit/.exec(text)?.[0] ?? 'managed-review-patch-unreadable');
@@ -28,14 +32,23 @@ async function worker(boundary, mode, scope = null) {
28
32
 
29
33
  // The digest of the patch a reviewer would see (diff against HEAD plus
30
34
  // untracked files): whether a review is still current. Nothing else moves.
31
- export async function readPatchDigest(boundary, scope = null) { return (await worker(boundary, 'digest', scope)).digest; }
35
+ export async function readPatchDigest(boundary, scope = null, base = null) { return (await worker(boundary, 'digest', scope, base)).digest; }
36
+
37
+ // The commit HEAD names now, or null (no repository, no commit yet).
38
+ export async function readHead(boundary) {
39
+ try {
40
+ const result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} head`, timeout: 60 });
41
+ const text = textContent(result).trim();
42
+ return BASE.test(text) ? text : null;
43
+ } catch { return null; }
44
+ }
32
45
 
33
46
  // The patch a reviewer sees, read inside the tool boundary. The worker writes
34
47
  // it to the boundary's temporary directory and names it, with its digest, on
35
48
  // its output; the file is read once, must match that digest (a file changed
36
49
  // meanwhile never passes) and is removed.
37
- export async function readPatchSnapshot(boundary, scope = null) {
38
- const { digest, bytes, name } = await worker(boundary, 'file', scope);
50
+ export async function readPatchSnapshot(boundary, scope = null, base = null) {
51
+ const { digest, bytes, name } = await worker(boundary, 'file', scope, base);
39
52
  if (!/^review-[0-9a-f-]{36}\.json$/.test(name ?? '')) throw Error('managed-review-patch-unreadable');
40
53
  return { patch: readOnce(boundary, name, bytes, digest).toString('utf8'), digest };
41
54
  }
@@ -44,8 +57,8 @@ export async function readPatchSnapshot(boundary, scope = null) {
44
57
  // Compared before and after a command, it names the files that command
45
58
  // changed, so a conversation answers for its own changes, not for another
46
59
  // conversation's in the same folder.
47
- export async function readChangedFiles(boundary) {
48
- const { digest, bytes, name } = await worker(boundary, 'files');
60
+ export async function readChangedFiles(boundary, base = null) {
61
+ const { digest, bytes, name } = await worker(boundary, 'files', null, base);
49
62
  if (!/^files-[0-9a-f-]{36}\.json$/.test(name ?? '')) throw Error('managed-review-patch-unreadable');
50
63
  return new Map(Object.entries(JSON.parse(readOnce(boundary, name, bytes, digest).toString('utf8'))));
51
64
  }
@@ -74,6 +87,9 @@ export function reviewUnavailableReason(message) {
74
87
  return 'failed';
75
88
  }
76
89
 
90
+ // What the diff is taken against: HEAD, or the commit the turn started from
91
+ // (commits made during the turn are part of the change).
92
+ const against = value => value?.base ? `diff against the commit the turn started from (${String(value.base).slice(0, 12)}), commits made since included` : 'diff against HEAD';
77
93
  // The patch as a reviewer reads it: the diff, then each new file in full.
78
94
  export function reviewText(snapshot) {
79
95
  let value;
@@ -82,7 +98,7 @@ export function reviewText(snapshot) {
82
98
  : f.mode === 'directory' ? `=== new folder ${f.path} (a repository of its own, not reviewed)`
83
99
  : f.encoding === 'omitted' ? `=== new file ${f.path} (${f.bytes} bytes, too large to review)`
84
100
  : f.encoding === 'utf8' ? `=== new file ${f.path}\n${f.contents}` : `=== new binary file ${f.path} (${Math.round(f.contents.length * 0.75)} bytes)`);
85
- return `Patch snapshot ${snapshot.digest}:\n--- diff against HEAD ---\n${value.patch || '(no changes to tracked files)'}\n${files.length ? `--- new files ---\n${files.join('\n\n')}` : ''}`;
101
+ return `Patch snapshot ${snapshot.digest}:\n--- ${against(value)} ---\n${value.patch || '(no changes to tracked files)'}\n${files.length ? `--- new files ---\n${files.join('\n\n')}` : ''}`;
86
102
  }
87
103
 
88
104
  // A patch larger than one reviewer reads at once (REVIEW_PART_BYTES, about
@@ -125,7 +141,7 @@ export function reviewParts(snapshot, size = REVIEW_PART_BYTES) {
125
141
  const parts = groups.map((group, i) => {
126
142
  const files = group.sections.map(s => s.file), diff = group.sections.filter(s => s.kind === 'diff').map(s => s.text).join(''), news = group.sections.filter(s => s.kind === 'new').map(s => s.text);
127
143
  const head = groups.length === 1 ? `Patch snapshot ${snapshot.digest}.` : `Patch snapshot ${snapshot.digest}, part ${i + 1} of ${groups.length}. This part's files: ${files.join(', ')}. The other parts are reviewed separately: judge this part, and read other files only for context.`;
128
- return { files, text: `${head}${skipped}\n--- diff against HEAD ---\n${diff || '(no changes to tracked files in this part)'}\n${news.length ? `--- new files ---\n${news.join('\n\n')}` : ''}` };
144
+ return { files, text: `${head}${skipped}\n--- ${against(value)} ---\n${diff || '(no changes to tracked files in this part)'}\n${news.length ? `--- new files ---\n${news.join('\n\n')}` : ''}` };
129
145
  });
130
146
  return { parts: parts.length ? parts : [{ files: [], text: `Patch snapshot ${snapshot.digest}: every changed file is too large to review.${skipped}` }], omitted };
131
147
  }
@@ -82,3 +82,23 @@ export async function executeRepositoryFetch(boundary,plan,signal,{transport=fet
82
82
  return {content:[{type:'text',text:`Fetched origin branches for ${plan.repository}. Working files and current branch were not changed.`}],details:{operation:'git.fetch',repository:plan.repository}};
83
83
  } finally {fs.rmSync(staging,{recursive:true,force:true});}
84
84
  }
85
+
86
+ // The fetch_origin tool of a company conversation: one approved fetch of the
87
+ // GitHub origin (Bypass fetches without asking).
88
+ export function fetchOriginTool(self) {
89
+ return {name:'fetch_origin',label:'Fetch origin',description:'Request one approved GitHub origin fetch. Refreshes origin branches without changing working files. Does not expose credentials or allow arbitrary network commands.',
90
+ parameters:{type:'object',properties:{},additionalProperties:false},
91
+ execute:async(id,_args,signal,_update,ctx)=>{
92
+ const plan=await repositoryFetchPlan(self.boundary);
93
+ if(self.permission==='trusted-full-access'&&!signal?.aborted)return executeRepositoryFetch(self.boundary,plan,signal);
94
+ const {piApprovalBroker}=await import('../runtime/inspection/approval-broker.ts');
95
+ const decision=await piApprovalBroker.request({cwd:self.cwd,rawSessionId:self.session.sessionManager.getSessionId(),toolCallId:id,
96
+ action:{kind:'external-provider-action',preconditionClass:'runtime-only',toolName:'fetch_origin',rawAction:plan,
97
+ targetPaths:[self.cwd],provider:'github',urlOrigin:'https://github.com',requestedScope:'fetch-origin-once',
98
+ reason:`Fetch origin branches from ${plan.repository}`,riskClass:'low',allowConsequence:'Download origin branches once; keep the current branch and working files unchanged.',denyConsequence:'No network request or credential read.'},
99
+ terminalConfirm:()=>ctx?.ui?.confirm?.('Tải nhánh từ origin',`Tải các nhánh của ${plan.repository} về ${self.cwd} một lần.\n\nĐồng ý: chỉ tải nhánh; nhánh hiện tại và file đang làm không đổi.\nTừ chối: không gửi request mạng, không đọc thông tin đăng nhập.`)??Promise.resolve(false),
100
+ unavailableFallback:'terminal-confirm',recheck:()=>!signal?.aborted&&Boolean(self.grant)});
101
+ if(!decision.allowed||!decision.consume()||signal?.aborted)throw Error('managed-operation-denied');
102
+ return executeRepositoryFetch(self.boundary,plan,signal);
103
+ }};
104
+ }
@@ -186,3 +186,31 @@ function capacityStatus(owner) {
186
186
  try { owner.session?.extensionRunner?.getUIContext?.()?.setWorkingMessage?.(wait ? `Agent Watch ${who}: waiting for a free company model account (${failureReason(wait.code)}). Esc stops.` : undefined); }
187
187
  catch { /* no Terminal */ }
188
188
  }
189
+
190
+ // A company answer that failed for a passing reason after Studio admitted it
191
+ // (the model's stream was cut or came back empty, the model or Studio's
192
+ // gateway did not answer, Studio could not be reached) is asked again by Pi's
193
+ // own turn retry: up to twice, 2 then 4 s apart, with the failed attempt left
194
+ // out of what the model sees. Tools run only after a whole answer, so nothing
195
+ // the agent did is repeated; the price is the request's input, billed again.
196
+ // Refusals before admission are asked again earlier, in wrapRoleStreams;
197
+ // everything else (quota, key, policy, a rejected or refused request, a closed
198
+ // run) fails at once, as before. Pi decides by the words of an error, which
199
+ // for a company failure would match "rate limited" or "timeout" in sentences
200
+ // that must not be retried; here it decides by Studio's code only.
201
+ export const TURN_RETRY_CODES = new Set(['upstream_interrupted', 'upstream_incomplete', 'upstream_timeout', 'upstream_unavailable',
202
+ 'connector_unavailable', 'connector_execution_failed', 'inference_unavailable', 'studio-unreachable']);
203
+ export const TURN_RETRY = { maxRetries: 2, baseDelayMs: 2_000 };
204
+
205
+ export function companyTurnRetryable(message) {
206
+ return message?.stopReason === 'error' && TURN_RETRY_CODES.has(failureCode(message.errorMessage));
207
+ }
208
+
209
+ // True when the session retries company failures by code. A Pi whose session
210
+ // no longer has the hook keeps retry off rather than retrying by words.
211
+ export function enableCompanyTurnRetry(session, settings) {
212
+ if (typeof session?._isRetryableError !== 'function') return false;
213
+ session._isRetryableError = companyTurnRetryable;
214
+ settings.applyOverrides({ retry: { enabled: true, ...TURN_RETRY } });
215
+ return true;
216
+ }
@@ -13,10 +13,17 @@ const MAX_SNAPSHOT = 16 * 1024 * 1024, MAX_UNTRACKED = 2000, READ_FILE = 400_000
13
13
  const git = args => execFileSync(managedGit(), ['--no-pager', ...args], { encoding: 'utf8', maxBuffer: MAX_SNAPSHOT + 1, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' } });
14
14
  const sha256 = data => createHash('sha256').update(data).digest('hex');
15
15
  const mode = process.argv[2];
16
+ // The arguments after the mode: a scope file, then the commit the patch starts
17
+ // from (the turn's starting HEAD; commits made since are part of the change).
18
+ // While HEAD has not moved, the patch is the plain one against HEAD.
19
+ const head = () => { try { return git(['rev-parse', '--verify', '-q', 'HEAD^{commit}']).trim(); } catch { return ''; } };
20
+ if (mode === 'head') { process.stdout.write(head() || 'none'); process.exit(0); }
21
+ const extra = process.argv.slice(3), given = extra.find(a => /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(a));
22
+ const base = given && given !== head() ? given : 'HEAD';
16
23
  // "files": what each changed or new file holds now (a hash, "deleted"), so
17
24
  // the runtime can tell which files one conversation's command changed.
18
25
  if (mode === 'files') {
19
- const changed = git(['diff', '--name-only', '--no-renames', '--relative', '-z', 'HEAD', '--', '.']).split('\0').filter(Boolean);
26
+ const changed = git(['diff', '--name-only', '--no-renames', '--relative', '-z', base, '--', '.']).split('\0').filter(Boolean);
20
27
  const fresh = git(['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean);
21
28
  if (changed.length + fresh.length > MAX_UNTRACKED * 4) throw Error('review-untracked-limit');
22
29
  const print = name => {
@@ -33,11 +40,11 @@ if (mode === 'files') {
33
40
  }
34
41
  // A scope (a file the runtime wrote in this boundary's temporary directory):
35
42
  // only these paths, the ones this conversation changed, make the patch.
36
- const scopeName = process.argv[3];
43
+ const scopeName = extra.find(a => a !== given);
37
44
  if (scopeName !== undefined && !/^scope-[0-9a-f-]{36}\.json$/.test(scopeName)) throw Error('review-scope-invalid');
38
45
  const scope = scopeName ? new Set(JSON.parse(fs.readFileSync(path.join(os.tmpdir(), scopeName), 'utf8'))) : null;
39
46
  if (scope && [...scope].some(p => typeof p !== 'string' || !p || path.isAbsolute(p) || p.split('/').includes('..'))) throw Error('review-scope-invalid');
40
- const patch = scope && !scope.size ? '' : git(['diff', '--binary', '--no-ext-diff', '--no-textconv', '--no-renames', 'HEAD', '--', ...(scope ? [...scope].sort().map(p => ':(literal)' + p) : ['.'])]);
47
+ const patch = scope && !scope.size ? '' : git(['diff', '--binary', '--no-ext-diff', '--no-textconv', '--no-renames', base, '--', ...(scope ? [...scope].sort().map(p => ':(literal)' + p) : ['.'])]);
41
48
  const paths = git(['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean).filter(p => !scope || scope.has(p)).sort();
42
49
  if (paths.length > MAX_UNTRACKED) throw Error('review-untracked-limit');
43
50
  const untracked = paths.map(name => {
@@ -55,7 +62,7 @@ const untracked = paths.map(name => {
55
62
  if (text !== null) return { path: name, mode, contents: text, encoding: 'utf8' };
56
63
  return stat.size > BINARY_INLINE ? { path: name, mode, encoding: 'omitted', bytes: stat.size, sha256: sha256(data) } : { path: name, mode, contents: data.toString('base64'), encoding: 'base64' };
57
64
  });
58
- const snapshot = Buffer.from(JSON.stringify({ patch, untracked }));
65
+ const snapshot = Buffer.from(JSON.stringify(base === 'HEAD' ? { patch, untracked } : { base, patch, untracked }));
59
66
  if (snapshot.length > MAX_SNAPSHOT) throw Error('review-patch-limit');
60
67
  // "digest": the digest and size only (is a review still current?). "file":
61
68
  // the snapshot goes to a new file in this boundary's temporary directory,
@@ -7,8 +7,13 @@ const OFFLINE = /ENOTFOUND|EAI_AGAIN|Could not resolve host|getaddrinfo|npm erro
7
7
  // name that does not resolve is the machine's network, not the sandbox.
8
8
  // `isolated`: a proxy listens on this Mac, so without approval a command does
9
9
  // not reach this Mac's own servers either (tool-boundary.mjs).
10
- export function sandboxDiagnostic(message, { network = false, isolated = false } = {}) {
10
+ // `docker`: the command ran with the Docker engine (run_with_docker);
11
+ // `engine`: one is there for run_with_docker, but this command ran without it.
12
+ 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;
13
+ export function sandboxDiagnostic(message, { network = false, isolated = false, docker = false, engine = false } = {}) {
11
14
  const text=String(message);
15
+ if(docker&&DOCKER_DAEMON.test(text))return 'managed-docker-unreachable: Lệnh chạy với Docker nhưng Docker engine không trả lời (Docker Desktop/Colima chưa chạy hoặc đang khởi động). Không xin duyệt lại cùng lệnh; báo người dùng mở Docker rồi thử lại.\n'+text;
16
+ 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: Máy chưa có Docker engine đang chạy (Docker Desktop, Colima, OrbStack…). Báo người dùng mở Docker; sau đó cuộc trò chuyện mới có run_with_docker.\n')+text;
12
17
  if(isolated&&/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;
13
18
  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 đang offline, cần VPN/proxy công ty, hoặc sai tên host). Không xin duyệt lại cùng lệnh; báo người dùng lỗi mạng này.\n'+text;
14
19
  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;