pi-subagents 0.47.1 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +2 -0
  3. package/docs/configuration.md +46 -2
  4. package/docs/observability.md +1 -1
  5. package/docs/tool-reference.md +1 -1
  6. package/package.json +1 -1
  7. package/src/agents/agents.ts +9 -3
  8. package/src/extension/config.ts +6 -0
  9. package/src/extension/doctor.ts +40 -0
  10. package/src/extension/index.ts +21 -0
  11. package/src/extension/public-execution.ts +5 -0
  12. package/src/extension/rpc.ts +3 -9
  13. package/src/extension/schemas.ts +2 -2
  14. package/src/intercom/intercom-bridge.ts +4 -1
  15. package/src/missions/lifecycle.ts +4 -7
  16. package/src/missions/store.ts +4 -4
  17. package/src/missions/workflow-state.ts +6 -2
  18. package/src/runs/background/active-async-capacity.ts +374 -0
  19. package/src/runs/background/active-run-index.ts +9 -5
  20. package/src/runs/background/async-execution.ts +117 -29
  21. package/src/runs/background/async-resume.ts +7 -1
  22. package/src/runs/background/async-status.ts +11 -5
  23. package/src/runs/background/chain-append.ts +33 -15
  24. package/src/runs/background/owned-process-tree.ts +104 -0
  25. package/src/runs/background/process-terminal.ts +17 -3
  26. package/src/runs/background/run-status.ts +5 -1
  27. package/src/runs/background/stale-run-reconciler.ts +3 -3
  28. package/src/runs/background/subagent-runner.ts +56 -31
  29. package/src/runs/foreground/chain-execution.ts +37 -2
  30. package/src/runs/foreground/execution.ts +90 -19
  31. package/src/runs/foreground/foreground-control.ts +12 -0
  32. package/src/runs/foreground/prompt-audit.ts +171 -0
  33. package/src/runs/foreground/subagent-executor.ts +614 -187
  34. package/src/runs/shared/acceptance.ts +13 -4
  35. package/src/runs/shared/llm-intent-arbiter.ts +286 -0
  36. package/src/runs/shared/parallel-utils.ts +2 -0
  37. package/src/runs/shared/pi-args.ts +44 -1
  38. package/src/runs/shared/run-fanout-budget.ts +280 -0
  39. package/src/runs/shared/single-output.ts +4 -2
  40. package/src/runs/shared/task-intent.ts +19 -3
  41. package/src/runs/shared/worktree.ts +17 -5
  42. package/src/shared/types.ts +92 -0
  43. package/src/shared/utils.ts +3 -1
  44. package/src/tui/fleet-status.ts +7 -5
  45. package/src/tui/fleet.ts +225 -12
  46. package/src/workflows/scripted-workflow.ts +22 -5
@@ -0,0 +1,374 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
5
+ import { TEMP_ROOT_DIR, type ActiveAsyncCapacitySnapshot, type AsyncStatus } from "../../shared/types.ts";
6
+ import { readStatus } from "../../shared/utils.ts";
7
+ import { readProcessTerminal } from "./process-terminal.ts";
8
+
9
+ export const ACTIVE_ASYNC_CAPACITY_DIR = path.join(TEMP_ROOT_DIR, "session-active-async-capacity");
10
+
11
+ export interface ActiveAsyncCapacityOwnerV1 {
12
+ version: 1;
13
+ reservationToken: string;
14
+ ownerSessionId: string;
15
+ ownerSessionKey: string;
16
+ slot: number;
17
+ runId: string;
18
+ sourceRunId?: string;
19
+ generation: number;
20
+ kind: "runner" | "workflow";
21
+ asyncDir: string;
22
+ reservedAt: number;
23
+ runnerProcessInstanceId?: string;
24
+ runnerStartedAt?: number;
25
+ }
26
+
27
+ export interface ActiveAsyncCapacityHandle {
28
+ readonly owner: ActiveAsyncCapacityOwnerV1;
29
+ markStarted(runnerProcessInstanceId: string): void;
30
+ markWorkflowStarted(): void;
31
+ rollback(): boolean;
32
+ reconcile(liveWorkflowRunIds?: ReadonlySet<string>): ActiveAsyncCapacitySnapshot;
33
+ }
34
+
35
+ interface CapacityOptions {
36
+ rootDir?: string;
37
+ now?: () => number;
38
+ token?: () => string;
39
+ afterSlotRename?: (releasedDir: string) => void;
40
+ }
41
+
42
+ export class ActiveAsyncCapacityError extends Error {
43
+ readonly snapshot: ActiveAsyncCapacitySnapshot;
44
+
45
+ constructor(snapshot: ActiveAsyncCapacitySnapshot) {
46
+ super(`Active async run capacity exhausted: ${snapshot.used}/${snapshot.limit} used.`);
47
+ this.name = "ActiveAsyncCapacityError";
48
+ this.snapshot = snapshot;
49
+ }
50
+ }
51
+
52
+ export function resolveMaxActiveAsyncRunsPerSession(value: unknown): number | undefined {
53
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return undefined;
54
+ return value === 0 ? undefined : value;
55
+ }
56
+
57
+ export function activeAsyncCapacitySessionKey(sessionId: string): string {
58
+ return createHash("sha256").update(sessionId).digest("hex");
59
+ }
60
+
61
+ function sessionDir(sessionId: string, rootDir: string): string {
62
+ return path.join(rootDir, activeAsyncCapacitySessionKey(sessionId));
63
+ }
64
+
65
+ function slotDir(poolDir: string, slot: number): string {
66
+ return path.join(poolDir, `slot-${slot}`);
67
+ }
68
+
69
+ function parseOwner(value: unknown): ActiveAsyncCapacityOwnerV1 | undefined {
70
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
71
+ const owner = value as Partial<ActiveAsyncCapacityOwnerV1>;
72
+ if (owner.version !== 1
73
+ || typeof owner.reservationToken !== "string" || !owner.reservationToken
74
+ || typeof owner.ownerSessionId !== "string" || !owner.ownerSessionId
75
+ || typeof owner.ownerSessionKey !== "string" || !owner.ownerSessionKey
76
+ || typeof owner.slot !== "number" || !Number.isInteger(owner.slot) || owner.slot < 0
77
+ || typeof owner.runId !== "string" || !owner.runId
78
+ || typeof owner.generation !== "number" || !Number.isInteger(owner.generation) || owner.generation < 0
79
+ || (owner.kind !== "runner" && owner.kind !== "workflow")
80
+ || typeof owner.asyncDir !== "string" || !owner.asyncDir
81
+ || typeof owner.reservedAt !== "number" || !Number.isFinite(owner.reservedAt)) return undefined;
82
+ if (owner.sourceRunId !== undefined && typeof owner.sourceRunId !== "string") return undefined;
83
+ if (owner.runnerProcessInstanceId !== undefined && (typeof owner.runnerProcessInstanceId !== "string" || !owner.runnerProcessInstanceId)) return undefined;
84
+ if (owner.runnerStartedAt !== undefined && (typeof owner.runnerStartedAt !== "number" || !Number.isFinite(owner.runnerStartedAt))) return undefined;
85
+ return owner as ActiveAsyncCapacityOwnerV1;
86
+ }
87
+
88
+ function readOwner(dir: string): ActiveAsyncCapacityOwnerV1 | undefined {
89
+ try {
90
+ return parseOwner(JSON.parse(fs.readFileSync(path.join(dir, "owner.json"), "utf-8")));
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ function occupiedSlots(poolDir: string): string[] {
97
+ try {
98
+ return fs.readdirSync(poolDir, { withFileTypes: true })
99
+ .filter((entry) => entry.isDirectory() && /^slot-\d+$/.test(entry.name))
100
+ .map((entry) => path.join(poolDir, entry.name));
101
+ } catch (error) {
102
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ function snapshotFor(sessionId: string, limit: number | undefined, rootDir: string): ActiveAsyncCapacitySnapshot {
108
+ return { used: occupiedSlots(sessionDir(sessionId, rootDir)).length, limit: limit ?? 0 };
109
+ }
110
+
111
+ function matchingOwner(dir: string, expected: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityOwnerV1 | undefined {
112
+ const owner = readOwner(dir);
113
+ return owner?.reservationToken === expected.reservationToken
114
+ && owner.runId === expected.runId
115
+ && owner.generation === expected.generation
116
+ ? owner
117
+ : undefined;
118
+ }
119
+
120
+ function withSlotClaim<T>(dir: string, operation: () => T): { acquired: true; value: T } | { acquired: false } {
121
+ const claimPath = path.join(dir, "capacity.claim");
122
+ const claimToken = randomUUID();
123
+ let claim: number | undefined;
124
+ try {
125
+ claim = fs.openSync(claimPath, "wx", 0o600);
126
+ fs.writeFileSync(claim, claimToken, "utf-8");
127
+ fs.closeSync(claim);
128
+ claim = undefined;
129
+ } catch (error) {
130
+ if (claim !== undefined) fs.closeSync(claim);
131
+ if ((error as NodeJS.ErrnoException).code === "EEXIST" || (error as NodeJS.ErrnoException).code === "ENOENT") return { acquired: false };
132
+ throw error;
133
+ }
134
+ try {
135
+ return { acquired: true, value: operation() };
136
+ } finally {
137
+ try {
138
+ if (fs.readFileSync(claimPath, "utf-8") === claimToken) fs.rmSync(claimPath, { force: true });
139
+ } catch (error) {
140
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
141
+ }
142
+ }
143
+ }
144
+
145
+ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false): boolean {
146
+ if (requireUnstarted && (expected.runnerProcessInstanceId || expected.runnerStartedAt)) return false;
147
+ const claimed = withSlotClaim(dir, () => {
148
+ const current = matchingOwner(dir, expected);
149
+ if (!current || (requireUnstarted && (current.runnerProcessInstanceId || current.runnerStartedAt))) return false;
150
+ const releasedDir = path.join(path.dirname(dir), `.${path.basename(dir)}.released-${randomUUID()}`);
151
+ fs.renameSync(dir, releasedDir);
152
+ options.afterSlotRename?.(releasedDir);
153
+ fs.rmSync(releasedDir, { recursive: true, force: true });
154
+ return true;
155
+ });
156
+ return claimed.acquired && claimed.value;
157
+ }
158
+
159
+ function terminalState(state: AsyncStatus["state"]): boolean {
160
+ return state !== "queued" && state !== "running" && state !== "paused";
161
+ }
162
+
163
+ function runnerCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus): boolean {
164
+ if (!owner.runnerProcessInstanceId
165
+ || status.sessionId !== owner.ownerSessionId
166
+ || status.runId !== owner.runId
167
+ || !terminalState(status.state)) return false;
168
+ if (status.processTerminal?.state === "not-started"
169
+ && status.processTerminal.runId === owner.runId
170
+ && status.processTerminal.runnerProcessInstanceId === owner.runnerProcessInstanceId
171
+ && typeof status.error === "string"
172
+ && status.error) return true;
173
+ const proof = readProcessTerminal(owner.asyncDir, {
174
+ runId: owner.runId,
175
+ runnerProcessInstanceId: owner.runnerProcessInstanceId,
176
+ });
177
+ return proof?.state === "observed"
178
+ && proof.runId === owner.runId
179
+ && proof.runnerProcessInstanceId === owner.runnerProcessInstanceId;
180
+ }
181
+
182
+ function workflowCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus, liveWorkflowRunIds: ReadonlySet<string>): boolean {
183
+ if (status.sessionId !== owner.ownerSessionId
184
+ || status.runId !== owner.runId
185
+ || status.mode !== "workflow"
186
+ || !terminalState(status.state)
187
+ || liveWorkflowRunIds.has(owner.runId)) return false;
188
+ for (const step of status.steps ?? []) {
189
+ if (step.status === "pending" || step.status === "running" || step.status === "paused") return false;
190
+ if (typeof step.async !== "boolean") return false;
191
+ if (!step.async) continue;
192
+ if (!step.runId) return false;
193
+ const childDir = path.join(path.dirname(owner.asyncDir), step.runId);
194
+ if (!fs.existsSync(childDir)) return false;
195
+ const childStatus = readStatus(childDir);
196
+ if (!childStatus || !terminalState(childStatus.state) || !childStatus.processTerminal?.runnerProcessInstanceId) return false;
197
+ const proof = readProcessTerminal(childDir, {
198
+ runId: step.runId,
199
+ runnerProcessInstanceId: childStatus.processTerminal.runnerProcessInstanceId,
200
+ });
201
+ if (proof?.state !== "observed" || proof.runId !== step.runId) return false;
202
+ }
203
+ return true;
204
+ }
205
+
206
+ function ownerCanRelease(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>): boolean {
207
+ const status = readStatus(owner.asyncDir);
208
+ if (!status) return false;
209
+ return owner.kind === "runner"
210
+ ? runnerCanRelease(owner, status)
211
+ : workflowCanRelease(owner, status, liveWorkflowRunIds);
212
+ }
213
+
214
+ export function reconcileActiveAsyncCapacity(
215
+ sessionId: string,
216
+ limit: number | undefined,
217
+ options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
218
+ ): ActiveAsyncCapacitySnapshot {
219
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
220
+ const poolDir = sessionDir(sessionId, rootDir);
221
+ const liveWorkflowRunIds = options.liveWorkflowRunIds ?? new Set<string>();
222
+ for (const dir of occupiedSlots(poolDir)) {
223
+ const owner = readOwner(dir);
224
+ if (!owner
225
+ || owner.ownerSessionId !== sessionId
226
+ || owner.ownerSessionKey !== activeAsyncCapacitySessionKey(sessionId)
227
+ || path.basename(dir) !== `slot-${owner.slot}`
228
+ || !ownerCanRelease(owner, liveWorkflowRunIds)) continue;
229
+ removeOwnedSlot(dir, owner, options);
230
+ }
231
+ return snapshotFor(sessionId, limit, rootDir);
232
+ }
233
+
234
+ export function getActiveAsyncCapacitySnapshot(
235
+ sessionId: string,
236
+ limit: number | undefined,
237
+ options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
238
+ ): ActiveAsyncCapacitySnapshot {
239
+ return reconcileActiveAsyncCapacity(sessionId, limit, options);
240
+ }
241
+
242
+ function createSlot(poolDir: string, owner: ActiveAsyncCapacityOwnerV1): boolean {
243
+ const destination = slotDir(poolDir, owner.slot);
244
+ fs.mkdirSync(poolDir, { recursive: true, mode: 0o700 });
245
+ try {
246
+ fs.mkdirSync(destination, { mode: 0o700 });
247
+ } catch (error) {
248
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
249
+ throw error;
250
+ }
251
+ // If owner persistence fails, the corrupt occupied directory remains and
252
+ // fails closed instead of becoming available to another admission.
253
+ fs.writeFileSync(path.join(destination, "owner.json"), `${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
254
+ return true;
255
+ }
256
+
257
+ function handleFor(owner: ActiveAsyncCapacityOwnerV1, limit: number, options: CapacityOptions, rollbackOwner?: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityHandle {
258
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
259
+ const dir = slotDir(sessionDir(owner.ownerSessionId, rootDir), owner.slot);
260
+ return {
261
+ owner,
262
+ markStarted(runnerProcessInstanceId) {
263
+ const claimed = withSlotClaim(dir, () => {
264
+ const current = matchingOwner(dir, owner);
265
+ if (!current) return false;
266
+ const next = { ...current, runnerProcessInstanceId, runnerStartedAt: options.now?.() ?? Date.now() };
267
+ // Mark memory first. If persistence fails after the process starts, caller
268
+ // cleanup must retain the occupied slot instead of rolling it back.
269
+ Object.assign(owner, next);
270
+ try {
271
+ writePrivateAtomicJson(path.join(dir, "owner.json"), next);
272
+ } catch (error) {
273
+ console.error(`Failed to bind active async capacity to runner '${runnerProcessInstanceId}'; capacity will remain occupied:`, error);
274
+ }
275
+ return true;
276
+ });
277
+ if (!claimed.acquired || !claimed.value) throw new Error(`Active async capacity ownership changed for run '${owner.runId}'.`);
278
+ },
279
+ markWorkflowStarted() {
280
+ const claimed = withSlotClaim(dir, () => {
281
+ const current = matchingOwner(dir, owner);
282
+ if (!current || current.kind !== "workflow") return false;
283
+ const next = { ...current, runnerStartedAt: options.now?.() ?? Date.now() };
284
+ Object.assign(owner, next);
285
+ try {
286
+ writePrivateAtomicJson(path.join(dir, "owner.json"), next);
287
+ } catch (error) {
288
+ console.error(`Failed to mark async workflow '${owner.runId}' started; capacity will remain occupied:`, error);
289
+ }
290
+ return true;
291
+ });
292
+ if (!claimed.acquired || !claimed.value) throw new Error(`Active async capacity ownership changed for workflow '${owner.runId}'.`);
293
+ },
294
+ rollback() {
295
+ if (!rollbackOwner) return removeOwnedSlot(dir, owner, options, true);
296
+ if (owner.runnerProcessInstanceId || owner.runnerStartedAt) return false;
297
+ const claimed = withSlotClaim(dir, () => {
298
+ const current = matchingOwner(dir, owner);
299
+ if (!current || current.runnerProcessInstanceId || current.runnerStartedAt) return false;
300
+ writePrivateAtomicJson(path.join(dir, "owner.json"), rollbackOwner);
301
+ Object.assign(owner, rollbackOwner);
302
+ return true;
303
+ });
304
+ return claimed.acquired && claimed.value;
305
+ },
306
+ reconcile(liveWorkflowRunIds) {
307
+ return reconcileActiveAsyncCapacity(owner.ownerSessionId, limit, { ...options, rootDir, liveWorkflowRunIds });
308
+ },
309
+ };
310
+ }
311
+
312
+ export function acquireActiveAsyncCapacity(
313
+ input: { sessionId: string; limit: number | undefined; runId: string; kind: "runner" | "workflow"; asyncDir: string },
314
+ options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
315
+ ): ActiveAsyncCapacityHandle | undefined {
316
+ if (input.limit === undefined) return undefined;
317
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
318
+ const reconciled = reconcileActiveAsyncCapacity(input.sessionId, input.limit, options);
319
+ if (reconciled.used >= input.limit) throw new ActiveAsyncCapacityError(reconciled);
320
+ const poolDir = sessionDir(input.sessionId, rootDir);
321
+ const token = options.token?.() ?? randomUUID();
322
+ for (let slot = 0; slot < input.limit; slot++) {
323
+ const owner: ActiveAsyncCapacityOwnerV1 = {
324
+ version: 1,
325
+ reservationToken: token,
326
+ ownerSessionId: input.sessionId,
327
+ ownerSessionKey: activeAsyncCapacitySessionKey(input.sessionId),
328
+ slot,
329
+ runId: input.runId,
330
+ generation: 0,
331
+ kind: input.kind,
332
+ asyncDir: input.asyncDir,
333
+ reservedAt: options.now?.() ?? Date.now(),
334
+ };
335
+ if (createSlot(poolDir, owner)) return handleFor(owner, input.limit, { ...options, rootDir });
336
+ }
337
+ throw new ActiveAsyncCapacityError(snapshotFor(input.sessionId, input.limit, rootDir));
338
+ }
339
+
340
+ export function transferActiveAsyncCapacity(
341
+ input: { sessionId: string; limit: number | undefined; sourceRunId: string; runId: string; asyncDir: string },
342
+ options: CapacityOptions = {},
343
+ ): ActiveAsyncCapacityHandle | undefined {
344
+ const limit = input.limit ?? 0;
345
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
346
+ const poolDir = sessionDir(input.sessionId, rootDir);
347
+ for (const dir of occupiedSlots(poolDir)) {
348
+ const source = readOwner(dir);
349
+ if (!source || source.ownerSessionId !== input.sessionId || source.runId !== input.sourceRunId) continue;
350
+ const claimed = withSlotClaim(dir, () => {
351
+ const current = matchingOwner(dir, source);
352
+ const status = readStatus(source.asyncDir);
353
+ if (!current || !status || status.runId !== input.sourceRunId || status.state === "queued" || status.state === "running") {
354
+ throw new Error(`Active async capacity source '${input.sourceRunId}' is not transferable.`);
355
+ }
356
+ const next: ActiveAsyncCapacityOwnerV1 = {
357
+ ...current,
358
+ runId: input.runId,
359
+ sourceRunId: input.sourceRunId,
360
+ generation: current.generation + 1,
361
+ kind: "runner",
362
+ asyncDir: input.asyncDir,
363
+ reservedAt: options.now?.() ?? Date.now(),
364
+ };
365
+ delete next.runnerProcessInstanceId;
366
+ delete next.runnerStartedAt;
367
+ writePrivateAtomicJson(path.join(dir, "owner.json"), next);
368
+ return handleFor(next, limit, { ...options, rootDir }, current);
369
+ });
370
+ if (!claimed.acquired) throw new Error(`Active async capacity transfer is already in progress for run '${input.sourceRunId}'.`);
371
+ return claimed.value;
372
+ }
373
+ return acquireActiveAsyncCapacity({ sessionId: input.sessionId, limit: input.limit, runId: input.runId, kind: "runner", asyncDir: input.asyncDir }, options);
374
+ }
@@ -16,6 +16,14 @@ export function isActiveAsyncState(state: AsyncStatus["state"]): boolean {
16
16
  return state === "queued" || state === "running";
17
17
  }
18
18
 
19
+ export function releaseActiveRunIndex(asyncDir: string): void {
20
+ try {
21
+ fs.rmSync(markerPath(asyncDir));
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
24
+ }
25
+ }
26
+
19
27
  export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state"]): void {
20
28
  const marker = markerPath(asyncDir);
21
29
  if (isActiveAsyncState(state)) {
@@ -23,11 +31,7 @@ export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state
23
31
  fs.writeFileSync(marker, "", { flag: "a" });
24
32
  return;
25
33
  }
26
- try {
27
- fs.rmSync(marker);
28
- } catch (error) {
29
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
30
- }
34
+ releaseActiveRunIndex(asyncDir);
31
35
  }
32
36
 
33
37
  export function readActiveRunIndex(asyncDirRoot: string): string[] | undefined {