pi-subagents 0.38.0 → 0.40.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 (59) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +85 -13
  3. package/agents/worker.md +1 -0
  4. package/package.json +1 -1
  5. package/prompts/review-loop.md +1 -1
  6. package/skills/pi-subagents/SKILL.md +4 -3
  7. package/skills/pi-subagents/references/execution-controls.md +22 -3
  8. package/skills/pi-subagents/references/management-authoring-rpc.md +5 -1
  9. package/skills/pi-subagents/references/prompting-and-roles.md +17 -3
  10. package/src/agents/agent-management.ts +71 -30
  11. package/src/agents/agent-serializer.ts +4 -0
  12. package/src/agents/agents.ts +60 -1
  13. package/src/api/preflight.ts +16 -12
  14. package/src/extension/chain-validation.ts +17 -1
  15. package/src/extension/rpc.ts +2 -0
  16. package/src/extension/schemas.ts +16 -3
  17. package/src/extension/tool-description.ts +6 -5
  18. package/src/intercom/result-intercom.ts +33 -4
  19. package/src/runs/background/async-execution.ts +63 -16
  20. package/src/runs/background/async-job-tracker.ts +5 -1
  21. package/src/runs/background/async-resume.ts +1 -0
  22. package/src/runs/background/async-status.ts +18 -2
  23. package/src/runs/background/chain-append.ts +48 -5
  24. package/src/runs/background/control-channel.ts +60 -0
  25. package/src/runs/background/notify.ts +26 -4
  26. package/src/runs/background/result-watcher.ts +20 -3
  27. package/src/runs/background/run-status.ts +29 -3
  28. package/src/runs/background/subagent-runner.ts +302 -18
  29. package/src/runs/foreground/async-stop-action.ts +65 -0
  30. package/src/runs/foreground/chain-execution.ts +143 -43
  31. package/src/runs/foreground/execution.ts +384 -202
  32. package/src/runs/foreground/foreground-control.ts +30 -0
  33. package/src/runs/foreground/subagent-executor.ts +314 -77
  34. package/src/runs/shared/capability-ceiling.ts +51 -19
  35. package/src/runs/shared/chain-outputs.ts +3 -1
  36. package/src/runs/shared/dynamic-fanout.ts +1 -1
  37. package/src/runs/shared/nested-events.ts +97 -20
  38. package/src/runs/shared/parallel-utils.ts +43 -12
  39. package/src/runs/shared/pi-args.ts +45 -3
  40. package/src/runs/shared/process-signal.ts +19 -0
  41. package/src/runs/shared/run-history.ts +45 -9
  42. package/src/runs/shared/runtime-acknowledged-extensions.ts +71 -0
  43. package/src/runs/shared/subagent-prompt-runtime.ts +30 -0
  44. package/src/runs/shared/usage-budget.ts +65 -0
  45. package/src/runs/shared/workflow-graph.ts +26 -1
  46. package/src/shared/settings.ts +17 -1
  47. package/src/shared/types.ts +124 -8
  48. package/src/shared/utils.ts +12 -1
  49. package/src/slash/slash-commands.ts +43 -1
  50. package/src/slash/slash-live-state.ts +5 -3
  51. package/src/tui/fleet-status.ts +12 -2
  52. package/src/tui/fleet.ts +181 -8
  53. package/src/tui/render.ts +4 -2
  54. package/src/watchdog/register-main.ts +14 -7
  55. package/src/watchdog/review.ts +4 -3
  56. package/src/watchdog/runtime.ts +170 -17
  57. package/src/watchdog/scope.ts +62 -0
  58. package/src/watchdog/settings.ts +41 -1
  59. package/src/watchdog/types.ts +10 -0
@@ -53,6 +53,13 @@ export interface StopRequest {
53
53
  reason?: string;
54
54
  }
55
55
 
56
+ export interface CheckpointDecisionRequest {
57
+ type: "approve-checkpoint" | "reject-checkpoint";
58
+ ts?: number;
59
+ source?: string;
60
+ reason?: string;
61
+ }
62
+
56
63
  export interface SteerRequest {
57
64
  type: "steer";
58
65
  id: string;
@@ -110,6 +117,14 @@ export function stopRequestPath(asyncDir: string): string {
110
117
  return path.join(controlInboxDir(asyncDir), "stop.json");
111
118
  }
112
119
 
120
+ export function approveCheckpointRequestPath(asyncDir: string): string {
121
+ return path.join(controlInboxDir(asyncDir), "approve-checkpoint.json");
122
+ }
123
+
124
+ export function rejectCheckpointRequestPath(asyncDir: string): string {
125
+ return path.join(controlInboxDir(asyncDir), "reject-checkpoint.json");
126
+ }
127
+
113
128
  /** Directory of parent-to-runner steering requests. */
114
129
  export function steerRequestsDir(asyncDir: string): string {
115
130
  return path.join(controlInboxDir(asyncDir), STEER_REQUESTS_DIR);
@@ -254,6 +269,18 @@ export function requestAsyncStop(
254
269
  return requestPath;
255
270
  }
256
271
 
272
+ export function requestAsyncCheckpointDecision(
273
+ asyncDir: string,
274
+ type: CheckpointDecisionRequest["type"],
275
+ payload: Omit<CheckpointDecisionRequest, "type"> = {},
276
+ deps: { now?: () => number } = {},
277
+ ): string {
278
+ const requestPath = type === "approve-checkpoint" ? approveCheckpointRequestPath(asyncDir) : rejectCheckpointRequestPath(asyncDir);
279
+ const request: CheckpointDecisionRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type };
280
+ writeAtomicJson(requestPath, request);
281
+ return requestPath;
282
+ }
283
+
257
284
  export function requestAsyncSteer(
258
285
  asyncDir: string,
259
286
  payload: { message: string; targetIndex?: number; targetIndexes?: number[]; source?: string; id?: string; ts?: number },
@@ -457,6 +484,21 @@ export function consumeStopRequest(
457
484
  return true;
458
485
  }
459
486
 
487
+ export function consumeCheckpointDecisionRequest(
488
+ asyncDir: string,
489
+ fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
490
+ ): "approved" | "rejected" | undefined {
491
+ if (fsImpl.existsSync(rejectCheckpointRequestPath(asyncDir))) {
492
+ try { fsImpl.rmSync(rejectCheckpointRequestPath(asyncDir), { force: true, recursive: true }); } catch {}
493
+ return "rejected";
494
+ }
495
+ if (fsImpl.existsSync(approveCheckpointRequestPath(asyncDir))) {
496
+ try { fsImpl.rmSync(approveCheckpointRequestPath(asyncDir), { force: true, recursive: true }); } catch {}
497
+ return "approved";
498
+ }
499
+ return undefined;
500
+ }
501
+
460
502
  /**
461
503
  * Parent side: portable interrupt = authoritative file request + best-effort OS
462
504
  * signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
@@ -513,6 +555,21 @@ export function deliverStopRequest(input: {
513
555
  requestAsyncStop(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
514
556
  }
515
557
 
558
+ export function deliverCheckpointDecisionRequest(input: {
559
+ asyncDir: string;
560
+ decision: "approved" | "rejected";
561
+ now?: () => number;
562
+ source?: string;
563
+ reason?: string;
564
+ }): void {
565
+ requestAsyncCheckpointDecision(
566
+ input.asyncDir,
567
+ input.decision === "approved" ? "approve-checkpoint" : "reject-checkpoint",
568
+ { ...(input.source ? { source: input.source } : {}), ...(input.reason ? { reason: input.reason } : {}) },
569
+ { now: input.now },
570
+ );
571
+ }
572
+
516
573
  /**
517
574
  * Runner side: watch the control inbox and route interrupt requests into
518
575
  * `onInterrupt`. Uses `fs.watch` when available plus an interval poll as a
@@ -526,6 +583,7 @@ export function watchAsyncControlInbox(
526
583
  onTimeout?: () => void;
527
584
  onStop?: () => void;
528
585
  onSteer?: (request: SteerRequest) => void;
586
+ onCheckpointDecision?: (decision: "approved" | "rejected") => void;
529
587
  onSteerCapability?: (capability: SteerCapability) => void;
530
588
  onSteerAck?: (ack: SteerAck) => void;
531
589
  pollIntervalMs?: number;
@@ -549,6 +607,8 @@ export function watchAsyncControlInbox(
549
607
  if (consumeStopRequest(asyncDir, fsImpl)) opts.onStop?.();
550
608
  if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
551
609
  if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
610
+ const checkpointDecision = consumeCheckpointDecisionRequest(asyncDir, fsImpl);
611
+ if (checkpointDecision) opts.onCheckpointDecision?.(checkpointDecision);
552
612
  for (const request of consumeSteerRequests(asyncDir, fsImpl)) opts.onSteer?.(request);
553
613
  for (const capability of consumeSteerCapabilities(asyncDir, fsImpl)) opts.onSteerCapability?.(capability);
554
614
  for (const ack of consumeSteerAcks(asyncDir, fsImpl)) opts.onSteerAck?.(ack);
@@ -15,10 +15,11 @@ import {
15
15
  resolveCompletionBatchConfig,
16
16
  } from "./completion-batcher.ts";
17
17
  import { SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT, type ParallelHandoffReference, type SubagentState } from "../../shared/types.ts";
18
+ import { isUnexplainedProcessSignal } from "../shared/process-signal.ts";
18
19
 
19
20
  export interface SubagentNotifyDetails {
20
21
  agent: string;
21
- status: "completed" | "failed" | "paused";
22
+ status: "completed" | "failed" | "paused" | "stopped";
22
23
  source?: "async" | "foreground";
23
24
  taskInfo?: string;
24
25
  resultPreview: string;
@@ -37,6 +38,21 @@ export interface CompletionNotification {
37
38
  summary?: string;
38
39
  exitCode?: number;
39
40
  state?: string;
41
+ processSignal?: string | null;
42
+ interrupted?: boolean;
43
+ timedOut?: boolean;
44
+ stopped?: boolean;
45
+ turnBudgetExceeded?: boolean;
46
+ results?: Array<{
47
+ status?: string;
48
+ success?: boolean;
49
+ exitCode?: number | null;
50
+ processSignal?: string | null;
51
+ interrupted?: boolean;
52
+ timedOut?: boolean;
53
+ stopped?: boolean;
54
+ turnBudgetExceeded?: boolean;
55
+ }>;
40
56
  timestamp?: number;
41
57
  durationMs?: number;
42
58
  cwd?: string;
@@ -92,7 +108,7 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
92
108
 
93
109
  export function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | undefined {
94
110
  const lines = content.split("\n");
95
- const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
111
+ const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
96
112
  if (!match) return undefined;
97
113
  const body = lines.slice(2);
98
114
  let sessionIndex = -1;
@@ -179,12 +195,18 @@ function completionBatchKey(result: CompletionNotification): string {
179
195
  export function buildCompletionDetails(result: CompletionNotification): SubagentNotifyDetails {
180
196
  const agent = result.agent ?? "unknown";
181
197
  const summary = typeof result.summary === "string" ? result.summary : "";
182
- const paused = !result.success && (
198
+ const stopped = result.stopped === true
199
+ || result.state === "stopped"
200
+ || (result.success !== true && result.exitCode !== 0 && isUnexplainedProcessSignal(result))
201
+ || result.results?.some((child) => child.stopped === true
202
+ || child.status === "stopped"
203
+ || (child.success !== true && child.exitCode !== 0 && isUnexplainedProcessSignal(child))) === true;
204
+ const paused = !stopped && !result.success && (
183
205
  result.exitCode === 0
184
206
  || result.state === "paused"
185
207
  || summary.startsWith("Paused after interrupt.")
186
208
  );
187
- const status = paused ? "paused" : result.success ? "completed" : "failed";
209
+ const status = stopped ? "stopped" : paused ? "paused" : result.success ? "completed" : "failed";
188
210
  const taskInfo =
189
211
  result.taskIndex !== undefined && result.totalTasks !== undefined
190
212
  ? ` (${result.taskIndex + 1}/${result.totalTasks})`
@@ -8,6 +8,7 @@ import {
8
8
  type NestedRunSummary,
9
9
  type ParallelHandoffReference,
10
10
  type SubagentResultIntercomChild,
11
+ type SubagentOutputState,
11
12
  type SubagentState,
12
13
  } from "../../shared/types.ts";
13
14
  import {
@@ -45,10 +46,15 @@ type ResultWatcherDeps = {
45
46
  type ResultFileChild = {
46
47
  agent?: string;
47
48
  output?: string;
49
+ outputState?: SubagentOutputState;
48
50
  error?: string;
49
51
  success?: boolean;
50
52
  state?: string;
53
+ interrupted?: boolean;
54
+ timedOut?: boolean;
51
55
  stopped?: boolean;
56
+ turnBudgetExceeded?: boolean;
57
+ processSignal?: string | null;
52
58
  sessionFile?: string;
53
59
  artifactPaths?: { outputPath?: string };
54
60
  intercomTarget?: string;
@@ -174,9 +180,9 @@ export function createResultWatcher(
174
180
  const hasResultChildren = Array.isArray(data.results) && data.results.length > 0;
175
181
  const resultChildren: ResultFileChild[] = hasResultChildren
176
182
  ? data.results!
177
- : [{ agent: data.agent ?? undefined, output: data.summary, success: data.success }];
183
+ : [{ agent: data.agent ?? undefined, output: data.summary, outputState: "unknown", success: data.success }];
178
184
  const normalizedChildren = attachNestedChildrenToResultChildren(runId, resultChildren.map((result = {}, index): SubagentResultIntercomChild => {
179
- const baseOutput = result.output ?? data.summary;
185
+ const baseOutput = hasResultChildren ? result.output : result.output ?? data.summary;
180
186
  const hasRealOutput = typeof baseOutput === "string" && baseOutput.trim().length > 0;
181
187
  const output = hasRealOutput ? baseOutput : "(no output)";
182
188
  const summary = result.success === false && result.error
@@ -193,7 +199,18 @@ export function createResultWatcher(
193
199
  : undefined;
194
200
  return {
195
201
  agent: result.agent ?? data.agent ?? `step-${index + 1}`,
196
- status: resolveSubagentResultStatus({ success: result.success, state: childState }),
202
+ status: resolveSubagentResultStatus({
203
+ success: result.success,
204
+ state: childState,
205
+ interrupted: result.interrupted,
206
+ timedOut: result.timedOut,
207
+ stopped: result.stopped,
208
+ turnBudgetExceeded: result.turnBudgetExceeded,
209
+ processSignal: result.processSignal,
210
+ }),
211
+ outputState: result.outputState === "present" || result.outputState === "absent" || result.outputState === "unknown"
212
+ ? result.outputState
213
+ : "unknown",
197
214
  summary,
198
215
  index,
199
216
  artifactPath: result.artifactPaths?.outputPath,
@@ -8,6 +8,7 @@ import { formatModelThinking } from "../../shared/formatters.ts";
8
8
  import { formatActivityLabel } from "../../shared/status-format.ts";
9
9
  import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
10
10
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
11
+ import { resolveSubagentResultStatus } from "../../intercom/result-intercom.ts";
11
12
  import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
12
13
  import { resolveAsyncRunLocation } from "./async-resume.ts";
13
14
  import { resolveSubagentRunId } from "./run-id-resolver.ts";
@@ -39,6 +40,11 @@ function hasExistingSessionFile(value: unknown): value is string {
39
40
  return typeof value === "string" && fs.existsSync(value);
40
41
  }
41
42
 
43
+ function formatCheckpointGuidance(runId: string | undefined, checkpoint: AsyncStatus["checkpoint"] | undefined): string | undefined {
44
+ if (!runId || !checkpoint || checkpoint.status !== "pending") return undefined;
45
+ return `Checkpoint: ${checkpoint.name}${checkpoint.message ? ` — ${checkpoint.message}` : ""}\nApprove: subagent({ action: "approve-checkpoint", id: "${runId}" })\nReject: subagent({ action: "reject-checkpoint", id: "${runId}" })`;
46
+ }
47
+
42
48
  function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
43
49
  if (options.stopped) return "Resume: unavailable; stopped runs are not resumable. Start a new run instead.";
44
50
  const knownChildren = children
@@ -126,6 +132,12 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
126
132
  else lines.push(`Transcript: subagent({ action: "status", id: "${run.runId}", index: 0, view: "transcript" })`);
127
133
  const detached = run.children.some((child) => child.status === "detached");
128
134
  const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
135
+ if (run.checkpoint?.status === "pending") {
136
+ lines.push(`Checkpoint: ${run.checkpoint.name}${run.checkpoint.message ? ` — ${run.checkpoint.message}` : ""}`);
137
+ lines.push(`Approve: subagent({ action: "approve-checkpoint", id: "${run.runId}" })`);
138
+ lines.push(`Reject: subagent({ action: "reject-checkpoint", id: "${run.runId}" })`);
139
+ return lines.join("\n");
140
+ }
129
141
  if (detached) {
130
142
  lines.push(`Recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
131
143
  } else if (resumable) {
@@ -403,7 +415,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
403
415
  if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
404
416
  if (status.state === "running") lines.push(`Steer running child: subagent({ action: "steer", id: "${status.runId}", message: "..." })`);
405
417
  if (status.state !== "running") {
406
- lines.push(formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile, { stopped: status.state === "stopped" || status.stopped === true }));
418
+ lines.push(formatCheckpointGuidance(status.runId, status.checkpoint) ?? formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile, { stopped: status.state === "stopped" || status.stopped === true }));
407
419
  }
408
420
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
409
421
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
@@ -415,7 +427,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
415
427
  if (resultPath) {
416
428
  try {
417
429
  const raw = fs.readFileSync(resultPath, "utf-8");
418
- const data = JSON.parse(raw) as { id?: string; runId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null }> };
430
+ const data = JSON.parse(raw) as { id?: string; runId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; processSignal?: string | null; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; interrupted?: boolean; processSignal?: string | null }> };
419
431
  if (params.view === "transcript") {
420
432
  try {
421
433
  return { content: [{ type: "text", text: formatAsyncResultTranscript(data, resultPath, { index: params.index, lines: params.lines }) }], details: { mode: "single", results: [] } };
@@ -424,7 +436,21 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
424
436
  return { content: [{ type: "text", text: message }], isError: true, details: { mode: "single", results: [] } };
425
437
  }
426
438
  }
427
- const status = data.state === "stopped" ? "stopped" : data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
439
+ const childStatuses = Array.isArray(data.results)
440
+ ? data.results.map((child) => resolveSubagentResultStatus({
441
+ success: child.success,
442
+ state: child.state,
443
+ interrupted: child.interrupted,
444
+ timedOut: child.timedOut,
445
+ stopped: child.stopped,
446
+ turnBudgetExceeded: child.turnBudgetExceeded,
447
+ processSignal: child.processSignal,
448
+ exitCode: typeof child.exitCode === "number" ? child.exitCode : undefined,
449
+ }))
450
+ : [];
451
+ const status = data.state === "stopped" || data.stopped === true || childStatuses.includes("stopped")
452
+ ? "stopped"
453
+ : data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
428
454
  const runId = data.runId ?? data.id ?? resolvedId;
429
455
  const lines = [`Run: ${runId}`, `State: ${status}`, `Result: ${resultPath}`];
430
456
  if (data.parallelHandoff?.path) lines.push(`Parallel handoff: ${data.parallelHandoff.path}`);