@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
@@ -11,19 +11,21 @@ import { searchForRole } from './web-search.mjs';
11
11
  import { shareGrant, releaseGrant } from './grant-share.mjs';
12
12
  import { managedThinkingLevels, nearestLevel } from './capabilities.mjs';
13
13
  import { nativeManagedModel, isVendor, piProvider, studioAPI, studioPath } from './native-catalog.mjs';
14
- import { repositoryFetchPlan, executeRepositoryFetch } from './repository-operation.mjs';
14
+ import { fetchOriginTool } from './repository-operation.mjs';
15
15
  import { describeFailure, failureCode, failureText } from '../runtime/managed-failure.mjs';
16
- import { wrapRoleStreams } from './request-stream.mjs';
16
+ import { enableCompanyTurnRetry, wrapRoleStreams } from './request-stream.mjs';
17
17
  import { runHelper } from './helper-run.mjs';
18
18
  import { askTool } from './member-questions.mjs';
19
19
  import { setTimeout as sleep } from 'node:timers/promises';
20
20
  import { stageCompaction, compactEarly, backOffFailedCompaction } from './compaction.mjs';
21
- import { readPatchDigest, readPatchSnapshot, readChangedFiles } from './patch-snapshot.mjs';
22
- import { workflowPolicy, repositoryChecks, planTool, currentPlan, PLAN_ENTRY, workflowPrompt, RunProcess, completionGate, pendingBaseline, pendingPaths, processEditTools } from './workflow.mjs';
21
+ import { readPatchDigest, readPatchSnapshot, readChangedFiles, readHead } from './patch-snapshot.mjs';
22
+ import { memberTurn } from './continuation.mjs';
23
+ import { workflowPolicy, repositoryChecks, planTool, currentPlan, PLAN_ENTRY, workflowPrompt, RunProcess, completionGate, pendingBaseline, pendingPaths, pendingHead, processEditTools } from './workflow.mjs';
23
24
  import { beforeDelegate } from './objections.mjs';
24
25
  import { HELPER_CALLS, HELPER_ROLES, helperRoles, helperPrompt, webPrompt, delegateDescription } from './helper-roles.mjs';
25
26
  import { loadAgentResources } from './agent-skills.mjs';
26
- import { restorePermission, permissionSetter, networkConfirmation } from './permission.mjs';
27
+ import { restorePermission, permissionSetter } from './permission.mjs';
28
+ import { commandTools, servicesPrompt } from './command-tools.mjs';
27
29
 
28
30
  const PROVIDER = 'agent_watch_managed';
29
31
  // Who the agent is: the model account may put another product's name in an
@@ -33,10 +35,10 @@ const BASE_PROMPT = 'You are Piagent, the company coding assistant. If an earlie
33
35
  // they name) are readable for reference; the sandbox keeps them read-only.
34
36
  const referencePrompt = home => ` The member may point you to folders outside the project for reference (an @path mention or a folder they name): read them with read, ls, grep and find. They are read-only: changes go in the project only. In those tools ~ is the member's home folder (${home}); in bash ~ is a private empty folder, so use absolute paths there.`;
35
37
  // Tests in the sandbox (tool-boundary.mjs, language-environment.mjs).
36
- const TEST_PROMPT = ' To run unit and integration tests, use bash. Installing packages (npm, pnpm, pip, uv, go, cargo, gradle, maven…) needs run_with_network once; the project\'s package caches are kept, so later runs work offline. '
38
+ const TEST_PROMPT = ' To run unit and integration tests, use bash. Installing packages (npm, pnpm, pip, uv, go, cargo, gradle, maven…) needs run_with_network once; the project\'s package caches are kept, so later runs work offline. ./mvnw and ./gradlew reuse the Maven and Gradle they already unpacked on this Mac; Maven\'s files are under $MAVEN_USER_HOME and $GRADLE_USER_HOME, not ~/.m2 or ~/.gradle (closed here). '
37
39
  + (process.platform === 'linux'
38
- ? 'A command without network reaches only the servers it starts itself, which end with it: start a test server and run the tests in one command; a database or service already running on this machine needs run_with_network. Docker is not available in the sandbox: ask the member to start those services.'
39
- : 'Servers on localhost (a test\'s own server, a local database) are reachable, except while a local proxy runs (the error says so: then use run_with_network). Docker and the iOS Simulator are not available in the sandbox: ask the member to start those services or run those tests.');
40
+ ? 'A command without network reaches only the servers it starts itself, which end with it: start a test server and run the tests in one command; a database or service already running on this machine needs run_with_network.'
41
+ : 'Servers on localhost (a test\'s own server, a local database) are reachable, except while a local proxy runs (the error says so: then use run_with_network).');
40
42
  const textContent = result => result.content?.filter(p => p.type === 'text').map(p => p.text).join('\n') ?? '';
41
43
  export function taskClass(text) {
42
44
  // Advisory hint only. Studio owns allowed models, budgets and run authority.
@@ -106,41 +108,12 @@ export class ManagedSession {
106
108
  const model = self.installModel(self.modelRuntime, preview);
107
109
  self.wrapStreams(self.modelRuntime, 'main');
108
110
  const customTools = processEditTools(self, self.boundary.tools(api));
109
- customTools.push({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.',
110
- parameters:{type:'object',properties:{},additionalProperties:false},
111
- execute:async(id,_args,signal,_update,ctx)=>{
112
- const plan=await repositoryFetchPlan(self.boundary);
113
- if(self.permission==='trusted-full-access'&&!signal?.aborted)return executeRepositoryFetch(self.boundary,plan,signal);
114
- const {piApprovalBroker}=await import('../runtime/inspection/approval-broker.ts');
115
- const decision=await piApprovalBroker.request({cwd:self.cwd,rawSessionId:self.session.sessionManager.getSessionId(),toolCallId:id,
116
- action:{kind:'external-provider-action',preconditionClass:'runtime-only',toolName:'fetch_origin',rawAction:plan,
117
- targetPaths:[self.cwd],provider:'github',urlOrigin:'https://github.com',requestedScope:'fetch-origin-once',
118
- 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.'},
119
- 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),
120
- unavailableFallback:'terminal-confirm',recheck:()=>!signal?.aborted&&Boolean(self.grant)});
121
- if(!decision.allowed||!decision.consume()||signal?.aborted)throw Error('managed-operation-denied');
122
- return executeRepositoryFetch(self.boundary,plan,signal);
123
- }});
111
+ customTools.push(fetchOriginTool(self));
124
112
  // With a research helper the web is its work: search results and pages
125
113
  // are read on its cheaper model, not in the main agent's context.
126
114
  if (!helperRoles(manifest).includes('research')) customTools.push(...self.webTools(self.modelRuntime, 'main'));
127
- customTools.push({name:'run_with_network',label:'Run with network',description:'Run one shell command that needs the internet (package install, download, git pull of a public repository), 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.',
128
- parameters:{type:'object',properties:{command:{type:'string',minLength:1,maxLength:4000},reason:{type:'string',minLength:1,maxLength:300},timeout:{type:'number',minimum:1,maximum:1800}},required:['command','reason'],additionalProperties:false},
129
- execute:async(id,args,signal,onUpdate,ctx)=>{
130
- // Bypass runs it without asking, unless it is one the member must confirm.
131
- const confirm=await networkConfirmation(self,args.command);
132
- const decision=confirm===null?{allowed:true,consume:()=>true}:await (await import('../runtime/inspection/approval-broker.ts')).piApprovalBroker.request({cwd:self.cwd,rawSessionId:self.session.sessionManager.getSessionId(),toolCallId:id,
133
- action:{kind:'external-provider-action',preconditionClass:'runtime-only',toolName:'run_with_network',rawAction:{command:args.command},commandPreview:String(args.command),
134
- targetPaths:[self.cwd],provider:'network',urlOrigin:null,requestedScope:'network-command-once',reason:(confirm==='ask'?String(args.reason):`Bypass still asks: ${confirm}. ${args.reason}`).slice(0,300),riskClass:'medium',
135
- allowConsequence:'Run this exact command once, with internet access; a server it starts accepts connections while it runs.',denyConsequence:'The command does not run; the agent is told you declined.'},
136
- terminalConfirm:()=>ctx?.ui?.confirm?.('Chạy lệnh có internet',`${args.command}\n\nLý do: ${args.reason}${confirm==='ask'?'':`\nBypass vẫn hỏi: ${confirm}`}\n\nĐồng ý: 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.\nTừ chối: lệnh không chạy, agent được báo bạn đã từ chối.`)??Promise.resolve(false),
137
- unavailableFallback:'terminal-confirm',recheck:()=>!signal?.aborted&&Boolean(self.grant)});
138
- if(!decision.allowed||!decision.consume()||signal?.aborted)throw Error('managed-operation-denied');
139
- let ok=false;
140
- const before=await self.changedFiles();
141
- try { const result=await self.boundary.invoke('bash',{command:args.command,...(args.timeout?{timeout:args.timeout}:{})},signal,onUpdate,ctx?.model,{network:true}); ok=!result?.isError; return result; }
142
- finally { await self.claimChanges(before); await self.run?.afterTool('bash',args,ok,()=>self.digest()); await self.refreshReview(); }
143
- }});
115
+ // Commands with network, and with Docker when this machine has an engine.
116
+ customTools.push(...commandTools(self));
144
117
  // Every helper role passes the tool's schema: Pi fixes a tool's schema
145
118
  // and description when the conversation opens, so a helper a Harness
146
119
  // saved later adds must already pass it. The system prompt, rebuilt each
@@ -160,7 +133,7 @@ export class ManagedSession {
160
133
  const agentsFiles = projectInstructions(self.cwd, self.boundary.repositoryTop);
161
134
  self.checks = repositoryChecks(agentsFiles, self.cwd, self.boundary.repositoryTop);
162
135
  // The Harness workflow (possibly changed on a later enrollment) adds its process to the prompt.
163
- const loader = managedResourceLoader(api, { systemPrompt: () => BASE_PROMPT + referencePrompt(self.boundary.userHome) + TEST_PROMPT + webPrompt(helperRoles(manifest)) + helperPrompt(helperRoles(self.manifest)) + workflowPrompt(workflowPolicy(self.manifest), self.checks), agentsFiles,
136
+ const loader = managedResourceLoader(api, { systemPrompt: () => BASE_PROMPT + referencePrompt(self.boundary.userHome) + TEST_PROMPT + servicesPrompt(self.boundary) + webPrompt(helperRoles(manifest)) + helperPrompt(helperRoles(self.manifest)) + workflowPrompt(workflowPolicy(self.manifest), self.checks), agentsFiles,
164
137
  skills: self.resources.skills, prompts: self.resources.prompts, ...await inlineExtensions(api, sdk, self.cwd, terminalExtensions) });
165
138
  const settings = api.SettingsManager.inMemory({ retry: { enabled: false, provider: { maxRetries: 0 } }, cacheWarming: 'off', enableInstallTelemetry: false, enableAnalytics: false, enableSkillCommands: true });
166
139
  const manager = sessionManager ?? api.SessionManager.inMemory(self.cwd);
@@ -191,6 +164,7 @@ export class ManagedSession {
191
164
  self.extensionsResult = result.extensionsResult;
192
165
  self.session = result.session;
193
166
  self.session.managedExecution = true;
167
+ self.turnRetry = enableCompanyTurnRetry(self.session, settings);
194
168
  // The member's access for this conversation: ask first, or Bypass (permission.mjs).
195
169
  self.permission = restorePermission(manager); self.session.managedSetPermission = permissionSetter(self, manager);
196
170
  // A helper's receipt (its tokens, the patch it read) is for the member's
@@ -218,9 +192,10 @@ export class ManagedSession {
218
192
  };
219
193
  self.review = manager.getEntries().filter(e => e.type === 'custom' && e.customType === 'agent-watch-review').at(-1)?.data ?? null;
220
194
  const abort = self.session.abort.bind(self.session);
221
- self.session.abort = async (...args) => { self.gateAbort?.abort(); return abort(...args); };
222
- // One member message is one run, harness rounds included (checks, review,
223
- // fixes after the answer): listeners hear "settled" once, when it ends.
195
+ self.session.abort = async (...args) => { self.gateAbort?.abort(); if (self.activePrompt) self.stopRequested = true; return abort(...args); };
196
+ // One member message is one turn, harness rounds included (checks, review,
197
+ // fixes after the answer, rounds that continue an unfinished checklist):
198
+ // listeners hear "settled" once, when it ends.
224
199
  const subscribe = self.session.subscribe.bind(self.session), listeners = self.listeners;
225
200
  self.session.subscribe = listener => {
226
201
  listeners.add(listener);
@@ -228,14 +203,8 @@ export class ManagedSession {
228
203
  return () => { listeners.delete(listener); off(); };
229
204
  };
230
205
  const prompt = self.session.prompt.bind(self.session);
231
- self.session.prompt = async (text, options) => {
232
- // An extension command (/bypass, /piagent-update) runs here, never as a turn.
233
- if (/^\/\S/.test(text) && self.session.extensionRunner?.getCommand(text.slice(1).split(/\s/)[0])) return prompt(text, options);
234
- // /skill:name, /command: the run sees what the member asked for in full.
235
- text = self.resources.expand(text);
236
- if (self.starting) await self.starting;
237
- if (self.activePrompt) return prompt(text, options); // steering/follow-up keep the root
238
- await self.refreshReview();
206
+ // One run: started for the round's request, closed with its report.
207
+ const runRound = async (text, deliver) => {
239
208
  const start = async () => {
240
209
  // The Harness (now possibly changed) decides which levels exist; ask
241
210
  // for the member's level, or the nearest one it offers.
@@ -281,14 +250,18 @@ export class ManagedSession {
281
250
  });
282
251
  self.run = new RunProcess(workflowPolicy(self.manifest), { request: text, complex: taskClass(text) === 'complex', checks: self.checks });
283
252
  // The files this conversation changed and has not settled: carried
284
- // over from a turn that was cut, otherwise none yet.
253
+ // over from a turn that was cut, otherwise none yet. The code is
254
+ // compared with the commit the turn started from, so a commit made
255
+ // during the turn is still its change.
285
256
  const pending = pendingBaseline(manager);
286
257
  self.ownPaths = new Set(pending !== null ? pendingPaths(manager) : []);
258
+ self.base = (pending !== null ? pendingHead(manager) : null) ?? await readHead(self.boundary);
287
259
  self.run.startDigest = pending ?? await self.digest();
288
260
  } catch (error) { self.preflight = describeFailure('main', failureText(error)); }
289
261
  self.activePrompt = true;
290
262
  })();
291
263
  try { await self.starting; } finally { self.starting = null; }
264
+ const round = { result: undefined, preflight: !!self.preflight, planUpdated: false, changed: false };
292
265
  try {
293
266
  // The last request of a long conversation was refused as too large:
294
267
  // by the model (it no longer fits the window of the model it runs on
@@ -296,13 +269,16 @@ export class ManagedSession {
296
269
  // model) or by Studio (a body over 4 MiB, before a 1M-token window is
297
270
  // full). The older part is summarised first, so continuing fits.
298
271
  if (!self.preflight && self.refusedAsTooLong()) { try { await self.session.compact(); } catch { /* nothing to compact: the request says why */ } }
299
- const result = await prompt(text, { ...options, expandPromptTemplates: false });
272
+ round.result = await deliver();
300
273
  if (self.run && !self.preflight) {
301
274
  self.gateAbort = new AbortController();
302
275
  try { await completionGate(self, self.run, self.gateAbort.signal); } catch { /* the answer stands; the report keeps what is known */ } finally { self.gateAbort = null; }
303
276
  }
304
- return result;
277
+ return round;
305
278
  } finally {
279
+ // The round's model work is over: a member message now waits for the next round.
280
+ self.between = true;
281
+ Object.assign(round, { planUpdated: !!self.run?.planUpdated, changed: !!self.run?.changed });
306
282
  await Promise.allSettled([...self.helpers.values()]);
307
283
  // What a command left running in the background ends with the turn.
308
284
  try { await self.boundary.stopStrays(); } catch { /* best effort; the boundary stops them on close */ }
@@ -317,12 +293,25 @@ export class ManagedSession {
317
293
  catch (error) { if (!process) throw error; await self.broker.request('close'); }
318
294
  manager.appendCustomEntry('agent-watch-run', { run_id: self.grant.run_id, state: 'closed' });
319
295
  }
320
- } finally {
321
- self.grant = null; self.run = null; self.activePrompt = false; self.preflight = null;
322
- self.notify({ type: 'agent_settled' });
323
- }
296
+ // A run that could not be closed (the network or Agent Watch went away)
297
+ // ends the turn as failed; the run stays "active" here, so reopening
298
+ // the conversation recovers or abandons it.
299
+ } catch (error) { round.closeFailed = failureCode(failureText(error)) || 'close_failed'; }
300
+ finally { self.grant = null; self.run = null; self.preflight = null; }
324
301
  }
325
302
  };
303
+ self.session.prompt = async (text, options) => {
304
+ // An extension command (/bypass, /piagent-update) runs here, never as a turn.
305
+ if (/^\/\S/.test(text) && self.session.extensionRunner?.getCommand(text.slice(1).split(/\s/)[0])) return prompt(text, options);
306
+ // /skill:name, /command: the run sees what the member asked for in full.
307
+ text = self.resources.expand(text);
308
+ if (self.starting) await self.starting;
309
+ // Between two rounds no model runs: the message waits for the next one.
310
+ if (self.between) return new Promise((resolve, reject) => self.queued.push({ text, options, resolve, reject }));
311
+ if (self.activePrompt) return prompt(text, options); // steering/follow-up keep the root
312
+ await self.refreshReview();
313
+ return memberTurn(self, { manager, prompt, runRound }, text, () => prompt(text, { ...options, expandPromptTemplates: false }));
314
+ };
326
315
  // Native ! commands use the same worker boundary as model bash calls.
327
316
  self.session.executeBash = async command => {
328
317
  try {
@@ -432,14 +421,14 @@ export class ManagedSession {
432
421
  // Whether this conversation changed code, and whether a check ran on it,
433
422
  // look only at the files it changed: another conversation (or the member)
434
423
  // changing other files in the same folder is not this run's change.
435
- async changeDigest() { return readPatchDigest(this.boundary, this.ownPaths); }
424
+ async changeDigest() { return readPatchDigest(this.boundary, this.ownPaths, this.base); }
436
425
  async folderDigest() { try { return await readPatchDigest(this.boundary); } catch { return null; } }
437
426
  // What a reviewer reads: this conversation's files; before it changed any,
438
427
  // the whole folder (the member's own changes, asked to be reviewed).
439
428
  reviewScope() { return this.ownPaths.size ? this.ownPaths : null; }
440
- async patchSnapshot() { return readPatchSnapshot(this.boundary, this.reviewScope()); }
441
- async patchDigest() { return readPatchDigest(this.boundary, this.reviewScope()); }
442
- async changedFiles() { try { return await readChangedFiles(this.boundary); } catch { return null; } }
429
+ async patchSnapshot() { return readPatchSnapshot(this.boundary, this.reviewScope(), this.base); }
430
+ async patchDigest() { return readPatchDigest(this.boundary, this.reviewScope(), this.base); }
431
+ async changedFiles() { try { return await readChangedFiles(this.boundary, this.base); } catch { return null; } }
443
432
  // A write or edit names its file; a command, the files it changed.
444
433
  claimPath(raw) {
445
434
  if (typeof raw !== 'string' || !raw) return;
@@ -8,6 +8,7 @@ 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 { containerEngine, engineEnvironment } from './container-engine.mjs';
11
12
  import { BWRAP, bubblewrapArgs, credentialFiles, referenceFolders } from './linux-sandbox.mjs';
12
13
  import { agentWatchDataDirectory } from './store.mjs';
13
14
 
@@ -38,7 +39,7 @@ function referenceRules(home, roots) {
38
39
  (deny file-read-data file-read-xattr (require-all (require-any (regex #"^${pattern(home)}/\\.") (subpath ${literal(path.join(home, 'Library'))}))${opened}))`;
39
40
  }
40
41
 
41
- export function managedSeatbelt({ cwd, sdkRoot, runtimeRoot, temporary, node, denied = [], readOnly = false, repository = [], gitDirs = [], toolchains = [], developer = [], browsers = [], network = false, references = null, skills = [], caches = [], loopback = true, license = null }) {
42
+ export function managedSeatbelt({ cwd, sdkRoot, runtimeRoot, temporary, node, denied = [], readOnly = false, repository = [], gitDirs = [], toolchains = [], developer = [], browsers = [], network = false, references = null, skills = [], caches = [], loopback = true, license = null, docker = null }) {
42
43
  // Default-deny protects Keychain/SSH agent/Watch IPC and process inspection.
43
44
  // System libraries/toolchains are read-only. Writes stay in this project and
44
45
  // a fresh temporary directory. No network exception or unsandboxed fallback.
@@ -97,7 +98,8 @@ ${network ? `(allow network-outbound (remote ip))
97
98
  (allow system-socket)
98
99
  (allow mach-lookup (global-name "com.apple.dnssd.service") (global-name "com.apple.trustd.agent") (global-name "com.apple.SystemConfiguration.configd"))
99
100
  (allow mach-lookup (global-name "com.apple.SecurityServer") (global-name "com.apple.ocspd"))
100
- (allow file-read* (subpath "/private/var/db/mds") (literal "/Library/Keychains/System.keychain") (subpath "/Library/Security/Trust Settings"))` : loopback ? '(allow network-outbound (remote ip "localhost:*"))' : ''}
101
+ (allow file-read* (subpath "/private/var/db/mds") (literal "/Library/Keychains/System.keychain") (subpath "/Library/Security/Trust Settings"))${docker ? `
102
+ (allow network-outbound (literal ${literal(docker)}))` : ''}` : loopback ? '(allow network-outbound (remote ip "localhost:*"))' : ''}
101
103
  ${denied.map(root => `(deny file-read* file-write* (require-all (subpath ${literal(root)})${skills.filter(dir => inside(dir, root) && dir !== root).map(dir => ` (require-not (subpath ${literal(dir)}))`).join('')}))`).join('\n')}
102
104
  (deny file-read* file-read-metadata file-write* (require-all (require-any (regex #"(^|/)(\\.env([./]|$)|credentials([./]|$)|\\.npmrc$|\\.netrc$)") (regex #"/\\.[^/]+/auth\\.json$") ${projectRoots.map(root => `(literal ${literal(path.join(root, 'auth.json'))})`).join(' ')})
103
105
  (require-not (regex #"(^|/)\\.env\\.(example|sample|template|dist|defaults)$"))))
@@ -227,6 +229,8 @@ export class ManagedToolBoundary {
227
229
  const toolchains = userToolchains(userHome, this.node), developer = developerTools(), browsers = playwrightBrowsers(userHome);
228
230
  const languages = languageEnvironment({ userHome, repositoryTop: this.repositoryTop, home: this.home });
229
231
  this.packageCache = languages.cache;
232
+ // The Docker engine, for commands the member approves (run_with_docker).
233
+ this.engine = readOnly ? null : containerEngine({ userHome });
230
234
  // A skill folder opens for reading only inside the agent folders (.claude,
231
235
  // .codex, .pi, .agents) or outside the home folder's hidden entries, and
232
236
  // never when it holds a closed folder (a link to ~/.ssh, the home folder).
@@ -245,7 +249,7 @@ export class ManagedToolBoundary {
245
249
  if (this.linux) {
246
250
  const references = referenceFolders(userHome).filter(dir => !narrowed.some(root => inside(dir, root) || inside(root, dir)));
247
251
  const empty = path.join(this.temporary, 'empty'); fs.writeFileSync(empty, '', { mode: 0o400 });
248
- this.linuxBase = { ...base, toolchains: [...base.toolchains, ...base.browsers], references, empty };
252
+ this.linuxBase = { ...base, toolchains: [...base.toolchains, ...base.browsers], references, empty, engineRoots: this.engine?.readRoots ?? [] };
249
253
  this.referenceCredentials = credentialFiles(references, { limit: 30_000 });
250
254
  this.projectCredentials = { at: 0, files: [] };
251
255
  } else {
@@ -254,11 +258,14 @@ export class ManagedToolBoundary {
254
258
  this.isolatedProfile = managedSeatbelt({ ...base, references: userHome, loopback: false });
255
259
  this.proxyPorts = proxyPorts ?? localProxyPorts(); this.proxyCheck = { at: 0, listening: Promise.resolve(false) };
256
260
  this.networkProfile = readOnly ? null : managedSeatbelt({ ...base, network: true });
261
+ // An approved network command that also reaches the Docker engine's socket.
262
+ this.dockerProfile = this.engine ? managedSeatbelt({ ...base, network: true, docker: this.engine.socket, toolchains: [...base.toolchains, ...this.engine.readRoots] }) : null;
257
263
  }
258
264
  // Every process a command starts carries this marker, also one it detaches.
259
265
  this.scope = randomUUID();
260
266
  this.environment = toolEnvironment({ git: this.git, node: this.node, home: this.home, temporary: this.temporary, toolchains, developer, scope: this.scope, browsers, languages,
261
267
  identity: identity !== undefined ? identity : gitIdentity(this.git, this.cwd) });
268
+ if (this.engine) this.environment.docker = { ...this.environment.network, ...engineEnvironment(this.engine, this.home) };
262
269
  if (searchTools) provisionSearchTools(this.home, userHome);
263
270
  else fs.writeFileSync(path.join(this.home, '.ripgreprc'), '', { mode: 0o600 });
264
271
  this.allowed = readOnly ? TOOL_NAMES.filter(name => !['write', 'edit'].includes(name) && (name !== 'bash' || commands)) : [...TOOL_NAMES];
@@ -274,7 +281,9 @@ export class ManagedToolBoundary {
274
281
  this.#tail = run.catch(() => {}); return run;
275
282
  } }));
276
283
  }
277
- async invoke(name, args, signal, onUpdate, model, { network = false } = {}) {
284
+ async invoke(name, args, signal, onUpdate, model, { network = false, docker = false } = {}) {
285
+ if (docker && !this.engine) throw new Error('managed-tool-unavailable');
286
+ network ||= docker;
278
287
  if (this.#closed || !this.allowed.includes(name) || network && (name !== 'bash' || !this.networkAllowed)) throw new Error('managed-tool-unavailable');
279
288
  if (signal?.aborted) throw new Error('managed-tool-cancelled');
280
289
  for (const [file, digest] of this.pins) if (hash(file) !== digest) throw new Error('managed-runtime-changed');
@@ -283,14 +292,14 @@ export class ManagedToolBoundary {
283
292
  if (name !== 'bash' && typeof args?.path === 'string' && /^@?~(\/|$)/.test(args.path)) args = { ...args, path: this.userHome + args.path.replace(/^@?~/, '') };
284
293
  if (!this.linux && !network && Date.now() - this.proxyCheck.at > 5000) this.proxyCheck = { at: Date.now(), listening: localProxyListening(this.proxyPorts) };
285
294
  const isolated = !this.linux && !network && await this.proxyCheck.listening;
286
- const message = JSON.stringify({ name, args, ...(network ? { network: true } : {}), ...(isolated ? { isolated: true } : {}), model: model ? { input: model.input, inputLimits: model.inputLimits } : undefined }) + '\n';
295
+ const message = JSON.stringify({ name, args, ...(network ? { network: true } : {}), ...(isolated ? { isolated: true } : {}), ...(docker ? { docker: true } : this.engine ? { engine: true } : {}), model: model ? { input: model.input, inputLimits: model.inputLimits } : undefined }) + '\n';
287
296
  if (Buffer.byteLength(message) > MAX_WIRE_BYTES) throw new Error('managed-tool-input-too-large');
288
297
  return await new Promise((resolve, reject) => {
289
- const [sandbox, sandboxArgs] = this.linux ? [BWRAP, this.bubblewrap(network)]
290
- : ['/usr/bin/sandbox-exec', ['-p', network ? this.networkProfile : isolated ? this.isolatedProfile : this.profile]];
298
+ const [sandbox, sandboxArgs] = this.linux ? [BWRAP, this.bubblewrap(network, docker)]
299
+ : ['/usr/bin/sandbox-exec', ['-p', docker ? this.dockerProfile : network ? this.networkProfile : isolated ? this.isolatedProfile : this.profile]];
291
300
  const child = spawn(sandbox, [...sandboxArgs, this.node, workerPath, this.sdkPath, this.cwd], {
292
301
  cwd: this.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
293
- env: network ? this.environment.network : this.environment.offline,
302
+ env: docker ? this.environment.docker : network ? this.environment.network : this.environment.offline,
294
303
  });
295
304
  this.#children.add(child);
296
305
  const decoder = new StringDecoder('utf8');
@@ -351,11 +360,13 @@ export class ManagedToolBoundary {
351
360
  }
352
361
  // bubblewrap arguments for one command: credential files of the project are
353
362
  // looked for again every 15 seconds (a file the member just added).
354
- bubblewrap(network) {
363
+ bubblewrap(network, docker = false) {
355
364
  if (Date.now() - this.projectCredentials.at > 15_000)
356
365
  this.projectCredentials = { at: Date.now(), files: credentialFiles([this.cwd, ...this.linuxBase.repository]) };
357
366
  const credentials = [...this.projectCredentials.files, ...(network ? [] : this.referenceCredentials)];
358
- return bubblewrapArgs({ ...this.linuxBase, references: network ? [] : this.linuxBase.references, network, credentials });
367
+ const { engineRoots, ...base } = this.linuxBase;
368
+ return bubblewrapArgs({ ...base, references: network ? [] : base.references, network, credentials,
369
+ ...(docker ? { docker: this.engine.socket, toolchains: [...base.toolchains, ...engineRoots] } : {}) });
359
370
  }
360
371
  // Processes a command left running (`nohup … &`, a detached child) outlive
361
372
  // it with the marker in their environment. They are stopped when a turn ends
@@ -11,7 +11,7 @@ const api = await import(pathToFileURL(sdkPath).href);
11
11
  const factories = { read: api.createReadToolDefinition, write: api.createWriteToolDefinition,
12
12
  edit: api.createEditToolDefinition, bash: api.createBashToolDefinition,
13
13
  grep: api.createGrepToolDefinition, find: api.createFindToolDefinition, ls: api.createLsToolDefinition };
14
- let active = false, controller, network = false, isolated = false;
14
+ let active = false, controller, network = false, isolated = false, docker = false, engine = false;
15
15
  const emit = value => process.stdout.write(JSON.stringify(value) + '\n');
16
16
  const input = createInterface({ input: process.stdin });
17
17
  input.on('line', async line => {
@@ -19,7 +19,7 @@ input.on('line', async line => {
19
19
  const message = JSON.parse(line);
20
20
  if (message.cancel === true) { controller?.abort(); return; }
21
21
  if (active || !Object.hasOwn(factories, message.name)) throw new Error('managed-worker-request-invalid');
22
- active = true; controller = new AbortController(); network = message.network === true; isolated = message.isolated === true;
22
+ active = true; controller = new AbortController(); network = message.network === true; isolated = message.isolated === true; docker = message.docker === true; engine = message.engine === true;
23
23
  const options = message.name === 'bash' ? { shellPath: '/bin/bash', exposeSessionEnvironment: false } : undefined;
24
24
  // The runtime copies ripgrep/fd here when the machine has or can fetch them.
25
25
  const provided = name => fs.existsSync(path.join(process.env.HOME ?? '', '.pi/agent/bin', name));
@@ -30,7 +30,7 @@ input.on('line', async line => {
30
30
  // Successful output is returned verbatim; the sandbox note is only added to
31
31
  // failures (Pi tools throw on denial and on a non-zero shell exit).
32
32
  emit({ result });
33
- } catch (error) { emit({ error: sandboxDiagnostic(error instanceof Error ? error.message : 'managed-worker-failed', { network, isolated }) }); }
33
+ } catch (error) { emit({ error: sandboxDiagnostic(error instanceof Error ? error.message : 'managed-worker-failed', { network, isolated, docker, engine }) }); }
34
34
  finally { if (active) { input.close(); process.stdin.destroy(); } }
35
35
  });
36
36
  process.stdin.on('end', () => controller?.abort());
@@ -76,24 +76,26 @@ export function isCheckCommand(command, declared = []) {
76
76
  // update_plan: the agent's checklist, kept as a session entry so it survives
77
77
  // compaction and "continue", and shown to the member beside the conversation.
78
78
  export const PLAN_ENTRY = 'agent-watch-plan';
79
- const STATUS = ['pending', 'in_progress', 'completed'];
79
+ // "deferred": left for later at the member's word, or no longer needed: not
80
+ // done, not open either.
81
+ const STATUS = ['pending', 'in_progress', 'completed', 'deferred'];
80
82
  export function normalizePlan(args) {
81
83
  const plan = Array.isArray(args?.plan) ? args.plan : [];
82
84
  if (plan.length < 1 || plan.length > 30 || plan.some(p => typeof p?.step !== 'string' || !p.step.trim() || p.step.length > 200 || !STATUS.includes(p.status)))
83
- throw Error('managed-plan-invalid: send 1 to 30 steps, each {step, status} with status pending, in_progress or completed.');
85
+ throw Error('managed-plan-invalid: send 1 to 30 steps, each {step, status} with status pending, in_progress, completed or deferred.');
84
86
  if (plan.filter(p => p.status === 'in_progress').length > 1) throw Error('managed-plan-invalid: mark at most one step in_progress.');
85
87
  return { plan: plan.map(p => ({ step: p.step.trim(), status: p.status })), explanation: typeof args.explanation === 'string' ? args.explanation.slice(0, 300) : '' };
86
88
  }
87
89
  export function currentPlan(manager) {
88
90
  return manager.getEntries().filter(e => e.type === 'custom' && e.customType === PLAN_ENTRY).at(-1)?.data ?? null;
89
91
  }
90
- export const unfinished = plan => !!plan?.plan?.some(p => p.status !== 'completed');
92
+ export const unfinished = plan => !!plan?.plan?.some(p => p.status === 'pending' || p.status === 'in_progress');
91
93
  export function planText(plan) {
92
- return plan?.plan?.map((p, i) => `${i + 1}. [${p.status === 'completed' ? 'x' : p.status === 'in_progress' ? '>' : ' '}] ${p.step}`).join('\n') ?? '';
94
+ return plan?.plan?.map((p, i) => `${i + 1}. [${p.status === 'completed' ? 'x' : p.status === 'in_progress' ? '>' : p.status === 'deferred' ? '-' : ' '}] ${p.step}`).join('\n') ?? '';
93
95
  }
94
96
  export function planTool(run) {
95
97
  return { name: 'update_plan', label: 'Plan',
96
- description: 'Keep a short checklist for work with several steps. Send the whole list each time; mark the step you work on in_progress and finished steps completed. The member sees the checklist next to the conversation.',
98
+ description: 'Keep a short checklist for work with several steps. Send the whole list each time; mark the step you work on in_progress and finished steps completed. Mark a step deferred when the member asked to leave it for later or it is no longer needed, and say why in your answer; never mark completed a step you did not do. The member sees the checklist next to the conversation.',
97
99
  parameters: { type: 'object', properties: { explanation: { type: 'string', maxLength: 300 }, plan: { type: 'array', minItems: 1, maxItems: 30,
98
100
  items: { type: 'object', properties: { step: { type: 'string', minLength: 1, maxLength: 200 }, status: { type: 'string', enum: STATUS } }, required: ['step', 'status'], additionalProperties: false } } },
99
101
  required: ['plan'], additionalProperties: false },
@@ -108,7 +110,7 @@ export function planTool(run) {
108
110
  // The guidance a policy adds to the main agent's system prompt.
109
111
  export function workflowPrompt(policy, checks) {
110
112
  const lines = [];
111
- if (policy.plan !== 'off') lines.push(`- Plan: for work with several steps keep a checklist with update_plan and update it as you go.${policy.plan === 'require' ? ' For a complex task, file edits are refused until a plan exists, and a checklist left unfinished comes back to you before the turn ends.' : ''}`);
113
+ if (policy.plan !== 'off') lines.push(`- Plan: for work with several steps keep a checklist with update_plan and update it as you go. When the member asks for several items (plans, files, tickets), put every item in the checklist and work through all of them; do not end your turn to report between items.${policy.plan === 'require' ? ' For a complex task, file edits are refused until a plan exists. A checklist left with pending steps sends you back to the next one until every step is completed or deferred: if you cannot go on without the member, ask with ask_user. Mark a step deferred only when the member asked to leave it for later or it is no longer needed; never mark completed a step you did not do.' : ''}`);
112
114
  if (policy.verify !== 'off') {
113
115
  const list = checks.commands.length ? `${checks.commands.map(c => '`' + c + '`').join(', ')}${checks.source === 'detected' ? ' (detected; the repository declares none in AGENTS.md "## Checks")' : ''}` : 'none declared: choose the tests, type check or build that cover the change';
114
116
  lines.push(`- Verify: after changing code, run the repository checks that cover the change and fix failures before you finish. Repository checks: ${list}.${policy.verify === 'require' ? ' The turn does not end until a check has passed on the final code.' : ''}`);
@@ -182,6 +184,11 @@ export function pendingBaseline(manager) {
182
184
  return last && last.settled === false && typeof last.digest === 'string' ? last.digest : null;
183
185
  }
184
186
  // The files that unsettled turn changed: its digest covers only those.
187
+ // The commit that unsettled turn started from: its digest is taken against it.
188
+ export function pendingHead(manager) {
189
+ const last = manager.getEntries().filter(e => e.type === 'custom' && e.customType === BASELINE_ENTRY).at(-1)?.data;
190
+ return last?.settled === false && typeof last.head === 'string' ? last.head : null;
191
+ }
185
192
  export function pendingPaths(manager) {
186
193
  const last = manager.getEntries().filter(e => e.type === 'custom' && e.customType === BASELINE_ENTRY).at(-1)?.data;
187
194
  return last?.settled === false && Array.isArray(last.paths) ? last.paths.filter(p => typeof p === 'string' && p).slice(0, 2000) : [];
@@ -195,7 +202,7 @@ export const PLAN_REFUSALS = 3;
195
202
  export class RunProcess {
196
203
  constructor(policy, { request, complex, checks }) {
197
204
  Object.assign(this, { policy, request: String(request ?? ''), complex, checks, startDigest: null, mutations: 0, lastCheck: null, checksRun: 0, checksFailed: 0,
198
- reviews: 0, blocking: 0, blockingOpen: 0, fixLoops: 0, planUpdated: false, planRefusals: new Set(), planSkipped: false, planAsked: false, unplannedChange: false,
205
+ reviews: 0, blocking: 0, blockingOpen: 0, fixLoops: 0, planUpdated: false, planRefusals: new Set(), planSkipped: false, unplannedChange: false,
199
206
  unknownTools: 0, changed: false, verified: false, reviewed: false, reviewUnavailable: false, outcome: null, legacyOutcome: null,
200
207
  // Helper objections to a brief, disagreements handed to the member, the
201
208
  // claims verify marked failed, and where the harness last sent the
@@ -240,7 +247,8 @@ export class RunProcess {
240
247
  // disagreements handed to the member ("disputed"); older versions get the
241
248
  // outcome the run would have had without that.
242
249
  report(plan, version = 1) {
243
- const steps = this.planUpdated || unfinished(plan) ? plan?.plan ?? [] : [];
250
+ // Steps left for later are not this run's work: Studio reads done/steps.
251
+ const steps = (this.planUpdated || unfinished(plan) ? plan?.plan ?? [] : []).filter(p => p.status !== 'deferred');
244
252
  const fallback = this.changed ? 'interrupted' : 'no_change', count = n => Math.min(n, 1000);
245
253
  return { version, policy: { plan: this.policy.plan, verify: this.policy.verify, review: this.policy.review }, changed: this.changed,
246
254
  plan_steps: steps.length, plan_done: steps.filter(p => p.status === 'completed').length, checks: count(this.checksRun), checks_failed: count(this.checksFailed),
@@ -346,16 +354,8 @@ export async function completionGate(managed, run, signal) {
346
354
  run.claimsDone = true; run.disputes.push({ source: 'verify' });
347
355
  await note(`Harness: the main agent and the verify subagent still disagree after two rounds; the member decides.\n${list}`, { phase: 'dispute', role: 'verify', claims });
348
356
  }
349
- // Plan "require": a checklist written in this turn is brought up to date
350
- // once before the turn ends (models often write it and never touch it again).
351
- const plan = currentPlan(session.sessionManager);
352
- if (run.changed && policy.plan === 'require' && run.planUpdated && !run.planAsked && unfinished(plan)) {
353
- run.planAsked = true;
354
- const open = plan.plan.filter(p => p.status !== 'completed').length;
355
- await send(`Harness process check: your checklist still has ${open} step(s) not marked completed:\n${planText(plan)}\nUpdate it with update_plan so it shows what was done. For a step you did not do, do it or say plainly why it is left.`,
356
- { phase: 'plan', planOpen: open });
357
- continue;
358
- }
357
+ // Plan "require": a checklist left with open steps is the next round's
358
+ // (continuation.mjs), after this run's checks and review.
359
359
  // An objection no corrected brief followed is answered to the member, once.
360
360
  const open = run.objections.filter(o => !o.answered && !o.disputed);
361
361
  if (open.length && !run.objectionsAsked) {
@@ -374,10 +374,10 @@ export async function completionGate(managed, run, signal) {
374
374
  : policy.verify !== 'off' && !run.verified ? 'unverified' : run.reviewUnavailable ? 'review_unavailable' : policy.review !== 'off' && !run.reviewed ? 'unreviewed' : 'clean';
375
375
  run.outcome = run.legacyOutcome !== 'interrupted' && run.disputes.length ? 'disputed' : run.legacyOutcome;
376
376
  const settled = run.outcome !== 'interrupted';
377
- session.sessionManager.appendCustomEntry(BASELINE_ENTRY, { digest: settled ? digest : run.startDigest, settled, outcome: run.outcome,
377
+ session.sessionManager.appendCustomEntry(BASELINE_ENTRY, { digest: settled ? digest : run.startDigest, settled, outcome: run.outcome, ...(managed.base ? { head: managed.base } : {}),
378
378
  ...(settled ? {} : { paths: [...(managed.ownPaths ?? [])].slice(0, 2000) }) });
379
379
  if (!run.changed && !run.disputes.length) return;
380
- const finalPlan = currentPlan(session.sessionManager), planOpen = run.planUpdated ? finalPlan?.plan?.filter(p => p.status !== 'completed').length ?? 0 : 0;
380
+ const finalPlan = currentPlan(session.sessionManager), planOpen = run.planUpdated ? finalPlan?.plan?.filter(p => p.status === 'pending' || p.status === 'in_progress').length ?? 0 : 0;
381
381
  const status = { verified: run.verified, reviewed: run.reviewed, blockingOpen: run.blockingOpen, checks: run.checksRun, fixLoops: run.fixLoops,
382
382
  ...(planOpen ? { planOpen } : {}), ...(run.planSkipped ? { planSkipped: true } : {}), ...(run.disputes.length ? { disputes: run.disputes.length } : {}), ...(run.changed ? {} : { unchanged: true }) };
383
383
  const words = [...(run.changed ? [run.verified ? 'a check passed on the final code' : policy.verify === 'off' ? null : 'no check passed on the final code',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",