pi-subagents 0.50.0 → 0.52.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 (109) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/agents/oracle.md +3 -1
  3. package/agents/reviewer.md +1 -0
  4. package/agents/scout.md +2 -2
  5. package/agents/worker.md +2 -1
  6. package/async-retention-discovery-worker.mjs +180 -0
  7. package/docs/agents.md +40 -2
  8. package/docs/configuration.md +30 -12
  9. package/docs/extension-api.md +42 -1
  10. package/docs/models.md +2 -0
  11. package/docs/observability.md +41 -5
  12. package/docs/tool-reference.md +38 -39
  13. package/docs/workflows.md +171 -3
  14. package/package.json +4 -2
  15. package/skills/pi-subagents/SKILL.md +6 -4
  16. package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
  17. package/skills/pi-subagents/references/execution-controls.md +29 -15
  18. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
  19. package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
  20. package/src/agents/agent-management.ts +124 -351
  21. package/src/agents/agents.ts +157 -31
  22. package/src/agents/skills.ts +1 -1
  23. package/src/api/external-job-provider.ts +185 -0
  24. package/src/api/preflight.ts +36 -8
  25. package/src/api/shared-types.ts +2 -0
  26. package/src/extension/config.ts +3 -3
  27. package/src/extension/doctor.ts +3 -6
  28. package/src/extension/fanout-child.ts +2 -2
  29. package/src/extension/index.ts +166 -88
  30. package/src/extension/public-execution.ts +31 -2
  31. package/src/extension/schemas.ts +12 -35
  32. package/src/extension/tool-description.ts +35 -24
  33. package/src/inspectors/herdr/actions.ts +2 -2
  34. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  35. package/src/inspectors/herdr/project-panes.ts +2 -2
  36. package/src/intercom/native-supervisor-channel.ts +32 -10
  37. package/src/missions/lifecycle.ts +6 -1
  38. package/src/missions/store.ts +4 -9
  39. package/src/missions/workflow-state.ts +2 -2
  40. package/src/profiles/profiles.ts +3 -1
  41. package/src/runs/background/active-run-index.ts +31 -8
  42. package/src/runs/background/async-execution.ts +68 -40
  43. package/src/runs/background/async-job-tracker.ts +20 -4
  44. package/src/runs/background/async-resume.ts +47 -15
  45. package/src/runs/background/async-retention.ts +886 -0
  46. package/src/runs/background/async-status.ts +39 -53
  47. package/src/runs/background/chain-append.ts +3 -33
  48. package/src/runs/background/completion-dedupe.ts +5 -1
  49. package/src/runs/background/completion-replay.ts +22 -12
  50. package/src/runs/background/control-channel.ts +14 -68
  51. package/src/runs/background/fleet-view.ts +68 -19
  52. package/src/runs/background/index-segment.ts +59 -0
  53. package/src/runs/background/inspect-rpc.ts +443 -0
  54. package/src/runs/background/notify.ts +31 -5
  55. package/src/runs/background/result-files.ts +158 -90
  56. package/src/runs/background/result-watcher.ts +116 -20
  57. package/src/runs/background/resume-guidance.ts +8 -5
  58. package/src/runs/background/retained-children.ts +13 -3
  59. package/src/runs/background/run-id-query.ts +7 -0
  60. package/src/runs/background/run-id-resolver.ts +11 -9
  61. package/src/runs/background/run-status.ts +27 -18
  62. package/src/runs/background/scheduled-runs.ts +24 -4
  63. package/src/runs/background/stale-run-reconciler.ts +6 -3
  64. package/src/runs/background/steering.ts +11 -1
  65. package/src/runs/background/subagent-runner.ts +253 -140
  66. package/src/runs/background/subagent-wait.ts +8 -8
  67. package/src/runs/background/terminal-run-index.ts +129 -0
  68. package/src/runs/background/wait-completions.ts +21 -4
  69. package/src/runs/background/wait-subscriptions.ts +81 -2
  70. package/src/runs/foreground/async-steering-action.ts +21 -14
  71. package/src/runs/foreground/execution.ts +3 -1
  72. package/src/runs/foreground/subagent-executor.ts +666 -1579
  73. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  74. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  75. package/src/runs/shared/acceptance.ts +4 -4
  76. package/src/runs/shared/chain-outputs.ts +1 -3
  77. package/src/runs/shared/external-job-bridge.ts +444 -0
  78. package/src/runs/shared/external-job-runner.ts +286 -0
  79. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  80. package/src/runs/shared/model-fallback.ts +98 -38
  81. package/src/runs/shared/orca-progress-tabs.ts +84 -22
  82. package/src/runs/shared/parallel-handoff.ts +46 -4
  83. package/src/runs/shared/parallel-utils.ts +4 -15
  84. package/src/runs/shared/permissions.ts +5 -1
  85. package/src/runs/shared/pi-args.ts +8 -1
  86. package/src/runs/shared/session-lease.ts +0 -6
  87. package/src/runs/shared/subagent-control.ts +26 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  89. package/src/runs/shared/workflow-graph.ts +1 -23
  90. package/src/runs/shared/worktree.ts +14 -2
  91. package/src/shared/atomic-json.ts +22 -2
  92. package/src/shared/capacity-resilient-json.ts +102 -0
  93. package/src/shared/completion-owner.ts +14 -0
  94. package/src/shared/file-system-retry.ts +49 -1
  95. package/src/shared/fork-context.ts +42 -0
  96. package/src/shared/prompt-resources.ts +0 -40
  97. package/src/shared/settings.ts +3 -27
  98. package/src/shared/types.ts +71 -26
  99. package/src/shared/utils.ts +8 -0
  100. package/src/shared/watch-strategy.ts +10 -0
  101. package/src/slash/slash-bridge.ts +2 -1
  102. package/src/slash/slash-commands.ts +29 -3
  103. package/src/tui/fleet.ts +63 -15
  104. package/src/watchdog/change-signature.ts +1 -1
  105. package/src/watchdog/lsp-diagnostics.ts +1 -0
  106. package/src/workflows/chat-progress.ts +2 -2
  107. package/src/workflows/scripted-workflow.ts +220 -67
  108. package/src/runs/foreground/chain-clarify.ts +0 -1354
  109. package/src/runs/foreground/chain-execution.ts +0 -1581
@@ -17,16 +17,29 @@ const MIRROR_FOOTER_RESERVE_BYTES = 16 * 1024;
17
17
  const COUNTER_LOCK_STALE_MS = 30_000;
18
18
  const COUNTER_LOCK_RETRIES = 200;
19
19
  const COUNTER_LOCK_RETRY_MS = 10;
20
+ const ORCA_CREATE_WAIT_TIMEOUT_MS = ORCA_CREATE_TIMEOUT_MS + ORCA_KILL_GRACE_MS + 3_000;
20
21
 
21
22
  const ORCA_CREATE_WATCHDOG_SCRIPT = [
22
23
  "const {spawn}=require('node:child_process');",
23
- "const timeout=Number(process.argv[1]),grace=Number(process.argv[2]),command=process.argv[3],args=process.argv.slice(4);",
24
- "const child=spawn(command,args,{stdio:'ignore',windowsHide:true});",
25
- "let hardKill;",
26
- "const timer=setTimeout(()=>{child.kill('SIGTERM');hardKill=setTimeout(()=>child.kill('SIGKILL'),grace)},timeout);",
27
- "const clear=()=>{clearTimeout(timer);if(hardKill)clearTimeout(hardKill)};",
28
- "child.once('error',()=>{clear();process.exitCode=1});",
29
- "child.once('close',code=>{clear();process.exitCode=code===0?0:1});",
24
+ "const fs=require('node:fs');",
25
+ "const timeout=Number(process.argv[1]),grace=Number(process.argv[2]),waitTimeout=Number(process.argv[3]);",
26
+ "const previous=process.argv[4],done=process.argv[5],command=process.argv[6],args=process.argv.slice(7);",
27
+ "function mark(){try{fs.writeFileSync(done.replace(/\\.pending$/,'.ready'),'')}catch{}}",
28
+ "function exists(file){try{return fs.existsSync(file)}catch{return false}}",
29
+ "function keepQueued(){try{const now=new Date();fs.utimesSync(done,now,now)}catch{}}",
30
+ "function predecessorReady(){if(previous==='-')return true;if(exists(previous.replace(/\\.pending$/,'.ready')))return true;try{return Date.now()-fs.statSync(previous).mtimeMs>=waitTimeout}catch{return true}}",
31
+ "function start(){",
32
+ " try{",
33
+ " const child=spawn(command,args,{stdio:'ignore',windowsHide:true});",
34
+ " let hardKill;",
35
+ " const timer=setTimeout(()=>{child.kill('SIGTERM');hardKill=setTimeout(()=>child.kill('SIGKILL'),grace)},timeout);",
36
+ " const clear=()=>{clearTimeout(timer);if(hardKill)clearTimeout(hardKill);mark()};",
37
+ " child.once('error',()=>{clear();process.exitCode=1});",
38
+ " child.once('close',code=>{clear();process.exitCode=code===0?0:1});",
39
+ " }catch{mark();process.exitCode=1}",
40
+ "}",
41
+ "if(predecessorReady())start();",
42
+ "else{(function waitPrev(){keepQueued();if(predecessorReady())return start();setTimeout(waitPrev,20)})();}",
30
43
  ].join("");
31
44
 
32
45
  const ORCA_CLEANUP_WATCHDOG_SCRIPT = [
@@ -114,7 +127,7 @@ function progressRoot(): string {
114
127
  function pruneStaleProgressFiles(root: string, now = Date.now()): void {
115
128
  try {
116
129
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
117
- if (!entry.isFile() || (!entry.name.endsWith(".log") && !entry.name.endsWith(".done"))) continue;
130
+ if (!entry.isFile() || (!entry.name.endsWith(".log") && !entry.name.endsWith(".done") && !entry.name.endsWith(".ready") && !entry.name.endsWith(".pending"))) continue;
118
131
  const file = path.join(root, entry.name);
119
132
  try {
120
133
  if (now - fs.statSync(file).mtimeMs > STALE_PROGRESS_MAX_AGE_MS) fs.rmSync(file, { force: true });
@@ -148,10 +161,12 @@ function resolveSequenceScope(cwd: string): string {
148
161
  }
149
162
  }
150
163
 
151
- function reserveTabSequence(root: string, cwd: string): number | undefined {
152
- const key = createHash("sha256").update(resolveSequenceScope(cwd)).digest("hex").slice(0, 20);
153
- const counterPath = path.join(root, `counter-${key}`);
154
- const lockPath = `${counterPath}.lock`;
164
+ function sequenceKey(cwd: string): string {
165
+ return createHash("sha256").update(resolveSequenceScope(cwd)).digest("hex").slice(0, 20);
166
+ }
167
+
168
+ function withSequenceLock<T>(root: string, key: string, fn: () => T): T | undefined {
169
+ const lockPath = path.join(root, `counter-${key}.lock`);
155
170
  let locked = false;
156
171
  for (let attempt = 0; attempt < COUNTER_LOCK_RETRIES; attempt++) {
157
172
  try {
@@ -171,21 +186,44 @@ function reserveTabSequence(root: string, cwd: string): number | undefined {
171
186
  }
172
187
  if (!locked) return undefined;
173
188
  try {
189
+ return fn();
190
+ } finally {
191
+ try { fs.rmSync(lockPath, { recursive: true, force: true }); } catch { /* stale lock cleanup handles crashes */ }
192
+ }
193
+ }
194
+
195
+ function predecessorMarker(pathValue: string | undefined): string | undefined {
196
+ if (!pathValue) return undefined;
197
+ const ready = pathValue.endsWith(".pending") ? pathValue.replace(/\.pending$/, ".ready") : pathValue;
198
+ const pending = ready.replace(/\.ready$/, ".pending");
199
+ if (fs.existsSync(ready) || fs.existsSync(pending)) return pending;
200
+ return undefined;
201
+ }
202
+
203
+ function reserveTabSequence(root: string, cwd: string): { sequence: number; previousCreateDone?: string; createDone: string } | undefined {
204
+ const key = sequenceKey(cwd);
205
+ const counterPath = path.join(root, `counter-${key}`);
206
+ const createDone = path.join(root, `create-${key}-${randomUUID()}.pending`);
207
+ return withSequenceLock(root, key, () => {
174
208
  let current = 0;
209
+ let previousCreateDone: string | undefined;
175
210
  try {
176
- const parsed = Number.parseInt(fs.readFileSync(counterPath, "utf-8"), 10);
211
+ const raw = fs.readFileSync(counterPath, "utf-8");
212
+ const [countLine, previousLine] = raw.split("\n");
213
+ const parsed = Number.parseInt(countLine ?? "", 10);
177
214
  if (Number.isSafeInteger(parsed) && parsed >= 0) current = parsed;
215
+ previousCreateDone = predecessorMarker(previousLine?.trim());
178
216
  } catch { /* first tab for this worktree */ }
179
217
  const next = current + 1;
180
218
  try {
181
- fs.writeFileSync(counterPath, `${next}\n`, { encoding: "utf-8", mode: 0o600 });
182
- return next;
219
+ fs.writeFileSync(createDone, "", { encoding: "utf-8", mode: 0o600 });
220
+ fs.writeFileSync(counterPath, `${next}\n${createDone}\n`, { encoding: "utf-8", mode: 0o600 });
221
+ return { sequence: next, previousCreateDone, createDone };
183
222
  } catch {
223
+ try { fs.rmSync(createDone, { force: true }); } catch { /* best effort */ }
184
224
  return undefined;
185
225
  }
186
- } finally {
187
- try { fs.rmSync(lockPath, { recursive: true, force: true }); } catch { /* stale lock cleanup handles crashes */ }
188
- }
226
+ });
189
227
  }
190
228
 
191
229
  export function resolvePiSessionId(sessionFile: string | undefined): string | undefined {
@@ -263,16 +301,21 @@ export function createOrcaProgressTab(input: {
263
301
  const agent = safeSegment(input.agent, "subagent");
264
302
  const index = typeof input.index === "number" && Number.isInteger(input.index) && input.index >= 0 ? input.index : 0;
265
303
  const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
266
- const tabSequence = reserveTabSequence(root, cwd);
267
- if (tabSequence === undefined) return undefined;
304
+ const reservation = reserveTabSequence(root, cwd);
305
+ if (reservation === undefined) return undefined;
306
+ const tabSequence = reservation.sequence;
268
307
  const stem = `${runId}-${index}-${randomUUID()}`;
269
308
  const logPath = path.join(root, `${stem}.log`);
270
309
  const donePath = path.join(root, `${stem}.done`);
271
310
  const title = `subagent · ${agent} · ${tabSequence}`;
311
+ const markCreateReady = () => {
312
+ try { fs.writeFileSync(reservation.createDone.replace(/\.pending$/, ".ready"), ""); } catch { /* unblock later tabs */ }
313
+ };
272
314
  try {
273
315
  fs.writeFileSync(logPath, `pi-subagents / ${agent}\nrun ${runId} · child ${index + 1}\n${"─".repeat(48)}\n`, { encoding: "utf-8", mode: 0o600 });
274
316
  fs.rmSync(donePath, { force: true });
275
317
  } catch {
318
+ markCreateReady();
276
319
  return undefined;
277
320
  }
278
321
 
@@ -303,11 +346,21 @@ export function createOrcaProgressTab(input: {
303
346
  failObserver();
304
347
  }
305
348
  };
349
+ let createSettled = false;
350
+ let cleanupPaths: string[] | undefined;
351
+ const scheduleDeferredCleanup = () => {
352
+ if (!createSettled || cleanupPaths === undefined) return;
353
+ scheduleCleanup(nodeExecutable, cleanupPaths);
354
+ cleanupPaths = undefined;
355
+ };
306
356
  try {
307
357
  const watchdog = spawn(nodeExecutable, [
308
358
  "-e", ORCA_CREATE_WATCHDOG_SCRIPT,
309
359
  String(ORCA_CREATE_TIMEOUT_MS),
310
360
  String(ORCA_KILL_GRACE_MS),
361
+ String(ORCA_CREATE_WAIT_TIMEOUT_MS),
362
+ reservation.previousCreateDone ?? "-",
363
+ reservation.createDone,
311
364
  command,
312
365
  "terminal", "create",
313
366
  "--worktree", `path:${path.resolve(cwd)}`,
@@ -322,11 +375,19 @@ export function createOrcaProgressTab(input: {
322
375
  env: input.env ?? process.env,
323
376
  });
324
377
  watchdog.once("close", (code) => {
378
+ createSettled = true;
325
379
  if (code !== 0) failObserver();
380
+ scheduleDeferredCleanup();
381
+ });
382
+ watchdog.once("error", () => {
383
+ markCreateReady();
384
+ createSettled = true;
385
+ failObserver();
386
+ scheduleDeferredCleanup();
326
387
  });
327
- watchdog.once("error", failObserver);
328
388
  watchdog.unref();
329
389
  } catch {
390
+ markCreateReady();
330
391
  failObserver();
331
392
  return undefined;
332
393
  }
@@ -368,7 +429,8 @@ export function createOrcaProgressTab(input: {
368
429
  logStream.end(footer, () => {
369
430
  if (!available) return;
370
431
  try { fs.writeFileSync(donePath, `${status}\n`, { encoding: "utf-8", mode: 0o600 }); } catch { /* best effort */ }
371
- scheduleCleanup(nodeExecutable, [logPath, donePath]);
432
+ cleanupPaths = [logPath, donePath];
433
+ scheduleDeferredCleanup();
372
434
  });
373
435
  },
374
436
  };
@@ -34,6 +34,48 @@ function readManifest(manifestPath: string): ParallelHandoffManifest | undefined
34
34
  return parsed;
35
35
  }
36
36
 
37
+ function resolveExistingPath(candidate: string): string {
38
+ try {
39
+ return fs.realpathSync(candidate);
40
+ } catch {
41
+ return path.resolve(candidate);
42
+ }
43
+ }
44
+
45
+ function pathInside(root: string, candidate: string): boolean {
46
+ const relative = path.relative(root, candidate);
47
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
48
+ }
49
+
50
+ export function resolveRetainedWorktreeCwd(manifestPath: string, runId: string, childIndex: number): string | undefined {
51
+ const manifest = readManifest(manifestPath);
52
+ if (!manifest) return undefined;
53
+ if (manifest.runId !== runId) throw new Error(`Managed worktree handoff belongs to run '${manifest.runId}', not '${runId}'.`);
54
+ const match = manifest.groups
55
+ .flatMap((group) => group.children.map((child) => ({ group, child })))
56
+ .find(({ child }) => child.index === childIndex);
57
+ if (!match) return undefined;
58
+ const cleanup = match.group.cleanup.tasks.find((task) => task.index === match.child.taskIndex);
59
+ if (!cleanup) throw new Error(`Async run '${runId}' child ${childIndex} has no managed worktree cleanup record.`);
60
+ const relativeCwd = path.relative(resolveExistingPath(match.group.repoRoot), resolveExistingPath(manifest.cwd));
61
+ if (path.isAbsolute(relativeCwd) || relativeCwd === ".." || relativeCwd.startsWith(`..${path.sep}`)) throw new Error(`Async run '${runId}' has an invalid managed worktree cwd.`);
62
+ const requiredCwd = path.join(cleanup.path, relativeCwd);
63
+ if (cleanup.worktreeRemoved || cleanup.branchRemoved) throw new Error(`Async run '${runId}' required managed worktree was removed: ${requiredCwd}`);
64
+ let worktreeRoot = "";
65
+ let resolvedRequiredCwd = "";
66
+ try {
67
+ const rootStat = fs.lstatSync(cleanup.path);
68
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("worktree root is not a directory");
69
+ worktreeRoot = fs.realpathSync(cleanup.path);
70
+ if (!fs.statSync(requiredCwd).isDirectory()) throw new Error("path is not a directory");
71
+ resolvedRequiredCwd = fs.realpathSync(requiredCwd);
72
+ } catch (error) {
73
+ throw new Error(`Async run '${runId}' required managed worktree cwd is missing: ${requiredCwd}`, { cause: error instanceof Error ? error : undefined });
74
+ }
75
+ if (!pathInside(worktreeRoot, resolvedRequiredCwd)) throw new Error(`Async run '${runId}' has an invalid managed worktree cwd.`);
76
+ return requiredCwd;
77
+ }
78
+
37
79
  function referenceFor(manifestPath: string, manifest: ParallelHandoffManifest): ParallelHandoffReference {
38
80
  const children = manifest.groups.flatMap((group) => group.children);
39
81
  return {
@@ -74,7 +116,7 @@ function missingDiff(input: { manifestPath: string; stepIndex: number; taskIndex
74
116
  export function writeParallelHandoffGroup(input: {
75
117
  manifestPath: string;
76
118
  runId: string;
77
- mode: "parallel" | "chain";
119
+ mode: "single" | "parallel" | "chain";
78
120
  source: "foreground" | "async";
79
121
  cwd: string;
80
122
  stepIndex: number;
@@ -162,7 +204,7 @@ export function parallelHandoffPath(baseDir: string, runId?: string): string {
162
204
  export function writePendingParallelHandoff(input: {
163
205
  manifestPath: string;
164
206
  runId: string;
165
- mode: "parallel" | "chain";
207
+ mode: "single" | "parallel" | "chain";
166
208
  source: "foreground" | "async";
167
209
  cwd: string;
168
210
  stepIndex: number;
@@ -173,11 +215,11 @@ export function writePendingParallelHandoff(input: {
173
215
  }
174
216
 
175
217
  export function formatParallelHandoffReference(reference: ParallelHandoffReference): string {
176
- return `Parallel handoff: ${reference.path} (${reference.childCount} children, ${reference.changedPatches} changed patches, cleanup ${reference.cleanupState})`;
218
+ return `Worktree handoff: ${reference.path} (${reference.childCount} children, ${reference.changedPatches} changed patches, cleanup ${reference.cleanupState})`;
177
219
  }
178
220
 
179
221
  export function formatParallelHandoffError(error: unknown): string {
180
- return `Parallel handoff unavailable: ${error instanceof Error ? error.message : String(error)}`;
222
+ return `Worktree handoff unavailable: ${error instanceof Error ? error.message : String(error)}`;
181
223
  }
182
224
 
183
225
  export function discardPreservedWorktrees(
@@ -65,13 +65,8 @@ export interface RunnerSubagentStep {
65
65
  capabilityAudit?: import("./capability-ceiling.ts").SubagentCapabilityAudit;
66
66
  /** Private stable logical-child path for inherited run fan-out accounting. */
67
67
  runFanoutPath?: string;
68
- }
69
-
70
- export interface RunnerCheckpointStep {
71
- checkpoint: string;
72
- message?: string;
73
- phase?: string;
74
- label?: string;
68
+ /** Run this single child in one managed worktree. */
69
+ worktree?: boolean;
75
70
  }
76
71
 
77
72
  export interface ParallelStepGroup {
@@ -100,11 +95,7 @@ export interface DynamicRunnerGroup {
100
95
  capabilityAudit?: import("./capability-ceiling.ts").SubagentCapabilityAudit;
101
96
  }
102
97
 
103
- export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup | RunnerCheckpointStep;
104
-
105
- export function isCheckpointRunnerStep(step: RunnerStep): step is RunnerCheckpointStep {
106
- return "checkpoint" in step;
107
- }
98
+ export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
108
99
 
109
100
  export function isParallelGroup(step: RunnerStep): step is ParallelStepGroup {
110
101
  return "parallel" in step && Array.isArray(step.parallel);
@@ -117,9 +108,7 @@ export function isDynamicRunnerGroup(step: RunnerStep): step is DynamicRunnerGro
117
108
  export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
118
109
  const flat: RunnerSubagentStep[] = [];
119
110
  for (const step of steps) {
120
- if (isCheckpointRunnerStep(step)) {
121
- continue;
122
- } else if (isParallelGroup(step)) {
111
+ if (isParallelGroup(step)) {
123
112
  for (const task of step.parallel) flat.push(task);
124
113
  } else if (isDynamicRunnerGroup(step)) {
125
114
  continue;
@@ -14,6 +14,10 @@ const MAX_PREVIEW_BYTES = 2048;
14
14
  const SECRET_KEY = /(?:authorization|cookie|credential|password|secret|token|api[-_]?key)/i;
15
15
  const SECRET_VALUE = /\b(?:Bearer\s+\S+|(?:sk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{8,})\b/gi;
16
16
 
17
+ export function redactSecretValues(value: string): string {
18
+ return value.replace(SECRET_VALUE, "[redacted]");
19
+ }
20
+
17
21
  export function validatePermissionRules(value: unknown, label: string): PermissionRules | undefined {
18
22
  if (value === undefined) return undefined;
19
23
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object mapping tool names to allow, ask, or deny.`);
@@ -66,7 +70,7 @@ function redact(value: unknown, key = "", depth = 0): unknown {
66
70
  if (Array.isArray(value)) return value.slice(0, 10).map((item) => redact(item, "", depth + 1));
67
71
  if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).slice(0, 20).map(([entryKey, entryValue]) => [entryKey, redact(entryValue, entryKey, depth + 1)]));
68
72
  if (typeof value === "string") {
69
- const redacted = value.replace(SECRET_VALUE, "[redacted]");
73
+ const redacted = redactSecretValues(value);
70
74
  return redacted.length > 500 ? `${redacted.slice(0, 500)}…` : redacted;
71
75
  }
72
76
  return value;
@@ -433,13 +433,20 @@ export function resolvePiLaunchToolPlan(
433
433
  ...internalTools,
434
434
  ]),
435
435
  ];
436
+ // Supervisor-coordination names stay in the --tools allowlist but are never
437
+ // strict requirements: children register contact_supervisor at runtime through
438
+ // the native supervisor channel (or pi-intercom). The pre-0.50 bridge always
439
+ // appended intercom alongside contact_supervisor, so that exact pairing is
440
+ // legacy plumbing, not a user demand for an external intercom provider;
441
+ // a lone intercom entry stays strictly required (#1207).
442
+ const legacySupervisorPairing = declaredBuiltinTools.includes("contact_supervisor");
436
443
  const requiredChildTools = explicitToolAllowlist
437
444
  ? [
438
445
  ...new Set([
439
446
  ...(input.tools !== undefined ? declaredBuiltinTools : []),
440
447
  ...(input.mcpDirectTools?.length ? effectiveMcpTools : []),
441
448
  ...internalTools,
442
- ]),
449
+ ].filter((tool) => tool !== "contact_supervisor" && (!legacySupervisorPairing || tool !== "intercom"))),
443
450
  ]
444
451
  : [];
445
452
  const permSystemExt = capabilityCeiling?.denyExtensions
@@ -1,4 +1,3 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { createHash, randomUUID } from "node:crypto";
3
2
  import * as fs from "node:fs";
4
3
  import * as os from "node:os";
@@ -79,11 +78,6 @@ function getProcessStartIdentity(pid: number): string | undefined {
79
78
  return undefined;
80
79
  }
81
80
  }
82
- if (process.platform === "darwin" || process.platform === "freebsd") {
83
- const result = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8" });
84
- const started = result.status === 0 ? result.stdout.trim() : "";
85
- return started ? `${process.platform}:${started}` : undefined;
86
- }
87
81
  return undefined;
88
82
  }
89
83
 
@@ -7,6 +7,8 @@ import {
7
7
  type ResolvedControlConfig,
8
8
  } from "../../shared/types.ts";
9
9
  import { isToolTimeoutExempt } from "./tool-timeout.ts";
10
+ import { createHash } from "node:crypto";
11
+ import { previewDisplayText } from "../../shared/display-text.ts";
10
12
 
11
13
  const CONTROL_EVENT_TYPES: ControlEventType[] = ["active_long_running", "needs_attention"];
12
14
  const CONTROL_NOTIFICATION_CHANNELS: ControlNotificationChannel[] = ["event", "async", "intercom"];
@@ -116,6 +118,10 @@ export function buildControlEvent(input: {
116
118
  currentPath?: string;
117
119
  elapsedMs?: number;
118
120
  recentFailureSummary?: string;
121
+ workflowKey?: string;
122
+ phase?: string;
123
+ label?: string;
124
+ taskPreview?: string;
119
125
  }): ControlEvent {
120
126
  const ts = input.ts ?? Date.now();
121
127
  const type = input.type ?? (input.to === "active_long_running" ? "active_long_running" : "needs_attention");
@@ -144,6 +150,10 @@ export function buildControlEvent(input: {
144
150
  ...(input.currentPath ? { currentPath: input.currentPath } : {}),
145
151
  ...(elapsedMs !== undefined ? { elapsedMs } : {}),
146
152
  ...(input.recentFailureSummary ? { recentFailureSummary: input.recentFailureSummary } : {}),
153
+ ...(input.workflowKey ? { workflowKey: input.workflowKey } : {}),
154
+ ...(input.phase ? { phase: input.phase } : {}),
155
+ ...(input.label ? { label: input.label } : {}),
156
+ ...(input.taskPreview ? { taskPreview: previewDisplayText(input.taskPreview, 160) } : {}),
147
157
  };
148
158
  }
149
159
 
@@ -153,7 +163,8 @@ export function shouldNotifyControlEvent(config: ResolvedControlConfig, event: C
153
163
 
154
164
  export function controlNotificationKey(event: ControlEvent, childIntercomTarget?: string): string {
155
165
  const childKey = childIntercomTarget ?? (event.index !== undefined ? `${event.runId}:${event.index}` : event.runId);
156
- return `${childKey}:${event.type}:${event.reason ?? "idle"}`;
166
+ const contextHash = createHash("sha256").update(formatControlNudge(event)).digest("hex").slice(0, 8);
167
+ return `${childKey}:${event.type}:${event.reason ?? "idle"}:${contextHash}`;
157
168
  }
158
169
 
159
170
  export function claimControlNotification(config: ResolvedControlConfig, event: ControlEvent, seenKeys: Set<string>, childIntercomTarget?: string): boolean {
@@ -175,6 +186,17 @@ function formatLongRunningFacts(event: ControlEvent): string | undefined {
175
186
  return facts.length > 0 ? facts.join(" | ") : undefined;
176
187
  }
177
188
 
189
+ export function formatControlNudge(event: ControlEvent): string {
190
+ const scope = event.label ?? event.phase ?? event.workflowKey ?? event.taskPreview;
191
+ if (event.recentFailureSummary) return previewDisplayText(`Resolve the recent failure${scope ? ` for ${scope}` : ""}: ${event.recentFailureSummary}. Report the smallest next step or ask for a decision.`, 160);
192
+ if (event.currentTool || event.currentPath) {
193
+ const current = [event.currentTool ? `tool ${event.currentTool}` : undefined, event.currentPath ? `path ${event.currentPath}` : undefined].filter(Boolean).join(" at ");
194
+ return previewDisplayText(`Check ${current}${scope ? ` for ${scope}` : ""}. Report the smallest next step or ask for a decision.`, 160);
195
+ }
196
+ if (scope) return previewDisplayText(`Continue ${scope}. Report the smallest next step or ask for a decision.`, 160);
197
+ return "What are you blocked on? Reply with the smallest next step or ask for a decision.";
198
+ }
199
+
178
200
  export function formatControlNoticeMessage(event: ControlEvent, childIntercomTarget?: string): string {
179
201
  const runTarget = event.runId;
180
202
  if (event.reason === "completion_guard") {
@@ -187,9 +209,9 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
187
209
  ].filter((line): line is string => Boolean(line)).join("\n");
188
210
  }
189
211
 
190
- const nudgeMessage = "What are you blocked on? Reply with the smallest next step or ask for a decision.";
191
- const steerCommand = `subagent({ action: "steer", id: "${runTarget}", ${event.index !== undefined ? `index: ${event.index}, ` : ""}message: "${nudgeMessage}" })`;
192
- const nestedResumeCommand = `subagent({ action: "resume", id: "${runTarget}", message: "${nudgeMessage}" })`;
212
+ const nudgeMessage = formatControlNudge(event);
213
+ const steerCommand = `subagent({ action: "steer", id: "${runTarget}", ${event.index !== undefined ? `index: ${event.index}, ` : ""}message: ${JSON.stringify(nudgeMessage)} })`;
214
+ const nestedResumeCommand = `subagent({ action: "resume", id: "${runTarget}", message: ${JSON.stringify(nudgeMessage)} })`;
193
215
  if (event.type === "active_long_running") {
194
216
  const facts = formatLongRunningFacts(event);
195
217
  return [
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
6
+ import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
6
7
  import { decodePermissionRules, permissionDecision, PERMISSION_AUDIT_PATH_ENV, PERMISSION_POLICY_ENV } from "./permissions.ts";
7
8
  import { consumeSteerRequestsFromDir, MAX_STEER_QUEUE_SIZE, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerDeliveryStatus, type SteerRequest } from "../background/control-channel.ts";
8
9
  import { SUBAGENT_CHILD_AGENT_ENV, SUBAGENT_CHILD_INDEX_ENV, SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_STEER_ACK_DIR_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
@@ -337,6 +338,7 @@ export function registerSteeringInbox(
337
338
  nativeRealpath?: (filePath: string) => string;
338
339
  legacySettleFallbackMs?: number;
339
340
  safetyPollIntervalMs?: number;
341
+ platform?: NodeJS.Platform;
340
342
  timers?: Pick<typeof globalThis, "setInterval" | "clearInterval">;
341
343
  } = {},
342
344
  ): void {
@@ -449,13 +451,17 @@ export function registerSteeringInbox(
449
451
  safetyInterval = (deps.timers?.setInterval ?? setInterval)(flush, deps.safetyPollIntervalMs ?? STEERING_SAFETY_POLL_INTERVAL_MS) as NodeJS.Timeout;
450
452
  safetyInterval.unref?.();
451
453
  };
452
- try {
453
- watcher = (deps.watch ?? fs.watch)(resolveWatchPath(steerInbox, deps.nativeRealpath), () => flush());
454
- watcher.on("error", startPolling);
455
- startSafetyPolling();
456
- } catch {
457
- watcher = undefined;
454
+ if (!shouldUseNativeFsWatch("child-steering-inbox", deps.platform)) {
458
455
  startPolling();
456
+ } else {
457
+ try {
458
+ watcher = (deps.watch ?? fs.watch)(resolveWatchPath(steerInbox, deps.nativeRealpath), () => flush());
459
+ watcher.on("error", startPolling);
460
+ startSafetyPolling();
461
+ } catch {
462
+ watcher = undefined;
463
+ startPolling();
464
+ }
459
465
  }
460
466
  };
461
467
  const activate = (): undefined => {
@@ -1,4 +1,4 @@
1
- import { isCheckpointStep, isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
1
+ import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
2
2
  import type { SingleResult, SubagentRunMode, WorkflowGraphNode, WorkflowGraphSnapshot, WorkflowNodeStatus } from "../../shared/types.ts";
3
3
 
4
4
  export interface WorkflowGraphBuildInput {
@@ -81,28 +81,6 @@ export function buildWorkflowGraphSnapshot(input: WorkflowGraphBuildInput): Work
81
81
 
82
82
  for (let stepIndex = 0; stepIndex < input.steps.length; stepIndex++) {
83
83
  const step = input.steps[stepIndex]!;
84
- if (isCheckpointStep(step)) {
85
- const status = normalizeStatus(input.stepStatuses?.[flatIndex]?.status)
86
- ?? (input.currentStepIndex === stepIndex ? "paused" : "pending");
87
- const checkpoint = { name: step.checkpoint, ...(step.message ? { message: step.message } : {}), status: status === "rejected" ? "rejected" as const : status === "completed" ? "approved" as const : "pending" as const, stepIndex };
88
- const id = `step-${stepIndex}`;
89
- nodes.push({
90
- id,
91
- kind: "checkpoint",
92
- phase: step.phase,
93
- label: step.label?.trim() || step.checkpoint,
94
- status,
95
- flatIndex,
96
- stepIndex,
97
- checkpoint,
98
- error: input.stepStatuses?.[flatIndex]?.error,
99
- });
100
- pushPhase(phases, step.phase, id);
101
- if (status === "paused" || input.currentStepIndex === stepIndex) currentNodeId = id;
102
- flatIndex++;
103
- continue;
104
- }
105
-
106
84
  if (isParallelStep(step)) {
107
85
  const groupId = `step-${stepIndex}`;
108
86
  const children: WorkflowGraphNode[] = [];
@@ -46,7 +46,7 @@ export interface WorktreeCleanupTask {
46
46
  }
47
47
 
48
48
  export type WorktreeCleanupIntent =
49
- | { kind: "preserve"; capturedDiffs?: WorktreeDiff[]; handoffManifestPath?: string }
49
+ | { kind: "preserve"; capturedDiffs?: WorktreeDiff[]; handoffManifestPath?: string; cleanupBlocker?: string }
50
50
  | {
51
51
  kind: "discard";
52
52
  authorization:
@@ -115,7 +115,7 @@ interface RepoState {
115
115
  const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
116
116
 
117
117
  function runGit(cwd: string, args: string[]): GitResult {
118
- const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" });
118
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true });
119
119
  return {
120
120
  stdout: result.stdout ?? "",
121
121
  stderr: result.stderr ?? "",
@@ -338,6 +338,7 @@ function runWorktreeSetupHook(
338
338
  input: WorktreeSetupHookInput,
339
339
  ): string[] {
340
340
  const result = spawnSync(hook.hookPath, [], {
341
+ windowsHide: true,
341
342
  cwd: input.worktreePath,
342
343
  encoding: "utf-8",
343
344
  input: JSON.stringify(input),
@@ -571,6 +572,17 @@ function cleanupSingleWorktree(
571
572
  const errors: string[] = [];
572
573
  let worktreeRemoved = false;
573
574
  let branchRemoved = false;
575
+ if (intent.kind === "preserve" && intent.cleanupBlocker) {
576
+ return {
577
+ index: worktree.index,
578
+ path: worktree.path,
579
+ branch: worktree.branch,
580
+ worktreeRemoved: false,
581
+ branchRemoved: false,
582
+ preserved: true,
583
+ reason: intent.cleanupBlocker,
584
+ };
585
+ }
574
586
  if (intent.kind !== "setup-rollback") {
575
587
  try {
576
588
  removeSyntheticPathsBeforeDiff(worktree);
@@ -1,9 +1,12 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, runFileSystemOperationWithRetry, waitForFileSystemRetry } from "./file-system-retry.ts";
4
5
 
5
6
  type AtomicJsonFs = Pick<typeof fs, "mkdirSync" | "writeFileSync" | "renameSync" | "rmSync">;
6
7
 
8
+ const MAX_PATH_COMPONENT_BYTES = 255;
9
+
7
10
  type AtomicJsonWriterOptions = {
8
11
  fs?: AtomicJsonFs;
9
12
  now?: () => number;
@@ -28,6 +31,13 @@ function renameWithRetry(
28
31
  }, { retryDelaysMs, wait });
29
32
  }
30
33
 
34
+ function tempBaseName(filePath: string, pid: number, nowMs: number, randomId: string): string {
35
+ const suffix = `.${pid}.${nowMs}.${randomId}.tmp`;
36
+ const preferred = `.${path.basename(filePath)}${suffix}`;
37
+ if (Buffer.byteLength(preferred, "utf-8") <= MAX_PATH_COMPONENT_BYTES) return preferred;
38
+ return `.${createHash("sha256").update(path.basename(filePath)).digest("hex")}${suffix}`;
39
+ }
40
+
31
41
  export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (filePath: string, payload: object) => void {
32
42
  const fsImpl = options.fs ?? fs;
33
43
  const now = options.now ?? Date.now;
@@ -46,13 +56,23 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
46
56
  }, { retryDelaysMs: directoryRetryDelaysMs, wait });
47
57
  const tempPath = path.join(
48
58
  path.dirname(filePath),
49
- `.${path.basename(filePath)}.${pid}.${now()}.${random().toString(36).slice(2)}.tmp`,
59
+ tempBaseName(filePath, pid, now(), random().toString(36).slice(2)),
50
60
  );
61
+ let writeError: unknown;
51
62
  try {
52
63
  fsImpl.writeFileSync(tempPath, JSON.stringify(payload, null, 2), mode === undefined ? "utf-8" : { encoding: "utf-8", mode });
53
64
  renameWithRetry(fsImpl, tempPath, filePath, renameRetryDelaysMs, wait);
65
+ } catch (error) {
66
+ writeError = error;
67
+ throw error;
54
68
  } finally {
55
- fsImpl.rmSync(tempPath, { force: true });
69
+ try {
70
+ fsImpl.rmSync(tempPath, { force: true });
71
+ } catch (cleanupError) {
72
+ // Preserve the write/rename failure: cleanup is best effort and must
73
+ // not hide the error callers need to classify or report.
74
+ if (writeError === undefined) throw cleanupError;
75
+ }
56
76
  }
57
77
  };
58
78
  }