pi-subagents 0.49.0 → 0.50.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 (75) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/agents/oracle.md +7 -5
  3. package/agents/researcher.md +1 -1
  4. package/agents/reviewer.md +2 -2
  5. package/agents/scout.md +1 -1
  6. package/agents/worker.md +1 -1
  7. package/docs/agents.md +2 -0
  8. package/docs/configuration.md +47 -3
  9. package/docs/extension-api.md +36 -0
  10. package/docs/missions.md +1 -1
  11. package/docs/observability.md +16 -0
  12. package/docs/tool-reference.md +19 -2
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  16. package/skills/pi-subagents/references/execution-controls.md +5 -4
  17. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  18. package/skills/pi-subagents/references/prompting-and-roles.md +31 -15
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +10 -0
  21. package/src/api/external-runs.ts +174 -84
  22. package/src/api/preflight.ts +12 -6
  23. package/src/extension/config.ts +33 -0
  24. package/src/extension/index.ts +48 -5
  25. package/src/extension/public-execution.ts +3 -1
  26. package/src/extension/rpc.ts +5 -1
  27. package/src/extension/schemas.ts +2 -1
  28. package/src/extension/tool-description.ts +4 -2
  29. package/src/inspectors/herdr/actions.ts +3 -7
  30. package/src/inspectors/herdr/project-panes.ts +2 -6
  31. package/src/inspectors/herdr/shell-command.ts +16 -0
  32. package/src/intercom/intercom-bridge.ts +2 -3
  33. package/src/intercom/native-supervisor-channel.ts +19 -42
  34. package/src/missions/goal-driver.ts +3 -1
  35. package/src/runs/background/active-run-index.ts +71 -1
  36. package/src/runs/background/async-execution.ts +49 -7
  37. package/src/runs/background/async-job-tracker.ts +1 -0
  38. package/src/runs/background/async-resume.ts +3 -5
  39. package/src/runs/background/async-status-snapshot.ts +277 -0
  40. package/src/runs/background/async-status.ts +8 -3
  41. package/src/runs/background/chain-root-attachment.ts +2 -2
  42. package/src/runs/background/completion-replay.ts +11 -1
  43. package/src/runs/background/fleet-view.ts +3 -1
  44. package/src/runs/background/result-files.ts +437 -0
  45. package/src/runs/background/result-watcher.ts +188 -41
  46. package/src/runs/background/retained-children.ts +69 -20
  47. package/src/runs/background/run-id-resolver.ts +30 -24
  48. package/src/runs/background/run-status.ts +7 -2
  49. package/src/runs/background/scheduled-runs.ts +54 -28
  50. package/src/runs/background/stale-run-reconciler.ts +27 -13
  51. package/src/runs/background/subagent-runner.ts +294 -32
  52. package/src/runs/background/subagent-wait.ts +2 -0
  53. package/src/runs/background/wait-completions.ts +5 -2
  54. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  55. package/src/runs/foreground/chain-execution.ts +16 -0
  56. package/src/runs/foreground/execution.ts +219 -15
  57. package/src/runs/foreground/subagent-executor.ts +114 -28
  58. package/src/runs/shared/completion-guard.ts +96 -6
  59. package/src/runs/shared/external-cli-runner.ts +4 -0
  60. package/src/runs/shared/model-fallback.ts +16 -2
  61. package/src/runs/shared/nested-events.ts +66 -62
  62. package/src/runs/shared/orca-progress-tabs.ts +375 -0
  63. package/src/runs/shared/parallel-utils.ts +2 -0
  64. package/src/runs/shared/subagent-control.ts +15 -0
  65. package/src/runs/shared/subagent-prompt-runtime.ts +1 -9
  66. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  67. package/src/runs/shared/tool-timeout.ts +93 -0
  68. package/src/shared/types.ts +29 -2
  69. package/src/slash/slash-commands.ts +34 -25
  70. package/src/slash/slash-live-state.ts +3 -0
  71. package/src/tui/fleet-status.ts +160 -45
  72. package/src/tui/fleet.ts +125 -15
  73. package/src/tui/render.ts +41 -10
  74. package/src/workflows/chat-progress.ts +6 -4
  75. package/src/workflows/scripted-workflow.ts +227 -70
@@ -239,8 +239,15 @@ class ScheduleStore {
239
239
  }
240
240
 
241
241
  get(id: string): ScheduleRecord {
242
+ const record = this.find(id);
243
+ if (!record) throw new Error(`Schedule '${id}' not found.`);
244
+ return record;
245
+ }
246
+
247
+ /** Like {@link get}, but returns undefined when the schedule no longer exists. */
248
+ find(id: string): ScheduleRecord | undefined {
242
249
  const file = path.join(scheduleDir(this.root, id, false, this.projectCwd), "schedule.json");
243
- if (!fs.existsSync(file)) throw new Error(`Schedule '${id}' not found.`);
250
+ if (!fs.existsSync(file)) return undefined;
244
251
  return parseSchedule(readJson(file, "schedule record"), file);
245
252
  }
246
253
 
@@ -361,6 +368,7 @@ export class ScheduledRunManager {
361
368
  private readonly stores = new Map<string, ScheduleStore>();
362
369
  private readonly contexts = new Map<string, ExtensionContext>();
363
370
  private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
371
+ private readonly observedAsyncIds = new Set<string>();
364
372
  private readonly now: () => number;
365
373
  private readonly randomId: () => string;
366
374
  private readonly timersApi: ScheduledRunTimers;
@@ -383,6 +391,7 @@ export class ScheduledRunManager {
383
391
  this.store = undefined;
384
392
  this.stores.clear();
385
393
  this.contexts.clear();
394
+ this.observedAsyncIds.clear();
386
395
  }
387
396
 
388
397
  async handleToolCall(params: SubagentParamsLike, ctx: ExtensionContext): Promise<AgentToolResult<Details>> {
@@ -407,26 +416,7 @@ export class ScheduledRunManager {
407
416
  }
408
417
 
409
418
  observedCompletionRunIds(): Set<string> {
410
- const runIds = new Set<string>();
411
- for (const store of this.stores.values()) {
412
- let ids: string[];
413
- try {
414
- ids = store.ids();
415
- } catch (error) {
416
- console.error(`Failed to inspect schedule store '${store.root}' during async completion discovery:`, error);
417
- continue;
418
- }
419
- for (const id of ids) {
420
- try {
421
- for (const run of store.history(id)) {
422
- if (run.state === "running" && run.asyncId) runIds.add(run.asyncId);
423
- }
424
- } catch (error) {
425
- console.error(`Failed to inspect schedule '${id}' in '${store.root}' during async completion discovery:`, error);
426
- }
427
- }
428
- }
429
- return runIds;
419
+ return new Set(this.observedAsyncIds);
430
420
  }
431
421
 
432
422
  handleAsyncCompletion(payload: unknown): void {
@@ -563,9 +553,10 @@ export class ScheduledRunManager {
563
553
  for (const schedule of store.list()) this.restoreOne(store, schedule);
564
554
  }
565
555
 
566
- private restoreOne(store: ScheduleStore, schedule: ScheduleRecord): void {
556
+ private restoreOne(store: ScheduleStore, schedule: ScheduleRecord, notBefore?: number, rearm = true): void {
567
557
  if (schedule.activeRunId) {
568
558
  const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
559
+ if (run?.state === "running" && run.asyncId) this.observedAsyncIds.add(run.asyncId);
569
560
  const startedAt = run?.startedAt ? Date.parse(run.startedAt) : Number.NaN;
570
561
  if (run?.state === "running" && run.asyncDir) {
571
562
  try {
@@ -588,26 +579,59 @@ export class ScheduledRunManager {
588
579
  fs.rmSync(path.join(store.directory(schedule.id), "active.lock"), { force: true });
589
580
  }
590
581
  }
591
- if (schedule.paused) return;
582
+ if (!rearm || schedule.paused) return;
592
583
  const next = nextRunAt(schedule);
593
584
  if (next === undefined) return;
594
- if (!schedule.activeRunId && next < this.now() && schedule.catchUp === "none") this.recordMissed(store, schedule, next, "timer");
595
- this.arm(schedule, store);
585
+ if (!schedule.activeRunId && next < this.now() && schedule.catchUp === "none") {
586
+ try {
587
+ this.recordMissed(store, schedule, next, "timer");
588
+ } catch (error) {
589
+ if (notBefore !== undefined) this.arm(schedule, store, notBefore);
590
+ throw error;
591
+ }
592
+ }
593
+ this.arm(schedule, store, notBefore);
596
594
  }
597
595
 
598
- private arm(schedule: ScheduleRecord, store: ScheduleStore): void {
596
+ private arm(schedule: ScheduleRecord, store: ScheduleStore, notBefore?: number): void {
599
597
  this.clearTimer(store, schedule.id);
600
598
  if (schedule.paused) return;
601
599
  const next = nextRunAt(schedule);
602
600
  if (next === undefined) return;
603
- const timer = this.timersApi.setTimeout(() => void this.fire(store, schedule.id), Math.min(Math.max(0, next - this.now()), MAX_TIMER_DELAY_MS));
601
+ const timer = this.timersApi.setTimeout(() => {
602
+ // A timer callback is outside every caller's try/catch, so an escaping
603
+ // rejection here reaches the process as an uncaught exception and exits
604
+ // Pi. Contain every failure to this one schedule.
605
+ void this.fire(store, schedule.id).catch((error) => {
606
+ console.warn(`[pi-subagents] Scheduled run '${schedule.id}' failed to fire: ${error instanceof Error ? error.message : String(error)}`);
607
+ this.restoreAfterFireError(store, schedule.id);
608
+ });
609
+ }, Math.min(Math.max(0, next - this.now(), (notBefore ?? 0) - this.now()), MAX_TIMER_DELAY_MS));
604
610
  timer.unref?.();
605
611
  this.timers.set(this.timerKey(store, schedule.id), timer);
606
612
  }
607
613
 
614
+ private restoreAfterFireError(store: ScheduleStore, id: string): void {
615
+ try {
616
+ const schedule = store.find(id);
617
+ if (!schedule) return;
618
+ const now = this.now();
619
+ const planned = schedule.trigger.kind === "interval" ? duePlannedAt(schedule, now) : undefined;
620
+ const notBefore = planned !== undefined && planned <= now ? Date.parse(nextAfter(schedule.trigger, planned, now)!) : undefined;
621
+ this.restoreOne(store, schedule, notBefore, schedule.trigger.kind === "interval");
622
+ } catch (error) {
623
+ console.warn(`[pi-subagents] Scheduled run '${id}' could not be restored after fire failure: ${error instanceof Error ? error.message : String(error)}`);
624
+ }
625
+ }
626
+
608
627
  private async fire(store: ScheduleStore, id: string): Promise<void> {
609
628
  this.clearTimer(store, id);
610
- const schedule = store.get(id);
629
+ // Schedules are project-scoped and shared, and `delete` only clears the
630
+ // timer inside the deleting process. Another session can therefore remove
631
+ // a schedule while this process still holds an armed timer for it; there
632
+ // is then nothing to run and nothing to re-arm.
633
+ const schedule = store.find(id);
634
+ if (!schedule) return;
611
635
  const planned = duePlannedAt(schedule, this.now());
612
636
  if (planned === undefined || schedule.paused) return;
613
637
  if (planned > this.now()) return this.arm(schedule, store);
@@ -661,6 +685,7 @@ export class ScheduledRunManager {
661
685
  if (result.isError || !asyncId) throw new Error(result.content.find((item) => item.type === "text")?.text ?? "Scheduled launch failed.");
662
686
  run.asyncId = asyncId;
663
687
  run.asyncDir = result.details?.asyncDir;
688
+ this.observedAsyncIds.add(asyncId);
664
689
  store.writeRun(schedule, run, "schedule.run.attached_async");
665
690
  this.arm(schedule, store);
666
691
  return run;
@@ -695,6 +720,7 @@ export class ScheduledRunManager {
695
720
  schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
696
721
  store.writeRun(schedule, skipped, "schedule.skipped_overlap");
697
722
  }
723
+ if (run.asyncId) this.observedAsyncIds.delete(run.asyncId);
698
724
  run.state = success ? "completed" : "failed_run";
699
725
  run.completedAt = timestamp(now);
700
726
  if (!success && error) run.error = error;
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
+ import { resultFilePath, resultPayloadPathForSessionRun, writeAsyncResultFile } from "./result-files.ts";
4
5
  import { releaseActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
5
6
  import { readStatus } from "../../shared/utils.ts";
6
7
  import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
@@ -98,14 +99,23 @@ interface ResultChildOutcome {
98
99
  }
99
100
 
100
101
  interface ResultRepairData {
101
- state: "complete" | "failed" | "paused" | "stopped";
102
+ state: "complete" | "failed" | "paused" | "stopped" | "rejected";
102
103
  results?: ResultChildOutcome[];
103
104
  }
104
105
 
106
+ function regularFileExists(filePath: string): boolean {
107
+ try {
108
+ return fs.statSync(filePath).isFile();
109
+ } catch (error) {
110
+ if (isNotFoundError(error)) return false;
111
+ throw error;
112
+ }
113
+ }
114
+
105
115
  function readResultRepairData(resultPath: string): ResultRepairData | undefined {
106
116
  try {
107
117
  const data = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { success?: boolean; state?: string; exitCode?: number; results?: unknown };
108
- const state = data.success ? "complete" : data.state === "stopped" ? "stopped" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
118
+ const state = data.success ? "complete" : data.state === "stopped" ? "stopped" : data.state === "rejected" ? "rejected" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
109
119
  const results = Array.isArray(data.results)
110
120
  ? data.results.map((entry, index) => {
111
121
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return {};
@@ -124,7 +134,7 @@ function readResultRepairData(resultPath: string): ResultRepairData | undefined
124
134
  }
125
135
  }
126
136
 
127
- function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): "complete" | "failed" | "paused" | "stopped" {
137
+ function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): ResultRepairData["state"] {
128
138
  if (child?.success === true) return "complete";
129
139
  if (child?.success === false) return "failed";
130
140
  return overallState;
@@ -197,7 +207,7 @@ function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, no
197
207
  };
198
208
  }
199
209
 
200
- function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: object; message: string } {
210
+ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: Record<string, unknown>; message: string } {
201
211
  const runId = status.runId || path.basename(asyncDir);
202
212
  const pid = typeof status.pid === "number" ? status.pid : "unknown";
203
213
  const baseMessage = reason ?? `Async runner process ${pid} exited or disappeared before writing a result. Marked run failed by stale-run reconciliation.`;
@@ -259,7 +269,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
259
269
 
260
270
  function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: string, now: number, reason?: string): ReconcileAsyncRunResult {
261
271
  const repair = buildFailedRepair(status, asyncDir, now, reason);
262
- writeAtomicJson(resultPath, repair.result);
272
+ if (repair.result.sessionId) writeAsyncResultFile(resultPath, repair.result);
263
273
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
264
274
  if (repair.status.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
265
275
  appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
@@ -267,14 +277,14 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
267
277
  ts: now,
268
278
  runId: repair.status.runId,
269
279
  pid: status.pid,
270
- resultPath,
280
+ ...(repair.result.sessionId ? { resultPath } : {}),
271
281
  message: repair.message,
272
282
  });
273
- return { status: repair.status, repaired: true, resultPath, message: repair.message };
283
+ return { status: repair.status, repaired: true, ...(repair.result.sessionId ? { resultPath } : {}), message: repair.message };
274
284
  }
275
285
 
276
286
  function terminal(state: AsyncStatus["state"]): boolean {
277
- return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
287
+ return state === "complete" || state === "failed" || state === "paused" || state === "stopped" || state === "rejected";
278
288
  }
279
289
 
280
290
  function* nestedRuns(children: NestedRunSummary[] | undefined): Generator<NestedRunSummary> {
@@ -344,17 +354,21 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
344
354
  if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].thinking must be a string.`);
345
355
  }
346
356
  const runId = effectiveStatus.runId || path.basename(asyncDir);
347
- const resultPath = path.join(options.resultsDir ?? DIRS.results, `${runId}.json`);
348
- if (fs.existsSync(resultPath)) {
357
+ const resultsDir = options.resultsDir ?? DIRS.results;
358
+ const resultPath = resultFilePath(resultsDir, runId);
359
+ const existingResultPath = effectiveStatus.sessionId
360
+ ? resultPayloadPathForSessionRun(resultsDir, effectiveStatus.sessionId, runId) ?? (regularFileExists(resultPath) ? resultPath : undefined)
361
+ : regularFileExists(resultPath) ? resultPath : undefined;
362
+ if (existingResultPath) {
349
363
  const terminalStatus = effectiveStatus.state === "running" || effectiveStatus.state === "queued"
350
- ? terminalStatusFromResult(effectiveStatus, resultPath, now)
364
+ ? terminalStatusFromResult(effectiveStatus, existingResultPath, now)
351
365
  : undefined;
352
366
  if (terminalStatus) {
353
367
  writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
354
368
  if (terminalStatus.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
355
- return { status: terminalStatus, repaired: true, resultPath, message: "Existing async result file was used to repair stale running status." };
369
+ return { status: terminalStatus, repaired: true, resultPath: existingResultPath, message: "Existing async result file was used to repair stale running status." };
356
370
  }
357
- if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath };
371
+ if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath: existingResultPath };
358
372
  }
359
373
  if (effectiveStatus.displayDismissedAt !== undefined) {
360
374
  return { status: null, repaired: false, resultPath };