pi-subagents 0.66.0 → 0.67.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 (115) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +4 -3
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/reviewer.md +3 -2
  5. package/docs/agents.md +6 -3
  6. package/docs/configuration.md +7 -5
  7. package/docs/extension-api.md +33 -18
  8. package/docs/missions.md +8 -0
  9. package/docs/models.md +1 -1
  10. package/docs/observability.md +4 -4
  11. package/docs/standalone-background.md +49 -0
  12. package/docs/tool-reference.md +18 -8
  13. package/docs/watchdog.md +35 -4
  14. package/docs/workflows.md +26 -12
  15. package/inspector-runner.mjs +2 -2
  16. package/package.json +1 -1
  17. package/prompts/parallel-review.md +1 -1
  18. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  19. package/skills/pi-subagents/SKILL.md +14 -0
  20. package/skills/pi-subagents/references/execution-controls.md +7 -5
  21. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  22. package/src/agents/advertised-agent-prompt.ts +34 -3
  23. package/src/agents/agents.ts +6 -0
  24. package/src/agents/builtin-names.ts +1 -0
  25. package/src/api/delegation.ts +4 -0
  26. package/src/api/preflight.ts +76 -45
  27. package/src/api/shared-types.ts +2 -0
  28. package/src/extension/fanout-child.ts +63 -4
  29. package/src/extension/index.ts +20 -8
  30. package/src/extension/public-execution.ts +4 -2
  31. package/src/extension/rpc.ts +4 -0
  32. package/src/extension/schemas.ts +67 -78
  33. package/src/extension/tool-description.ts +29 -82
  34. package/src/inspectors/actions.ts +148 -0
  35. package/src/inspectors/ghostty/actions.ts +74 -0
  36. package/src/inspectors/ghostty/plugin.ts +17 -0
  37. package/src/inspectors/herdr/actions.ts +99 -179
  38. package/src/inspectors/herdr/plugin.ts +20 -0
  39. package/src/inspectors/herdr/project-panes.ts +1 -1
  40. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  41. package/src/inspectors/plugins.ts +8 -0
  42. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  43. package/src/inspectors/types.ts +51 -0
  44. package/src/intercom/intercom-bridge.ts +50 -8
  45. package/src/intercom/native-supervisor-channel.ts +22 -13
  46. package/src/runs/background/active-async-capacity.ts +4 -0
  47. package/src/runs/background/async-execution.ts +45 -56
  48. package/src/runs/background/async-resume.ts +5 -9
  49. package/src/runs/background/auto-drain.ts +6 -3
  50. package/src/runs/background/binary-bootstrap.ts +33 -0
  51. package/src/runs/background/fleet-view.ts +30 -2
  52. package/src/runs/background/notify.ts +31 -1
  53. package/src/runs/background/owned-process-tree.ts +29 -2
  54. package/src/runs/background/run-child-session.ts +61 -4
  55. package/src/runs/background/run-status.ts +3 -0
  56. package/src/runs/background/runner-aliases.ts +11 -3
  57. package/src/runs/background/runner-child-launch.ts +2 -0
  58. package/src/runs/background/runner-child-sessions.ts +5 -4
  59. package/src/runs/background/scheduled-runs.ts +40 -13
  60. package/src/runs/background/steering.ts +20 -2
  61. package/src/runs/background/subagent-runner.ts +47 -31
  62. package/src/runs/background/subagent-wait.ts +51 -8
  63. package/src/runs/background/wait-tool.ts +1 -1
  64. package/src/runs/foreground/async-steering-action.ts +18 -7
  65. package/src/runs/foreground/execution.ts +44 -31
  66. package/src/runs/foreground/prompt-audit.ts +3 -1
  67. package/src/runs/foreground/subagent-executor.ts +110 -100
  68. package/src/runs/foreground/workflow-detach-reconcile.ts +2 -0
  69. package/src/runs/foreground/workflow-foreground-steering.ts +2 -1
  70. package/src/runs/shared/acceptance.ts +5 -2
  71. package/src/runs/shared/async-status-projection.ts +4 -0
  72. package/src/runs/shared/capability-ceiling.ts +2 -0
  73. package/src/runs/shared/child-hooks.ts +25 -10
  74. package/src/runs/shared/child-launch.ts +12 -2
  75. package/src/runs/shared/child-lifecycle.ts +6 -3
  76. package/src/runs/shared/child-runtime-config.ts +3 -1
  77. package/src/runs/shared/child-session.ts +33 -2
  78. package/src/runs/shared/child-tool-plan.ts +122 -3
  79. package/src/runs/shared/completion-guard.ts +5 -3
  80. package/src/runs/shared/effective-system-prompt.ts +33 -0
  81. package/src/runs/shared/external-cli-runner.ts +9 -7
  82. package/src/runs/shared/llm-intent-arbiter.ts +12 -3
  83. package/src/runs/shared/model-fallback.ts +2 -0
  84. package/src/runs/shared/orca-progress-tabs.ts +1 -1
  85. package/src/runs/shared/pi-spawn.ts +10 -0
  86. package/src/runs/shared/subagent-prompt-runtime.ts +9 -3
  87. package/src/runs/shared/task-intent.ts +46 -13
  88. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  89. package/src/runs/shared/worktree.ts +42 -12
  90. package/src/shared/fork-context.ts +15 -72
  91. package/src/shared/launch-contract.ts +65 -2
  92. package/src/shared/opencode-session-headers.ts +30 -0
  93. package/src/shared/types.ts +4 -1
  94. package/src/slash/delegation-adapters.ts +3 -1
  95. package/src/slash/delegation-request.ts +14 -0
  96. package/src/slash/slash-commands.ts +2 -1
  97. package/src/slash/subagents-admin.ts +11 -4
  98. package/src/tui/fleet-status.ts +164 -19
  99. package/src/tui/fleet.ts +16 -14
  100. package/src/tui/render.ts +149 -28
  101. package/src/watchdog/child-status.ts +8 -0
  102. package/src/watchdog/model-selection.ts +20 -0
  103. package/src/watchdog/permission-arbiter.ts +3 -1
  104. package/src/watchdog/register-child.ts +1 -0
  105. package/src/watchdog/register-main.ts +31 -27
  106. package/src/watchdog/review.ts +132 -67
  107. package/src/watchdog/runtime.ts +82 -20
  108. package/src/watchdog/scope.ts +1 -1
  109. package/src/watchdog/settings.ts +9 -3
  110. package/src/watchdog/tool-actions.ts +13 -12
  111. package/src/watchdog/turn-delta.ts +23 -0
  112. package/src/watchdog/types.ts +4 -0
  113. package/src/workflows/scripted-workflow.ts +237 -7
  114. package/src/workflows/workflow-checklist.ts +2 -2
  115. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -54,6 +54,16 @@ export interface SubagentNotifyDetails {
54
54
  watchdogBlockers?: SubagentNotifyWatchdogBlocker[];
55
55
  }
56
56
 
57
+ export interface IncrementalChildCompletion {
58
+ workflowRunId: string;
59
+ childKey: string;
60
+ childRunId?: string;
61
+ outcome: "completed" | "failed" | "paused" | "stopped";
62
+ outputReference?: string;
63
+ error?: string;
64
+ workflowRunning: boolean;
65
+ }
66
+
57
67
  export interface CompletionNotification {
58
68
  [key: string]: unknown;
59
69
  id?: string | null;
@@ -308,6 +318,26 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
308
318
  .join("\n");
309
319
  }
310
320
 
321
+ export function scheduledCompletionTriggersTurn(origin: ScheduleOrigin | undefined, outcome: string): boolean {
322
+ return !(origin?.quiet === true && outcome === "completed");
323
+ }
324
+
325
+ export function formatIncrementalChildCompletion(child: IncrementalChildCompletion): string {
326
+ const statusText = child.outcome === "completed" ? "completed"
327
+ : child.outcome === "failed" ? "failed"
328
+ : child.outcome === "paused" ? "paused (needs attention)"
329
+ : "stopped";
330
+ const workflowStatus = child.workflowRunning ? "workflow still running" : "workflow finished";
331
+ return [
332
+ `Workflow child ${statusText}: **${child.childKey}**`,
333
+ `Workflow run: ${child.workflowRunId}`,
334
+ ...(child.childRunId ? [`Child run: ${child.childRunId}`] : []),
335
+ ...(child.outputReference ? [`Output: ${child.outputReference}`] : []),
336
+ ...(child.error ? [`Error: ${child.error}`] : []),
337
+ `Status: ${workflowStatus}`,
338
+ ].join("\n");
339
+ }
340
+
311
341
  export function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | undefined {
312
342
  const lines = content.split("\n");
313
343
  const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
@@ -675,7 +705,7 @@ export default function registerSubagentNotify(
675
705
  details,
676
706
  sessionId: result.sessionId,
677
707
  completionOwnerId: result.completionOwnerId,
678
- triggerTurn: result.triggerTurn !== false,
708
+ triggerTurn: result.triggerTurn !== false && scheduledCompletionTriggersTurn(result.scheduleOrigin, details.status),
679
709
  resolve,
680
710
  };
681
711
  if (notificationDebug.enabled) item.trace = traceIdentity(result);
@@ -35,6 +35,23 @@ function activeProcessGroupMembers(processGroupId: number): number[] | { diagnos
35
35
  return members;
36
36
  }
37
37
 
38
+ function knownDetachedDescendants(processGroupId: number): number[] {
39
+ const result = spawnSync("ps", ["-axo", "pid=,ppid=,pgid=,stat="], { encoding: "utf-8" });
40
+ if (result.error || result.status !== 0) return [];
41
+ const owned = new Set<number>();
42
+ const outside: { pid: number; ppid: number }[] = [];
43
+ for (const line of result.stdout.split("\n")) {
44
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/.exec(line);
45
+ if (!match || match[4]!.startsWith("Z")) continue;
46
+ const pid = Number(match[1]);
47
+ const ppid = Number(match[2]);
48
+ const pgid = Number(match[3]);
49
+ if (pgid === processGroupId) owned.add(pid);
50
+ else outside.push({ pid, ppid });
51
+ }
52
+ return outside.filter((row) => owned.has(row.ppid)).map((row) => row.pid);
53
+ }
54
+
38
55
  async function waitUntilGroupTerminal(
39
56
  processGroupId: number,
40
57
  timeoutMs: number,
@@ -56,6 +73,15 @@ function observed(processGroupId: number): ProcessTreeTerminal {
56
73
  return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() };
57
74
  }
58
75
 
76
+ function observedUnlessLiveDetached(processGroupId: number, detached: readonly number[]): ProcessTreeTerminal {
77
+ const live = detached.filter((pid) => {
78
+ const result = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf-8" });
79
+ return result.status === 0 && Boolean(result.stdout.trim()) && !result.stdout.trim().startsWith("Z");
80
+ });
81
+ if (live.length === 0) return observed(processGroupId);
82
+ return { state: "unknown", reason: "verification-failed", diagnostic: `Owned detached descendant(s) still active: ${live.join(", ")}.` };
83
+ }
84
+
59
85
  /** Owns one writer process group and arbitrates its cleanup exactly once. */
60
86
  export interface OwnedProcessTreeController {
61
87
  terminate(): Promise<ProcessTreeTerminal>;
@@ -77,12 +103,13 @@ export function createOwnedProcessTreeController(
77
103
  signalProcess(target, "SIGTERM");
78
104
  return { state: "unknown", reason: "unsupported-platform" };
79
105
  }
106
+ const detached = knownDetachedDescendants(pid);
80
107
  const term = signalProcess(target, "SIGTERM");
81
108
  if (term !== "sent" && term !== "absent") {
82
109
  return { state: "unknown", reason: "signal-failed", diagnostic: term.diagnostic };
83
110
  }
84
111
  const termExit = await waitUntilGroupTerminal(pid, options.termGraceMs ?? DEFAULT_TERM_GRACE_MS);
85
- if (termExit === false) return observed(pid);
112
+ if (termExit === false) return observedUnlessLiveDetached(pid, detached);
86
113
 
87
114
  const kill = signalProcess(target, "SIGKILL");
88
115
  if (kill !== "sent" && kill !== "absent") {
@@ -95,7 +122,7 @@ export function createOwnedProcessTreeController(
95
122
  if (killExit !== false) {
96
123
  return { state: "unknown", reason: "verification-failed", diagnostic: killExit.diagnostic };
97
124
  }
98
- return observed(pid);
125
+ return observedUnlessLiveDetached(pid, detached);
99
126
  })();
100
127
  return termination;
101
128
  };
@@ -23,10 +23,11 @@ import { formatSubagentModelVerificationError } from "../shared/model-fallback.t
23
23
  import { isMutatingTool, resolveCurrentPath } from "../shared/long-running-guard.ts";
24
24
  import { effectiveToolTimeoutMs, formatToolTimeoutMessage, toolTimeoutCallKey } from "../shared/tool-timeout.ts";
25
25
  import { createReportedChildSessionInput, type InProcessChildLaunch } from "../shared/child-launch.ts";
26
- import { projectChildSessionEventForJson, type ChildSession, type ChildSessionEvent, type ChildSessionFactory } from "../shared/child-session.ts";
26
+ import { childSessionHasQueuedMessages, projectChildSessionEventForJson, type ChildSession, type ChildSessionEvent, type ChildSessionFactory } from "../shared/child-session.ts";
27
27
  import { formatSteerMessage } from "../shared/subagent-prompt-runtime.ts";
28
28
  import { getReadonlySessionEvidence, requestReadonlySessionEvidence, type SettledReadonlyEvidence } from "../shared/readonly-session-evidence.ts";
29
29
  import type { SteerDeliveryStatus, SteerRequest } from "./control-channel.ts";
30
+ import { takeMatchingAcceptedSteer, unconsumedSteerReason } from "./steering.ts";
30
31
 
31
32
  export interface ChildEventContext {
32
33
  runId: string;
@@ -84,6 +85,8 @@ export interface RunChildSessionInput {
84
85
  registerTimeout?: (interrupt: (() => void) | undefined) => void;
85
86
  registerStop?: (stop: (() => void) | undefined) => void;
86
87
  registerSteer?: (steer: StepSteerHandler | undefined) => void;
88
+ /** Consumption (or unconsumed settlement) after the child accepted a steer or follow-up. */
89
+ onSteerOutcome?: (request: SteerRequest, delivery: SteerDelivery) => void;
87
90
  /** Receives the sink the child's watchdog hook reports status through; the launch's `watchdogStatus` must forward to it. */
88
91
  registerWatchdogStatus?: (sink: ((event: ChildWatchdogStatusEvent) => void) | undefined) => void;
89
92
  timeoutMessage?: string;
@@ -187,12 +190,14 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
187
190
  let currentPath: string | undefined;
188
191
  let toolCount = 0;
189
192
  let session: ChildSession | undefined;
193
+ const acceptedSteers: Array<{ request: SteerRequest; text: string }> = [];
190
194
  let unsubscribe: (() => void) | undefined;
191
195
  let settled = false;
192
196
  let promptSettled = false;
193
197
  let forcedTermination = false;
194
198
  let cleanTerminalAssistantStopReceived = false;
195
199
  let agentSettledReceived = false;
200
+ let queuedDrainHold = false;
196
201
  let compactionStartedReceived = false;
197
202
  let afterCompactionSettlement = false;
198
203
  let finalDrainTimer: NodeJS.Timeout | undefined;
@@ -311,14 +316,28 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
311
316
  }
312
317
  // If the child emits its terminal event but its run never settles (a hook
313
318
  // is stuck), abort it after a short grace period and then finish without it.
319
+ const observeQueuedDrainHold = (): boolean => {
320
+ if (childSessionHasQueuedMessages(session)) queuedDrainHold = true;
321
+ return queuedDrainHold;
322
+ };
314
323
  function startFinalDrain(): void {
315
324
  if (childWatchdogIsActive(childWatchdogState)) {
316
325
  armWatchdogTail();
317
326
  return;
318
327
  }
328
+ if (promptSettled || finalDrainTimer || settled) return;
329
+ if (observeQueuedDrainHold()) return;
330
+ armFinalDrainTimer();
331
+ }
332
+ function armFinalDrainTimer(): void {
319
333
  if (promptSettled || finalDrainTimer || settled) return;
320
334
  finalDrainTimer = setTimeout(() => {
321
335
  if (settled || promptSettled) return;
336
+ if (input.launch.capture.finalDrainHeld() || observeQueuedDrainHold()) {
337
+ finalDrainTimer = undefined;
338
+ armFinalDrainTimer();
339
+ return;
340
+ }
322
341
  forcedTermination = true;
323
342
  if (!cleanTerminalAssistantStopReceived && !agentSettledReceived && !error && !assistantError) {
324
343
  error = `Subagent session did not settle within ${FINAL_STOP_GRACE_MS}ms after its terminal event. Aborting it.`;
@@ -334,6 +353,8 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
334
353
  }
335
354
  const applyChildLifecycle = (action: ChildLifecycleAction): void => {
336
355
  if (action === "cancel-drain") {
356
+ cleanTerminalAssistantStopReceived = false;
357
+ agentSettledReceived = false;
337
358
  clearFinalDrainTimers();
338
359
  clearWatchdogTailTimer();
339
360
  return;
@@ -401,6 +422,9 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
401
422
  compactionStartedReceived = false;
402
423
  afterCompactionSettlement = false;
403
424
  }
425
+ if (event.type === "turn_start" || event.type === "agent_start" || event.type === "auto_retry_start") {
426
+ queuedDrainHold = false;
427
+ }
404
428
  if (event.type === "agent_start" || event.type === "auto_retry_start") {
405
429
  compactionStartedReceived = false;
406
430
  afterCompactionSettlement = false;
@@ -470,6 +494,16 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
470
494
  messages.push(event.message);
471
495
  const text = extractTextFromContent(event.message.content);
472
496
  if (text) writeOutputText(text);
497
+ if (event.type === "message_end" && event.message.role === "user" && text) {
498
+ const matched = takeMatchingAcceptedSteer(acceptedSteers, text);
499
+ if (matched) {
500
+ input.onSteerOutcome?.(matched.request, {
501
+ state: "delivered",
502
+ deliveryStatus: "delivered",
503
+ message: "Child consumed the steering input.",
504
+ });
505
+ }
506
+ }
473
507
 
474
508
  if (input.childWatchdog && event.type === "message_end") {
475
509
  const next = applyChildWatchdogMessage(childWatchdogState, event.message);
@@ -529,9 +563,15 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
529
563
  };
530
564
 
531
565
  /** The child run ended (or was forced to end); fold in the outcome once the child's shutdown work is done. */
566
+ const failUnconsumedSteers = (): void => {
567
+ for (const entry of acceptedSteers.splice(0)) {
568
+ input.onSteerOutcome?.(entry.request, { state: "failed", message: unconsumedSteerReason(entry.request.mode) });
569
+ }
570
+ };
532
571
  const settle = (promptError: unknown, forced = false): void => {
533
572
  if (settled) return;
534
573
  settled = true;
574
+ failUnconsumedSteers();
535
575
  const closed = finish();
536
576
  const finalOutput = getFinalOutput(messages);
537
577
  let finalError = error ?? assistantError;
@@ -611,6 +651,16 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
611
651
  return;
612
652
  }
613
653
  session = created;
654
+ const steer = created.steer.bind(created);
655
+ const followUp = created.followUp.bind(created);
656
+ created.steer = async (text) => {
657
+ if (cleanTerminalAssistantStopReceived || agentSettledReceived) queuedDrainHold = true;
658
+ return steer(text);
659
+ };
660
+ created.followUp = async (text) => {
661
+ if (cleanTerminalAssistantStopReceived || agentSettledReceived) queuedDrainHold = true;
662
+ return followUp(text);
663
+ };
614
664
  checkContinuation();
615
665
  if (continuation && (created.modelId !== continuation.modelId || input.launch.capture.completionIntentContext?.()?.model?.api !== continuation.expected.api)) throw new Error("Read-only continuation model changed");
616
666
  unsubscribe = created.subscribe(processEvent);
@@ -618,15 +668,22 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
618
668
  input.registerSteer?.(async (request) => {
619
669
  const text = formatSteerMessage(request);
620
670
  const followUp = request.mode === "follow_up";
671
+ const accepted = { request, text };
672
+ acceptedSteers.push(accepted);
673
+ const queued: SteerDelivery = {
674
+ state: "queued",
675
+ deliveryStatus: "queued",
676
+ message: followUp ? "Pi queued the follow-up input." : "Pi accepted the steering input.",
677
+ };
621
678
  try {
622
679
  if (followUp) await created.followUp(text);
623
680
  else await created.steer(text);
624
681
  } catch (steerError) {
682
+ const index = acceptedSteers.indexOf(accepted);
683
+ if (index >= 0) acceptedSteers.splice(index, 1);
625
684
  return { state: "failed", message: steerError instanceof Error ? steerError.message : String(steerError) };
626
685
  }
627
- return followUp
628
- ? { state: "queued", deliveryStatus: "queued", message: "Pi queued the follow-up input." }
629
- : { state: "delivered", deliveryStatus: "delivered", message: "Pi accepted the steering input." };
686
+ return queued;
630
687
  });
631
688
  if (interrupted || timedOut || stopped) abortChild();
632
689
  checkContinuation();
@@ -32,6 +32,7 @@ import { getExternalJobProvider } from "../../api/external-job-provider.ts";
32
32
  import { formatTimeoutRecoveryLines } from "../shared/mutation-evidence.ts";
33
33
  import { formatWorkflowChecklistText, projectWorkflowChecklist } from "../../workflows/workflow-checklist.ts";
34
34
  import { validHostStepNodes } from "../shared/host-step-status.ts";
35
+ import { workflowAsyncChildSteeringGuidance } from "../shared/workflow-async-child-guidance.ts";
35
36
 
36
37
  interface RunStatusParams {
37
38
  action?: string;
@@ -624,6 +625,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
624
625
  const display = runStatusStepDisplayName(step);
625
626
  const phase = step.phase ? `[${step.phase}] ` : "";
626
627
  lines.push(`${stepLineLabel(status, index)}: ${phase}${display} ${step.status}${modelText}${stepActivityText ? `, ${stepActivityText}` : ""}${steeringSuffix}${acceptanceText}${budgetText}${errorText}`);
628
+ if (status.mode === "workflow" && step.runId) lines.push(` Child run: ${step.runId}`);
627
629
  const structuredOutputPreview = step.structuredOutput === undefined ? undefined : formatWorkflowJsonPreview(step.structuredOutput, 4_000);
628
630
  if (structuredOutputPreview !== undefined) lines.push(` Structured output: ${structuredOutputPreview}`);
629
631
  if (step.structuredOutputPath) lines.push(` Structured output path: ${step.structuredOutputPath}`);
@@ -688,6 +690,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
688
690
  lines.push(`Steer live foreground child: subagent({ action: "steer", id: "${control.runId}", index: ${index}, message: "..." })`);
689
691
  }
690
692
  }
693
+ lines.push(...workflowAsyncChildSteeringGuidance(status, deps.state));
691
694
  }
692
695
  if (nestedWarning) lines.push(`Warning: ${nestedWarning}`);
693
696
  if (status.workflowReceiptPath) lines.push(`Workflow receipt: ${status.workflowReceiptPath}`);
@@ -21,8 +21,6 @@ export const HOST_PEER_ALIASES: ReadonlyArray<{ specifier: string; pkg: string;
21
21
  { specifier: "@earendil-works/pi-coding-agent", pkg: "@earendil-works/pi-coding-agent", subpath: "." },
22
22
  { specifier: "@earendil-works/pi-agent-core", pkg: "@earendil-works/pi-agent-core", subpath: "." },
23
23
  { specifier: "@earendil-works/pi-agent-core/node", pkg: "@earendil-works/pi-agent-core", subpath: "./node" },
24
- { specifier: "@earendil-works/chord", pkg: "@earendil-works/chord", subpath: "." },
25
- { specifier: "@earendil-works/chord/context", pkg: "@earendil-works/chord", subpath: "./context" },
26
24
  { specifier: "@earendil-works/pi-tui", pkg: "@earendil-works/pi-tui", subpath: "." },
27
25
  { specifier: "@earendil-works/pi-ai", pkg: "@earendil-works/pi-ai", subpath: "./compat" },
28
26
  { specifier: "@earendil-works/pi-ai/compat", pkg: "@earendil-works/pi-ai", subpath: "./compat" },
@@ -33,6 +31,12 @@ export const HOST_PEER_ALIASES: ReadonlyArray<{ specifier: string; pkg: string;
33
31
  { specifier: "typebox/value", pkg: "typebox", subpath: "./value" },
34
32
  ];
35
33
 
34
+ /** Public Pi manifests introduce chord in 0.85.0 (absent through 0.84.4). */
35
+ const CHORD_PEER_ALIASES = [
36
+ { specifier: "@earendil-works/chord", pkg: "@earendil-works/chord", subpath: "." },
37
+ { specifier: "@earendil-works/chord/context", pkg: "@earendil-works/chord", subpath: "./context" },
38
+ ];
39
+
36
40
  /** Experimental dependencies accidentally published in exactly Pi 0.85.0. */
37
41
  const PI0850_PEER_ALIASES = [
38
42
  { specifier: "@earendil-works/pi-server", pkg: "@earendil-works/pi-server", subpath: "." },
@@ -137,7 +141,11 @@ export function resolveHostPeerAliases(
137
141
  const supplemental: string[] = [];
138
142
  const hostManifest = readManifest(piPackageRoot);
139
143
  const isPi0850 = hostManifest?.version === "0.85.0";
140
- const required = isPi0850 ? [...HOST_PEER_ALIASES, ...PI0850_PEER_ALIASES] : HOST_PEER_ALIASES;
144
+ // Only known stable pre-chord versions may omit it. Unknown/prerelease
145
+ // hosts retain the required aliases, rather than hiding a broken install.
146
+ const stableVersion = typeof hostManifest?.version === "string" ? /^0\.(\d+)\.\d+$/.exec(hostManifest.version) : null;
147
+ const isPreChord = stableVersion !== null && Number(stableVersion[1]) < 85;
148
+ const required = [...HOST_PEER_ALIASES, ...(isPreChord ? [] : CHORD_PEER_ALIASES), ...(isPi0850 ? PI0850_PEER_ALIASES : [])];
141
149
  for (const { specifier, pkg, subpath } of required) {
142
150
  const packageDir = findPeerPackageDir(piPackageRoot, pkg, hostManifest?.name);
143
151
  let target = packageDir ? resolvePackageSubpath(packageDir, subpath) : undefined;
@@ -18,6 +18,7 @@ export interface RunnerChildLaunchContext {
18
18
  runFanoutBudget?: BuildInProcessChildLaunchInput["runFanoutBudget"];
19
19
  capabilityCeiling?: BuildInProcessChildLaunchInput["capabilityCeiling"];
20
20
  inheritedChildRuntime?: InheritedChildRuntime;
21
+ hostAvailableBuiltins?: readonly string[];
21
22
  }
22
23
 
23
24
  export function buildRunnerChildLaunch(step: RunnerSubagentStep, ctx: RunnerChildLaunchContext, attempt: {
@@ -81,6 +82,7 @@ export function buildRunnerChildLaunch(step: RunnerSubagentStep, ctx: RunnerChil
81
82
  thinkingCeiling: step.thinkingCeiling,
82
83
  maxSubagentDepth: step.maxSubagentDepth,
83
84
  inherited: ctx.inheritedChildRuntime,
85
+ hostAvailableBuiltins: ctx.hostAvailableBuiltins,
84
86
  host: "runner",
85
87
  });
86
88
  }
@@ -5,11 +5,12 @@
5
5
  * the parent aliases that specifier (and the other host peer packages) to the
6
6
  * installed pi package through `JITI_ALIAS` when it spawns the runner, see
7
7
  * `runner-aliases.ts`. Tests replace the factory with a scripted one by
8
- * naming a module in the runner config.
8
+ * naming a module in the runner config. The binary bootstrap supplies its
9
+ * embedded SDK through the default factory's existing loader option instead.
9
10
  */
10
11
  import * as path from "node:path";
11
12
  import { pathToFileURL } from "node:url";
12
- import { createDefaultChildSessionFactory, type ChildSessionFactory } from "../shared/child-session.ts";
13
+ import { createDefaultChildSessionFactory, type ChildSessionFactory, type DefaultChildSessionFactoryOptions } from "../shared/child-session.ts";
13
14
 
14
15
  export interface RunnerChildSessionConfig {
15
16
  /** Test seam: module whose default export is a `ChildSessionFactory`, or a function returning one. */
@@ -20,8 +21,8 @@ function isChildSessionFactory(value: unknown): value is ChildSessionFactory {
20
21
  return Boolean(value) && typeof value === "object" && typeof (value as ChildSessionFactory).create === "function" && typeof (value as ChildSessionFactory).dispose === "function";
21
22
  }
22
23
 
23
- export async function loadRunnerChildSessionFactory(config: RunnerChildSessionConfig): Promise<ChildSessionFactory> {
24
- if (!config.childSessionFactoryModule) return createDefaultChildSessionFactory();
24
+ export async function loadRunnerChildSessionFactory(config: RunnerChildSessionConfig, options?: DefaultChildSessionFactoryOptions): Promise<ChildSessionFactory> {
25
+ if (!config.childSessionFactoryModule) return createDefaultChildSessionFactory(options);
25
26
  const loaded = await import(pathToFileURL(path.resolve(config.childSessionFactoryModule)).href) as { default?: unknown };
26
27
  const candidate = typeof loaded.default === "function" ? (loaded.default as () => unknown)() : loaded.default;
27
28
  if (!isChildSessionFactory(candidate)) {
@@ -54,6 +54,7 @@ export interface ScheduleRecord {
54
54
  timeoutMs?: number;
55
55
  paused: boolean;
56
56
  sessionOnly?: boolean;
57
+ quiet?: boolean;
57
58
  ownerSessionFile?: string;
58
59
  createdAt: string;
59
60
  updatedAt: string;
@@ -308,6 +309,7 @@ function parseSchedule(value: unknown, file: string): ScheduleRecord {
308
309
  if (typeof record.trigger.every !== "string" || typeof record.trigger.everyMs !== "number" || typeof record.trigger.anchorAt !== "string" || typeof record.trigger.nextRunAt !== "string") throw new Error(`Schedule record '${file}' has an invalid interval trigger.`);
309
310
  } else throw new Error(`Schedule record '${file}' has an unsupported trigger.`);
310
311
  if (record.sessionOnly !== undefined && typeof record.sessionOnly !== "boolean") throw new Error(`Schedule record '${file}' has invalid sessionOnly.`);
312
+ if (record.quiet !== undefined && typeof record.quiet !== "boolean") throw new Error(`Schedule record '${file}' has invalid quiet.`);
311
313
  if (record.sessionOnly === true && (typeof record.ownerSessionFile !== "string" || !record.ownerSessionFile.trim())) throw new Error(`Schedule record '${file}' is session-only but has no owner session file.`);
312
314
  return { ...record, target: parseScheduleTarget(record.target, file) } as ScheduleRecord;
313
315
  }
@@ -448,7 +450,7 @@ function sanitizeTarget(params: SubagentParamsLike): { target?: ScheduleTarget;
448
450
  return { target: { workflowScript: params.workflowScript.trim(), ...(baseRef === undefined ? {} : { baseRef }) } };
449
451
  }
450
452
 
451
- function executionParams(schedule: ScheduleRecord): SubagentParamsLike {
453
+ function executionParams(schedule: ScheduleRecord, quiet = false): SubagentParamsLike {
452
454
  return {
453
455
  ...schedule.target,
454
456
  async: true,
@@ -456,7 +458,7 @@ function executionParams(schedule: ScheduleRecord): SubagentParamsLike {
456
458
  cwd: schedule.cwd,
457
459
  mission: false,
458
460
  // Scheduled fires have no operator watching, so completions must name the origin.
459
- scheduleOrigin: { id: schedule.id, ...(schedule.name ? { name: schedule.name } : {}) },
461
+ scheduleOrigin: { id: schedule.id, ...(schedule.name ? { name: schedule.name } : {}), ...(quiet ? { quiet: true } : {}) },
460
462
  ...(schedule.timeoutMs === undefined ? {} : { timeoutMs: schedule.timeoutMs }),
461
463
  };
462
464
  }
@@ -612,6 +614,8 @@ export class ScheduledRunManager {
612
614
  if (params.catchUp !== undefined && params.catchUp !== "none" && params.catchUp !== "latest") return textResult("catchUp must be 'none' or 'latest'.", undefined, undefined, true);
613
615
  if (params.missionId !== undefined || params.mission !== undefined || params.missionUpdate !== undefined || params.missionStatus !== undefined || params.missionScope !== undefined) return textResult("Mission attachment is deferred from this first schedule slice.", undefined, undefined, true);
614
616
  if (params.on !== undefined || params.timezone !== undefined || every === "day" || every === "week" || every === "month" || every === "year") return textResult("Calendar schedules are deferred from this first safe slice. Use a fixed interval such as every:'24h' or every:'7d'.", undefined, undefined, true);
617
+ if (params.quiet !== undefined && typeof params.quiet !== "boolean") return textResult("quiet must be a boolean.", undefined, undefined, true);
618
+ if (at && params.quiet === true) return textResult("quiet is only supported for recurring schedules.", undefined, undefined, true);
615
619
  const sessionOnly = params.sessionOnly === true;
616
620
  if (sessionOnly && params.cwd !== undefined && !samePath(params.cwd, ctx.cwd)) return textResult("sessionOnly schedules cannot use an explicit cross-project cwd.", undefined, undefined, true);
617
621
  const ownerSessionFile = sessionOnly ? ctx.sessionManager.getSessionFile() : undefined;
@@ -644,13 +648,14 @@ export class ScheduledRunManager {
644
648
  ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }),
645
649
  paused: false,
646
650
  ...(sessionOnly ? { sessionOnly: true, ownerSessionFile: path.resolve(ownerSessionFile!) } : {}),
651
+ ...(trigger.kind === "interval" && params.quiet === true ? { quiet: true } : {}),
647
652
  createdAt: timestamp(now),
648
653
  updatedAt: timestamp(now),
649
654
  };
650
655
  store.write(schedule);
651
656
  store.appendEvent(schedule, "schedule.created");
652
657
  this.arm(schedule, store);
653
- return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${at ? `at ${at}` : `every ${every}`}\nSession only: ${schedule.sessionOnly === true ? "yes" : "no"}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}`, [schedule]);
658
+ return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${at ? `at ${at}` : `every ${every}`}\nSession only: ${schedule.sessionOnly === true ? "yes" : "no"}\nQuiet: ${schedule.quiet === true ? "yes" : "no"}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}`, [schedule]);
654
659
  }
655
660
 
656
661
  private list(): AgentToolResult<Details> {
@@ -661,7 +666,7 @@ export class ScheduledRunManager {
661
666
 
662
667
  private show(params: SubagentParamsLike): AgentToolResult<Details> {
663
668
  const schedule = this.resolve(params);
664
- return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Session only: ${schedule.sessionOnly === true ? "yes" : "no"}`, `Target: ${targetLabel(schedule.target)}`, `CWD: ${shortenPath(schedule.cwd)}`, `Next: ${schedule.trigger.nextRunAt ?? "none"}`, `Catch up: ${schedule.catchUp}`, schedule.activeRunId ? `Active run: ${schedule.activeRunId}` : undefined].filter(Boolean).join("\n"), [schedule]);
669
+ return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Session only: ${schedule.sessionOnly === true ? "yes" : "no"}`, `Quiet: ${schedule.quiet === true ? "yes" : "no"}`, `Target: ${targetLabel(schedule.target)}`, `CWD: ${shortenPath(schedule.cwd)}`, `Next: ${schedule.trigger.nextRunAt ?? "none"}`, `Catch up: ${schedule.catchUp}`, schedule.activeRunId ? `Active run: ${schedule.activeRunId}` : undefined].filter(Boolean).join("\n"), [schedule]);
665
670
  }
666
671
 
667
672
  private history(params: SubagentParamsLike): AgentToolResult<Details> {
@@ -689,7 +694,18 @@ export class ScheduledRunManager {
689
694
  if (!scheduleBelongsToSession(schedule, context)) {
690
695
  return textResult(`Skipped schedule ${schedule.id}: current session is not its owner.`, [schedule]);
691
696
  }
692
- const run = await this.launch(store, schedule, this.now(), "manual", false);
697
+ if (params.quiet !== undefined && typeof params.quiet !== "boolean") return textResult("quiet must be a boolean.", undefined, undefined, true);
698
+ const run = await this.launch(store, schedule, this.now(), "manual", false, params.quiet === true);
699
+ const updated = store.get(schedule.id);
700
+ if (run.state === "running") {
701
+ const now = this.now();
702
+ if (updated.trigger.kind === "interval") updated.trigger.nextRunAt = timestamp(now + updated.trigger.everyMs);
703
+ else updated.trigger.nextRunAt = undefined;
704
+ updated.updatedAt = timestamp(now);
705
+ store.write(updated);
706
+ store.appendEvent(updated, "schedule.manual_satisfied");
707
+ this.arm(updated, store);
708
+ }
693
709
  return textResult(`Manual schedule run ${run.id}: ${run.state}${run.asyncId ? ` (async ${run.asyncId})` : ""}.`, [store.get(schedule.id)], [run], run.state === "failed_launch");
694
710
  }
695
711
 
@@ -708,8 +724,16 @@ export class ScheduledRunManager {
708
724
 
709
725
  private remove(params: SubagentParamsLike): AgentToolResult<Details> {
710
726
  const schedule = this.resolve(params);
711
- if (schedule.activeRunId) return textResult(`Schedule ${schedule.id} has active run ${schedule.activeRunId}; stop that run before deleting the schedule.`, [schedule], undefined, true);
712
727
  const store = this.requireStore();
728
+ if (schedule.activeRunId) {
729
+ const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
730
+ let terminal = false;
731
+ if (run?.scheduleId === schedule.id && run.state === "running" && run.asyncId && run.asyncDir) {
732
+ const status = readJson(path.join(run.asyncDir, "status.json"), "async status") as Partial<AsyncStatus>;
733
+ terminal = status.runId === run.asyncId && typeof status.state === "string" && ["complete", "failed", "stopped", "rejected"].includes(status.state);
734
+ }
735
+ if (!terminal) return textResult(`Schedule ${schedule.id} has active run ${schedule.activeRunId}; stop that run before deleting the schedule.`, [schedule], undefined, true);
736
+ }
713
737
  this.clearTimer(store, schedule.id);
714
738
  store.appendEvent(schedule, "schedule.deleted");
715
739
  store.delete(schedule.id);
@@ -807,8 +831,9 @@ export class ScheduledRunManager {
807
831
  await this.launch(store, schedule, planned, "timer", true);
808
832
  }
809
833
 
810
- private async launch(store: ScheduleStore, schedule: ScheduleRecord, planned: number, dueReason: ScheduleRunRecord["dueReason"], advance: boolean): Promise<ScheduleRunRecord> {
834
+ private async launch(store: ScheduleStore, schedule: ScheduleRecord, planned: number, dueReason: ScheduleRunRecord["dueReason"], advance: boolean, quiet?: boolean): Promise<ScheduleRunRecord> {
811
835
  const now = this.now();
836
+ const nextRunAtBeforeClaim = schedule.trigger.nextRunAt;
812
837
  const run: ScheduleRunRecord = { schemaVersion: 1, id: this.randomId(), scheduleId: schedule.id, plannedAt: timestamp(planned), dueReason, state: "running", startedAt: timestamp(now) };
813
838
  if (schedule.activeRunId) {
814
839
  run.state = "skipped";
@@ -849,7 +874,7 @@ export class ScheduledRunManager {
849
874
  store.write(schedule);
850
875
  store.writeRun(schedule, run, "schedule.run.started");
851
876
  try {
852
- const result = await this.deps.launch(executionParams(schedule), this.requireContext(store), new AbortController().signal);
877
+ const result = await this.deps.launch(executionParams(schedule, dueReason === "manual" ? quiet === true : schedule.quiet === true), this.requireContext(store), new AbortController().signal);
853
878
  const asyncId = result.details?.asyncId ?? result.details?.runId;
854
879
  if (result.isError || !asyncId) throw new Error(result.content.find((item) => item.type === "text")?.text ?? "Scheduled launch failed.");
855
880
  run.asyncId = asyncId;
@@ -862,12 +887,14 @@ export class ScheduledRunManager {
862
887
  run.state = "failed_launch";
863
888
  run.completedAt = timestamp(this.now());
864
889
  run.error = error instanceof Error ? error.message : String(error);
865
- schedule.activeRunId = undefined;
866
- schedule.updatedAt = timestamp(this.now());
867
- store.write(schedule);
868
- store.writeRun(schedule, run, "schedule.run.failed");
890
+ const latest = store.get(schedule.id);
891
+ latest.activeRunId = undefined;
892
+ if (!advance && nextRunAtBeforeClaim) latest.trigger.nextRunAt = nextRunAtBeforeClaim;
893
+ latest.updatedAt = timestamp(this.now());
894
+ store.write(latest);
895
+ store.writeRun(latest, run, "schedule.run.failed");
869
896
  fs.rmSync(lockPath, { force: true });
870
- this.arm(schedule, store);
897
+ this.arm(latest, store);
871
898
  return run;
872
899
  }
873
900
  }
@@ -14,6 +14,7 @@ import type {
14
14
  import { readStatus } from "../../shared/utils.ts";
15
15
  import { previewDisplayText } from "../../shared/display-text.ts";
16
16
  import { redactSecretValues } from "../shared/permissions.ts";
17
+ import type { SteerDeliveryMode } from "./control-channel.ts";
17
18
 
18
19
  export const MAX_STEERING_REQUESTS = 20;
19
20
  export const STEERING_MESSAGE_PREVIEW_LIMIT = 160;
@@ -22,6 +23,21 @@ export function steeringMessagePreview(message: string): string {
22
23
  return previewDisplayText(redactSecretValues(message), STEERING_MESSAGE_PREVIEW_LIMIT);
23
24
  }
24
25
 
26
+ /** FIFO match of one accepted steer to an emitted user message; equal text claims the oldest entry. */
27
+ export function takeMatchingAcceptedSteer<T extends { text: string }>(accepted: T[], messageText: string): T | undefined {
28
+ const index = accepted.findIndex((entry) => entry.text === messageText);
29
+ if (index < 0) return undefined;
30
+ const [entry] = accepted.splice(index, 1);
31
+ return entry;
32
+ }
33
+
34
+ /** Settlement reason for one accepted request that never got a matching user `message_end`. */
35
+ export function unconsumedSteerReason(mode?: SteerDeliveryMode): string {
36
+ return mode === "follow_up"
37
+ ? "child completed before consuming follow-up"
38
+ : "child completed before consuming steering";
39
+ }
40
+
25
41
  export function steeringReceipt(message: string, receipt: string): string {
26
42
  const preview = steeringMessagePreview(message);
27
43
  const longestFence = Math.max(2, ...[...preview.matchAll(/`{3,}/g)].map((match) => match[0]!.length));
@@ -93,7 +109,9 @@ export function updateSteeringTarget(
93
109
  if (fields.replacementRunId) target.replacementRunId = fields.replacementRunId;
94
110
  return target;
95
111
  }
96
- if ((target.state === "routed" || target.state === "queued") && state !== "routed" && state !== "queued") status.pending = Math.max(0, status.pending - 1);
112
+ const wasPending = target.state === "routed" || target.state === "queued";
113
+ const nowPending = state === "routed" || state === "queued";
114
+ if (wasPending && !nowPending) status.pending = Math.max(0, status.pending - 1);
97
115
  target.state = state;
98
116
  if (state === "routed") target.routedAt = now;
99
117
  if (state === "delivered") {
@@ -108,7 +126,7 @@ export function updateSteeringTarget(
108
126
  if (state === "recovered") target.recoveredAt = now;
109
127
  if (fields.reason) target.reason = fields.reason;
110
128
  if (fields.replacementRunId) target.replacementRunId = fields.replacementRunId;
111
- incrementStateCount(status, state);
129
+ if (!wasPending || !nowPending) incrementStateCount(status, state);
112
130
  return target;
113
131
  }
114
132