@piagent/platform 1.18.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 +22 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- 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 +1 -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 +7 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +7 -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 +8 -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 +8 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- 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/patch-snapshot.mjs +25 -9
- package/packages/piagent-core/managed/repository-operation.mjs +20 -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 +50 -62
- 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-DlbuyiQ3.js → MarkdownMessage-nggiOt0N.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{index-BGxsZHB5.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 +20 -8
- 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/server/loopback-server.ts +1 -1
- package/packages/piagent-webui/server/member-routes.ts +6 -3
- 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
|
@@ -10,6 +10,10 @@ import { execFileSync } from 'node:child_process';
|
|
|
10
10
|
// network (run_with_network), later runs work offline;
|
|
11
11
|
// - the member's own dependency caches as read-only seeds, where the tool can
|
|
12
12
|
// layer one (Go, Gradle, Maven), so what is already downloaded is reused;
|
|
13
|
+
// - the distributions the Maven Wrapper (./mvnw) and the Gradle Wrapper
|
|
14
|
+
// (./gradlew) already unpacked on this Mac, linked read-only into the
|
|
15
|
+
// project's cache, so they run without asking for network; one they have
|
|
16
|
+
// to download stays in the cache;
|
|
13
17
|
// - runtimes installed in places the sandbox otherwise keeps closed (JDKs
|
|
14
18
|
// under ~/Library/Java, the Android SDK, the system gem folder, MacPorts);
|
|
15
19
|
// - SwiftPM and xcodebuild, which cannot start their own sandbox inside this
|
|
@@ -62,6 +66,50 @@ case "$(basename "$0"):$1" in
|
|
|
62
66
|
esac
|
|
63
67
|
`;
|
|
64
68
|
|
|
69
|
+
// Each <name>/<hash> distribution folder of the member's Maven Wrapper as a
|
|
70
|
+
// link in the project's cache; one already there (linked, or downloaded by an
|
|
71
|
+
// approved command) is kept.
|
|
72
|
+
function linkDistributions(source, target) {
|
|
73
|
+
let names = [];
|
|
74
|
+
try { names = fs.readdirSync(source, { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { return; }
|
|
75
|
+
for (const name of names) {
|
|
76
|
+
let hashes = [];
|
|
77
|
+
try { hashes = fs.readdirSync(path.join(source, name.name), { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { continue; }
|
|
78
|
+
for (const hash of hashes) {
|
|
79
|
+
const link = path.join(target, name.name, hash.name);
|
|
80
|
+
try { fs.lstatSync(link); continue; } catch { /* not there yet */ }
|
|
81
|
+
try { fs.mkdirSync(path.dirname(link), { recursive: true, mode: 0o700 }); fs.symlinkSync(path.join(source, name.name, hash.name), link); } catch { /* best effort */ }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The Gradle Wrapper locks <hash>/<zip>.lck and checks <zip>.ok before it
|
|
87
|
+
// uses a distribution, so each <name>/<hash> is a folder of the cache with
|
|
88
|
+
// the marker copied and the unpacked distribution linked.
|
|
89
|
+
function linkGradleDistributions(source, target) {
|
|
90
|
+
let names = [];
|
|
91
|
+
try { names = fs.readdirSync(source, { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { return; }
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
let hashes = [];
|
|
94
|
+
try { hashes = fs.readdirSync(path.join(source, name.name), { withFileTypes: true }).filter(entry => entry.isDirectory()); } catch { continue; }
|
|
95
|
+
for (const hash of hashes) {
|
|
96
|
+
const from = path.join(source, name.name, hash.name), to = path.join(target, name.name, hash.name);
|
|
97
|
+
let entries = [];
|
|
98
|
+
try { entries = fs.readdirSync(from, { withFileTypes: true }); } catch { continue; }
|
|
99
|
+
if (!entries.some(entry => entry.name.endsWith('.ok'))) continue; // not fully unpacked
|
|
100
|
+
try { fs.mkdirSync(to, { recursive: true, mode: 0o700 }); } catch { continue; }
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const link = path.join(to, entry.name);
|
|
103
|
+
try { fs.lstatSync(link); continue; } catch { /* not there yet */ }
|
|
104
|
+
try {
|
|
105
|
+
if (entry.isDirectory()) fs.symlinkSync(path.join(from, entry.name), link);
|
|
106
|
+
else if (entry.name.endsWith('.ok')) fs.writeFileSync(link, '', { mode: 0o600 });
|
|
107
|
+
} catch { /* best effort */ }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
65
113
|
export function languageEnvironment({ userHome, repositoryTop, home }) {
|
|
66
114
|
const linux = process.platform === 'linux';
|
|
67
115
|
const root = linux ? path.join(process.env.XDG_CACHE_HOME || path.join(userHome, '.cache'), 'piagent/sandbox') : path.join(userHome, 'Library/Caches/Piagent/sandbox');
|
|
@@ -80,11 +128,19 @@ export function languageEnvironment({ userHome, repositoryTop, home }) {
|
|
|
80
128
|
// .NET restores over TLS through the macOS Security framework, closed
|
|
81
129
|
// here: packages the member restored are read from their own folder.
|
|
82
130
|
nuget: existing(path.join(userHome, '.nuget/packages')),
|
|
131
|
+
mavenWrapper: existing(path.join(userHome, '.m2/wrapper/dists')),
|
|
132
|
+
gradleWrapper: existing(path.join(userHome, '.gradle/wrapper/dists')),
|
|
83
133
|
};
|
|
84
134
|
const java = javaHome(userHome), android = existing(path.join(userHome, linux ? 'Android/Sdk' : 'Library/Android/sdk'));
|
|
85
135
|
const readRoots = [...new Set([...Object.values(seeds), java, android, existing(path.join(userHome, 'Library/Java/JavaVirtualMachines')),
|
|
86
136
|
existing('/Library/Ruby'), existing('/opt/local')].filter(Boolean))];
|
|
87
137
|
|
|
138
|
+
// ./mvnw looks for its distribution in MAVEN_USER_HOME/wrapper/dists/<name>/<hash>
|
|
139
|
+
// (the sandbox's HOME is an empty folder per conversation).
|
|
140
|
+
if (seeds.mavenWrapper) linkDistributions(seeds.mavenWrapper, at('m2/wrapper/dists'));
|
|
141
|
+
// ./gradlew: GRADLE_USER_HOME/wrapper/dists/<name>/<hash>.
|
|
142
|
+
if (seeds.gradleWrapper) linkGradleDistributions(seeds.gradleWrapper, at('gradle/wrapper/dists'));
|
|
143
|
+
|
|
88
144
|
const bin = path.join(home, '.piagent/bin');
|
|
89
145
|
fs.mkdirSync(bin, { recursive: true, mode: 0o700 });
|
|
90
146
|
// (Only macOS: SwiftPM on Linux starts no sandbox of its own.)
|
|
@@ -102,6 +158,7 @@ export function languageEnvironment({ userHome, repositoryTop, home }) {
|
|
|
102
158
|
CARGO_HOME: at('cargo'), GRADLE_USER_HOME: at('gradle'), NUGET_PACKAGES: at('nuget'), COMPOSER_CACHE_DIR: at('composer'),
|
|
103
159
|
PUB_CACHE: at('pub'), GEM_HOME: at('gem'), BUNDLE_PATH: at('bundle'), CP_HOME_DIR: at('cocoapods'), PIAGENT_DERIVED_DATA: at('DerivedData'),
|
|
104
160
|
DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1',
|
|
161
|
+
MAVEN_USER_HOME: at('m2'),
|
|
105
162
|
MAVEN_OPTS: [`-Dmaven.repo.local=${at('m2/repository')}`, ...(seeds.maven ? [`-Dmaven.repo.local.tail=${seeds.maven}`] : [])].join(' '),
|
|
106
163
|
...(seeds.gradle ? { GRADLE_RO_DEP_CACHE: seeds.gradle } : {}),
|
|
107
164
|
...(seeds.nuget ? { NUGET_FALLBACK_PACKAGES: seeds.nuget } : {}),
|
|
@@ -52,7 +52,7 @@ export function credentialFiles(roots, { limit = SCAN_ENTRIES } = {}) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const depth = file => file.split('/').length;
|
|
55
|
-
export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, node, readOnly = false, repository = [], gitDirs = [], toolchains = [], skills = [], caches = [], references = [], network = false, credentials = [] }) {
|
|
55
|
+
export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, node, readOnly = false, repository = [], gitDirs = [], toolchains = [], skills = [], caches = [], references = [], network = false, credentials = [], docker = null }) {
|
|
56
56
|
const exists = file => { try { fs.statSync(file); return true; } catch { return false; } };
|
|
57
57
|
const readable = [...new Set([sdkRoot, runtimeRoot, path.dirname(node), ...repository, ...toolchains, ...skills, ...references, ...(readOnly ? [cwd, ...gitDirs] : [])])].filter(exists);
|
|
58
58
|
const writable = [...new Set([...caches, ...(readOnly ? [] : [cwd, ...gitDirs])])].filter(exists);
|
|
@@ -66,6 +66,8 @@ export function bubblewrapArgs({ cwd, sdkRoot, runtimeRoot, temporary, empty, no
|
|
|
66
66
|
'--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--bind', temporary, temporary,
|
|
67
67
|
...mounts.flatMap(([kind, dir]) => [kind, dir, dir]),
|
|
68
68
|
...credentials.filter(item => exists(item.path)).flatMap(item => item.directory ? ['--tmpfs', item.path] : ['--ro-bind', empty, item.path]),
|
|
69
|
+
// An approved Docker command reaches the engine's socket (run_with_docker).
|
|
70
|
+
...(docker && exists(docker) ? ['--bind', docker, docker] : []),
|
|
69
71
|
'--remount-ro', '/', '--chdir', cwd,
|
|
70
72
|
];
|
|
71
73
|
}
|
|
@@ -10,14 +10,18 @@ const MAX_SNAPSHOT = 16 * 1024 * 1024;
|
|
|
10
10
|
|
|
11
11
|
// The worker runs inside the tool boundary; what it prints is one short line.
|
|
12
12
|
// `scope`: only these paths (relative to the project) make the patch.
|
|
13
|
-
|
|
13
|
+
// `base`: the commit the patch starts from (the turn's starting HEAD), so a
|
|
14
|
+
// commit made during the turn stays part of its change; none: HEAD.
|
|
15
|
+
const BASE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
16
|
+
async function worker(boundary, mode, scope = null, base = null) {
|
|
14
17
|
let scopeName = null;
|
|
15
18
|
if (scope) {
|
|
16
19
|
scopeName = `scope-${randomUUID()}.json`;
|
|
17
20
|
fs.writeFileSync(path.join(boundary.temporary, scopeName), JSON.stringify([...scope]), { mode: 0o600, flag: 'wx' });
|
|
18
21
|
}
|
|
19
22
|
let result;
|
|
20
|
-
|
|
23
|
+
const from = typeof base === 'string' && BASE.test(base) ? ' ' + base : '';
|
|
24
|
+
try { result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} ${mode}${scopeName ? ' ' + scopeName : ''}${from}`, timeout: 60 }); }
|
|
21
25
|
finally { if (scopeName) fs.rmSync(path.join(boundary.temporary, scopeName), { force: true }); }
|
|
22
26
|
const text = textContent(result).trim();
|
|
23
27
|
if (result.details?.truncation?.truncated || text.includes('\n')) throw Error(/review-(patch|file|untracked)-limit/.exec(text)?.[0] ?? 'managed-review-patch-unreadable');
|
|
@@ -28,14 +32,23 @@ async function worker(boundary, mode, scope = null) {
|
|
|
28
32
|
|
|
29
33
|
// The digest of the patch a reviewer would see (diff against HEAD plus
|
|
30
34
|
// untracked files): whether a review is still current. Nothing else moves.
|
|
31
|
-
export async function readPatchDigest(boundary, scope = null) { return (await worker(boundary, 'digest', scope)).digest; }
|
|
35
|
+
export async function readPatchDigest(boundary, scope = null, base = null) { return (await worker(boundary, 'digest', scope, base)).digest; }
|
|
36
|
+
|
|
37
|
+
// The commit HEAD names now, or null (no repository, no commit yet).
|
|
38
|
+
export async function readHead(boundary) {
|
|
39
|
+
try {
|
|
40
|
+
const result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} head`, timeout: 60 });
|
|
41
|
+
const text = textContent(result).trim();
|
|
42
|
+
return BASE.test(text) ? text : null;
|
|
43
|
+
} catch { return null; }
|
|
44
|
+
}
|
|
32
45
|
|
|
33
46
|
// The patch a reviewer sees, read inside the tool boundary. The worker writes
|
|
34
47
|
// it to the boundary's temporary directory and names it, with its digest, on
|
|
35
48
|
// its output; the file is read once, must match that digest (a file changed
|
|
36
49
|
// meanwhile never passes) and is removed.
|
|
37
|
-
export async function readPatchSnapshot(boundary, scope = null) {
|
|
38
|
-
const { digest, bytes, name } = await worker(boundary, 'file', scope);
|
|
50
|
+
export async function readPatchSnapshot(boundary, scope = null, base = null) {
|
|
51
|
+
const { digest, bytes, name } = await worker(boundary, 'file', scope, base);
|
|
39
52
|
if (!/^review-[0-9a-f-]{36}\.json$/.test(name ?? '')) throw Error('managed-review-patch-unreadable');
|
|
40
53
|
return { patch: readOnce(boundary, name, bytes, digest).toString('utf8'), digest };
|
|
41
54
|
}
|
|
@@ -44,8 +57,8 @@ export async function readPatchSnapshot(boundary, scope = null) {
|
|
|
44
57
|
// Compared before and after a command, it names the files that command
|
|
45
58
|
// changed, so a conversation answers for its own changes, not for another
|
|
46
59
|
// conversation's in the same folder.
|
|
47
|
-
export async function readChangedFiles(boundary) {
|
|
48
|
-
const { digest, bytes, name } = await worker(boundary, 'files');
|
|
60
|
+
export async function readChangedFiles(boundary, base = null) {
|
|
61
|
+
const { digest, bytes, name } = await worker(boundary, 'files', null, base);
|
|
49
62
|
if (!/^files-[0-9a-f-]{36}\.json$/.test(name ?? '')) throw Error('managed-review-patch-unreadable');
|
|
50
63
|
return new Map(Object.entries(JSON.parse(readOnce(boundary, name, bytes, digest).toString('utf8'))));
|
|
51
64
|
}
|
|
@@ -74,6 +87,9 @@ export function reviewUnavailableReason(message) {
|
|
|
74
87
|
return 'failed';
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
// What the diff is taken against: HEAD, or the commit the turn started from
|
|
91
|
+
// (commits made during the turn are part of the change).
|
|
92
|
+
const against = value => value?.base ? `diff against the commit the turn started from (${String(value.base).slice(0, 12)}), commits made since included` : 'diff against HEAD';
|
|
77
93
|
// The patch as a reviewer reads it: the diff, then each new file in full.
|
|
78
94
|
export function reviewText(snapshot) {
|
|
79
95
|
let value;
|
|
@@ -82,7 +98,7 @@ export function reviewText(snapshot) {
|
|
|
82
98
|
: f.mode === 'directory' ? `=== new folder ${f.path} (a repository of its own, not reviewed)`
|
|
83
99
|
: f.encoding === 'omitted' ? `=== new file ${f.path} (${f.bytes} bytes, too large to review)`
|
|
84
100
|
: f.encoding === 'utf8' ? `=== new file ${f.path}\n${f.contents}` : `=== new binary file ${f.path} (${Math.round(f.contents.length * 0.75)} bytes)`);
|
|
85
|
-
return `Patch snapshot ${snapshot.digest}:\n---
|
|
101
|
+
return `Patch snapshot ${snapshot.digest}:\n--- ${against(value)} ---\n${value.patch || '(no changes to tracked files)'}\n${files.length ? `--- new files ---\n${files.join('\n\n')}` : ''}`;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
// A patch larger than one reviewer reads at once (REVIEW_PART_BYTES, about
|
|
@@ -125,7 +141,7 @@ export function reviewParts(snapshot, size = REVIEW_PART_BYTES) {
|
|
|
125
141
|
const parts = groups.map((group, i) => {
|
|
126
142
|
const files = group.sections.map(s => s.file), diff = group.sections.filter(s => s.kind === 'diff').map(s => s.text).join(''), news = group.sections.filter(s => s.kind === 'new').map(s => s.text);
|
|
127
143
|
const head = groups.length === 1 ? `Patch snapshot ${snapshot.digest}.` : `Patch snapshot ${snapshot.digest}, part ${i + 1} of ${groups.length}. This part's files: ${files.join(', ')}. The other parts are reviewed separately: judge this part, and read other files only for context.`;
|
|
128
|
-
return { files, text: `${head}${skipped}\n---
|
|
144
|
+
return { files, text: `${head}${skipped}\n--- ${against(value)} ---\n${diff || '(no changes to tracked files in this part)'}\n${news.length ? `--- new files ---\n${news.join('\n\n')}` : ''}` };
|
|
129
145
|
});
|
|
130
146
|
return { parts: parts.length ? parts : [{ files: [], text: `Patch snapshot ${snapshot.digest}: every changed file is too large to review.${skipped}` }], omitted };
|
|
131
147
|
}
|
|
@@ -82,3 +82,23 @@ export async function executeRepositoryFetch(boundary,plan,signal,{transport=fet
|
|
|
82
82
|
return {content:[{type:'text',text:`Fetched origin branches for ${plan.repository}. Working files and current branch were not changed.`}],details:{operation:'git.fetch',repository:plan.repository}};
|
|
83
83
|
} finally {fs.rmSync(staging,{recursive:true,force:true});}
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
// The fetch_origin tool of a company conversation: one approved fetch of the
|
|
87
|
+
// GitHub origin (Bypass fetches without asking).
|
|
88
|
+
export function fetchOriginTool(self) {
|
|
89
|
+
return {name:'fetch_origin',label:'Fetch origin',description:'Request one approved GitHub origin fetch. Refreshes origin branches without changing working files. Does not expose credentials or allow arbitrary network commands.',
|
|
90
|
+
parameters:{type:'object',properties:{},additionalProperties:false},
|
|
91
|
+
execute:async(id,_args,signal,_update,ctx)=>{
|
|
92
|
+
const plan=await repositoryFetchPlan(self.boundary);
|
|
93
|
+
if(self.permission==='trusted-full-access'&&!signal?.aborted)return executeRepositoryFetch(self.boundary,plan,signal);
|
|
94
|
+
const {piApprovalBroker}=await import('../runtime/inspection/approval-broker.ts');
|
|
95
|
+
const decision=await piApprovalBroker.request({cwd:self.cwd,rawSessionId:self.session.sessionManager.getSessionId(),toolCallId:id,
|
|
96
|
+
action:{kind:'external-provider-action',preconditionClass:'runtime-only',toolName:'fetch_origin',rawAction:plan,
|
|
97
|
+
targetPaths:[self.cwd],provider:'github',urlOrigin:'https://github.com',requestedScope:'fetch-origin-once',
|
|
98
|
+
reason:`Fetch origin branches from ${plan.repository}`,riskClass:'low',allowConsequence:'Download origin branches once; keep the current branch and working files unchanged.',denyConsequence:'No network request or credential read.'},
|
|
99
|
+
terminalConfirm:()=>ctx?.ui?.confirm?.('Tải nhánh từ origin',`Tải các nhánh của ${plan.repository} về ${self.cwd} một lần.\n\nĐồng ý: chỉ tải nhánh; nhánh hiện tại và file đang làm không đổi.\nTừ chối: không gửi request mạng, không đọc thông tin đăng nhập.`)??Promise.resolve(false),
|
|
100
|
+
unavailableFallback:'terminal-confirm',recheck:()=>!signal?.aborted&&Boolean(self.grant)});
|
|
101
|
+
if(!decision.allowed||!decision.consume()||signal?.aborted)throw Error('managed-operation-denied');
|
|
102
|
+
return executeRepositoryFetch(self.boundary,plan,signal);
|
|
103
|
+
}};
|
|
104
|
+
}
|
|
@@ -13,10 +13,17 @@ const MAX_SNAPSHOT = 16 * 1024 * 1024, MAX_UNTRACKED = 2000, READ_FILE = 400_000
|
|
|
13
13
|
const git = args => execFileSync(managedGit(), ['--no-pager', ...args], { encoding: 'utf8', maxBuffer: MAX_SNAPSHOT + 1, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' } });
|
|
14
14
|
const sha256 = data => createHash('sha256').update(data).digest('hex');
|
|
15
15
|
const mode = process.argv[2];
|
|
16
|
+
// The arguments after the mode: a scope file, then the commit the patch starts
|
|
17
|
+
// from (the turn's starting HEAD; commits made since are part of the change).
|
|
18
|
+
// While HEAD has not moved, the patch is the plain one against HEAD.
|
|
19
|
+
const head = () => { try { return git(['rev-parse', '--verify', '-q', 'HEAD^{commit}']).trim(); } catch { return ''; } };
|
|
20
|
+
if (mode === 'head') { process.stdout.write(head() || 'none'); process.exit(0); }
|
|
21
|
+
const extra = process.argv.slice(3), given = extra.find(a => /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(a));
|
|
22
|
+
const base = given && given !== head() ? given : 'HEAD';
|
|
16
23
|
// "files": what each changed or new file holds now (a hash, "deleted"), so
|
|
17
24
|
// the runtime can tell which files one conversation's command changed.
|
|
18
25
|
if (mode === 'files') {
|
|
19
|
-
const changed = git(['diff', '--name-only', '--no-renames', '--relative', '-z',
|
|
26
|
+
const changed = git(['diff', '--name-only', '--no-renames', '--relative', '-z', base, '--', '.']).split('\0').filter(Boolean);
|
|
20
27
|
const fresh = git(['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean);
|
|
21
28
|
if (changed.length + fresh.length > MAX_UNTRACKED * 4) throw Error('review-untracked-limit');
|
|
22
29
|
const print = name => {
|
|
@@ -33,11 +40,11 @@ if (mode === 'files') {
|
|
|
33
40
|
}
|
|
34
41
|
// A scope (a file the runtime wrote in this boundary's temporary directory):
|
|
35
42
|
// only these paths, the ones this conversation changed, make the patch.
|
|
36
|
-
const scopeName =
|
|
43
|
+
const scopeName = extra.find(a => a !== given);
|
|
37
44
|
if (scopeName !== undefined && !/^scope-[0-9a-f-]{36}\.json$/.test(scopeName)) throw Error('review-scope-invalid');
|
|
38
45
|
const scope = scopeName ? new Set(JSON.parse(fs.readFileSync(path.join(os.tmpdir(), scopeName), 'utf8'))) : null;
|
|
39
46
|
if (scope && [...scope].some(p => typeof p !== 'string' || !p || path.isAbsolute(p) || p.split('/').includes('..'))) throw Error('review-scope-invalid');
|
|
40
|
-
const patch = scope && !scope.size ? '' : git(['diff', '--binary', '--no-ext-diff', '--no-textconv', '--no-renames',
|
|
47
|
+
const patch = scope && !scope.size ? '' : git(['diff', '--binary', '--no-ext-diff', '--no-textconv', '--no-renames', base, '--', ...(scope ? [...scope].sort().map(p => ':(literal)' + p) : ['.'])]);
|
|
41
48
|
const paths = git(['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean).filter(p => !scope || scope.has(p)).sort();
|
|
42
49
|
if (paths.length > MAX_UNTRACKED) throw Error('review-untracked-limit');
|
|
43
50
|
const untracked = paths.map(name => {
|
|
@@ -55,7 +62,7 @@ const untracked = paths.map(name => {
|
|
|
55
62
|
if (text !== null) return { path: name, mode, contents: text, encoding: 'utf8' };
|
|
56
63
|
return stat.size > BINARY_INLINE ? { path: name, mode, encoding: 'omitted', bytes: stat.size, sha256: sha256(data) } : { path: name, mode, contents: data.toString('base64'), encoding: 'base64' };
|
|
57
64
|
});
|
|
58
|
-
const snapshot = Buffer.from(JSON.stringify({ patch, untracked }));
|
|
65
|
+
const snapshot = Buffer.from(JSON.stringify(base === 'HEAD' ? { patch, untracked } : { base, patch, untracked }));
|
|
59
66
|
if (snapshot.length > MAX_SNAPSHOT) throw Error('review-patch-limit');
|
|
60
67
|
// "digest": the digest and size only (is a review still current?). "file":
|
|
61
68
|
// the snapshot goes to a new file in this boundary's temporary directory,
|
|
@@ -7,8 +7,13 @@ const OFFLINE = /ENOTFOUND|EAI_AGAIN|Could not resolve host|getaddrinfo|npm erro
|
|
|
7
7
|
// name that does not resolve is the machine's network, not the sandbox.
|
|
8
8
|
// `isolated`: a proxy listens on this Mac, so without approval a command does
|
|
9
9
|
// not reach this Mac's own servers either (tool-boundary.mjs).
|
|
10
|
-
|
|
10
|
+
// `docker`: the command ran with the Docker engine (run_with_docker);
|
|
11
|
+
// `engine`: one is there for run_with_docker, but this command ran without it.
|
|
12
|
+
const DOCKER_DAEMON = /Cannot connect to the Docker daemon|connect to the docker API|docker\.sock|Is the docker daemon running|Could not find a valid Docker environment/i;
|
|
13
|
+
export function sandboxDiagnostic(message, { network = false, isolated = false, docker = false, engine = false } = {}) {
|
|
11
14
|
const text=String(message);
|
|
15
|
+
if(docker&&DOCKER_DAEMON.test(text))return 'managed-docker-unreachable: Lệnh chạy với Docker nhưng Docker engine không trả lời (Docker Desktop/Colima chưa chạy hoặc đang khởi động). Không xin duyệt lại cùng lệnh; báo người dùng mở Docker rồi thử lại.\n'+text;
|
|
16
|
+
if(!docker&&DOCKER_DAEMON.test(text))return (engine?'managed-docker-blocked: Lệnh bash thường không dùng được Docker trong chế độ công ty. Lệnh docker, docker compose, hoặc test cần Docker (Testcontainers) chạy qua run_with_docker để người dùng duyệt đúng lệnh đó.\n':'managed-docker-unavailable: Máy chưa có Docker engine đang chạy (Docker Desktop, Colima, OrbStack…). Báo người dùng mở Docker; sau đó cuộc trò chuyện mới có run_with_docker.\n')+text;
|
|
12
17
|
if(isolated&&/ECONNREFUSED|EPERM|connect|Connection refused|could not connect/i.test(text))return 'managed-loopback-blocked: Máy đang chạy một proxy cục bộ, nên lệnh không có mạng cũng không được kết nối tới server trên localhost (proxy có thể đưa lệnh ra internet). Test cần server hoặc database trên localhost: dùng run_with_network để người dùng duyệt đúng lệnh đó.\n'+text;
|
|
13
18
|
if(network&&!DENIAL.test(text)&&OFFLINE.test(text))return 'managed-network-unreachable: Lệnh đã được duyệt và chạy có mạng nhưng không tới được host (máy đang offline, cần VPN/proxy công ty, hoặc sai tên host). Không xin duyệt lại cùng lệnh; báo người dùng lỗi mạng này.\n'+text;
|
|
14
19
|
if(!DENIAL.test(text)&&OFFLINE.test(text))return 'managed-network-blocked: Lệnh bash thường không có mạng trong chế độ công ty. Nếu lệnh thật sự cần mạng (cài package, tải dữ liệu), dùng run_with_network để người dùng duyệt đúng lệnh đó.\n'+text;
|
|
@@ -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
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);
|
|
@@ -219,9 +192,10 @@ export class ManagedSession {
|
|
|
219
192
|
};
|
|
220
193
|
self.review = manager.getEntries().filter(e => e.type === 'custom' && e.customType === 'agent-watch-review').at(-1)?.data ?? null;
|
|
221
194
|
const abort = self.session.abort.bind(self.session);
|
|
222
|
-
self.session.abort = async (...args) => { self.gateAbort?.abort(); return abort(...args); };
|
|
223
|
-
// One member message is one
|
|
224
|
-
// 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.
|
|
225
199
|
const subscribe = self.session.subscribe.bind(self.session), listeners = self.listeners;
|
|
226
200
|
self.session.subscribe = listener => {
|
|
227
201
|
listeners.add(listener);
|
|
@@ -229,14 +203,8 @@ export class ManagedSession {
|
|
|
229
203
|
return () => { listeners.delete(listener); off(); };
|
|
230
204
|
};
|
|
231
205
|
const prompt = self.session.prompt.bind(self.session);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (/^\/\S/.test(text) && self.session.extensionRunner?.getCommand(text.slice(1).split(/\s/)[0])) return prompt(text, options);
|
|
235
|
-
// /skill:name, /command: the run sees what the member asked for in full.
|
|
236
|
-
text = self.resources.expand(text);
|
|
237
|
-
if (self.starting) await self.starting;
|
|
238
|
-
if (self.activePrompt) return prompt(text, options); // steering/follow-up keep the root
|
|
239
|
-
await self.refreshReview();
|
|
206
|
+
// One run: started for the round's request, closed with its report.
|
|
207
|
+
const runRound = async (text, deliver) => {
|
|
240
208
|
const start = async () => {
|
|
241
209
|
// The Harness (now possibly changed) decides which levels exist; ask
|
|
242
210
|
// for the member's level, or the nearest one it offers.
|
|
@@ -282,14 +250,18 @@ export class ManagedSession {
|
|
|
282
250
|
});
|
|
283
251
|
self.run = new RunProcess(workflowPolicy(self.manifest), { request: text, complex: taskClass(text) === 'complex', checks: self.checks });
|
|
284
252
|
// The files this conversation changed and has not settled: carried
|
|
285
|
-
// 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.
|
|
286
256
|
const pending = pendingBaseline(manager);
|
|
287
257
|
self.ownPaths = new Set(pending !== null ? pendingPaths(manager) : []);
|
|
258
|
+
self.base = (pending !== null ? pendingHead(manager) : null) ?? await readHead(self.boundary);
|
|
288
259
|
self.run.startDigest = pending ?? await self.digest();
|
|
289
260
|
} catch (error) { self.preflight = describeFailure('main', failureText(error)); }
|
|
290
261
|
self.activePrompt = true;
|
|
291
262
|
})();
|
|
292
263
|
try { await self.starting; } finally { self.starting = null; }
|
|
264
|
+
const round = { result: undefined, preflight: !!self.preflight, planUpdated: false, changed: false };
|
|
293
265
|
try {
|
|
294
266
|
// The last request of a long conversation was refused as too large:
|
|
295
267
|
// by the model (it no longer fits the window of the model it runs on
|
|
@@ -297,13 +269,16 @@ export class ManagedSession {
|
|
|
297
269
|
// model) or by Studio (a body over 4 MiB, before a 1M-token window is
|
|
298
270
|
// full). The older part is summarised first, so continuing fits.
|
|
299
271
|
if (!self.preflight && self.refusedAsTooLong()) { try { await self.session.compact(); } catch { /* nothing to compact: the request says why */ } }
|
|
300
|
-
|
|
272
|
+
round.result = await deliver();
|
|
301
273
|
if (self.run && !self.preflight) {
|
|
302
274
|
self.gateAbort = new AbortController();
|
|
303
275
|
try { await completionGate(self, self.run, self.gateAbort.signal); } catch { /* the answer stands; the report keeps what is known */ } finally { self.gateAbort = null; }
|
|
304
276
|
}
|
|
305
|
-
return
|
|
277
|
+
return round;
|
|
306
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 });
|
|
307
282
|
await Promise.allSettled([...self.helpers.values()]);
|
|
308
283
|
// What a command left running in the background ends with the turn.
|
|
309
284
|
try { await self.boundary.stopStrays(); } catch { /* best effort; the boundary stops them on close */ }
|
|
@@ -318,12 +293,25 @@ export class ManagedSession {
|
|
|
318
293
|
catch (error) { if (!process) throw error; await self.broker.request('close'); }
|
|
319
294
|
manager.appendCustomEntry('agent-watch-run', { run_id: self.grant.run_id, state: 'closed' });
|
|
320
295
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
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; }
|
|
325
301
|
}
|
|
326
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
|
+
};
|
|
327
315
|
// Native ! commands use the same worker boundary as model bash calls.
|
|
328
316
|
self.session.executeBash = async command => {
|
|
329
317
|
try {
|
|
@@ -433,14 +421,14 @@ export class ManagedSession {
|
|
|
433
421
|
// Whether this conversation changed code, and whether a check ran on it,
|
|
434
422
|
// look only at the files it changed: another conversation (or the member)
|
|
435
423
|
// changing other files in the same folder is not this run's change.
|
|
436
|
-
async changeDigest() { return readPatchDigest(this.boundary, this.ownPaths); }
|
|
424
|
+
async changeDigest() { return readPatchDigest(this.boundary, this.ownPaths, this.base); }
|
|
437
425
|
async folderDigest() { try { return await readPatchDigest(this.boundary); } catch { return null; } }
|
|
438
426
|
// What a reviewer reads: this conversation's files; before it changed any,
|
|
439
427
|
// the whole folder (the member's own changes, asked to be reviewed).
|
|
440
428
|
reviewScope() { return this.ownPaths.size ? this.ownPaths : null; }
|
|
441
|
-
async patchSnapshot() { return readPatchSnapshot(this.boundary, this.reviewScope()); }
|
|
442
|
-
async patchDigest() { return readPatchDigest(this.boundary, this.reviewScope()); }
|
|
443
|
-
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; } }
|
|
444
432
|
// A write or edit names its file; a command, the files it changed.
|
|
445
433
|
claimPath(raw) {
|
|
446
434
|
if (typeof raw !== 'string' || !raw) return;
|