@ferris1225/pi-subagents 4.3.0 → 4.3.2

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/src/dispatch.ts CHANGED
@@ -24,7 +24,8 @@ import {
24
24
  type RunView,
25
25
  type RunWaitReason,
26
26
  } from "./monitor.ts";
27
- import type { SubagentRuntime } from "./runtime.ts";
27
+ import { formatPhaseLeaseReceipt } from "./prompt.ts";
28
+ import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
28
29
  import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
29
30
  import {
30
31
  getProjectRoot,
@@ -60,7 +61,7 @@ const IsolationSchema = Type.Optional(
60
61
  const WaitSchema = Type.Optional(
61
62
  Type.Boolean({
62
63
  description:
63
- "Block until every run started by this call settles, then return their results in-turn (each result still arrives as a completion message too). Only for one-shot (pi -p) sessions or a next step that needs these results within this turn.",
64
+ "Block until every run started by this call settles, then return each result exactly once in this tool response. If the tool call is aborted, undelivered results fall back to completion messages. Intended for one-shot (pi -p) sessions or an immediate dependent step.",
64
65
  }),
65
66
  );
66
67
 
@@ -68,7 +69,7 @@ const TaskItem = Type.Object({
68
69
  agent: Type.String({ description: "Name of the agent to invoke" }),
69
70
  task: Type.String({
70
71
  ...NON_BLANK_TASK_OPTIONS,
71
- description: "Self-contained task to delegate (the agent has no memory of this conversation)",
72
+ description: "Substantial self-contained phase worth a fresh paid context (the agent has no memory of this conversation)",
72
73
  }),
73
74
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
74
75
  isolation: IsolationSchema,
@@ -77,9 +78,9 @@ const TaskItem = Type.Object({
77
78
  const SubagentParams = Type.Object({
78
79
  agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
79
80
  task: Type.Optional(
80
- Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Self-contained task to delegate (single mode)" }),
81
+ Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Substantial self-contained phase worth a fresh paid context (single mode)" }),
81
82
  ),
82
- tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
83
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "Independently justified, disjoint phases for parallel execution" })),
83
84
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
84
85
  isolation: IsolationSchema,
85
86
  wait: WaitSchema,
@@ -136,9 +137,8 @@ function toolUsage(runtime: SubagentRuntime, runIds: number[]): { usage?: Usage
136
137
  }
137
138
 
138
139
  /** In-turn wait behind dispatch `wait: true` — the escape hatch for one-shot
139
- * `pi -p` parents that exit at end of turn: hold the call until every run it
140
- * started settles, then hand back their result blocks. Interactive sessions
141
- * never take this path; their results arrive as completion wake-ups. No
140
+ * `pi -p` parents that exit at end of turn or an immediate dependent step: hold
141
+ * the call until every run it started settles, then hand back result blocks. No
142
142
  * timer: a waiter resolves the moment its run's result registers (children
143
143
  * are bounded by the idle watchdog), an already-parked run answers
144
144
  * immediately with its resume handle, and the turn's abort signal remains the
@@ -327,11 +327,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
327
327
  (mode: "single" | "parallel", background = false) =>
328
328
  (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
329
329
 
330
+ const phaseLeaseReceipt = (runIds: number[]): string =>
331
+ formatPhaseLeaseReceipt(
332
+ runIds
333
+ .map((runId) => runtime.threads.get(runId))
334
+ .filter((thread): thread is SubagentThread => thread !== undefined),
335
+ );
336
+
330
337
  /** Pacing note appended to dispatch confirmations whenever runs are actually
331
338
  * waiting. Slot waits and repository-lane waits are stated separately with
332
339
  * the real capacity: a lane-serialized shared writer or a starting child
333
- * must never read as an exhausted pool, or the model stops dispatching while
334
- * slots are free. Empty when nothing is waiting. */
340
+ * must never read as an exhausted pool. Empty when nothing is waiting. */
335
341
  const queuePacingNote = (): string => {
336
342
  const runs = monitor.getRuns();
337
343
  const queuedWith = (reason: RunWaitReason): number =>
@@ -354,7 +360,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
354
360
  (slotWaiting === 0 ? ` (${freeSlots} of ${capacity} slots free; parallel writers avoid the lane via worktree isolation)` : ""),
355
361
  );
356
362
  }
357
- return ` Pacing: ${parts.join(" · ")}. Keep dispatching independent units.`;
363
+ return ` Pacing: ${parts.join(" · ")}.`;
358
364
  };
359
365
 
360
366
  const startBackground = createBackgroundDispatcher({
@@ -374,14 +380,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
374
380
  pi.registerTool({
375
381
  name: "subagent",
376
382
  label: "Subagent",
377
- description: [
378
- "Dispatch enabled agents as isolated leaf Pi child processes: single {agent, task} or parallel {tasks: [...]}. Dispatching never blocks your turn — runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
379
- "Put every genuinely independent unit in one `tasks` array: there is no per-call cap, and runs beyond the machine's free process slots simply wait and start as slots free.",
380
- "Parallel write-capable agents default to a detached Git worktree so writers run concurrently; explicit `shared` keeps the caller's checkout and serializes same-repository writers. Worktree setup failure never silently falls back to shared.",
381
- "A configured child-model failure continues the retained session on the current main model.",
382
- ].join(" "),
383
- promptSnippet:
384
- "Dispatch isolated background agents for recon or implementation, and for cleanup, docs sync, or result merging only when that work exists; never blocks your turn, and completions wake you automatically.",
383
+ description: "Start paid leaf runs for broad reconnaissance or substantial self-contained work. Each active normalized task+cwd owns its phase; exact duplicates are rejected. Batch scopes must be independent. wait:true returns results in-turn; otherwise completions wake main. Parallel writers default to detached Git worktrees; isolation:'shared' serializes same-repository writes.",
385
384
  parameters: SubagentParams,
386
385
 
387
386
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -451,17 +450,16 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
451
450
  }
452
451
 
453
452
  // Sub-agents run detached from the foreground turn: the editor stays
454
- // available and completion messages later wake the main agent. The turn is
455
- // NOT terminated here the model can keep dispatching independent units
456
- // or do its own work, and the background queue paces how many child
457
- // processes actually run at once, so no per-call task cap is enforced.
453
+ // available for disjoint orchestration while the launch receipt leases
454
+ // each delegated phase. The queue paces child processes without changing
455
+ // phase ownership or requiring a per-call task cap.
458
456
  if (params.tasks && params.tasks.length > 0) {
459
- const results: SingleResult[] = [];
460
- // Preserve caller order (and deterministic completion batching) while
461
- // preparing each isolated filesystem before its queue entry can start.
462
- for (const item of params.tasks) {
457
+ // Admission is synchronous and ordered; slow worktree preparation belongs
458
+ // to the bounded queue. Promise.all preserves caller result order while no
459
+ // item waits for a sibling's filesystem setup.
460
+ const results = await Promise.all(params.tasks.map((item) => {
463
461
  const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
464
- results.push(await startBackground(
462
+ return startBackground(
465
463
  item.agent,
466
464
  item.task,
467
465
  item.cwd,
@@ -472,13 +470,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
472
470
  catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
473
471
  catalogAgent?.isolation,
474
472
  ),
475
- ));
476
- }
473
+ { deliveryRoute: params.wait ? "await" : "background" },
474
+ );
475
+ }));
477
476
  const startedRuns = results.filter((result) => result.exitCode === -1);
478
477
  const started = startedRuns.length;
479
- const startedRefs = startedRuns.map((result) =>
480
- result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`,
481
- );
478
+ const startedIds = startedRuns
479
+ .map((result) => result.runId)
480
+ .filter((id): id is number => id !== undefined);
482
481
  const failureLines = results.flatMap((result, index) => {
483
482
  if (result.exitCode === -1) return [];
484
483
  const reason = getResultOutput(result).trim() || "unknown startup failure";
@@ -489,21 +488,15 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
489
488
  if (started === 0) {
490
489
  // Pi marks custom-tool failures only when execute throws; returning an
491
490
  // `isError` property is still a successful AgentToolResult.
492
- throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
491
+ throw new Error(`No subagents started.\n${failureLines.join("\n")}`);
493
492
  }
494
493
  if (params.wait) {
495
- const startedIds = startedRuns
496
- .map((result) => result.runId)
497
- .filter((id): id is number => id !== undefined);
498
494
  const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("parallel", true)(results)));
499
- const text = [
500
- `Started ${started} subagent${started === 1 ? "" : "s"} (${startedRefs.join(", ")}) and waited in-turn.`,
501
- ...(failureLines.length > 0
502
- ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
503
- : []),
504
- "",
505
- blocks,
506
- ].join("\n");
495
+ if (signal?.aborted) runtime.fallbackAwaitDelivery(startedIds);
496
+ else runtime.completeAwaitDelivery(startedIds);
497
+ const text = failureLines.length > 0
498
+ ? `${blocks}\n\nLaunch failures:\n${failureLines.join("\n")}`
499
+ : blocks;
507
500
  return {
508
501
  content: [{ type: "text", text }],
509
502
  details: makeDetails("parallel", true)(results),
@@ -511,10 +504,8 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
511
504
  };
512
505
  }
513
506
  const text = [
514
- `Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}. They run in the background and never block you — dispatch more independent units now or keep working; each result resumes you automatically when you are idle.`,
515
- ...(failureLines.length > 0
516
- ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
517
- : []),
507
+ phaseLeaseReceipt(startedIds),
508
+ ...(failureLines.length > 0 ? ["Launch failures:", ...failureLines] : []),
518
509
  ].join("\n") + queuePacingNote();
519
510
  return {
520
511
  content: [{ type: "text", text }],
@@ -534,21 +525,26 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
534
525
  singleCatalogAgent ? isWriteCapableAgent(singleCatalogAgent) : undefined,
535
526
  singleCatalogAgent?.isolation,
536
527
  ),
528
+ { deliveryRoute: params.wait ? "await" : "background" },
537
529
  );
538
530
  if (result.exitCode !== -1) {
539
531
  throw new Error(getResultOutput(result));
540
532
  }
541
- const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
542
533
  if (params.wait && result.runId !== undefined) {
543
534
  const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("single", true)([result])));
535
+ if (signal?.aborted) runtime.fallbackAwaitDelivery([result.runId]);
536
+ else runtime.completeAwaitDelivery([result.runId]);
544
537
  return {
545
- content: [{ type: "text", text: `Started ${runRef} and waited in-turn.\n\n${blocks}` }],
538
+ content: [{ type: "text", text: blocks }],
546
539
  details: makeDetails("single", true)([result]),
547
540
  ...toolUsage(runtime, [result.runId]),
548
541
  };
549
542
  }
550
543
  return {
551
- content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you — dispatch more independent units now or keep working; its result resumes you automatically when you are idle.${queuePacingNote()}` }],
544
+ content: [{
545
+ type: "text",
546
+ text: phaseLeaseReceipt(result.runId === undefined ? [] : [result.runId]) + queuePacingNote(),
547
+ }],
552
548
  details: makeDetails("single", true)([result]),
553
549
  };
554
550
 
package/src/durable.ts CHANGED
@@ -20,7 +20,7 @@ import { uptime } from "node:os";
20
20
  import { dirname, join } from "node:path";
21
21
  import type { UsageStats } from "./rpc-run.ts";
22
22
  import type { SubagentThread } from "./runtime.ts";
23
- import { getResultOutput, isFailedResult, getProjectRoot, PROJECT_ROOTS_DIR_NAME, type SingleResult } from "./spawn.ts";
23
+ import { getResultOutput, isFailedResult, getProjectRoot, getSubagentsRoot, type SingleResult } from "./spawn.ts";
24
24
  import {
25
25
  isPathInside,
26
26
  restoreWorktreeIsolation,
@@ -34,7 +34,7 @@ export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
34
34
  const THREADS_MANIFEST_VERSION = 1;
35
35
 
36
36
  /** Project directories whose newest file has not been touched for this long
37
- * are deleted wholesale on load, so per-project sessions/worktrees/results
37
+ * are deleted wholesale at session start, so per-project sessions/worktrees/results
38
38
  * can never accumulate forever. Parked threads' manifest references always
39
39
  * win over the age rule. */
40
40
  export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
@@ -118,12 +118,6 @@ export function getThreadsManifestPath(configPath: string, cwd: string): string
118
118
  return join(getProjectRoot(configPath, cwd), THREADS_MANIFEST_FILE_NAME);
119
119
  }
120
120
 
121
- /** Location of the pre-per-project global manifest; only read by the
122
- * one-time migration that folds it into the project roots. */
123
- function getLegacyManifestPath(configPath: string): string {
124
- return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
125
- }
126
-
127
121
  function normalizeUsage(value: unknown): UsageStats {
128
122
  const raw = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
129
123
  const num = (key: string): number => (typeof raw[key] === "number" && Number.isFinite(raw[key]) ? raw[key] : 0);
@@ -226,7 +220,7 @@ function projectManifestPaths(durableRoot: string): string[] {
226
220
  * sweeps that must see references from anywhere. */
227
221
  export async function readThreadRecords(configPath: string): Promise<ThreadRecord[]> {
228
222
  const manifests = await Promise.all(
229
- projectManifestPaths(join(dirname(configPath), PROJECT_ROOTS_DIR_NAME))
223
+ projectManifestPaths(getSubagentsRoot(configPath))
230
224
  .map((path) => readManifestRecords(path)),
231
225
  );
232
226
  return manifests.flat();
@@ -365,12 +359,12 @@ async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
365
359
  }
366
360
 
367
361
  /** Drop records past their retention age along with their artifacts. Runs at
368
- * extension load; the fixed age honors the no-config-knobs policy. */
362
+ * session start; the fixed age honors the no-config-knobs policy. */
369
363
  export async function pruneThreadRecords(
370
364
  configPath: string,
371
365
  now = Date.now(),
372
366
  ): Promise<void> {
373
- const durableRoot = join(dirname(configPath), PROJECT_ROOTS_DIR_NAME);
367
+ const durableRoot = getSubagentsRoot(configPath);
374
368
  for (const path of projectManifestPaths(durableRoot)) {
375
369
  await withFileMutationQueue(path, async () => {
376
370
  const records = await readManifestRecords(path);
@@ -390,47 +384,6 @@ export async function pruneThreadRecords(
390
384
  }
391
385
  }
392
386
 
393
- /** One-time move of the pre-per-project global manifest beside the config into
394
- * the project roots its records belong to, so an upgrade keeps parked work
395
- * resumable and pi home is left without a manifest. Existing project records
396
- * win over legacy ones; the legacy file is removed only after every group
397
- * landed, and an unreadable file stays put for the next boot. */
398
- export async function migrateLegacyThreadsManifest(configPath: string): Promise<void> {
399
- const legacyPath = getLegacyManifestPath(configPath);
400
- let records: ThreadRecord[];
401
- try {
402
- const parsed = JSON.parse(await readFile(legacyPath, "utf8")) as { records?: unknown };
403
- if (!Array.isArray(parsed.records)) return;
404
- records = parsed.records.flatMap((record) => {
405
- const normalized = normalizeRecord(record);
406
- return normalized ? [normalized] : [];
407
- });
408
- } catch {
409
- return;
410
- }
411
- const groups = new Map<string, ThreadRecord[]>();
412
- for (const record of records) {
413
- const path = getThreadsManifestPath(configPath, record.cwd);
414
- const group = groups.get(path);
415
- if (group) group.push(record);
416
- else groups.set(path, [record]);
417
- }
418
- let migrated = true;
419
- for (const [path, group] of groups) {
420
- await withFileMutationQueue(path, async () => {
421
- const existing = await readManifestRecords(path);
422
- const merged = [...existing];
423
- for (const record of group) {
424
- if (!merged.some((candidate) => candidate.runId === record.runId)) merged.push(record);
425
- }
426
- await writeManifest(path, merged);
427
- }).catch(() => {
428
- migrated = false;
429
- });
430
- }
431
- if (migrated) await rm(legacyPath, { force: true }).catch(() => undefined);
432
- }
433
-
434
387
  /** Paths a manifest still references; used by the state-root sweep so
435
388
  * freshly created-but-unrecorded directories are never touched. */
436
389
  export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<string> {
@@ -490,7 +443,7 @@ export async function pruneStaleProjectRoots(configPath: string, options: { now?
490
443
  const now = options.now ?? Date.now();
491
444
  const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
492
445
  const referenced = referencedDurablePaths(records);
493
- const root = join(dirname(configPath), PROJECT_ROOTS_DIR_NAME);
446
+ const root = getSubagentsRoot(configPath);
494
447
  let projects: Dirent[];
495
448
  try {
496
449
  projects = readdirSync(root, { withFileTypes: true });
package/src/index.ts CHANGED
@@ -78,24 +78,21 @@ export default function (pi: ExtensionAPI): void {
78
78
  },
79
79
  });
80
80
 
81
+ pi.on("session_start", async () => {
82
+ await bootstrapDurableState(runtime);
83
+ });
81
84
  registerAnnouncements(pi, runtime);
82
85
 
83
- // Durable bootstrap: restore parked threads from the manifest so a reload or
84
- // restart keeps status and resume working, then age out old records and sweep
85
- // leaked temp/state directories. Registration never blocks on it and every
86
- // stage is best-effort; the restore pass is published as runtime.durableRestore
87
- // so the tools and the session-start notice wait for it instead of racing it.
88
- void bootstrapDurableState(runtime);
89
-
90
- // Proactive dispatch: inject the delegation directive into the parent system prompt.
86
+ // Inject the routing contract plus bounded live phase leases into each parent turn.
91
87
  pi.on("before_agent_start", async (event, ctx) => {
88
+ await runtime.durableRestore;
92
89
  const config = await loadConfig(configPath);
93
90
  const { agents } = discoverAgents(ctx.cwd, {
94
91
  scope: config.agentScope,
95
92
  enabledNames: config.enabledAgents,
96
93
  projectTrusted: ctx.isProjectTrusted?.() === true,
97
94
  });
98
- const directive = buildDelegationDirective(agents);
95
+ const directive = buildDelegationDirective(agents, runtime.threads.values());
99
96
  if (!directive) return undefined;
100
97
  return { systemPrompt: `${event.systemPrompt}\n${directive}` };
101
98
  });
package/src/prompt.ts CHANGED
@@ -1,76 +1,131 @@
1
1
  /**
2
2
  * Builds the delegation directive injected into the parent model's
3
3
  * system prompt via `before_agent_start`. It is paid on every turn, so it
4
- * stays a lean routing contract when a child context pays for itself,
5
- * how wide to fan out, and how results come back. Tool metadata stays
6
- * intentionally minimal so role/process guidance is not paid for twice.
4
+ * stays a lean routing, phase-ownership, and verification contract.
5
+ * Detailed role guidance remains in each child's own prompt.
7
6
  */
8
7
 
8
+ import { resolve } from "node:path";
9
9
  import type { AgentConfig } from "./agents.ts";
10
10
  import { formatCatalogEntry } from "./agents.ts";
11
11
 
12
+ export interface PhaseLeaseSource {
13
+ id: number;
14
+ agentName: string;
15
+ task: string;
16
+ cwd: string;
17
+ state: "queued" | "resuming" | "running" | "interrupting" | "parked" | "completed" | "failed" | "stopped";
18
+ lifecycleOperation?: "park" | "resume" | "stop" | "settle";
19
+ }
20
+
21
+ const ACTIVE_LEASE_STATES = new Set<PhaseLeaseSource["state"]>([
22
+ "queued",
23
+ "resuming",
24
+ "running",
25
+ "interrupting",
26
+ "parked",
27
+ ]);
28
+ const MAX_ACTIVE_LEASES = 2;
29
+ const MAX_LEASE_TASK_LENGTH = 56;
30
+
12
31
  function bullets(lines: readonly string[]): string {
13
32
  return lines.map((line) => `- ${line}`).join("\n");
14
33
  }
15
34
 
35
+ function phaseForAgent(agentName: string): string {
36
+ if (agentName === "scout") return "broad reconnaissance";
37
+ if (agentName === "artisan") return "implementation and targeted checks";
38
+ if (agentName === "steward") return "pre-commit cleanup and cross-cutting docs";
39
+ return "delegated scope";
40
+ }
41
+
42
+ function isActivePhaseLease(source: PhaseLeaseSource): boolean {
43
+ return source.lifecycleOperation === "settle" || ACTIVE_LEASE_STATES.has(source.state);
44
+ }
45
+
46
+ function normalizedTask(task: string): string {
47
+ return task.replace(/\s+/gu, " ").trim();
48
+ }
49
+
50
+ function normalizedCwd(cwd: string): string {
51
+ const resolved = resolve(cwd);
52
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
53
+ }
54
+
55
+ export function findDuplicateActiveDispatch(
56
+ sources: Iterable<PhaseLeaseSource>,
57
+ task: string,
58
+ cwd: string,
59
+ ): PhaseLeaseSource | undefined {
60
+ const taskKey = normalizedTask(task);
61
+ const cwdKey = normalizedCwd(cwd);
62
+ return [...sources].find((source) =>
63
+ isActivePhaseLease(source) &&
64
+ normalizedTask(source.task) === taskKey &&
65
+ normalizedCwd(source.cwd) === cwdKey,
66
+ );
67
+ }
68
+
69
+ function summarizeLeaseTask(task: string): string {
70
+ const oneLine = normalizedTask(task);
71
+ const characters = [...oneLine];
72
+ return characters.length <= MAX_LEASE_TASK_LENGTH
73
+ ? oneLine
74
+ : `${characters.slice(0, MAX_LEASE_TASK_LENGTH - 1).join("")}…`;
75
+ }
76
+
77
+ function formatActivePhaseLeases(sources: Iterable<PhaseLeaseSource>): string {
78
+ const active = [...sources].filter(isActivePhaseLease);
79
+ if (active.length === 0) return "";
80
+ const lines = active.slice(0, MAX_ACTIVE_LEASES).map((source) => {
81
+ const state = source.lifecycleOperation === "settle" ? "settling" : source.state;
82
+ return `- #${source.id} ${phaseForAgent(source.agentName)} (${source.agentName}, ${state}): ${summarizeLeaseTask(source.task)}`;
83
+ });
84
+ if (active.length > MAX_ACTIVE_LEASES) {
85
+ lines.push(`- … ${active.length - MAX_ACTIVE_LEASES} more active lease${active.length - MAX_ACTIVE_LEASES === 1 ? "" : "s"} omitted`);
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+
90
+ export function formatPhaseLeaseReceipt(sources: Iterable<PhaseLeaseSource>): string {
91
+ const leases = formatActivePhaseLeases(sources);
92
+ if (!leases) return "";
93
+ return `Active phase lease:\n${leases}\nDo not duplicate it; continue only disjoint work.`;
94
+ }
95
+
16
96
  export function buildDelegationDirective(
17
97
  agents: AgentConfig[],
98
+ activeLeaseSources: Iterable<PhaseLeaseSource> = [],
18
99
  ): string {
19
- if (agents.length === 0) return "";
100
+ const activeLeases = formatActivePhaseLeases(activeLeaseSources);
101
+ if (agents.length === 0 && !activeLeases) return "";
20
102
 
21
- const catalog = agents.map(formatCatalogEntry).join("\n");
103
+ const catalog = agents.length > 0 ? agents.map(formatCatalogEntry).join("\n") : "- (none enabled)";
22
104
  const hasScout = agents.some((agent) => agent.name === "scout");
23
105
  const hasArtisan = agents.some((agent) => agent.name === "artisan");
24
106
  const hasSteward = agents.some((agent) => agent.name === "steward");
25
107
 
26
108
  const dispatchRules = [
27
- `Delegate aggressively: child contexts are cheap, yours is scarce. A unit is delegable when it can proceed independently and return a compact result${hasArtisan ? "; when the unit is a code change, default it to \`artisan\`" : ""}.`,
28
- "Keep inline what fails either test — a lookup, a single focused edit, an answer already in context, or a single artifact you must absorb yourself (one issue, one spec), where delegation saves search, not that read. Cluster related questions into one brief instead of firing many small dispatches; a child loses context between runs.",
29
- ...(hasScout
30
- ? [
31
- "`scout`: split a broad question into parallel scouts with disjoint scopes. Its findings are leads, never proof — re-read the cited line ranges before acting on them (a child you brief re-verifies).",
32
- ]
33
- : []),
34
- ...(hasArtisan
35
- ? [
36
- "`artisan`: brief it as the edit authorization for implement, fix, refactor, or test. Not cleanup, docs sync, or merging results.",
37
- ]
38
- : []),
39
- ...(hasSteward
40
- ? [
41
- "`steward`: dispatch only when the work is cleanup, documentation sync, or merging named result artifacts — do not invent a tidy pass. For cleanup, name the scope (uncommitted diff, Git range, directory). After a wide fan-out, pass the result-artifact paths to one steward and read its brief instead of every result yourself.",
42
- ]
43
- : []),
44
- "A discovered defect is not a change: re-read the current code and confirm it is not a false positive before you edit or brief a writer to edit.",
45
- "Parallelize by default: map the todo list onto ONE `tasks` dispatch. One child owns one deliverable and its files; only genuinely dependent work waits for its prerequisite.",
46
- "Brief each child completely — goal, exact paths, constraints, expected output; it has no conversation memory and cannot delegate. Resume parked threads with `subagent_control resume`.",
47
- ];
48
-
49
- const handoffRules = [
50
- "Dispatch never blocks or ends your turn — keep working, but only on what the children are not: never re-read a scope you just delegated. Each completion resumes you automatically; never sleep or poll for it.",
51
- "Results are already shown; add only your conclusion or next action, never a restatement.",
52
- "Never declare the overall task done while a dispatched run is still active.",
53
- ];
54
-
55
- const verificationRules = [
56
- "Never report an unrun check as passed; surface unavailable checks and pre-existing failures, and inspect the actual diff before reporting completion.",
57
- "Commit or push only when explicitly requested and applicable checks pass.",
109
+ "Main owns routing, architecture, integration, the final gate, and release. Each child starts a paid context; delegate only when saved main-context work exceeds handoff cost.",
110
+ "Keep atomic lookups, focused edits, known answers, and context-heavy work in main. Cluster related reconnaissance into one scout brief.",
111
+ ...(hasScout ? ["`scout`: broad or unfamiliar reconnaissance; return compact findings and decisive citations."] : []),
112
+ ...(hasArtisan ? ["`artisan`: substantial self-contained implementation, including affected tests, docs, comments, and targeted checks."] : []),
113
+ ...(hasSteward ? ["`steward`: one pre-commit cleanup or cross-cutting docs/comments pass for a completed broad or multi-writer change; keep small diff hygiene inline."] : []),
114
+ "One owner per phase; dependent phases wait. A launch leases that phase: main may inspect its result, citations, diff, and check output but must not rerun it.",
115
+ "Parallelize only independently justified, disjoint scopes. Brief goal, scope, constraints, and expected output; resume retained work with `subagent_control`.",
116
+ "Completions deliver automatically. Never poll, restate a result, or finish while a run is active.",
117
+ "Inspect the integrated diff and actual check output. Never report an unrun check as passed.",
58
118
  ];
59
119
 
60
120
  return `
61
- ## Sub-agent delegation (pi-subagents)
62
-
63
- \`subagent\` runs isolated leaf Pi child processes in the background.
121
+ ## Sub-agent delegation
64
122
 
65
123
  Agents:
66
124
  ${catalog}
67
125
 
68
- Dispatch:
69
- ${bullets(dispatchRules)}
70
-
71
- Result handoff:
72
- ${bullets(handoffRules)}
126
+ Rules:
127
+ ${bullets(dispatchRules)}${activeLeases ? `
73
128
 
74
- Verification:
75
- ${bullets(verificationRules)}`;
129
+ Active phase leases:
130
+ ${activeLeases}` : ""}`;
76
131
  }
package/src/recovery.ts CHANGED
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
5
5
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
6
  import { dirname, join } from "node:path";
7
7
  import { stripVTControlCharacters } from "node:util";
8
+ import { getSubagentsRoot } from "./spawn.ts";
8
9
  import { removeWorktreeGroup, worktreeGroupDir, type WorktreeFinalization } from "./worktree.ts";
9
10
 
10
11
  export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
@@ -27,7 +28,7 @@ interface RecoveryManifest {
27
28
  }
28
29
 
29
30
  export function getRecoveryManifestPath(configPath: string): string {
30
- return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
31
+ return join(getSubagentsRoot(configPath), RECOVERY_MANIFEST_FILE_NAME);
31
32
  }
32
33
 
33
34
  function normalizeRecord(value: unknown): RecoveryRecord | undefined {
@@ -46,21 +47,45 @@ function normalizeRecord(value: unknown): RecoveryRecord | undefined {
46
47
  };
47
48
  }
48
49
 
49
- export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
50
+ interface RecoveryManifestRead {
51
+ valid: boolean;
52
+ records: RecoveryRecord[];
53
+ }
54
+
55
+ async function readManifest(path: string): Promise<RecoveryManifestRead> {
50
56
  try {
51
- const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
52
- records?: unknown;
57
+ const parsed = JSON.parse(await readFile(path, "utf8")) as { records?: unknown };
58
+ if (!Array.isArray(parsed.records)) return { valid: false, records: [] };
59
+ return {
60
+ valid: true,
61
+ records: parsed.records.flatMap((record) => {
62
+ const normalized = normalizeRecord(record);
63
+ return normalized ? [normalized] : [];
64
+ }),
53
65
  };
54
- if (!Array.isArray(parsed.records)) return [];
55
- return parsed.records.flatMap((record) => {
56
- const normalized = normalizeRecord(record);
57
- return normalized ? [normalized] : [];
58
- });
59
66
  } catch {
60
- return [];
67
+ return { valid: false, records: [] };
61
68
  }
62
69
  }
63
70
 
71
+ export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
72
+ return (await readManifest(getRecoveryManifestPath(configPath))).records;
73
+ }
74
+
75
+ /** Move the previous agent-root manifest into the internal-state root without
76
+ * dropping retained artifact pointers. Invalid legacy files stay untouched. */
77
+ export async function relocateRecoveryManifest(configPath: string): Promise<void> {
78
+ const legacyPath = join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
79
+ const currentPath = getRecoveryManifestPath(configPath);
80
+ if (legacyPath === currentPath || !existsSync(legacyPath)) return;
81
+ await withFileMutationQueue(legacyPath, async () => {
82
+ const legacy = await readManifest(legacyPath);
83
+ if (!legacy.valid) return;
84
+ await persistRecoveryRecords(configPath, legacy.records);
85
+ await rm(legacyPath, { force: true });
86
+ });
87
+ }
88
+
64
89
  function recoveryKey(record: RecoveryRecord): string {
65
90
  return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
66
91
  }