pi-subagents 0.47.0 → 0.48.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 +37 -0
- package/README.md +2 -0
- package/agents/reviewer.md +3 -4
- package/docs/configuration.md +46 -2
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +1 -1
- package/package.json +1 -1
- package/src/agents/agents.ts +9 -3
- package/src/extension/config.ts +16 -0
- package/src/extension/doctor.ts +40 -0
- package/src/extension/index.ts +30 -11
- package/src/extension/public-execution.ts +5 -0
- package/src/extension/rpc.ts +7 -9
- package/src/extension/schemas.ts +4 -4
- package/src/intercom/intercom-bridge.ts +4 -1
- package/src/intercom/native-supervisor-channel.ts +102 -4
- package/src/missions/lifecycle.ts +4 -7
- package/src/missions/store.ts +4 -4
- package/src/missions/workflow-state.ts +6 -2
- package/src/runs/background/active-async-capacity.ts +374 -0
- package/src/runs/background/active-run-index.ts +46 -0
- package/src/runs/background/async-execution.ts +117 -29
- package/src/runs/background/async-job-tracker.ts +279 -134
- package/src/runs/background/async-resume.ts +7 -1
- package/src/runs/background/async-status.ts +33 -4
- package/src/runs/background/chain-append.ts +33 -15
- package/src/runs/background/control-channel.ts +55 -17
- package/src/runs/background/owned-process-tree.ts +104 -0
- package/src/runs/background/process-terminal.ts +17 -3
- package/src/runs/background/result-watcher.ts +87 -6
- package/src/runs/background/run-status.ts +23 -1
- package/src/runs/background/scheduled-runs.ts +23 -0
- package/src/runs/background/stale-run-reconciler.ts +10 -3
- package/src/runs/background/subagent-runner.ts +75 -33
- package/src/runs/foreground/async-dismiss-action.ts +85 -0
- package/src/runs/foreground/async-steering-action.ts +6 -7
- package/src/runs/foreground/chain-execution.ts +37 -2
- package/src/runs/foreground/execution.ts +90 -19
- package/src/runs/foreground/foreground-control.ts +12 -0
- package/src/runs/foreground/prompt-audit.ts +171 -0
- package/src/runs/foreground/subagent-executor.ts +648 -195
- package/src/runs/shared/acceptance.ts +13 -4
- package/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/src/runs/shared/parallel-utils.ts +2 -0
- package/src/runs/shared/pi-args.ts +44 -1
- package/src/runs/shared/run-fanout-budget.ts +280 -0
- package/src/runs/shared/single-output.ts +4 -2
- package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
- package/src/runs/shared/task-intent.ts +19 -3
- package/src/runs/shared/worktree.ts +17 -5
- package/src/shared/artifacts.ts +1 -1
- package/src/shared/file-coalescer.ts +9 -0
- package/src/shared/types.ts +97 -1
- package/src/shared/utils.ts +3 -1
- package/src/tui/fleet-status.ts +7 -5
- package/src/tui/fleet.ts +225 -12
- package/src/workflows/scripted-workflow.ts +26 -6
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, waitForFileSystemRetry } from "../../shared/file-system-retry.ts";
|
|
5
|
+
import {
|
|
6
|
+
TEMP_ROOT_DIR,
|
|
7
|
+
type RunFanoutBudgetDescriptor,
|
|
8
|
+
type RunFanoutBudgetSnapshot,
|
|
9
|
+
type RunFanoutRejection,
|
|
10
|
+
} from "../../shared/types.ts";
|
|
11
|
+
|
|
12
|
+
export const RUN_FANOUT_BUDGET_ENV = "PI_SUBAGENT_RUN_FANOUT_BUDGET";
|
|
13
|
+
const RUN_FANOUT_ROOT = path.join(TEMP_ROOT_DIR, "run-fanout-budgets");
|
|
14
|
+
|
|
15
|
+
interface ManifestV1 {
|
|
16
|
+
version: 1;
|
|
17
|
+
rootRunId: string;
|
|
18
|
+
limit: number;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ClaimV1 {
|
|
23
|
+
version: 1;
|
|
24
|
+
claimId: string;
|
|
25
|
+
path: string;
|
|
26
|
+
claimedAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class RunFanoutLimitError extends Error {
|
|
30
|
+
readonly rejection: RunFanoutRejection;
|
|
31
|
+
readonly snapshot: RunFanoutBudgetSnapshot;
|
|
32
|
+
|
|
33
|
+
constructor(rejection: RunFanoutRejection) {
|
|
34
|
+
super(formatRunFanoutRejection(rejection));
|
|
35
|
+
this.name = "RunFanoutLimitError";
|
|
36
|
+
this.rejection = rejection;
|
|
37
|
+
this.snapshot = { used: rejection.used, limit: rejection.limit, remaining: rejection.remaining };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeRootRunId(rootRunId: string): string {
|
|
42
|
+
return rootRunId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || randomUUID();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseManifest(value: unknown): ManifestV1 | undefined {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
47
|
+
const manifest = value as Partial<ManifestV1>;
|
|
48
|
+
if (manifest.version !== 1 || typeof manifest.rootRunId !== "string" || !manifest.rootRunId
|
|
49
|
+
|| !Number.isInteger(manifest.limit) || (manifest.limit ?? 0) <= 0
|
|
50
|
+
|| typeof manifest.createdAt !== "number" || !Number.isFinite(manifest.createdAt)) return undefined;
|
|
51
|
+
return manifest as ManifestV1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readManifest(directory: string): ManifestV1 {
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf-8"));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(`Run fan-out budget manifest is unreadable at '${directory}': ${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
}
|
|
61
|
+
const manifest = parseManifest(parsed);
|
|
62
|
+
if (!manifest) throw new Error(`Run fan-out budget manifest is invalid at '${directory}'.`);
|
|
63
|
+
return manifest;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateDirectory(directory: string): string {
|
|
67
|
+
let realDirectory: string;
|
|
68
|
+
let realRoot: string;
|
|
69
|
+
try {
|
|
70
|
+
realDirectory = fs.realpathSync(path.resolve(directory));
|
|
71
|
+
realRoot = fs.realpathSync(path.resolve(RUN_FANOUT_ROOT));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(`Run fan-out budget directory is unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
if (realDirectory !== realRoot && !realDirectory.startsWith(`${realRoot}${path.sep}`)) {
|
|
76
|
+
throw new Error("Run fan-out budget directory resolves outside the managed budget root.");
|
|
77
|
+
}
|
|
78
|
+
return realDirectory;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createRunFanoutBudget(rootRunId: string, limit: number): RunFanoutBudgetDescriptor {
|
|
82
|
+
if (!Number.isInteger(limit) || limit <= 0) throw new Error("Run fan-out limit must be a positive integer.");
|
|
83
|
+
fs.mkdirSync(RUN_FANOUT_ROOT, { recursive: true, mode: 0o700 });
|
|
84
|
+
let directory: string;
|
|
85
|
+
do directory = path.join(RUN_FANOUT_ROOT, `${safeRootRunId(rootRunId)}-${randomUUID()}`);
|
|
86
|
+
while (fs.existsSync(directory));
|
|
87
|
+
fs.mkdirSync(path.join(directory, "claims"), { recursive: true, mode: 0o700 });
|
|
88
|
+
const manifest: ManifestV1 = { version: 1, rootRunId, limit, createdAt: Date.now() };
|
|
89
|
+
fs.writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: "wx" });
|
|
90
|
+
return { version: 1, rootRunId, directory, limit };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validateRunFanoutBudgetDescriptor(value: unknown): RunFanoutBudgetDescriptor {
|
|
94
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Run fan-out budget descriptor is missing or invalid.");
|
|
95
|
+
const descriptor = value as Partial<RunFanoutBudgetDescriptor>;
|
|
96
|
+
if (descriptor.version !== 1 || typeof descriptor.rootRunId !== "string" || !descriptor.rootRunId
|
|
97
|
+
|| typeof descriptor.directory !== "string" || !descriptor.directory
|
|
98
|
+
|| !Number.isInteger(descriptor.limit) || (descriptor.limit ?? 0) <= 0
|
|
99
|
+
|| (descriptor.parentPath !== undefined && typeof descriptor.parentPath !== "string")) {
|
|
100
|
+
throw new Error("Run fan-out budget descriptor is invalid.");
|
|
101
|
+
}
|
|
102
|
+
const directory = validateDirectory(descriptor.directory);
|
|
103
|
+
const manifest = readManifest(directory);
|
|
104
|
+
if (manifest.rootRunId !== descriptor.rootRunId || manifest.limit !== descriptor.limit) {
|
|
105
|
+
throw new Error("Run fan-out budget descriptor does not match its manifest.");
|
|
106
|
+
}
|
|
107
|
+
return { version: 1, rootRunId: descriptor.rootRunId, directory, limit: descriptor.limit, ...(descriptor.parentPath ? { parentPath: descriptor.parentPath } : {}) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function writeRunFanoutBudgetDescriptor(asyncDir: string, descriptor: RunFanoutBudgetDescriptor): void {
|
|
111
|
+
const valid = validateRunFanoutBudgetDescriptor(descriptor);
|
|
112
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
113
|
+
fs.writeFileSync(path.join(asyncDir, "run-fanout-budget.json"), `${JSON.stringify(valid)}\n`, { mode: 0o600 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function readRunFanoutBudgetDescriptor(asyncDir: string | undefined): RunFanoutBudgetDescriptor | undefined {
|
|
117
|
+
if (!asyncDir) return undefined;
|
|
118
|
+
const descriptorPath = path.join(asyncDir, "run-fanout-budget.json");
|
|
119
|
+
if (!fs.existsSync(descriptorPath)) return undefined;
|
|
120
|
+
try {
|
|
121
|
+
return validateRunFanoutBudgetDescriptor(JSON.parse(fs.readFileSync(descriptorPath, "utf-8")));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new Error(`Invalid persisted run fan-out budget '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function encodeRunFanoutBudgetDescriptor(descriptor: RunFanoutBudgetDescriptor): string {
|
|
128
|
+
return Buffer.from(JSON.stringify(validateRunFanoutBudgetDescriptor(descriptor)), "utf-8").toString("base64url");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function decodeRunFanoutBudgetDescriptor(encoded: string | undefined): RunFanoutBudgetDescriptor | undefined {
|
|
132
|
+
if (!encoded) return undefined;
|
|
133
|
+
try {
|
|
134
|
+
return validateRunFanoutBudgetDescriptor(JSON.parse(Buffer.from(encoded, "base64url").toString("utf-8")));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new Error(`Invalid inherited run fan-out budget: ${error instanceof Error ? error.message : String(error)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface AdmissionLockOwner {
|
|
141
|
+
pid: number;
|
|
142
|
+
token: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ADMISSION_LOCK_STALE_MS = 60_000;
|
|
146
|
+
|
|
147
|
+
function readAdmissionLockOwner(lockPath: string): AdmissionLockOwner | undefined {
|
|
148
|
+
try {
|
|
149
|
+
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf-8")) as Partial<AdmissionLockOwner>;
|
|
150
|
+
if (Number.isSafeInteger(owner.pid) && (owner.pid ?? 0) > 0 && typeof owner.token === "string" && owner.token) return owner as AdmissionLockOwner;
|
|
151
|
+
} catch {}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function admissionLockIsStale(lockPath: string): boolean {
|
|
156
|
+
const owner = readAdmissionLockOwner(lockPath);
|
|
157
|
+
if (owner) {
|
|
158
|
+
try {
|
|
159
|
+
process.kill(owner.pid, 0);
|
|
160
|
+
return false;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return (error as NodeJS.ErrnoException).code !== "EPERM";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
try { return Date.now() - fs.statSync(lockPath).mtimeMs > ADMISSION_LOCK_STALE_MS; }
|
|
166
|
+
catch (error) {
|
|
167
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function withAdmissionLock<T>(directory: string, operation: () => T): T {
|
|
173
|
+
const lockPath = path.join(directory, "admission.lock");
|
|
174
|
+
const owner: AdmissionLockOwner = { pid: process.pid, token: randomUUID() };
|
|
175
|
+
for (let attempt = 0; ; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
fs.mkdirSync(lockPath, { mode: 0o700 });
|
|
178
|
+
try { fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { mode: 0o600 }); }
|
|
179
|
+
catch (error) {
|
|
180
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
186
|
+
if (admissionLockIsStale(lockPath)) {
|
|
187
|
+
const stalePath = path.join(directory, `admission.stale-${randomUUID()}`);
|
|
188
|
+
try {
|
|
189
|
+
fs.renameSync(lockPath, stalePath);
|
|
190
|
+
fs.rmSync(stalePath, { recursive: true, force: true });
|
|
191
|
+
continue;
|
|
192
|
+
} catch (reclaimError) {
|
|
193
|
+
if ((reclaimError as NodeJS.ErrnoException).code !== "ENOENT") throw reclaimError;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const delay = DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt];
|
|
197
|
+
if (delay === undefined) throw new Error(`Timed out acquiring run fan-out admission lock at '${directory}'.`);
|
|
198
|
+
waitForFileSystemRetry(delay);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
try { return operation(); }
|
|
202
|
+
finally {
|
|
203
|
+
if (readAdmissionLockOwner(lockPath)?.token === owner.token) fs.rmSync(lockPath, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function claimCount(directory: string): number {
|
|
208
|
+
const claimsDir = path.join(directory, "claims");
|
|
209
|
+
let entries: fs.Dirent[];
|
|
210
|
+
try { entries = fs.readdirSync(claimsDir, { withFileTypes: true }); }
|
|
211
|
+
catch (error) {
|
|
212
|
+
throw new Error(`Run fan-out claims directory is unreadable at '${claimsDir}': ${error instanceof Error ? error.message : String(error)}`);
|
|
213
|
+
}
|
|
214
|
+
return entries.filter((entry) => /^\d{6}\.json$/.test(entry.name)).length;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getRunFanoutBudgetSnapshot(descriptor: RunFanoutBudgetDescriptor): RunFanoutBudgetSnapshot {
|
|
218
|
+
const valid = validateRunFanoutBudgetDescriptor(descriptor);
|
|
219
|
+
const used = claimCount(valid.directory);
|
|
220
|
+
return { used, limit: valid.limit, remaining: Math.max(0, valid.limit - used) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function qualifyRunFanoutPaths(descriptor: RunFanoutBudgetDescriptor, paths: string[]): string[] {
|
|
224
|
+
const prefix = descriptor.parentPath?.trim();
|
|
225
|
+
return paths.map((item) => prefix ? `${prefix}/${item}` : item);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function commitRunFanoutBatch<T>(descriptor: RunFanoutBudgetDescriptor, paths: string[], commit: (snapshot: RunFanoutBudgetSnapshot) => T): T {
|
|
229
|
+
const valid = validateRunFanoutBudgetDescriptor(descriptor);
|
|
230
|
+
if (paths.length === 0) return commit(getRunFanoutBudgetSnapshot(valid));
|
|
231
|
+
const qualified = qualifyRunFanoutPaths(valid, paths);
|
|
232
|
+
return withAdmissionLock(valid.directory, () => {
|
|
233
|
+
const before = getRunFanoutBudgetSnapshot(valid);
|
|
234
|
+
if (qualified.length > before.remaining) {
|
|
235
|
+
throw new RunFanoutLimitError({ code: "RUN_FANOUT_LIMIT", path: qualified[before.remaining] ?? qualified[0]!, requested: qualified.length, ...before });
|
|
236
|
+
}
|
|
237
|
+
const created: string[] = [];
|
|
238
|
+
try {
|
|
239
|
+
for (const claimPath of qualified) {
|
|
240
|
+
for (let slot = 0; slot < valid.limit; slot++) {
|
|
241
|
+
const slotPath = path.join(valid.directory, "claims", `${String(slot).padStart(6, "0")}.json`);
|
|
242
|
+
try {
|
|
243
|
+
const fd = fs.openSync(slotPath, "wx", 0o600);
|
|
244
|
+
created.push(slotPath);
|
|
245
|
+
try {
|
|
246
|
+
const claim: ClaimV1 = { version: 1, claimId: randomUUID(), path: claimPath, claimedAt: Date.now() };
|
|
247
|
+
fs.writeFileSync(fd, `${JSON.stringify(claim)}\n`, "utf-8");
|
|
248
|
+
} finally { fs.closeSync(fd); }
|
|
249
|
+
break;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return commit(getRunFanoutBudgetSnapshot(valid));
|
|
257
|
+
} catch (error) {
|
|
258
|
+
for (const slotPath of created) {
|
|
259
|
+
try { fs.unlinkSync(slotPath); } catch {}
|
|
260
|
+
}
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function claimRunFanoutBatch(descriptor: RunFanoutBudgetDescriptor, paths: string[]): RunFanoutBudgetSnapshot {
|
|
267
|
+
return commitRunFanoutBatch(descriptor, paths, (snapshot) => snapshot);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function claimRunFanoutBatchWithCommit<T>(descriptor: RunFanoutBudgetDescriptor, paths: string[], commit: () => T): T {
|
|
271
|
+
return commitRunFanoutBatch(descriptor, paths, commit);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function formatRunFanoutBudget(snapshot: RunFanoutBudgetSnapshot): string {
|
|
275
|
+
return `Run fan-out: ${snapshot.used}/${snapshot.limit} used, ${snapshot.remaining} remaining`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function formatRunFanoutRejection(rejection: RunFanoutRejection): string {
|
|
279
|
+
return `Run fan-out limit reached at ${rejection.path} (${rejection.used}/${rejection.limit} used; ${rejection.requested} requested, ${rejection.remaining} remaining). No children from this admission group were started. Start a new top-level run or raise config.maxSubagentSpawnsPerRun.`;
|
|
280
|
+
}
|
|
@@ -214,17 +214,19 @@ export function finalizeSingleOutput(params: {
|
|
|
214
214
|
outputPath?: string;
|
|
215
215
|
outputMode?: OutputMode;
|
|
216
216
|
exitCode: number;
|
|
217
|
+
/** Keep the saved-output reference when a post-run acceptance gate rejects an otherwise completed child. */
|
|
218
|
+
preserveSavedOutput?: boolean;
|
|
217
219
|
savedPath?: string;
|
|
218
220
|
outputReference?: SavedOutputReference;
|
|
219
221
|
saveError?: string;
|
|
220
222
|
}): { displayOutput: string; savedPath?: string; outputReference?: SavedOutputReference; saveError?: string } {
|
|
221
223
|
let displayOutput = params.truncatedOutput || params.fullOutput;
|
|
222
|
-
if (params.exitCode === 0 && params.savedPath) {
|
|
224
|
+
if ((params.exitCode === 0 || params.preserveSavedOutput) && params.savedPath) {
|
|
223
225
|
const outputReference = params.outputReference ?? formatSavedOutputReference(params.savedPath, params.fullOutput);
|
|
224
226
|
if (params.outputMode === "file-only") {
|
|
225
227
|
return { displayOutput: outputReference.message, savedPath: params.savedPath, outputReference };
|
|
226
228
|
}
|
|
227
|
-
displayOutput += `\n\n${outputReference.message}`;
|
|
229
|
+
if (!displayOutput.includes(outputReference.message)) displayOutput += `\n\n${outputReference.message}`;
|
|
228
230
|
return { displayOutput, savedPath: params.savedPath, outputReference };
|
|
229
231
|
}
|
|
230
232
|
if (params.exitCode === 0 && params.saveError && params.outputPath) {
|
|
@@ -31,6 +31,7 @@ const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEX
|
|
|
31
31
|
const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
|
|
32
32
|
export const SUBAGENT_INTERCOM_SESSION_NAME_ENV = "PI_SUBAGENT_INTERCOM_SESSION_NAME";
|
|
33
33
|
const STEERING_LEGACY_SETTLE_FALLBACK_MS = 1000;
|
|
34
|
+
const STEERING_SAFETY_POLL_INTERVAL_MS = 5000;
|
|
34
35
|
|
|
35
36
|
const STRUCTURED_OUTPUT_INSTRUCTIONS = [
|
|
36
37
|
"This subagent step has a strict structured output contract.",
|
|
@@ -331,7 +332,13 @@ function registerToolBudget(pi: ExtensionAPI, budget: ResolvedToolBudget | undef
|
|
|
331
332
|
|
|
332
333
|
export function registerSteeringInbox(
|
|
333
334
|
pi: ExtensionAPI,
|
|
334
|
-
deps: {
|
|
335
|
+
deps: {
|
|
336
|
+
watch?: typeof fs.watch;
|
|
337
|
+
nativeRealpath?: (filePath: string) => string;
|
|
338
|
+
legacySettleFallbackMs?: number;
|
|
339
|
+
safetyPollIntervalMs?: number;
|
|
340
|
+
timers?: Pick<typeof globalThis, "setInterval" | "clearInterval">;
|
|
341
|
+
} = {},
|
|
335
342
|
): void {
|
|
336
343
|
const steerInbox = process.env[SUBAGENT_STEER_INBOX_ENV]?.trim();
|
|
337
344
|
if (!steerInbox) return;
|
|
@@ -350,6 +357,7 @@ export function registerSteeringInbox(
|
|
|
350
357
|
let canSteer = typeof sendUserMessage === "function";
|
|
351
358
|
let watcher: fs.FSWatcher | undefined;
|
|
352
359
|
let interval: NodeJS.Timeout | undefined;
|
|
360
|
+
let safetyInterval: NodeJS.Timeout | undefined;
|
|
353
361
|
let settleFallback: NodeJS.Timeout | undefined;
|
|
354
362
|
const legacySettleFallbackMs = deps.legacySettleFallbackMs ?? STEERING_LEGACY_SETTLE_FALLBACK_MS;
|
|
355
363
|
const acknowledge = (request: SteerRequest, state: "delivered" | "queued" | "failed", message: string, deliveryStatus?: SteerDeliveryStatus): void => {
|
|
@@ -431,14 +439,24 @@ export function registerSteeringInbox(
|
|
|
431
439
|
return;
|
|
432
440
|
}
|
|
433
441
|
started = true;
|
|
442
|
+
const startPolling = (): void => {
|
|
443
|
+
if (interval || disposed) return;
|
|
444
|
+
interval = (deps.timers?.setInterval ?? setInterval)(flush, 250) as NodeJS.Timeout;
|
|
445
|
+
interval.unref?.();
|
|
446
|
+
};
|
|
447
|
+
const startSafetyPolling = (): void => {
|
|
448
|
+
if (safetyInterval || disposed) return;
|
|
449
|
+
safetyInterval = (deps.timers?.setInterval ?? setInterval)(flush, deps.safetyPollIntervalMs ?? STEERING_SAFETY_POLL_INTERVAL_MS) as NodeJS.Timeout;
|
|
450
|
+
safetyInterval.unref?.();
|
|
451
|
+
};
|
|
434
452
|
try {
|
|
435
453
|
watcher = (deps.watch ?? fs.watch)(resolveWatchPath(steerInbox, deps.nativeRealpath), () => flush());
|
|
436
|
-
watcher.on("error",
|
|
454
|
+
watcher.on("error", startPolling);
|
|
455
|
+
startSafetyPolling();
|
|
437
456
|
} catch {
|
|
438
457
|
watcher = undefined;
|
|
458
|
+
startPolling();
|
|
439
459
|
}
|
|
440
|
-
interval = setInterval(flush, 250);
|
|
441
|
-
interval.unref?.();
|
|
442
460
|
};
|
|
443
461
|
const activate = (): undefined => {
|
|
444
462
|
start();
|
|
@@ -534,7 +552,8 @@ export function registerSteeringInbox(
|
|
|
534
552
|
disposed = true;
|
|
535
553
|
clearSettleFallback();
|
|
536
554
|
try { watcher?.close(); } catch {}
|
|
537
|
-
if (interval) clearInterval(interval);
|
|
555
|
+
if (interval) (deps.timers?.clearInterval ?? clearInterval)(interval);
|
|
556
|
+
if (safetyInterval) (deps.timers?.clearInterval ?? clearInterval)(safetyInterval);
|
|
538
557
|
});
|
|
539
558
|
}
|
|
540
559
|
|
|
@@ -70,6 +70,18 @@ const RESEARCH_AGENT_PATTERNS = [
|
|
|
70
70
|
/\bresearch(?:er)?\b/i,
|
|
71
71
|
];
|
|
72
72
|
|
|
73
|
+
// Review/severity vocabulary like "must-fix items" or "should-fix tests" is not
|
|
74
|
+
// the verb. Rather than dash-guarding every pattern (which would also suppress
|
|
75
|
+
// CLI flags like "--fix" and genuine clause-level dashes like "branch—fix it"),
|
|
76
|
+
// strip the known severity compounds (must|should|needs + dash + verb) from the
|
|
77
|
+
// task text before matching. Dash coverage: ASCII hyphen + U+2010..U+2015.
|
|
78
|
+
const SEVERITY_COMPOUND_PATTERN = /\b(?:must|should|needs)[\-\u2010-\u2015](?:fix|edit|update|add|remove|replace|create|apply|make|do|implement|modify|delete|patch)\b/gi;
|
|
79
|
+
|
|
80
|
+
function stripSeverityCompounds(task: string): string {
|
|
81
|
+
return task.replace(SEVERITY_COMPOUND_PATTERN, " ");
|
|
82
|
+
}
|
|
83
|
+
export { stripSeverityCompounds };
|
|
84
|
+
|
|
73
85
|
const FIX_OR_PATCH_IMPLEMENTATION_PATTERN = /\b(?:fix|patch)\s+(?:(?:it|this|that|them|each|any|all|these|those)\b|(?:(?:a|an|the|any|all)\s+)?(?:(?:failing|failed|broken|flaky|red|cold|start|current|existing|reported|approved|known|regression|unit|integration|e2e|source|typescript|type-?script|ts|type-?check|compiler)\s+)*(?:bug|defect|issues?|problems?|failures?|regressions?|tests?|errors?|items?|typos?|code|source|implementation|component|function|module|class|method|logic|file|files|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command|lint(?:ing)?|build|ci|type-?check|type\s+checking)\b)/i;
|
|
74
86
|
|
|
75
87
|
const WORKER_IMPLEMENTATION_PATTERNS = [
|
|
@@ -146,7 +158,7 @@ function hasImplementationIntent(agent: string, taskText: string): boolean {
|
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
export function classifyTaskMutationIntent(agent: string, task: string): TaskMutationIntent {
|
|
149
|
-
const taskText = stripFrameworkInstructions(task);
|
|
161
|
+
const taskText = stripSeverityCompounds(stripFrameworkInstructions(task));
|
|
150
162
|
const taskTextWithoutScopedConstraints = stripPatterns(taskText, SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
|
|
151
163
|
const prohibitions = analyzeNoEditProhibitions(taskTextWithoutScopedConstraints);
|
|
152
164
|
if (prohibitions.present) {
|
|
@@ -164,7 +176,11 @@ export function expectsImplementationMutation(agent: string, task: string): bool
|
|
|
164
176
|
return classifyTaskMutationIntent(agent, task).kind === "implementation";
|
|
165
177
|
}
|
|
166
178
|
|
|
167
|
-
/** Bare write verbs that make a task write-capable for acceptance inference.
|
|
179
|
+
/** Bare write verbs that make a task write-capable for acceptance inference.
|
|
180
|
+
* Severity compounds ("must-fix items") are stripped before matching, so they
|
|
181
|
+
* are not write imperatives, while CLI flags ("--fix", "-w"), clause-level
|
|
182
|
+
* dashes ("branch—fix it"), and hyphenated imperatives ("hot-fix the bug")
|
|
183
|
+
* all stay write-capable. */
|
|
168
184
|
const MAY_MUTATE_VERB_PATTERN = /\b(?:fix|implement|update|write|edit|modify|migrate|delete|remove|refactor|commit)\b/i;
|
|
169
185
|
|
|
170
186
|
/**
|
|
@@ -174,7 +190,7 @@ const MAY_MUTATE_VERB_PATTERN = /\b(?:fix|implement|update|write|edit|modify|mig
|
|
|
174
190
|
* does.
|
|
175
191
|
*/
|
|
176
192
|
export function taskMayMutate(task: string): boolean {
|
|
177
|
-
const taskText = stripPatterns(stripFrameworkInstructions(task), SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
|
|
193
|
+
const taskText = stripPatterns(stripSeverityCompounds(stripFrameworkInstructions(task)), SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
|
|
178
194
|
const prohibitions = analyzeNoEditProhibitions(taskText);
|
|
179
195
|
if (prohibitions.blanket) return false;
|
|
180
196
|
return MAY_MUTATE_VERB_PATTERN.test(stripPatterns(prohibitions.strippedText, READ_ONLY_DELIVERABLE_PATTERNS));
|
|
@@ -4,6 +4,7 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { resolveAuthorityDecision, type AuthorityPolicyConfig } from "../../policy/authority.ts";
|
|
6
6
|
import { PROJECT_SUBAGENTS_RELATIVE_DIR } from "../../shared/artifacts.ts";
|
|
7
|
+
import { getAgentDir } from "../../shared/utils.ts";
|
|
7
8
|
|
|
8
9
|
export interface WorktreeSetup {
|
|
9
10
|
cwd: string;
|
|
@@ -149,11 +150,17 @@ function resolveRepoState(cwd: string): RepoState {
|
|
|
149
150
|
|
|
150
151
|
function normalizeComparableCwd(cwd: string): string {
|
|
151
152
|
const resolved = path.resolve(cwd);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
let existing = resolved;
|
|
154
|
+
const missingSegments: string[] = [];
|
|
155
|
+
while (true) {
|
|
156
|
+
try {
|
|
157
|
+
return path.join(fs.realpathSync(existing), ...missingSegments.reverse());
|
|
158
|
+
} catch {
|
|
159
|
+
const parent = path.dirname(existing);
|
|
160
|
+
if (parent === existing) return resolved;
|
|
161
|
+
missingSegments.push(path.basename(existing));
|
|
162
|
+
existing = parent;
|
|
163
|
+
}
|
|
157
164
|
}
|
|
158
165
|
}
|
|
159
166
|
|
|
@@ -196,6 +203,11 @@ function resolveWorktreeBaseDir(configuredBaseDir: string | undefined, repoRoot:
|
|
|
196
203
|
|
|
197
204
|
const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
|
|
198
205
|
const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
|
|
206
|
+
const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
|
|
207
|
+
const relativeToExtensions = path.relative(extensionsDir, normalizeComparableCwd(resolved));
|
|
208
|
+
if (!relativeToExtensions || (!relativeToExtensions.startsWith(`..${path.sep}`) && relativeToExtensions !== ".." && !path.isAbsolute(relativeToExtensions))) {
|
|
209
|
+
throw new Error(`worktree base directory cannot be inside Pi extensions directory: ${extensionsDir}. Choose a directory outside it.`);
|
|
210
|
+
}
|
|
199
211
|
try {
|
|
200
212
|
fs.mkdirSync(resolved, { recursive: true });
|
|
201
213
|
} catch (error) {
|
package/src/shared/artifacts.ts
CHANGED
|
@@ -228,7 +228,7 @@ export function appendJsonl(filePath: string, line: string): void {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
export function cleanupOldArtifacts(dir: string, maxAgeDays: number): void {
|
|
231
|
-
if (!fs.existsSync(dir)) return;
|
|
231
|
+
if (maxAgeDays <= 0 || !fs.existsSync(dir)) return;
|
|
232
232
|
|
|
233
233
|
const markerPath = path.join(dir, CLEANUP_MARKER_FILE);
|
|
234
234
|
const now = Date.now();
|
|
@@ -5,6 +5,7 @@ interface TimerApi {
|
|
|
5
5
|
|
|
6
6
|
interface FileCoalescer {
|
|
7
7
|
schedule(file: string, delayMs?: number): boolean;
|
|
8
|
+
flush(file: string): boolean;
|
|
8
9
|
clear(): void;
|
|
9
10
|
}
|
|
10
11
|
|
|
@@ -30,6 +31,14 @@ export function createFileCoalescer(
|
|
|
30
31
|
pending.set(file, timer);
|
|
31
32
|
return true;
|
|
32
33
|
},
|
|
34
|
+
flush(file: string): boolean {
|
|
35
|
+
const timer = pending.get(file);
|
|
36
|
+
if (timer === undefined) return false;
|
|
37
|
+
timerApi.clearTimeout(timer);
|
|
38
|
+
pending.delete(file);
|
|
39
|
+
handler(file);
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
33
42
|
clear(): void {
|
|
34
43
|
for (const timer of pending.values()) {
|
|
35
44
|
timerApi.clearTimeout(timer);
|