pi-subagents 0.34.0 → 0.35.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 +78 -9
- package/README.md +213 -32
- package/index.ts +1 -0
- package/install.mjs +1 -1
- package/package.json +23 -8
- package/prompts/review-loop.md +3 -1
- package/skills/pi-subagents/SKILL.md +87 -25
- package/src/agents/agent-management.ts +82 -15
- package/src/agents/agent-serializer.ts +19 -0
- package/src/agents/agents.ts +91 -49
- package/src/agents/frontmatter.ts +67 -13
- package/src/agents/skills.ts +25 -12
- package/src/api/background-work.ts +197 -0
- package/src/api/delegation.ts +158 -0
- package/src/extension/chain-validation.ts +165 -0
- package/src/extension/doctor.ts +15 -0
- package/src/extension/fanout-child.ts +3 -1
- package/src/extension/index.ts +65 -124
- package/src/extension/rpc.ts +10 -2
- package/src/extension/schemas.ts +18 -14
- package/src/extension/steering-notices.ts +35 -0
- package/src/extension/tool-description.ts +20 -9
- package/src/intercom/intercom-bridge.ts +3 -2
- package/src/intercom/native-supervisor-channel.ts +9 -1
- package/src/intercom/result-intercom.ts +4 -0
- package/src/runs/background/async-execution.ts +293 -44
- package/src/runs/background/async-job-tracker.ts +56 -9
- package/src/runs/background/async-resume.ts +159 -52
- package/src/runs/background/async-status.ts +25 -18
- package/src/runs/background/auto-drain.ts +67 -0
- package/src/runs/background/chain-root-attachment.ts +16 -8
- package/src/runs/background/control-channel.ts +260 -13
- package/src/runs/background/fleet-view.ts +23 -2
- package/src/runs/background/notify.ts +79 -10
- package/src/runs/background/result-watcher.ts +12 -9
- package/src/runs/background/run-id-resolver.ts +14 -2
- package/src/runs/background/run-status.ts +23 -15
- package/src/runs/background/scheduled-runs.ts +3 -0
- package/src/runs/background/stale-run-reconciler.ts +32 -10
- package/src/runs/background/steering.ts +237 -0
- package/src/runs/background/subagent-runner.ts +898 -236
- package/src/runs/background/subagent-wait.ts +484 -0
- package/src/runs/background/top-level-async.ts +2 -1
- package/src/runs/background/wait-config.ts +36 -0
- package/src/runs/background/wait-tool.ts +26 -0
- package/src/runs/foreground/async-steering-action.ts +230 -0
- package/src/runs/foreground/chain-clarify.ts +22 -6
- package/src/runs/foreground/chain-execution.ts +50 -32
- package/src/runs/foreground/execution.ts +308 -94
- package/src/runs/foreground/subagent-executor.ts +592 -268
- package/src/runs/shared/acceptance.ts +355 -97
- package/src/runs/shared/child-protocol.ts +121 -0
- package/src/runs/shared/completion-guard.ts +8 -127
- package/src/runs/shared/dynamic-fanout.ts +6 -4
- package/src/runs/shared/model-fallback.ts +36 -0
- package/src/runs/shared/nested-events.ts +9 -4
- package/src/runs/shared/nested-render.ts +4 -1
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +34 -7
- package/src/runs/shared/pi-spawn.ts +18 -12
- package/src/runs/shared/session-lease.ts +279 -0
- package/src/runs/shared/single-output.ts +61 -6
- package/src/runs/shared/spawn-budget.ts +128 -0
- package/src/runs/shared/subagent-control.ts +10 -6
- package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
- package/src/runs/shared/task-intent.ts +176 -0
- package/src/runs/shared/tool-availability.ts +65 -0
- package/src/runs/shared/turn-budget.ts +49 -4
- package/src/shared/atomic-json.ts +4 -1
- package/src/shared/fork-context.ts +28 -3
- package/src/shared/model-info.ts +7 -4
- package/src/shared/status-format.ts +7 -1
- package/src/shared/types.ts +203 -25
- package/src/shared/utils.ts +35 -7
- package/src/slash/delegation-adapters.ts +457 -0
- package/src/slash/delegation-request.ts +103 -0
- package/src/slash/prompt-template-bridge.ts +167 -344
- package/src/slash/slash-commands.ts +239 -6
- package/src/slash/subagents-admin.ts +428 -0
- package/src/slash/subagents-editor.ts +86 -0
- package/src/tui/fleet.ts +405 -0
- package/src/tui/render.ts +90 -16
- package/src/watchdog/change-signature.ts +127 -0
- package/src/watchdog/child-status.ts +205 -0
- package/src/watchdog/emission-guard.ts +123 -0
- package/src/watchdog/lsp-diagnostics.ts +532 -0
- package/src/watchdog/model-selection.ts +167 -0
- package/src/watchdog/register-child.ts +117 -0
- package/src/watchdog/register-main.ts +433 -0
- package/src/watchdog/render.ts +54 -0
- package/src/watchdog/review.ts +293 -0
- package/src/watchdog/runtime.ts +712 -0
- package/src/watchdog/settings.ts +528 -0
- package/src/watchdog/tool-actions.ts +155 -0
- package/src/watchdog/turn-delta.ts +161 -0
- package/src/watchdog/types.ts +188 -0
- package/src/watchdog/warning-format.ts +73 -0
- package/src/runs/background/wait.ts +0 -394
|
@@ -45,6 +45,7 @@ export interface PiSpawnDeps {
|
|
|
45
45
|
execPath?: string;
|
|
46
46
|
argv1?: string;
|
|
47
47
|
existsSync?: (filePath: string) => boolean;
|
|
48
|
+
realpathSync?: (filePath: string) => string;
|
|
48
49
|
readFileSync?: (filePath: string, encoding: "utf-8") => string;
|
|
49
50
|
resolvePackageJson?: () => string;
|
|
50
51
|
resolvePackageEntry?: () => string;
|
|
@@ -69,10 +70,11 @@ function normalizePath(filePath: string): string {
|
|
|
69
70
|
return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
export function
|
|
73
|
+
export function resolvePiCliScript(
|
|
73
74
|
deps: PiSpawnDeps = {},
|
|
74
75
|
): string | undefined {
|
|
75
76
|
const existsSync = deps.existsSync ?? fs.existsSync;
|
|
77
|
+
const realpathSync = deps.realpathSync ?? fs.realpathSync;
|
|
76
78
|
const readFileSync =
|
|
77
79
|
deps.readFileSync ??
|
|
78
80
|
((filePath, encoding) => fs.readFileSync(filePath, encoding));
|
|
@@ -81,7 +83,14 @@ export function resolveWindowsPiCliScript(
|
|
|
81
83
|
if (argv1) {
|
|
82
84
|
const argvPath = normalizePath(argv1);
|
|
83
85
|
if (isRunnableNodeScript(argvPath, existsSync)) {
|
|
84
|
-
|
|
86
|
+
try {
|
|
87
|
+
const canonicalArgvPath = realpathSync(argvPath);
|
|
88
|
+
if (isRunnableNodeScript(canonicalArgvPath, existsSync) && findPiPackageRootFromEntry(canonicalArgvPath)) {
|
|
89
|
+
return canonicalArgvPath;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Host package metadata is untrusted here; keep resolving the installed Pi CLI.
|
|
93
|
+
}
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
|
|
@@ -115,7 +124,7 @@ export function resolveWindowsPiCliScript(
|
|
|
115
124
|
return candidate;
|
|
116
125
|
}
|
|
117
126
|
} catch {
|
|
118
|
-
//
|
|
127
|
+
// Verified CLI resolution is optional; falling back to `pi` lets PATH handle execution.
|
|
119
128
|
return undefined;
|
|
120
129
|
}
|
|
121
130
|
|
|
@@ -132,15 +141,12 @@ export function getPiSpawnCommand(
|
|
|
132
141
|
return { command: piBinary, args };
|
|
133
142
|
}
|
|
134
143
|
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
args: [piCliPath, ...args],
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
+
const piCliPath = resolvePiCliScript(deps);
|
|
145
|
+
if (piCliPath) {
|
|
146
|
+
return {
|
|
147
|
+
command: deps.execPath ?? process.execPath,
|
|
148
|
+
args: [piCliPath, ...args],
|
|
149
|
+
};
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
return { command: "pi", args };
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { createAtomicJsonWriter } from "../../shared/atomic-json.ts";
|
|
7
|
+
import { TEMP_ROOT_DIR } from "../../shared/types.ts";
|
|
8
|
+
|
|
9
|
+
export const SESSION_LEASES_DIR = path.join(TEMP_ROOT_DIR, "session-leases");
|
|
10
|
+
|
|
11
|
+
export interface SessionLeaseRequest {
|
|
12
|
+
sessionFile: string;
|
|
13
|
+
runId: string;
|
|
14
|
+
sourceRunId: string;
|
|
15
|
+
parentSessionId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SessionLeaseOwner {
|
|
19
|
+
version: 1;
|
|
20
|
+
token: string;
|
|
21
|
+
canonicalSessionFile: string;
|
|
22
|
+
runId: string;
|
|
23
|
+
sourceRunId: string;
|
|
24
|
+
parentSessionId?: string;
|
|
25
|
+
pid: number;
|
|
26
|
+
hostname: string;
|
|
27
|
+
processStartIdentity?: string;
|
|
28
|
+
writerState: "none" | "spawning" | "running";
|
|
29
|
+
writerPid?: number;
|
|
30
|
+
writerProcessStartIdentity?: string;
|
|
31
|
+
acquiredAt: string;
|
|
32
|
+
acquiredAtMs: number;
|
|
33
|
+
updatedAtMs: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SessionLeaseHandle {
|
|
37
|
+
leaseDir: string;
|
|
38
|
+
owner: SessionLeaseOwner;
|
|
39
|
+
updateWriter(writer: { state: "none" | "spawning" } | { state: "running"; pid: number }): void;
|
|
40
|
+
release(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface SessionLeaseOptions {
|
|
44
|
+
rootDir?: string;
|
|
45
|
+
now?: () => number;
|
|
46
|
+
token?: () => string;
|
|
47
|
+
pid?: number;
|
|
48
|
+
hostname?: string;
|
|
49
|
+
processStartIdentity?: string;
|
|
50
|
+
isProcessAlive?: (pid: number) => boolean | undefined;
|
|
51
|
+
getProcessStartIdentity?: (pid: number) => string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SessionLeaseConflictError extends Error {
|
|
55
|
+
readonly owner?: SessionLeaseOwner;
|
|
56
|
+
|
|
57
|
+
constructor(message: string, owner?: SessionLeaseOwner) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "SessionLeaseConflictError";
|
|
60
|
+
this.owner = owner;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getProcessStartIdentity(pid: number): string | undefined {
|
|
65
|
+
if (process.platform === "linux") {
|
|
66
|
+
try {
|
|
67
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
68
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
69
|
+
if (commandEnd === -1) return undefined;
|
|
70
|
+
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
|
|
71
|
+
const startTicks = fields[19];
|
|
72
|
+
return startTicks ? `linux:${startTicks}` : undefined;
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (process.platform === "darwin" || process.platform === "freebsd") {
|
|
78
|
+
const result = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8" });
|
|
79
|
+
const started = result.status === 0 ? result.stdout.trim() : "";
|
|
80
|
+
return started ? `${process.platform}:${started}` : undefined;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function processIsAlive(pid: number): boolean | undefined {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
91
|
+
if (code === "ESRCH") return false;
|
|
92
|
+
if (code === "EPERM") return true;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function canonicalSessionFilePath(sessionFile: string): string {
|
|
98
|
+
return fs.realpathSync.native(path.resolve(sessionFile));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function sessionLeaseDir(sessionFile: string, rootDir = SESSION_LEASES_DIR): string {
|
|
102
|
+
const canonical = canonicalSessionFilePath(sessionFile);
|
|
103
|
+
const key = process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
104
|
+
const digest = createHash("sha256").update(key).digest("hex");
|
|
105
|
+
return path.join(rootDir, digest);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseOwner(value: unknown): SessionLeaseOwner | undefined {
|
|
109
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
110
|
+
const owner = value as Partial<SessionLeaseOwner>;
|
|
111
|
+
if (owner.version !== 1
|
|
112
|
+
|| typeof owner.token !== "string"
|
|
113
|
+
|| typeof owner.canonicalSessionFile !== "string"
|
|
114
|
+
|| typeof owner.runId !== "string"
|
|
115
|
+
|| typeof owner.sourceRunId !== "string"
|
|
116
|
+
|| typeof owner.pid !== "number"
|
|
117
|
+
|| !Number.isInteger(owner.pid)
|
|
118
|
+
|| owner.pid <= 0
|
|
119
|
+
|| typeof owner.hostname !== "string"
|
|
120
|
+
|| (owner.writerState !== "none" && owner.writerState !== "spawning" && owner.writerState !== "running")
|
|
121
|
+
|| typeof owner.acquiredAt !== "string"
|
|
122
|
+
|| typeof owner.acquiredAtMs !== "number"
|
|
123
|
+
|| typeof owner.updatedAtMs !== "number") return undefined;
|
|
124
|
+
if (owner.parentSessionId !== undefined && typeof owner.parentSessionId !== "string") return undefined;
|
|
125
|
+
if (owner.processStartIdentity !== undefined && typeof owner.processStartIdentity !== "string") return undefined;
|
|
126
|
+
if (owner.writerPid !== undefined && (typeof owner.writerPid !== "number" || !Number.isInteger(owner.writerPid) || owner.writerPid <= 0)) return undefined;
|
|
127
|
+
if (owner.writerProcessStartIdentity !== undefined && typeof owner.writerProcessStartIdentity !== "string") return undefined;
|
|
128
|
+
if (owner.writerState === "running" && owner.writerPid === undefined) return undefined;
|
|
129
|
+
if (owner.writerState !== "running" && (owner.writerPid !== undefined || owner.writerProcessStartIdentity !== undefined)) return undefined;
|
|
130
|
+
return owner as SessionLeaseOwner;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readLeaseOwner(leaseDir: string): SessionLeaseOwner | undefined {
|
|
134
|
+
try {
|
|
135
|
+
return parseOwner(JSON.parse(fs.readFileSync(path.join(leaseDir, "owner.json"), "utf-8")));
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function conflictMessage(canonicalSessionFile: string, owner: SessionLeaseOwner | undefined): string {
|
|
142
|
+
if (!owner) {
|
|
143
|
+
return `Direct revival of session '${canonicalSessionFile}' is blocked by an existing lease with unreadable owner metadata. Refusing to reclaim it without proof that the owner is stale.`;
|
|
144
|
+
}
|
|
145
|
+
const parent = owner.parentSessionId ? `, parent session '${owner.parentSessionId}'` : "";
|
|
146
|
+
return `Direct revival of session '${canonicalSessionFile}' is already owned by run '${owner.runId}' (source run '${owner.sourceRunId}'${parent}, pid ${owner.pid} on ${owner.hostname}). Wait for that revival to finish or start a separate continuation without reusing this session file.`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function processDemonstrablyGone(
|
|
150
|
+
pid: number,
|
|
151
|
+
startIdentity: string | undefined,
|
|
152
|
+
options: Required<Pick<SessionLeaseOptions, "isProcessAlive" | "getProcessStartIdentity">>,
|
|
153
|
+
): boolean {
|
|
154
|
+
const alive = options.isProcessAlive(pid);
|
|
155
|
+
if (alive === false) return true;
|
|
156
|
+
if (alive !== true || !startIdentity) return false;
|
|
157
|
+
const currentIdentity = options.getProcessStartIdentity(pid);
|
|
158
|
+
return currentIdentity !== undefined && currentIdentity !== startIdentity;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function demonstrablyStale(owner: SessionLeaseOwner, options: Required<Pick<SessionLeaseOptions, "hostname" | "isProcessAlive" | "getProcessStartIdentity">>): boolean {
|
|
162
|
+
if (owner.hostname !== options.hostname) return false;
|
|
163
|
+
if (!processDemonstrablyGone(owner.pid, owner.processStartIdentity, options)) return false;
|
|
164
|
+
if (owner.writerState === "spawning") return false;
|
|
165
|
+
if (owner.writerState === "none") return true;
|
|
166
|
+
return owner.writerPid !== undefined
|
|
167
|
+
&& processDemonstrablyGone(owner.writerPid, owner.writerProcessStartIdentity, options);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function createLeaseDirectory(leaseDir: string, owner: SessionLeaseOwner): boolean {
|
|
171
|
+
const tempDir = `${leaseDir}.candidate-${owner.token}`;
|
|
172
|
+
fs.mkdirSync(path.dirname(leaseDir), { recursive: true, mode: 0o700 });
|
|
173
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
174
|
+
fs.mkdirSync(tempDir, { mode: 0o700 });
|
|
175
|
+
try {
|
|
176
|
+
fs.writeFileSync(path.join(tempDir, "owner.json"), JSON.stringify(owner, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
177
|
+
try {
|
|
178
|
+
fs.renameSync(tempDir, leaseDir);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (fs.existsSync(leaseDir)) return false;
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function acquireSessionLease(request: SessionLeaseRequest, options: SessionLeaseOptions = {}): SessionLeaseHandle {
|
|
190
|
+
const canonicalSessionFile = canonicalSessionFilePath(request.sessionFile);
|
|
191
|
+
const rootDir = options.rootDir ?? SESSION_LEASES_DIR;
|
|
192
|
+
const leaseDir = sessionLeaseDir(canonicalSessionFile, rootDir);
|
|
193
|
+
const now = options.now ?? Date.now;
|
|
194
|
+
const pid = options.pid ?? process.pid;
|
|
195
|
+
const hostname = options.hostname ?? os.hostname();
|
|
196
|
+
const getIdentity = options.getProcessStartIdentity ?? getProcessStartIdentity;
|
|
197
|
+
const processStartIdentity = options.processStartIdentity
|
|
198
|
+
?? getIdentity(pid)
|
|
199
|
+
?? (pid === process.pid ? `runtime:${Math.round(Date.now() - process.uptime() * 1000)}` : undefined);
|
|
200
|
+
const acquiredAtMs = now();
|
|
201
|
+
const owner: SessionLeaseOwner = {
|
|
202
|
+
version: 1,
|
|
203
|
+
token: options.token?.() ?? randomUUID(),
|
|
204
|
+
canonicalSessionFile,
|
|
205
|
+
runId: request.runId,
|
|
206
|
+
sourceRunId: request.sourceRunId,
|
|
207
|
+
...(request.parentSessionId ? { parentSessionId: request.parentSessionId } : {}),
|
|
208
|
+
pid,
|
|
209
|
+
hostname,
|
|
210
|
+
...(processStartIdentity ? { processStartIdentity } : {}),
|
|
211
|
+
writerState: "none",
|
|
212
|
+
acquiredAt: new Date(acquiredAtMs).toISOString(),
|
|
213
|
+
acquiredAtMs,
|
|
214
|
+
updatedAtMs: acquiredAtMs,
|
|
215
|
+
};
|
|
216
|
+
const staleOptions = {
|
|
217
|
+
hostname,
|
|
218
|
+
isProcessAlive: options.isProcessAlive ?? processIsAlive,
|
|
219
|
+
getProcessStartIdentity: getIdentity,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
223
|
+
if (createLeaseDirectory(leaseDir, owner)) {
|
|
224
|
+
const writeOwner = createAtomicJsonWriter();
|
|
225
|
+
return {
|
|
226
|
+
leaseDir,
|
|
227
|
+
owner,
|
|
228
|
+
updateWriter(writer) {
|
|
229
|
+
const currentOwner = readLeaseOwner(leaseDir);
|
|
230
|
+
if (!currentOwner || currentOwner.token !== owner.token) {
|
|
231
|
+
throw new Error(`Session revival lease ownership changed for run '${owner.runId}'.`);
|
|
232
|
+
}
|
|
233
|
+
const writerProcessStartIdentity = writer.state === "running" ? getIdentity(writer.pid) : undefined;
|
|
234
|
+
const nextOwner: SessionLeaseOwner = {
|
|
235
|
+
...owner,
|
|
236
|
+
writerState: writer.state,
|
|
237
|
+
...(writer.state === "running" ? { writerPid: writer.pid } : {}),
|
|
238
|
+
...(writerProcessStartIdentity ? { writerProcessStartIdentity } : {}),
|
|
239
|
+
updatedAtMs: now(),
|
|
240
|
+
};
|
|
241
|
+
delete nextOwner.writerPid;
|
|
242
|
+
delete nextOwner.writerProcessStartIdentity;
|
|
243
|
+
if (writer.state === "running") {
|
|
244
|
+
nextOwner.writerPid = writer.pid;
|
|
245
|
+
if (writerProcessStartIdentity) nextOwner.writerProcessStartIdentity = writerProcessStartIdentity;
|
|
246
|
+
}
|
|
247
|
+
writeOwner(path.join(leaseDir, "owner.json"), nextOwner);
|
|
248
|
+
delete owner.writerPid;
|
|
249
|
+
delete owner.writerProcessStartIdentity;
|
|
250
|
+
Object.assign(owner, nextOwner);
|
|
251
|
+
},
|
|
252
|
+
release() {
|
|
253
|
+
const currentOwner = readLeaseOwner(leaseDir);
|
|
254
|
+
if (!currentOwner || currentOwner.token !== owner.token) return;
|
|
255
|
+
fs.rmSync(leaseDir, { recursive: true, force: true });
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const existingOwner = readLeaseOwner(leaseDir);
|
|
261
|
+
if (!existingOwner || !demonstrablyStale(existingOwner, staleOptions)) {
|
|
262
|
+
throw new SessionLeaseConflictError(conflictMessage(canonicalSessionFile, existingOwner), existingOwner);
|
|
263
|
+
}
|
|
264
|
+
// The per-owner tombstone is retained. Every contender that observed this
|
|
265
|
+
// stale token targets the same occupied destination, so only the first can
|
|
266
|
+
// rename it and later contenders cannot move a successor lease by mistake.
|
|
267
|
+
const tombstone = `${leaseDir}.stale-${existingOwner.token.replace(/[^A-Za-z0-9._-]/g, "-")}`;
|
|
268
|
+
try {
|
|
269
|
+
fs.renameSync(leaseDir, tombstone);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
272
|
+
if (code === "ENOENT" || fs.existsSync(tombstone)) continue;
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const existingOwner = readLeaseOwner(leaseDir);
|
|
278
|
+
throw new SessionLeaseConflictError(conflictMessage(canonicalSessionFile, existingOwner), existingOwner);
|
|
279
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
3
4
|
import type { OutputMode, SavedOutputReference } from "../../shared/types.ts";
|
|
5
|
+
import { hasMutationToolCapability } from "./completion-guard.ts";
|
|
4
6
|
|
|
5
7
|
export interface SingleOutputSnapshot {
|
|
6
8
|
exists: boolean;
|
|
@@ -8,6 +10,47 @@ export interface SingleOutputSnapshot {
|
|
|
8
10
|
size?: number;
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Content the child itself sent to the configured output path, taken from its
|
|
15
|
+
* last `write` tool call whose tool result reports success. Unlike reading the
|
|
16
|
+
* path from disk, this cannot be polluted by a sibling run writing the same
|
|
17
|
+
* path (#420); requiring the successful tool result keeps failed, cancelled,
|
|
18
|
+
* or unanswered write calls from counting as authored output. Returns
|
|
19
|
+
* undefined when no such write exists (e.g. bash or edit-based construction),
|
|
20
|
+
* in which case callers must not assume file authorship.
|
|
21
|
+
*/
|
|
22
|
+
export function extractChildWrittenOutput(
|
|
23
|
+
messages: Message[] | undefined,
|
|
24
|
+
outputPath: string | undefined,
|
|
25
|
+
cwd?: string,
|
|
26
|
+
): string | undefined {
|
|
27
|
+
if (!messages?.length || !outputPath) return undefined;
|
|
28
|
+
const resolvedTarget = path.resolve(cwd ?? ".", outputPath);
|
|
29
|
+
const comparableTarget = process.platform === "win32" ? resolvedTarget.toLowerCase() : resolvedTarget;
|
|
30
|
+
const successfulCallIds = new Set<string>();
|
|
31
|
+
for (const message of messages) {
|
|
32
|
+
if (message.role === "toolResult" && message.isError === false && typeof message.toolCallId === "string") {
|
|
33
|
+
successfulCallIds.add(message.toolCallId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
let content: string | undefined;
|
|
37
|
+
for (const message of messages) {
|
|
38
|
+
if (message.role !== "assistant") continue;
|
|
39
|
+
for (const part of message.content) {
|
|
40
|
+
if (part.type !== "toolCall" || part.name !== "write" || !successfulCallIds.has(part.id)) continue;
|
|
41
|
+
const args = typeof part.arguments === "object" && part.arguments !== null && !Array.isArray(part.arguments)
|
|
42
|
+
? part.arguments as Record<string, unknown>
|
|
43
|
+
: {};
|
|
44
|
+
if (typeof args.path !== "string" || typeof args.content !== "string") continue;
|
|
45
|
+
const resolvedWritePath = path.resolve(cwd ?? ".", args.path);
|
|
46
|
+
const comparableWritePath = process.platform === "win32" ? resolvedWritePath.toLowerCase() : resolvedWritePath;
|
|
47
|
+
if (comparableWritePath !== comparableTarget) continue;
|
|
48
|
+
content = args.content;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return content;
|
|
52
|
+
}
|
|
53
|
+
|
|
11
54
|
export function normalizeSingleOutputOverride(
|
|
12
55
|
output: string | boolean | undefined,
|
|
13
56
|
defaultOutput: string | undefined,
|
|
@@ -33,22 +76,34 @@ export function resolveSingleOutputPath(
|
|
|
33
76
|
return path.resolve(baseCwd, output);
|
|
34
77
|
}
|
|
35
78
|
|
|
36
|
-
|
|
79
|
+
interface OutputInstructionCapabilities {
|
|
80
|
+
tools?: string[];
|
|
81
|
+
mcpDirectTools?: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatOutputPathInstruction(outputPath: string, capabilities?: OutputInstructionCapabilities): string {
|
|
85
|
+
const delivery = !capabilities || hasMutationToolCapability(capabilities.tools, capabilities.mcpDirectTools)
|
|
86
|
+
? `Write your findings to exactly this path: ${outputPath}`
|
|
87
|
+
: [
|
|
88
|
+
"Return the complete artifact in your final response.",
|
|
89
|
+
`The runtime will persist it to exactly this path: ${outputPath}`,
|
|
90
|
+
"Do not call contact_supervisor merely because no write-capable tool is available.",
|
|
91
|
+
].join("\n");
|
|
37
92
|
return [
|
|
38
|
-
|
|
93
|
+
delivery,
|
|
39
94
|
"This path is authoritative for this run.",
|
|
40
95
|
"Ignore any other output filename or output path mentioned elsewhere, including output destinations in the base agent prompt, system prompt, or task instructions.",
|
|
41
96
|
].join("\n");
|
|
42
97
|
}
|
|
43
98
|
|
|
44
|
-
export function injectSingleOutputInstruction(task: string, outputPath: string | undefined): string {
|
|
99
|
+
export function injectSingleOutputInstruction(task: string, outputPath: string | undefined, capabilities?: OutputInstructionCapabilities): string {
|
|
45
100
|
if (!outputPath) return task;
|
|
46
|
-
return `${task}\n\n---\n**Output:**\n${formatOutputPathInstruction(outputPath)}`;
|
|
101
|
+
return `${task}\n\n---\n**Output:**\n${formatOutputPathInstruction(outputPath, capabilities)}`;
|
|
47
102
|
}
|
|
48
103
|
|
|
49
|
-
export function injectOutputPathSystemPrompt(systemPrompt: string, outputPath: string | undefined): string {
|
|
104
|
+
export function injectOutputPathSystemPrompt(systemPrompt: string, outputPath: string | undefined, capabilities?: OutputInstructionCapabilities): string {
|
|
50
105
|
if (!outputPath) return systemPrompt;
|
|
51
|
-
const instruction = `Runtime output path override:\n${formatOutputPathInstruction(outputPath)}`;
|
|
106
|
+
const instruction = `Runtime output path override:\n${formatOutputPathInstruction(outputPath, capabilities)}`;
|
|
52
107
|
return systemPrompt ? `${systemPrompt}\n\n${instruction}` : instruction;
|
|
53
108
|
}
|
|
54
109
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveMaxSubagentSpawnsPerSession,
|
|
3
|
+
type ExtensionConfig,
|
|
4
|
+
type SpawnBudgetSnapshot,
|
|
5
|
+
type SubagentState,
|
|
6
|
+
} from "../../shared/types.ts";
|
|
7
|
+
|
|
8
|
+
const MAX_GRANT_HISTORY = 20;
|
|
9
|
+
|
|
10
|
+
function sessionState(state: SubagentState, config: ExtensionConfig, sessionId: string | null) {
|
|
11
|
+
let counters = state.subagentSpawns;
|
|
12
|
+
if (!counters || counters.sessionId !== sessionId) {
|
|
13
|
+
counters = {
|
|
14
|
+
sessionId,
|
|
15
|
+
count: 0,
|
|
16
|
+
configuredLimit: resolveMaxSubagentSpawnsPerSession(config.maxSubagentSpawnsPerSession) ?? null,
|
|
17
|
+
granted: 0,
|
|
18
|
+
grantHistory: [],
|
|
19
|
+
};
|
|
20
|
+
state.subagentSpawns = counters;
|
|
21
|
+
}
|
|
22
|
+
if (counters.configuredLimit === undefined) {
|
|
23
|
+
counters.configuredLimit = resolveMaxSubagentSpawnsPerSession(config.maxSubagentSpawnsPerSession) ?? null;
|
|
24
|
+
}
|
|
25
|
+
counters.granted ??= 0;
|
|
26
|
+
counters.grantHistory ??= [];
|
|
27
|
+
return counters;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getSpawnBudgetSnapshot(
|
|
31
|
+
state: SubagentState,
|
|
32
|
+
config: ExtensionConfig,
|
|
33
|
+
sessionId: string | null = state.currentSessionId,
|
|
34
|
+
): SpawnBudgetSnapshot {
|
|
35
|
+
const counters = sessionState(state, config, sessionId);
|
|
36
|
+
const configuredLimit = counters.configuredLimit ?? null;
|
|
37
|
+
const granted = configuredLimit === null ? 0 : counters.granted ?? 0;
|
|
38
|
+
const limit = configuredLimit === null ? null : configuredLimit + granted;
|
|
39
|
+
return {
|
|
40
|
+
used: counters.count,
|
|
41
|
+
configuredLimit,
|
|
42
|
+
granted,
|
|
43
|
+
limit,
|
|
44
|
+
remaining: limit === null ? null : Math.max(0, limit - counters.count),
|
|
45
|
+
grantRemaining: configuredLimit === null ? null : Math.max(0, configuredLimit - granted),
|
|
46
|
+
grantHistory: [...(counters.grantHistory ?? [])],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatSpawnBudgetSummary(snapshot: SpawnBudgetSnapshot): string {
|
|
51
|
+
if (snapshot.limit === null) return "unlimited";
|
|
52
|
+
return `${snapshot.used}/${snapshot.limit} used, ${snapshot.remaining} remaining (configured ${snapshot.configuredLimit}; granted ${snapshot.granted}; grant allowance ${snapshot.grantRemaining})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatSpawnBudget(snapshot: SpawnBudgetSnapshot): string {
|
|
56
|
+
return `Spawn budget: ${formatSpawnBudgetSummary(snapshot)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function preflightSpawnBudget(
|
|
60
|
+
state: SubagentState,
|
|
61
|
+
config: ExtensionConfig,
|
|
62
|
+
sessionId: string | null,
|
|
63
|
+
requested: number,
|
|
64
|
+
): { snapshot: SpawnBudgetSnapshot; error?: string } {
|
|
65
|
+
const snapshot = getSpawnBudgetSnapshot(state, config, sessionId);
|
|
66
|
+
if (requested <= 0 || snapshot.limit === null || requested <= (snapshot.remaining ?? 0)) return { snapshot };
|
|
67
|
+
return {
|
|
68
|
+
snapshot,
|
|
69
|
+
error: `Subagent spawn limit reached for this session (${snapshot.used}/${snapshot.limit} used, ${requested} requested). ${snapshot.remaining} remaining; the declared run cannot fit, so no children were started. Grant budget explicitly from the root interactive session or start a new session.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function reserveSpawnBudget(
|
|
74
|
+
state: SubagentState,
|
|
75
|
+
config: ExtensionConfig,
|
|
76
|
+
sessionId: string | null,
|
|
77
|
+
requested: number,
|
|
78
|
+
): { snapshot: SpawnBudgetSnapshot; error?: string } {
|
|
79
|
+
const checked = preflightSpawnBudget(state, config, sessionId, requested);
|
|
80
|
+
if (checked.error || requested <= 0 || checked.snapshot.limit === null) return checked;
|
|
81
|
+
state.subagentSpawns!.count += requested;
|
|
82
|
+
return { snapshot: getSpawnBudgetSnapshot(state, config, sessionId) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function preflightSpawnBudgetGrant(
|
|
86
|
+
state: SubagentState,
|
|
87
|
+
config: ExtensionConfig,
|
|
88
|
+
sessionId: string,
|
|
89
|
+
additional: number,
|
|
90
|
+
): { snapshot: SpawnBudgetSnapshot; error?: string } {
|
|
91
|
+
const snapshot = getSpawnBudgetSnapshot(state, config, sessionId);
|
|
92
|
+
if (!Number.isInteger(additional) || additional <= 0) {
|
|
93
|
+
return { snapshot, error: "action='grant-spawn-budget' requires additional to be a positive integer." };
|
|
94
|
+
}
|
|
95
|
+
if (snapshot.configuredLimit === null || snapshot.limit === null) {
|
|
96
|
+
return { snapshot, error: "The current session has no configured spawn cap, so it does not need a budget grant." };
|
|
97
|
+
}
|
|
98
|
+
if (additional > (snapshot.grantRemaining ?? 0)) {
|
|
99
|
+
return {
|
|
100
|
+
snapshot,
|
|
101
|
+
error: `Spawn budget grant rejected: ${additional} requested but only ${snapshot.grantRemaining} of the original configured limit remains grantable.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { snapshot };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function grantSpawnBudget(
|
|
108
|
+
state: SubagentState,
|
|
109
|
+
config: ExtensionConfig,
|
|
110
|
+
sessionId: string,
|
|
111
|
+
additional: number,
|
|
112
|
+
now = Date.now(),
|
|
113
|
+
): { snapshot: SpawnBudgetSnapshot; error?: string } {
|
|
114
|
+
const checked = preflightSpawnBudgetGrant(state, config, sessionId, additional);
|
|
115
|
+
if (checked.error) return checked;
|
|
116
|
+
const before = checked.snapshot;
|
|
117
|
+
if (before.limit === null) {
|
|
118
|
+
return { snapshot: before, error: "The current session has no configured spawn cap, so it does not need a budget grant." };
|
|
119
|
+
}
|
|
120
|
+
const counters = state.subagentSpawns!;
|
|
121
|
+
counters.granted = (counters.granted ?? 0) + additional;
|
|
122
|
+
const limit = before.limit + additional;
|
|
123
|
+
counters.grantHistory = [
|
|
124
|
+
...(counters.grantHistory ?? []),
|
|
125
|
+
{ sessionId, amount: additional, grantedAt: now, previousLimit: before.limit, limit },
|
|
126
|
+
].slice(-MAX_GRANT_HISTORY);
|
|
127
|
+
return { snapshot: getSpawnBudgetSnapshot(state, config, sessionId) };
|
|
128
|
+
}
|
|
@@ -74,9 +74,10 @@ export function deriveActivityState(input: {
|
|
|
74
74
|
config: ResolvedControlConfig;
|
|
75
75
|
startedAt: number;
|
|
76
76
|
lastActivityAt?: number;
|
|
77
|
+
currentTool?: string;
|
|
77
78
|
now?: number;
|
|
78
79
|
}): ActivityState | undefined {
|
|
79
|
-
if (!input.config.enabled) return undefined;
|
|
80
|
+
if (!input.config.enabled || input.currentTool) return undefined;
|
|
80
81
|
const now = input.now ?? Date.now();
|
|
81
82
|
const lastActivity = input.lastActivityAt ?? input.startedAt;
|
|
82
83
|
const ageMs = Math.max(0, now - lastActivity);
|
|
@@ -174,7 +175,8 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
|
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
const nudgeMessage = "What are you blocked on? Reply with the smallest next step or ask for a decision.";
|
|
177
|
-
const
|
|
178
|
+
const steerCommand = `subagent({ action: "steer", id: "${runTarget}", ${event.index !== undefined ? `index: ${event.index}, ` : ""}message: "${nudgeMessage}" })`;
|
|
179
|
+
const nestedResumeCommand = `subagent({ action: "resume", id: "${runTarget}", message: "${nudgeMessage}" })`;
|
|
178
180
|
if (event.type === "active_long_running") {
|
|
179
181
|
const facts = formatLongRunningFacts(event);
|
|
180
182
|
return [
|
|
@@ -182,8 +184,9 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
|
|
|
182
184
|
`Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
|
|
183
185
|
`Signal: ${event.message}`,
|
|
184
186
|
facts ? `Facts: ${facts}` : undefined,
|
|
185
|
-
"Hint: Inspect status
|
|
186
|
-
`
|
|
187
|
+
"Hint: Inspect status first. Use steer for a top-level live async child, routed resume for a live nested child, or resume to revive a paused/completed/failed child.",
|
|
188
|
+
`Top-level live async nudge: ${steerCommand}`,
|
|
189
|
+
`Routed live nested nudge: ${nestedResumeCommand}`,
|
|
187
190
|
childIntercomTarget ? `Direct intercom target: ${childIntercomTarget}` : undefined,
|
|
188
191
|
`Status: subagent({ action: "status", id: "${runTarget}" })`,
|
|
189
192
|
`Interrupt: subagent({ action: "interrupt", id: "${runTarget}" })`,
|
|
@@ -195,8 +198,9 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
|
|
|
195
198
|
`Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
|
|
196
199
|
`Signal: ${event.message}`,
|
|
197
200
|
event.recentFailureSummary ? `Recent failures: ${event.recentFailureSummary}` : undefined,
|
|
198
|
-
"Hint: Inspect status first unless the run is clearly blocked.
|
|
199
|
-
`
|
|
201
|
+
"Hint: Inspect status first unless the run is clearly blocked. Use steer for a top-level live async child, routed resume for a live nested child, or resume to revive a paused/completed/failed child.",
|
|
202
|
+
`Top-level live async nudge: ${steerCommand}`,
|
|
203
|
+
`Routed live nested nudge: ${nestedResumeCommand}`,
|
|
200
204
|
childIntercomTarget ? `Direct intercom target: ${childIntercomTarget}` : undefined,
|
|
201
205
|
`Status: subagent({ action: "status", id: "${runTarget}" })`,
|
|
202
206
|
`Interrupt: subagent({ action: "interrupt", id: "${runTarget}" })`,
|