pi-subagents 0.54.0 → 0.55.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 (61) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/agents/reviewer.md +12 -2
  3. package/docs/agents.md +4 -4
  4. package/docs/configuration.md +4 -4
  5. package/docs/extension-api.md +14 -4
  6. package/docs/models.md +24 -3
  7. package/docs/observability.md +14 -3
  8. package/docs/tool-reference.md +21 -6
  9. package/docs/workflows.md +7 -1
  10. package/package.json +1 -1
  11. package/prompts/council.md +9 -0
  12. package/prompts/parallel-review.md +5 -1
  13. package/prompts/review-loop.md +9 -5
  14. package/skills/council-mode/SKILL.md +33 -10
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +16 -1
  16. package/skills/pi-subagents/references/execution-controls.md +11 -4
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +3 -3
  18. package/skills/pi-subagents/references/prompting-and-roles.md +8 -8
  19. package/src/agents/agent-management.ts +30 -9
  20. package/src/agents/agent-serializer.ts +2 -2
  21. package/src/agents/agents.ts +146 -41
  22. package/src/api/external-job-provider.ts +10 -1
  23. package/src/api/preflight.ts +26 -17
  24. package/src/api/project-panes.ts +2 -0
  25. package/src/extension/index.ts +20 -1
  26. package/src/extension/public-execution.ts +12 -9
  27. package/src/extension/rpc.ts +77 -3
  28. package/src/extension/schemas.ts +2 -1
  29. package/src/extension/tool-description.ts +9 -6
  30. package/src/inspectors/herdr/focus.ts +55 -0
  31. package/src/inspectors/herdr/project-panes.ts +228 -44
  32. package/src/integrations/herdr-status.ts +26 -4
  33. package/src/runs/background/async-execution.ts +103 -9
  34. package/src/runs/background/async-job-tracker.ts +33 -14
  35. package/src/runs/background/async-resume.ts +20 -2
  36. package/src/runs/background/async-status.ts +10 -0
  37. package/src/runs/background/control-channel.ts +98 -10
  38. package/src/runs/background/notify.ts +62 -1
  39. package/src/runs/background/run-status.ts +15 -1
  40. package/src/runs/background/subagent-runner.ts +226 -37
  41. package/src/runs/foreground/async-stop-action.ts +23 -2
  42. package/src/runs/foreground/execution.ts +81 -7
  43. package/src/runs/foreground/subagent-executor.ts +348 -40
  44. package/src/runs/foreground/workflow-detach-reconcile.ts +3 -0
  45. package/src/runs/shared/acceptance.ts +1 -0
  46. package/src/runs/shared/child-identity.ts +36 -0
  47. package/src/runs/shared/completion-guard.ts +50 -1
  48. package/src/runs/shared/external-job-bridge.ts +53 -37
  49. package/src/runs/shared/external-job-runner.ts +126 -23
  50. package/src/runs/shared/model-fallback.ts +10 -0
  51. package/src/runs/shared/orca-progress-tabs.ts +71 -9
  52. package/src/runs/shared/parallel-utils.ts +12 -0
  53. package/src/runs/shared/pi-args.ts +9 -12
  54. package/src/runs/shared/subagent-prompt-runtime.ts +3 -1
  55. package/src/runs/shared/tool-availability.ts +1 -3
  56. package/src/shared/launch-contract.ts +6 -5
  57. package/src/shared/thinking-ceiling.ts +52 -0
  58. package/src/shared/types.ts +59 -2
  59. package/src/tui/fleet-status.ts +66 -13
  60. package/src/tui/render.ts +5 -4
  61. package/src/workflows/scripted-workflow.ts +107 -10
@@ -11,7 +11,7 @@ import { createCapacityResilientJsonWriter } from "../../shared/capacity-resilie
11
11
  import { isStorageCapacityError } from "../../shared/file-system-retry.ts";
12
12
  import { updateActiveRunIndex } from "./active-run-index.ts";
13
13
  import { createChildTranscriptWriter, type ChildTranscriptWriter } from "../../shared/child-transcript.ts";
14
- import { closeSteerInbox, consumeInterruptRequest, consumeSteerRequests, deliverInterruptRequest, deliverStopRequest, deliverTimeoutRequest, enqueueStepSteer, steerAcksDir, steerCapabilityPath, stepSteerInboxDir, watchAsyncControlInbox, type SteerAck, type SteerCapability, type SteerRequest } from "./control-channel.ts";
14
+ import { closeSteerInbox, consumeInterruptRequest, consumeSteerRequests, deliverInterruptRequest, deliverStopRequest, deliverTimeoutRequest, enqueueStepSteer, steerAcksDir, steerCapabilityPath, stepSteerInboxDir, watchAsyncControlInbox, type SteerAck, type SteerCapability, type SteerRequest, type StopRequest } from "./control-channel.ts";
15
15
  import { appendJsonl as appendRawJsonl, formatOutputArtifactContent, getArtifactPaths, writeArtifact, writeMetadata } from "../../shared/artifacts.ts";
16
16
  import { PI_CODING_AGENT_PACKAGE, getPiSpawnCommand, resolveInstalledPiPackageRoot } from "../shared/pi-spawn.ts";
17
17
  import { captureSingleOutputSnapshot, extractChildWrittenOutput, finalizeSingleOutput, formatSavedOutputReference, injectOutputPathSystemPrompt, injectSingleOutputInstruction, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
@@ -48,6 +48,7 @@ import {
48
48
  type SteeringStatus,
49
49
  type SteeringTargetState,
50
50
  type SteeringTargetStatus,
51
+ type SubagentChildStatusEvent,
51
52
  DEFAULT_MAX_OUTPUT,
52
53
  type MaxOutputConfig,
53
54
  SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
@@ -85,7 +86,7 @@ import { readChildToolDiagnosticError } from "../shared/tool-availability.ts";
85
86
  import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
86
87
  import { claimRunFanoutBatch, getRunFanoutBudgetSnapshot } from "../shared/run-fanout-budget.ts";
87
88
  import { nestedSummaryFromAsyncStatus, projectNestedEvents, resolveNestedAsyncDir, writeNestedEvent } from "../shared/nested-events.ts";
88
- import { formatModelAttemptNote, isContextOverflow, isRetryableModelFailure, recordRetryableModelFailure } from "../shared/model-fallback.ts";
89
+ import { formatModelAttemptNote, formatSubagentModelVerificationError, isContextOverflow, isRetryableModelFailure, recordRetryableModelFailure } from "../shared/model-fallback.ts";
89
90
  import {
90
91
  SUBAGENT_STARTUP_RETRY_DELAYS_MS,
91
92
  formatSubagentExtensionConflictError,
@@ -99,7 +100,7 @@ import { createOwnedProcessTreeController, type OwnedProcessTreeController } fro
99
100
  import { createSteeringStatus, recordSteeringRequest, steeringStatus, terminalSteeringNoticeState, updateSteeringTarget } from "./steering.ts";
100
101
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
101
102
  import { PROMPT_REDACTED, detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput, hasEmptyTerminalAssistantResponse, readStatus } from "../../shared/utils.ts";
102
- import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
103
+ import { evaluateCompletionMutationGuard, validateImplementationToolContract } from "../shared/completion-guard.ts";
103
104
  import {
104
105
  createMutatingFailureState,
105
106
  didMutatingToolFail,
@@ -123,6 +124,7 @@ import {
123
124
  type WorktreeSetup,
124
125
  } from "../shared/worktree.ts";
125
126
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
127
+ import { assertThinkingWithinCeiling, decodeThinkingCeiling, SUBAGENT_THINKING_CEILING_ENV } from "../../shared/thinking-ceiling.ts";
126
128
  import { launchBindingDigest } from "../../shared/launch-contract.ts";
127
129
  import { writeInitialProgressFile } from "../../shared/settings.ts";
128
130
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
@@ -130,6 +132,7 @@ import { acceptanceFailureMessage, aggregateAcceptanceReport, buildSkippedAccept
130
132
  import { attachContractProjections, isAgentContractV1 } from "../shared/agent-contract.ts";
131
133
  import { waitForImportedAsyncRoot } from "./chain-root-attachment.ts";
132
134
  import { appendRunnerStepsToStatus, consumeChainAppendRequests, countPendingChainAppendRequests, statusStepDescription } from "./chain-append.ts";
135
+ import { asyncStatusChildIdentity } from "../shared/child-identity.ts";
133
136
  import { appendTurnBudgetSystemPrompt, formatTurnBudgetOutput, initialTurnBudgetState, turnBudgetDecision, turnBudgetDeferredNote, turnBudgetDeferredState, turnBudgetExceededMessage, turnBudgetSoftNote, turnBudgetState } from "../shared/turn-budget.ts";
134
137
  import { initialToolBudgetState, toolBudgetState } from "../shared/tool-budget.ts";
135
138
  import { effectiveToolTimeoutMs, formatToolTimeoutMessage, toolTimeoutCallKey } from "../shared/tool-timeout.ts";
@@ -560,6 +563,8 @@ function runPiStreaming(
560
563
  toolTimeoutMs?: number,
561
564
  runDeadlineAt?: number,
562
565
  orcaProgressTab?: OrcaProgressTab,
566
+ expectedModelForVerification?: string,
567
+ modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string }>,
563
568
  ): Promise<RunPiStreamingResult> {
564
569
  return new Promise((resolve) => {
565
570
  const startedAt = Date.now();
@@ -718,7 +723,14 @@ function runPiStreaming(
718
723
  if (text) writeOutputText(text);
719
724
 
720
725
  if (event.type !== "message_end" || event.message.role !== "assistant") return;
721
- if (event.message.model) model = event.message.model;
726
+ const hasToolCall = assistantStartsToolCall(event.message);
727
+ if (event.message.model) {
728
+ model = event.message.model;
729
+ if (expectedModelForVerification && !hasToolCall) {
730
+ const modelVerificationError = formatSubagentModelVerificationError(expectedModelForVerification, event.message.model, modelVerificationRegistry);
731
+ if (modelVerificationError && !error) error = modelVerificationError;
732
+ }
733
+ }
722
734
  if (event.message.errorMessage) assistantError = event.message.errorMessage;
723
735
  const eventUsage = event.message.usage;
724
736
  if (eventUsage) {
@@ -1279,6 +1291,43 @@ async function runSingleStepInner(
1279
1291
  let task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
1280
1292
  if (ctx.outputs) task = resolveOutputReferences(task, ctx.outputs);
1281
1293
  const taskForCompletionGuard = task;
1294
+ let resolvedTaskToolPlan: ReturnType<typeof resolvePiLaunchToolPlan> | undefined;
1295
+ if (!step.runner) {
1296
+ resolvedTaskToolPlan = resolvePiLaunchToolPlan(omitUndefinedProperties({
1297
+ tools: step.tools,
1298
+ extensions: step.extensions,
1299
+ subagentOnlyExtensions: step.subagentOnlyExtensions,
1300
+ mcpDirectTools: step.mcpDirectTools,
1301
+ cwd: step.cwd ?? ctx.cwd,
1302
+ requireReadTool: Boolean(step.skills?.length),
1303
+ structuredOutput: Boolean(effectiveStructuredOutput),
1304
+ capabilityCeiling: step.capabilityCeiling ?? ctx.capabilityCeiling,
1305
+ inheritedCapabilityCeiling: decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV]),
1306
+ permissionRules: step.permissionRules,
1307
+ }));
1308
+ const contractTools = resolvedTaskToolPlan.explicitToolAllowlist ? resolvedTaskToolPlan.effectiveToolAllowlist : undefined;
1309
+ const contractError = validateImplementationToolContract({
1310
+ agent: step.agent,
1311
+ task: taskForCompletionGuard,
1312
+ tools: contractTools,
1313
+ mcpDirectTools: resolvedTaskToolPlan.effectiveMcpTools,
1314
+ configuredExtensions: resolvedTaskToolPlan.configuredExtensions,
1315
+ requestedTools: resolvedTaskToolPlan.requestedBuiltinTools,
1316
+ acceptanceRole: step.acceptanceRole,
1317
+ completionGuard: step.completionGuard,
1318
+ });
1319
+ if (contractError) {
1320
+ return omitUndefinedProperties({
1321
+ agent: step.agent,
1322
+ context: step.context,
1323
+ output: contractError,
1324
+ error: contractError,
1325
+ exitCode: 1,
1326
+ capabilityCeiling: resolvedTaskToolPlan.capabilityCeiling,
1327
+ capabilityAudit: resolvedTaskToolPlan.capabilityAudit,
1328
+ });
1329
+ }
1330
+ }
1282
1331
  if (step.effectiveAcceptance) {
1283
1332
  const acceptancePrompt = formatAcceptancePrompt(step.effectiveAcceptance, { reportOptional: isAgentContractV1(step.agentContract) });
1284
1333
  if (acceptancePrompt) task = `${task}\n${acceptancePrompt}`;
@@ -1395,6 +1444,7 @@ async function runSingleStepInner(
1395
1444
  timeoutMessage: ctx.timeoutMessage,
1396
1445
  stopMessage: ctx.stopMessage,
1397
1446
  onExternalJob: ctx.onExternalJob,
1447
+ followUp: step.externalJobFollowUp,
1398
1448
  }));
1399
1449
  try { fs.writeFileSync(ctx.outputFile, external.output, "utf-8"); } catch { /* Observability output is best-effort. */ }
1400
1450
  const resolvedOutput = step.outputPath && external.exitCode === 0
@@ -1466,6 +1516,13 @@ async function runSingleStepInner(
1466
1516
  modelAttemptsLoop: while (modelIndex < candidates.length) {
1467
1517
  if (ctx.timeoutSignal?.aborted || ctx.stopSignal?.aborted || ctx.skipAcceptance?.()) break;
1468
1518
  const candidate = candidates[modelIndex];
1519
+ const expectedModelForVerification = candidate && !(step.skipPrimaryModelVerification && modelIndex === 0) ? candidate : undefined;
1520
+ try {
1521
+ assertThinkingWithinCeiling({ model: candidate, configThinking: step.thinking, ceiling: step.thinkingCeiling ?? decodeThinkingCeiling(process.env[SUBAGENT_THINKING_CEILING_ENV]), agent: step.agent, runId: ctx.id });
1522
+ } catch (error) {
1523
+ const message = error instanceof Error ? error.message : String(error);
1524
+ return omitUndefinedProperties({ agent: step.agent, output: message, error: message, exitCode: 1, context: step.context, thinkingCeiling: step.thinkingCeiling });
1525
+ }
1469
1526
  ctx.onAttemptStart?.(omitUndefinedProperties({ model: candidate, thinking: resolveEffectiveThinking(candidate, step.thinking) }));
1470
1527
  const outputSnapshot = captureSingleOutputSnapshot(step.outputPath);
1471
1528
  if (effectiveStructuredOutput) {
@@ -1529,13 +1586,14 @@ async function runSingleStepInner(
1529
1586
  : undefined,
1530
1587
  childWatchdog,
1531
1588
  waitToolEnabled: step.waitToolEnabled,
1589
+ thinkingCeiling: step.thinkingCeiling,
1532
1590
  }));
1533
1591
  if (!launchWarningsEmitted && warnings.length > 0) {
1534
1592
  for (const warning of warnings) console.warn(`[pi-subagents] ${warning}`);
1535
1593
  launchWarningsEmitted = true;
1536
1594
  }
1537
1595
  if (step.definitionDigest) {
1538
- const toolPlan = resolvePiLaunchToolPlan(omitUndefinedProperties({
1596
+ const toolPlan = resolvedTaskToolPlan ?? resolvePiLaunchToolPlan(omitUndefinedProperties({
1539
1597
  tools: step.tools,
1540
1598
  extensions: step.extensions,
1541
1599
  subagentOnlyExtensions: step.subagentOnlyExtensions,
@@ -1554,6 +1612,7 @@ async function runSingleStepInner(
1554
1612
  ...(candidate ? { model: candidate } : {}),
1555
1613
  modelCandidates: candidates as string[],
1556
1614
  ...(resolveEffectiveThinking(candidate, step.thinking) ? { thinking: resolveEffectiveThinking(candidate, step.thinking) } : {}),
1615
+ ...(step.thinkingCeiling ? { thinkingCeiling: step.thinkingCeiling } : {}),
1557
1616
  systemPrompt: appendTurnBudgetSystemPrompt(step.systemPrompt ?? "", ctx.turnBudget),
1558
1617
  systemPromptMode: step.systemPromptMode,
1559
1618
  inheritProjectContext: step.inheritProjectContext,
@@ -1590,6 +1649,8 @@ async function runSingleStepInner(
1590
1649
  ctx.toolTimeoutMs,
1591
1650
  ctx.deadlineAt,
1592
1651
  ctx.orcaProgressTab,
1652
+ expectedModelForVerification,
1653
+ step.modelVerificationRegistry,
1593
1654
  );
1594
1655
  if (run.processCloseObservedAt !== undefined) {
1595
1656
  writerProcesses.push({
@@ -1662,21 +1723,25 @@ async function runSingleStepInner(
1662
1723
  ? "Subagent produced no output (possible model cold-start or empty response)."
1663
1724
  : undefined;
1664
1725
  const completionGuardEnabled = isAgentContractV1(step.agentContract) ? step.completionGuard === true : step.completionGuard !== false;
1665
- const completionGuard = run.exitCode === 0 && !run.error && !toolAvailabilityError && !structuredError && !hiddenError?.hasError && !emptyOutputError && completionGuardEnabled
1726
+ const completionToolPlan = resolvedTaskToolPlan;
1727
+ const completionGuard = run.exitCode === 0 && !run.error && !structuredError && !hiddenError?.hasError && !emptyOutputError && completionGuardEnabled
1666
1728
  ? evaluateCompletionMutationGuard(omitUndefinedProperties({
1667
1729
  agent: step.agent,
1668
1730
  task: taskForCompletionGuard,
1669
1731
  messages: run.messages,
1670
- tools: step.tools,
1671
- mcpDirectTools: step.mcpDirectTools,
1732
+ tools: completionToolPlan ? (completionToolPlan.explicitToolAllowlist ? completionToolPlan.effectiveToolAllowlist : undefined) : step.tools,
1733
+ mcpDirectTools: completionToolPlan?.effectiveMcpTools ?? step.mcpDirectTools,
1734
+ toolAvailabilityError,
1672
1735
  }))
1673
1736
  : undefined;
1674
1737
  const completionGuardTriggered = completionGuard?.triggered === true && !run.observedMutationAttempt;
1738
+ const completionGuardBlocked = completionGuard?.blocked === true;
1675
1739
  const fileMutationEffect = completionGuard
1676
1740
  ? {
1677
- status: completionGuard.expectedMutation ? completionGuardTriggered ? "missing" as const : "observed" as const : "not-applicable" as const,
1741
+ status: completionGuardBlocked ? "blocked" as const : completionGuard.expectedMutation ? completionGuardTriggered ? "missing" as const : "observed" as const : "not-applicable" as const,
1678
1742
  expected: completionGuard.expectedMutation,
1679
- attempted: completionGuard.attemptedMutation || run.observedMutationAttempt === true,
1743
+ attempted: completionGuardBlocked ? false : completionGuard.attemptedMutation || run.observedMutationAttempt === true,
1744
+ ...(completionGuardBlocked && completionGuard.message ? { message: completionGuard.message } : {}),
1680
1745
  ...(completionGuardTriggered ? { message: "Subagent completed without making edits for an implementation task." } : {}),
1681
1746
  }
1682
1747
  : undefined;
@@ -1959,21 +2024,8 @@ async function runSingleStep(
1959
2024
  step: SubagentStep,
1960
2025
  ctx: SingleStepContext,
1961
2026
  ): Promise<StepResult & { completionGuardTriggered?: boolean }> {
1962
- if (step.importAsyncRoot) return runSingleStepInner(step, ctx);
1963
- const orcaProgressTab = createOrcaProgressTab({
1964
- cwd: step.cwd ?? ctx.cwd,
1965
- runId: ctx.id,
1966
- agent: step.agent,
1967
- index: ctx.flatIndex,
1968
- });
1969
- try {
1970
- const result = await runSingleStepInner(step, { ...ctx, orcaProgressTab });
1971
- orcaProgressTab?.finish(result.stopped ? "stopped" : result.exitCode === 0 && !result.error ? "completed" : "failed", result.sessionFile);
1972
- return result;
1973
- } catch (error) {
1974
- orcaProgressTab?.finish("failed");
1975
- throw error;
1976
- }
2027
+ if (!step.importAsyncRoot) ctx.orcaProgressTab?.section({ agent: step.agent, index: ctx.flatIndex, count: ctx.flatStepCount });
2028
+ return runSingleStepInner(step, ctx);
1977
2029
  }
1978
2030
 
1979
2031
  type RunnerStatusStep = NonNullable<AsyncStatus["steps"]>[number] & {
@@ -2242,6 +2294,7 @@ async function runSubagent(
2242
2294
  const activeChildInterrupts = new Map<number, () => void>();
2243
2295
  const activeChildTimeouts = new Map<number, () => void>();
2244
2296
  const activeChildStops = new Map<number, () => void>();
2297
+ const childStopRequests = new Map<number, { childId: string; requestedAt: number }>();
2245
2298
  const activeChildTurnBudgetAborts = new Map<number, (message: string, state?: TurnBudgetState) => void>();
2246
2299
  const pendingStepSteers: SteerRequest[] = [];
2247
2300
  const steeringCapabilities = new Map<number, SteerCapability>();
@@ -2285,6 +2338,7 @@ async function runSubagent(
2285
2338
  ...(task.launchContractDigest ? { launchContractDigest: task.launchContractDigest } : {}),
2286
2339
  ...(task.launchResolvedExtensions ? { launchResolvedExtensions: task.launchResolvedExtensions } : {}),
2287
2340
  ...(task.capabilityCeiling ? { capabilityCeiling: task.capabilityCeiling } : {}),
2341
+ ...(task.thinkingCeiling ? { thinkingCeiling: task.thinkingCeiling } : {}),
2288
2342
  status: "pending",
2289
2343
  ...(task.toolBudget ? { toolBudget: initialToolBudgetState(task.toolBudget) } : {}),
2290
2344
  ...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
@@ -2310,6 +2364,7 @@ async function runSubagent(
2310
2364
  structured: Boolean(step.collect.outputSchema),
2311
2365
  ...(step.agentContract ? { agentContract: step.agentContract } : {}),
2312
2366
  ...(step.capabilityCeiling ? { capabilityCeiling: step.capabilityCeiling } : {}),
2367
+ ...(step.thinkingCeiling ? { thinkingCeiling: step.thinkingCeiling } : {}),
2313
2368
  status: "pending",
2314
2369
  ...(step.parallel.toolBudget ? { toolBudget: initialToolBudgetState(step.parallel.toolBudget) } : {}),
2315
2370
  recentTools: [],
@@ -2332,6 +2387,7 @@ async function runSubagent(
2332
2387
  ...(step.launchContractDigest ? { launchContractDigest: step.launchContractDigest } : {}),
2333
2388
  ...(step.launchResolvedExtensions ? { launchResolvedExtensions: step.launchResolvedExtensions } : {}),
2334
2389
  ...(step.capabilityCeiling ? { capabilityCeiling: step.capabilityCeiling } : {}),
2390
+ ...(step.thinkingCeiling ? { thinkingCeiling: step.thinkingCeiling } : {}),
2335
2391
  status: "pending",
2336
2392
  ...(step.toolBudget ? { toolBudget: initialToolBudgetState(step.toolBudget) } : {}),
2337
2393
  ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
@@ -2354,6 +2410,19 @@ async function runSubagent(
2354
2410
  step.processTerminal = { version: 1, state: "pending", runId: id, runnerProcessInstanceId: config.runnerProcessInstanceId };
2355
2411
  }
2356
2412
  }
2413
+ const initialAgentLabel = initialStatusSteps.length === 1
2414
+ ? initialStatusSteps[0]!.agent
2415
+ : (config.resultMode ?? (flatSteps.length > 1 ? "chain" : "single")) === "parallel"
2416
+ ? `parallel:${initialStatusSteps.map((step) => step.agent).join("+")}`
2417
+ : `chain:${initialStatusSteps.map((step) => step.agent).join("->")}`;
2418
+ const orcaProgressTab = flatSteps.every((step) => step.importAsyncRoot) ? undefined : createOrcaProgressTab({
2419
+ cwd,
2420
+ runId: id,
2421
+ agent: initialAgentLabel,
2422
+ index: 0,
2423
+ stepCount: Math.max(initialStatusSteps.length, 1),
2424
+ });
2425
+
2357
2426
  const statusPayload: RunnerStatusPayload = omitUndefinedProperties({
2358
2427
  lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
2359
2428
  runId: id,
@@ -2572,6 +2641,83 @@ async function runSubagent(
2572
2641
  statusPayload.lastUpdate = Date.now();
2573
2642
  writeStatusPayload();
2574
2643
  };
2644
+ const childStopTargetId = (index: number): string => asyncStatusChildIdentity(requiredStatusStep(statusPayload, index), index);
2645
+ const appendChildStatusEvent = (index: number, childId: string, status: "stopping" | "stopped", now = Date.now()): void => {
2646
+ const step = requiredStatusStep(statusPayload, index);
2647
+ appendJsonl(eventsPath, JSON.stringify({
2648
+ type: "subagent.child-status",
2649
+ version: 1,
2650
+ ts: now,
2651
+ runId: id,
2652
+ childId,
2653
+ status,
2654
+ reason: "user",
2655
+ source: "async",
2656
+ stepIndex: index,
2657
+ agent: step.agent,
2658
+ ...(step.runId ? { childRunId: step.runId } : {}),
2659
+ ...(step.workflowKey ? { workflowKey: step.workflowKey } : {}),
2660
+ ...(step.phase ? { phase: step.phase } : {}),
2661
+ ...(step.label ? { label: step.label } : {}),
2662
+ } satisfies SubagentChildStatusEvent));
2663
+ };
2664
+ const appendTerminalChildStatusEvent = (index: number, now = Date.now()): void => {
2665
+ const request = childStopRequests.get(index);
2666
+ if (request) appendChildStatusEvent(index, request.childId, "stopped", now);
2667
+ };
2668
+ const markChildStopRequested = (index: number, childId: string, now = Date.now()): boolean => {
2669
+ const step = statusPayload.steps[index];
2670
+ if (!step || (step.status !== "pending" && step.status !== "running")) return false;
2671
+ childStopRequests.set(index, { childId, requestedAt: now });
2672
+ step.stopRequested = true;
2673
+ step.stopRequestedAt = now;
2674
+ delete step.activityState;
2675
+ statusPayload.lastUpdate = now;
2676
+ writeStatusPayload();
2677
+ appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.stop_requested", ts: now, runId: id, stepIndex: index, childId, agent: step.agent }));
2678
+ appendChildStatusEvent(index, childId, "stopping", now);
2679
+ return true;
2680
+ };
2681
+ const markChildStopped = (index: number, now = Date.now()): void => {
2682
+ const step = requiredStatusStep(statusPayload, index);
2683
+ if (step.status === "stopped") return;
2684
+ step.status = "stopped";
2685
+ step.error = stopMessage;
2686
+ step.exitCode = 1;
2687
+ step.stopped = true;
2688
+ step.stopRequested = true;
2689
+ step.stopRequestedAt = childStopRequests.get(index)?.requestedAt ?? step.stopRequestedAt ?? now;
2690
+ delete step.activityState;
2691
+ step.endedAt = now;
2692
+ step.durationMs = step.startedAt ? now - step.startedAt : 0;
2693
+ step.lastActivityAt = now;
2694
+ statusPayload.lastUpdate = now;
2695
+ writeStatusPayload();
2696
+ const childId = childStopRequests.get(index)?.childId ?? childStopTargetId(index);
2697
+ appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.stopped", ts: now, runId: id, stepIndex: index, childId, agent: step.agent, exitCode: 1, durationMs: step.durationMs }));
2698
+ appendChildStatusEvent(index, childId, "stopped", now);
2699
+ };
2700
+ const childStopResult = (index: number, agent: string, context?: "fresh" | "fork"): SingleStepResult => {
2701
+ markChildStopped(index);
2702
+ return stoppedStepResult(agent, context);
2703
+ };
2704
+ const stopChildStep = (request: StopRequest): void => {
2705
+ if (request.targetIndex === undefined) {
2706
+ stopRunner();
2707
+ return;
2708
+ }
2709
+ const childId = request.childId ?? childStopTargetId(request.targetIndex);
2710
+ const now = Date.now();
2711
+ if (!markChildStopRequested(request.targetIndex, childId, now)) {
2712
+ appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.stop_failed", ts: now, runId: id, stepIndex: request.targetIndex, childId, message: "Child is not pending or running." }));
2713
+ return;
2714
+ }
2715
+ const stop = activeChildStops.get(request.targetIndex);
2716
+ if (stop) stop();
2717
+ else if (requiredStatusStep(statusPayload, request.targetIndex).status === "pending") {
2718
+ appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.stop_queued", ts: now, runId: id, stepIndex: request.targetIndex, childId }));
2719
+ }
2720
+ };
2575
2721
  const registerStepInterrupt = (flatIndex: number, interrupt: (() => void) | undefined): void => {
2576
2722
  if (!interrupt) {
2577
2723
  activeChildInterrupts.delete(flatIndex);
@@ -2594,7 +2740,7 @@ async function runSubagent(
2594
2740
  return;
2595
2741
  }
2596
2742
  activeChildStops.set(flatIndex, stop);
2597
- if (stopped) stop();
2743
+ if (stopped || childStopRequests.has(flatIndex)) stop();
2598
2744
  };
2599
2745
  const registerStepTurnBudgetAbort = (flatIndex: number, abort: ((message: string, state?: TurnBudgetState) => void) | undefined): void => {
2600
2746
  if (!abort) {
@@ -3499,7 +3645,7 @@ async function runSubagent(
3499
3645
  const disposeControlInbox = watchAsyncControlInbox(asyncDir, {
3500
3646
  onInterrupt: interruptRunner,
3501
3647
  onTimeout: timeoutRunner,
3502
- onStop: stopRunner,
3648
+ onStop: stopChildStep,
3503
3649
  onSteer: (request) => {
3504
3650
  const targetStep = request.targetIndex !== undefined ? statusPayload.steps[request.targetIndex] : undefined;
3505
3651
  if (targetStep?.status === "pending") {
@@ -3575,6 +3721,19 @@ async function runSubagent(
3575
3721
  if (materialized.parallel.length > 1 && step.parallel.outputPath && !step.parallel.namespaceOutputPath) {
3576
3722
  throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} materialized ${materialized.parallel.length} items that resolve output to the same path: ${step.parallel.outputPath}. Remove the explicit output path or use an inherited relative agent output so each item can be isolated.`);
3577
3723
  }
3724
+ for (const [itemIndex] of materialized.parallel.entries()) {
3725
+ const thinkingOverride = step.thinkingOverrides?.[itemIndex];
3726
+ const model = thinkingOverride ? applyThinkingSuffix(step.parallel.model, thinkingOverride, true) : step.parallel.model;
3727
+ const configThinking = thinkingOverride ? thinkingOverride : step.parallel.thinking;
3728
+ const candidates = step.parallel.modelCandidates !== undefined
3729
+ ? step.parallel.modelCandidates.length > 0
3730
+ ? step.parallel.modelCandidates.map((candidate) => thinkingOverride ? applyThinkingSuffix(candidate, thinkingOverride, true) ?? candidate : candidate)
3731
+ : [undefined]
3732
+ : model ? [model] : [undefined];
3733
+ for (const candidate of candidates) {
3734
+ assertThinkingWithinCeiling({ model: candidate, configThinking, ceiling: step.parallel.thinkingCeiling ?? decodeThinkingCeiling(process.env[SUBAGENT_THINKING_CEILING_ENV]), agent: step.parallel.agent, runId: id });
3735
+ }
3736
+ }
3578
3737
  if (materialized.collectedOnEmpty) await validateDynamicCollection(step.collect.outputSchema, materialized.collectedOnEmpty);
3579
3738
  if (!config.runFanoutBudget) throw new Error("Async runner is missing its run fan-out budget identity.");
3580
3739
  const runFanoutBudget = claimRunFanoutBatch(config.runFanoutBudget, materialized.parallel.map((_, itemIndex) => `chain[${stepIndex}].expand[${itemIndex}]`));
@@ -3731,6 +3890,7 @@ async function runSubagent(
3731
3890
  ...(task.skills ? { skills: task.skills } : {}),
3732
3891
  ...(task.model ? { model: task.model } : {}),
3733
3892
  ...(task.thinking ? { thinking: task.thinking } : {}),
3893
+ ...(task.thinkingCeiling ? { thinkingCeiling: task.thinkingCeiling } : {}),
3734
3894
  ...(task.modelCandidates && task.modelCandidates.length > 0 ? { attemptedModels: task.modelCandidates } : task.model ? { attemptedModels: [task.model] } : {}),
3735
3895
  recentTools: [],
3736
3896
  recentOutput: [],
@@ -3802,6 +3962,7 @@ async function runSubagent(
3802
3962
  }
3803
3963
  if (timedOut) return timedOutStepResult(task.agent, task.context);
3804
3964
  if (stopped) return stoppedStepResult(task.agent, task.context);
3965
+ if (childStopRequests.has(fi)) return childStopResult(fi, task.agent, task.context);
3805
3966
  if (interrupted) return pausedStepResult(task.agent, task.context);
3806
3967
  if (aborted && failFast) {
3807
3968
  const skippedAt = Date.now();
@@ -3861,7 +4022,8 @@ async function runSubagent(
3861
4022
  onWriterProcess,
3862
4023
  onExternalProcess: (process) => updateExternalProcess(fi, process),
3863
4024
  onExternalJob: (externalJob) => updateExternalJob(fi, externalJob),
3864
- skipAcceptance: () => timedOut || stopped,
4025
+ skipAcceptance: () => timedOut || stopped || childStopRequests.has(fi),
4026
+ orcaProgressTab,
3865
4027
  }), config.deadlineAt);
3866
4028
  const taskEndTime = Date.now();
3867
4029
  const childInterrupted = singleResult.interrupted === true;
@@ -3918,12 +4080,13 @@ async function runSubagent(
3918
4080
  statusPayload.lastUpdate = taskEndTime;
3919
4081
  writeStatusPayload();
3920
4082
  appendCapabilityCeilingAppliedEvent(eventsPath, id, fi, task.agent, singleResult);
3921
- appendJsonl(eventsPath, JSON.stringify({
3922
- type: stopped || childStopped ? "subagent.step.stopped" : timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
3923
- ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
3924
- exitCode: stopped || childStopped ? 1 : timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode, durationMs: taskEndTime - taskStartTime,
3925
- }));
3926
- if (singleResult.exitCode !== 0 && failFast) aborted = true;
4083
+ appendJsonl(eventsPath, JSON.stringify({
4084
+ type: stopped || childStopped ? "subagent.step.stopped" : timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
4085
+ ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
4086
+ exitCode: stopped || childStopped ? 1 : timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode, durationMs: taskEndTime - taskStartTime,
4087
+ }));
4088
+ if (stopped || childStopped) appendTerminalChildStatusEvent(fi, taskEndTime);
4089
+ if (singleResult.exitCode !== 0 && failFast && !childStopped) aborted = true;
3927
4090
  return stopped || childStopped ? { ...singleResult, output: stopMessage, error: stopMessage, exitCode: 1, interrupted: false, timedOut: false, stopped: true, skipped: false } : timedOut ? { ...singleResult, output: timeoutMessage ?? "Subagent timed out.", error: timeoutMessage ?? "Subagent timed out.", exitCode: 1, interrupted: false, timedOut: true, skipped: false } : { ...singleResult, skipped: false };
3928
4091
  }, globalSemaphore);
3929
4092
 
@@ -4176,6 +4339,7 @@ async function runSubagent(
4176
4339
  }
4177
4340
  if (timedOut) return timedOutStepResult(task.agent, task.context);
4178
4341
  if (stopped) return stoppedStepResult(task.agent, task.context);
4342
+ if (childStopRequests.has(fi)) return childStopResult(fi, task.agent, task.context);
4179
4343
  if (interrupted) return pausedStepResult(task.agent, task.context);
4180
4344
  if (aborted && failFast) {
4181
4345
  const skippedAt = Date.now();
@@ -4251,7 +4415,8 @@ async function runSubagent(
4251
4415
  onWriterProcess,
4252
4416
  onExternalProcess: (process) => updateExternalProcess(fi, process),
4253
4417
  onExternalJob: (externalJob) => updateExternalJob(fi, externalJob),
4254
- skipAcceptance: () => timedOut || stopped,
4418
+ skipAcceptance: () => timedOut || stopped || childStopRequests.has(fi),
4419
+ orcaProgressTab,
4255
4420
  }), config.deadlineAt);
4256
4421
  if (task.sessionFile) {
4257
4422
  latestSessionFile = task.sessionFile;
@@ -4319,6 +4484,7 @@ async function runSubagent(
4319
4484
  ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
4320
4485
  exitCode: stopped || childStopped ? 1 : timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode, durationMs: taskDuration,
4321
4486
  }));
4487
+ if (stopped || childStopped) appendTerminalChildStatusEvent(fi, taskEndTime);
4322
4488
  if (singleResult.completionGuardTriggered) {
4323
4489
  const event = buildControlEvent(omitUndefinedProperties({
4324
4490
  from: requiredStatusStep(statusPayload, fi).activityState,
@@ -4333,7 +4499,7 @@ async function runSubagent(
4333
4499
  appendControlEvent(event);
4334
4500
  }
4335
4501
 
4336
- if (singleResult.exitCode !== 0 && failFast) aborted = true;
4502
+ if (singleResult.exitCode !== 0 && failFast && !childStopped) aborted = true;
4337
4503
  return stopped || childStopped ? { ...singleResult, output: stopMessage, error: stopMessage, exitCode: 1, interrupted: false, timedOut: false, stopped: true, skipped: false } : timedOut ? { ...singleResult, output: timeoutMessage ?? "Subagent timed out.", error: timeoutMessage ?? "Subagent timed out.", exitCode: 1, interrupted: false, timedOut: true, skipped: false } : { ...singleResult, skipped: false };
4338
4504
  },
4339
4505
  globalSemaphore,
@@ -4490,6 +4656,26 @@ async function runSubagent(
4490
4656
  }
4491
4657
  } else {
4492
4658
  const seqStep = step as SubagentStep;
4659
+ if (timedOut) {
4660
+ results.push(timedOutStepResult(seqStep.agent, seqStep.context));
4661
+ flatIndex++;
4662
+ continue;
4663
+ }
4664
+ if (stopped) {
4665
+ results.push(stoppedStepResult(seqStep.agent, seqStep.context));
4666
+ flatIndex++;
4667
+ continue;
4668
+ }
4669
+ if (childStopRequests.has(flatIndex)) {
4670
+ results.push(childStopResult(flatIndex, seqStep.agent, seqStep.context));
4671
+ flatIndex++;
4672
+ continue;
4673
+ }
4674
+ if (interrupted) {
4675
+ results.push(pausedStepResult(seqStep.agent, seqStep.context));
4676
+ flatIndex++;
4677
+ continue;
4678
+ }
4493
4679
  let singleWorktreeSetup: WorktreeSetup | undefined;
4494
4680
  if (seqStep.worktree) {
4495
4681
  try {
@@ -4574,7 +4760,8 @@ async function runSubagent(
4574
4760
  onWriterProcess,
4575
4761
  onExternalProcess: (process) => updateExternalProcess(flatIndex, process),
4576
4762
  onExternalJob: (externalJob) => updateExternalJob(flatIndex, externalJob),
4577
- skipAcceptance: () => timedOut || stopped,
4763
+ skipAcceptance: () => timedOut || stopped || childStopRequests.has(flatIndex),
4764
+ orcaProgressTab,
4578
4765
  }), config.deadlineAt);
4579
4766
  } catch (error) {
4580
4767
  if (singleWorktreeSetup) cleanupWorktrees(singleWorktreeSetup);
@@ -4721,6 +4908,7 @@ async function runSubagent(
4721
4908
  durationMs: stepEndTime - stepStartTime,
4722
4909
  tokens: stepTokens,
4723
4910
  }));
4911
+ if (stopped || childStopped) appendTerminalChildStatusEvent(flatIndex, stepEndTime);
4724
4912
  if (singleWorktreeSetup && !singleResult.detached) {
4725
4913
  const diffs = diffWorktrees(singleWorktreeSetup, [seqStep.agent], path.join(asyncDir, "worktree-diffs", `step-${stepIndex}`));
4726
4914
  const diffSummary = formatWorktreeDiffSummary(diffs);
@@ -5023,6 +5211,7 @@ async function runSubagent(
5023
5211
  statusPayload.lastUpdate = Date.now();
5024
5212
  }
5025
5213
  writeStatusPayload();
5214
+ orcaProgressTab?.finish(statusPayload.state === "complete" ? "completed" : statusPayload.state === "stopped" ? "stopped" : "failed", effectiveSessionFile);
5026
5215
  appendJsonl(
5027
5216
  eventsPath,
5028
5217
  JSON.stringify({
@@ -3,6 +3,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
3
3
  import type { Details, SubagentState } from "../../shared/types.ts";
4
4
  import { deliverStopRequest } from "../background/control-channel.ts";
5
5
  import { reconcileAsyncRun } from "../background/stale-run-reconciler.ts";
6
+ import { isStoppableAsyncStatusStep, resolveAsyncStatusChild, type ResolvedAsyncStatusChild } from "../shared/child-identity.ts";
6
7
 
7
8
  function getAsyncStopTarget(
8
9
  state: SubagentState,
@@ -25,6 +26,7 @@ export function stopAsyncRun(
25
26
  runId: string | undefined,
26
27
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean,
27
28
  location?: { asyncDir: string | null; resolvedId?: string },
29
+ childId?: string,
28
30
  ): AgentToolResult<Details> | null {
29
31
  const target = getAsyncStopTarget(state, runId, location);
30
32
  if (!target) return null;
@@ -43,15 +45,34 @@ export function stopAsyncRun(
43
45
  details: { mode: "management", results: [] },
44
46
  };
45
47
  }
48
+ let child: ResolvedAsyncStatusChild | undefined;
49
+ if (childId !== undefined) {
50
+ const resolution = resolveAsyncStatusChild(status, childId);
51
+ if (!resolution.ok) {
52
+ return {
53
+ content: [{ type: "text", text: resolution.message }],
54
+ isError: true,
55
+ details: { mode: "management", results: [] },
56
+ };
57
+ }
58
+ child = resolution.child;
59
+ if (!isStoppableAsyncStatusStep(child.step)) {
60
+ return {
61
+ content: [{ type: "text", text: `Child '${childId}' in async run '${status.runId}' is ${child.step.status}; stop only supports pending or running children.` }],
62
+ isError: true,
63
+ details: { mode: "management", results: [] },
64
+ };
65
+ }
66
+ }
46
67
  try {
47
- deliverStopRequest({ asyncDir: target.asyncDir, pid: typeof status.pid === "number" ? status.pid : undefined, kill, source: "stop-action" });
68
+ deliverStopRequest({ asyncDir: target.asyncDir, pid: typeof status.pid === "number" ? status.pid : undefined, kill, source: "stop-action", targetIndex: child?.index, childId: child?.id ?? childId });
48
69
  const tracked = state.asyncJobs.get(target.asyncId);
49
70
  if (tracked) {
50
71
  tracked.activityState = undefined;
51
72
  tracked.updatedAt = Date.now();
52
73
  }
53
74
  return {
54
- content: [{ type: "text", text: `Stop requested for async run ${target.asyncId}.` }],
75
+ content: [{ type: "text", text: child ? `Stop requested for child ${child.id} in async run ${target.asyncId}.` : `Stop requested for async run ${target.asyncId}.` }],
55
76
  details: { mode: "management", results: [] },
56
77
  };
57
78
  } catch (error) {