@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.
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/catalog/claude-model-additions.json +49 -2
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +13 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +13 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +14 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +14 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +3 -3
- package/evals/runtime-conformance-v1/runner.mjs +13 -17
- package/package.json +1 -1
- package/packages/piagent-core/managed/call-purpose.mjs +3 -2
- package/packages/piagent-core/managed/command-tools.mjs +59 -0
- package/packages/piagent-core/managed/container-engine.mjs +102 -0
- package/packages/piagent-core/managed/continuation.mjs +131 -0
- package/packages/piagent-core/managed/doctor.mjs +7 -0
- package/packages/piagent-core/managed/language-environment.mjs +57 -0
- package/packages/piagent-core/managed/linux-sandbox.mjs +3 -1
- package/packages/piagent-core/managed/native-catalog.mjs +14 -1
- package/packages/piagent-core/managed/patch-snapshot.mjs +25 -9
- package/packages/piagent-core/managed/repository-operation.mjs +20 -0
- package/packages/piagent-core/managed/request-stream.mjs +28 -0
- package/packages/piagent-core/managed/review-snapshot-worker.mjs +11 -4
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +6 -1
- package/packages/piagent-core/managed/session.mjs +52 -63
- package/packages/piagent-core/managed/tool-boundary.mjs +21 -10
- package/packages/piagent-core/managed/tool-worker.mjs +3 -3
- package/packages/piagent-core/managed/workflow.mjs +20 -20
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CeQ79h30.js → MarkdownMessage-nggiOt0N.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{index-BNQK7rHr.js → index-CM9cs-gP.js} +16 -14
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/gateway/company-relay.ts +14 -2
- package/packages/piagent-webui/gateway/gateway-service.ts +38 -0
- package/packages/piagent-webui/gateway/git-branches.ts +94 -0
- package/packages/piagent-webui/gateway/git-repositories.ts +46 -0
- package/packages/piagent-webui/gateway/managed-projection.ts +9 -2
- package/packages/piagent-webui/gateway/project-registry.ts +10 -6
- package/packages/piagent-webui/gateway/session-catalog.ts +13 -6
- package/packages/piagent-webui/gateway/session-operation-lifecycle.ts +10 -3
- package/packages/piagent-webui/server/loopback-server.ts +9 -2
- package/packages/piagent-webui/server/member-routes.ts +41 -0
- package/packages/piagent-webui/server/tool-call-summary.ts +3 -1
- package/packages/piagent-webui/server/transcript-projection.ts +20 -4
- package/packages/piagent-webui/shared/process-copy.ts +3 -1
- package/packages/piagent-webui/shared/turn-end.ts +68 -0
- package/schemas/piagent-webui/session-catalog-v1.schema.json +11 -1
- package/schemas/piagent-webui/transcript-v1.schema.json +23 -2
- package/scripts/company-terminal.mjs +15 -0
- package/scripts/configure-model-scope.sh +1 -0
- 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 {
|
|
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 {
|
|
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
|
|
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.
|
|
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).
|
|
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(
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
223
|
-
// fixes after the answer
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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"))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
350
|
-
//
|
|
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
|
|
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',
|