pi-subagents 0.46.0 → 0.47.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 (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/agents.md +1 -1
  3. package/docs/configuration.md +14 -6
  4. package/docs/extension-api.md +1 -1
  5. package/docs/missions.md +5 -3
  6. package/docs/models.md +3 -1
  7. package/docs/observability.md +3 -3
  8. package/docs/tool-reference.md +2 -2
  9. package/docs/watchdog.md +1 -1
  10. package/package.json +1 -1
  11. package/skills/pi-subagents/references/execution-controls.md +4 -4
  12. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  13. package/src/extension/config.ts +3 -0
  14. package/src/extension/fanout-child.ts +5 -4
  15. package/src/extension/index.ts +30 -3
  16. package/src/extension/rpc.ts +3 -6
  17. package/src/extension/schemas.ts +25 -5
  18. package/src/extension/tool-description.ts +26 -8
  19. package/src/inspectors/herdr/project-panes.ts +2 -1
  20. package/src/missions/store.ts +2 -1
  21. package/src/missions/workflow-state.ts +19 -13
  22. package/src/runs/background/async-execution.ts +10 -5
  23. package/src/runs/background/async-job-tracker.ts +15 -0
  24. package/src/runs/background/async-resume.ts +19 -3
  25. package/src/runs/background/async-status.ts +6 -1
  26. package/src/runs/background/control-channel.ts +36 -0
  27. package/src/runs/background/result-watcher.ts +16 -2
  28. package/src/runs/background/scheduled-runs.ts +2 -1
  29. package/src/runs/background/stale-run-reconciler.ts +2 -21
  30. package/src/runs/background/subagent-runner.ts +47 -6
  31. package/src/runs/foreground/async-steering-action.ts +1 -1
  32. package/src/runs/foreground/chain-execution.ts +3 -0
  33. package/src/runs/foreground/execution.ts +3 -0
  34. package/src/runs/foreground/subagent-executor.ts +95 -12
  35. package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
  36. package/src/runs/shared/dynamic-fanout.ts +1 -1
  37. package/src/runs/shared/model-fallback.ts +8 -4
  38. package/src/runs/shared/model-scope.ts +12 -2
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/worktree.ts +3 -2
  41. package/src/shared/artifacts.ts +14 -14
  42. package/src/shared/display-text.ts +100 -0
  43. package/src/shared/formatters.ts +4 -6
  44. package/src/shared/settings.ts +15 -2
  45. package/src/shared/types.ts +11 -1
  46. package/src/shared/utils.ts +43 -33
  47. package/src/slash/slash-commands.ts +3 -1
  48. package/src/tui/fleet-status.ts +14 -10
  49. package/src/tui/render.ts +30 -26
  50. package/src/watchdog/change-signature.ts +4 -3
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { resolveAgentName, type AgentConfig, type AgentScope } from "../../agents/agents.ts";
7
- import { getArtifactsDir, getChainRunsDir, getProjectArtifactPackagingWarning } from "../../shared/artifacts.ts";
7
+ import { getArtifactsDir, getChainRunsDir, getProjectArtifactPackagingWarning, getProjectSubagentsDir } from "../../shared/artifacts.ts";
8
8
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
9
9
  import { ChainClarifyComponent, type ChainClarifyResult } from "./chain-clarify.ts";
10
10
  import { resolveEffectiveThinking, toModelInfo, type ModelInfo } from "../../shared/model-info.ts";
@@ -39,6 +39,7 @@ import {
39
39
  isParallelStep,
40
40
  isDynamicParallelStep,
41
41
  resolveChainPath,
42
+ resolveExistingReadPaths,
42
43
  resolveStepBehavior,
43
44
  suppressProgressForReadOnlyTask,
44
45
  taskDisallowsFileUpdates,
@@ -51,7 +52,7 @@ import {
51
52
  type StepOverrides,
52
53
  } from "../../shared/settings.ts";
53
54
  import { discoverAvailableSkills, normalizeSkillInput } from "../../agents/skills.ts";
54
- import { buildAsyncRunnerSteps, executeAsyncChain, executeAsyncSingle, formatAsyncStartedMessage, isAsyncAvailable, workflowAwaitedAsyncResultPath } from "../background/async-execution.ts";
55
+ import { buildAsyncRunnerSteps, DEFAULT_ASYNC_TIMEOUT_MS, executeAsyncChain, executeAsyncSingle, formatAsyncStartedMessage, isAsyncAvailable, workflowAwaitedAsyncResultPath } from "../background/async-execution.ts";
55
56
  import { isScheduledRunAction, type ScheduledRunAction } from "../background/scheduled-runs.ts";
56
57
  import { enqueueChainAppendRequest, readPendingChainAppendRequests, runnerStepOutputNames } from "../background/chain-append.ts";
57
58
  import { ChainOutputValidationError, validateChainOutputBindingsWithContext } from "../shared/chain-outputs.ts";
@@ -84,6 +85,13 @@ import { applySteeringRecoveryAgentConfig, buildRevivedAsyncTask, resolveAsyncRe
84
85
  import { deliverCheckpointDecisionRequest, deliverInterruptRequest, readRevivalBriefs, requestAsyncSteer, type SteerDeliveryMode } from "../background/control-channel.ts";
85
86
  import { updateSteeringTarget, waitForSteeringAction } from "../background/steering.ts";
86
87
  import { steerAsyncRun } from "./async-steering-action.ts";
88
+ import {
89
+ removeWorkflowForegroundSteeringRoute,
90
+ resolveWorkflowForegroundSteeringTarget,
91
+ steerWorkflowForegroundTarget,
92
+ workflowForegroundSteeringDir,
93
+ workflowForegroundSteeringLaunchOptions,
94
+ } from "./workflow-foreground-steering.ts";
87
95
  import { stopAsyncRun } from "./async-stop-action.ts";
88
96
  import { reconcileAsyncRun } from "../background/stale-run-reconciler.ts";
89
97
  import { resolveAsyncRootResultPath, waitForImportedAsyncRoot } from "../background/chain-root-attachment.ts";
@@ -403,6 +411,7 @@ function resolveRequestedCwd(runtimeCwd: string, requestedCwd: string | undefine
403
411
  function removeForegroundControlIfIdle(state: SubagentState, runId: string): boolean {
404
412
  const control = state.foregroundControls.get(runId);
405
413
  if (control && (!foregroundSchedulingSettled(control) || (control.activeChildren?.size ?? 0) > 0)) return false;
414
+ if (control) removeWorkflowForegroundSteeringRoute(control);
406
415
  state.foregroundControls.delete(runId);
407
416
  if (state.lastForegroundControlId === runId) state.lastForegroundControlId = null;
408
417
  return true;
@@ -1450,7 +1459,7 @@ async function resumeAsyncRun(input: {
1450
1459
  inheritProjectContext: recoveryDescriptor.inheritProjectContext,
1451
1460
  inheritSkills: recoveryDescriptor.inheritSkills,
1452
1461
  source: "project",
1453
- filePath: recoveryDescriptor.agentFilePath ?? path.join(recoveryDescriptor.cwd, ".pi-subagents-recovery-agent"),
1462
+ filePath: recoveryDescriptor.agentFilePath ?? path.join(getProjectSubagentsDir(recoveryDescriptor.cwd), "recovery-agent"),
1454
1463
  } : undefined);
1455
1464
  if (!agentConfig) {
1456
1465
  return {
@@ -2096,7 +2105,17 @@ function applySingleAgentLaunchDefaults(params: SubagentParamsLike, agents: Agen
2096
2105
 
2097
2106
  export const DEFAULT_FOREGROUND_TIMEOUT_MS = 30 * 60 * 1000;
2098
2107
 
2099
- function resolveForegroundTimeout(params: SubagentParamsLike, defaultTimeoutMs?: number): { timeoutMs?: number; error?: string } {
2108
+ // Async single-agent runs also need a wall-clock backstop: a child whose bash
2109
+ // tool blocks forever (e.g. a background process inheriting the terminal with
2110
+ // no bash `timeout` arg) would otherwise hang the parent indefinitely with
2111
+ // zero signal. Same generous default as foreground; explicit timeoutMs/
2112
+ // maxRuntimeMs and agent-level defaultTimeoutMs remain authoritative.
2113
+ //
2114
+ // Deliberately NOT applied at the workflow level: async scripted workflows
2115
+ // stay unbounded as a whole, while each runner child has its own deadline.
2116
+ export { DEFAULT_ASYNC_TIMEOUT_MS };
2117
+
2118
+ export function resolveForegroundTimeout(params: SubagentParamsLike, defaultTimeoutMs?: number): { timeoutMs?: number; error?: string } {
2100
2119
  const rawTimeout = params.timeoutMs;
2101
2120
  const rawMaxRuntime = params.maxRuntimeMs;
2102
2121
  if (rawTimeout === undefined && rawMaxRuntime === undefined) {
@@ -2115,6 +2134,21 @@ function resolveForegroundTimeout(params: SubagentParamsLike, defaultTimeoutMs?:
2115
2134
  return timeoutMs === undefined ? {} : { timeoutMs };
2116
2135
  }
2117
2136
 
2137
+ /**
2138
+ * Resolve the effective launch timeout for a single-agent run, applying the
2139
+ * async/foreground default when neither the caller nor the agent set one.
2140
+ *
2141
+ * The async default is deliberately applied only to plain single-agent
2142
+ * launches. Composite launches keep their top-level execution unbounded when
2143
+ * no timeout is set; their runner children resolve separate deadlines.
2144
+ * Exported so the executor wiring is directly testable.
2145
+ */
2146
+ export function resolveSingleAgentLaunchTimeout(params: SubagentParamsLike, async: boolean): { timeoutMs?: number; error?: string } {
2147
+ const isComposite = (params.chain?.length ?? 0) > 0 || (params.tasks?.length ?? 0) > 0 || params.workflowScript !== undefined;
2148
+ const defaultTimeoutMs = !async ? DEFAULT_FOREGROUND_TIMEOUT_MS : isComposite ? undefined : DEFAULT_ASYNC_TIMEOUT_MS;
2149
+ return resolveForegroundTimeout(params, defaultTimeoutMs);
2150
+ }
2151
+
2118
2152
  function resolveToolBudget(
2119
2153
  raw: unknown,
2120
2154
  label = "toolBudget",
@@ -3155,6 +3189,7 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
3155
3189
  const result = await runSync(input.ctx.cwd, input.agents, task.agent, taskText, compactOptional<Parameters<typeof runSync>[4]>({
3156
3190
  permissions: input.permissions,
3157
3191
  parentSessionId: input.ctx.sessionManager.getSessionId() ?? undefined,
3192
+ ...workflowForegroundSteeringLaunchOptions(input.foregroundControl, index),
3158
3193
  context: input.contextPolicy.contextForAgent(task.agent),
3159
3194
  cwd: taskCwd,
3160
3195
  signal: input.signal,
@@ -3830,8 +3865,9 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3830
3865
  // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
3831
3866
  // absolute paths pass through; relative paths resolve against the child cwd.
3832
3867
  const reads = readsOverride !== undefined ? readsOverride : agentConfig.defaultReads ?? false;
3833
- const readsInstruction = Array.isArray(reads) && reads.length > 0
3834
- ? `[Read from: ${reads.map((f) => resolveChainPath(f, effectiveCwd)).join(", ")}]\n\n`
3868
+ const readPaths = Array.isArray(reads) ? resolveExistingReadPaths(reads, effectiveCwd) : [];
3869
+ const readsInstruction = readPaths.length > 0
3870
+ ? `[Read from: ${readPaths.join(", ")}]\n\n`
3835
3871
  : "";
3836
3872
  task = readsInstruction + task;
3837
3873
  task = injectSingleOutputInstruction(task, outputPath, agentConfig);
@@ -3875,6 +3911,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3875
3911
  r = await runSync(ctx.cwd, agents, params.agent!, task, compactOptional<Parameters<typeof runSync>[4]>({
3876
3912
  permissions: deps.config.permissions,
3877
3913
  parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
3914
+ ...workflowForegroundSteeringLaunchOptions(foregroundControl, 0),
3878
3915
  context: data.contextPolicy.contextForAgent(params.agent!),
3879
3916
  cwd: effectiveCwd,
3880
3917
  signal,
@@ -4483,10 +4520,24 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4483
4520
  void Promise.resolve().then(async () => {
4484
4521
  const workflowResults: SingleResult[] = [];
4485
4522
  const { action: _action, agent: _agent, task: _task, resume: _resume, tasks: _tasks, chain: _chain, concurrency: _concurrency, foregroundOnly: _foregroundOnly, clarify: _clarify, timeoutMs: _timeoutMs, maxRuntimeMs: _maxRuntimeMs, usageBudget: _usageBudget, missionId: _missionId, mission: _mission, ...workflowChildDefaults } = workflowRequest;
4523
+ const workflowSteps = new Map<string, NonNullable<AsyncStatus["steps"]>[number]>();
4524
+ let projectedTraceLength = 0;
4525
+ let projectedTraceTail: NonNullable<Details["workflow"]>["trace"][number] | undefined;
4486
4526
  const updateTrace = (trace: NonNullable<Details["workflow"]>["trace"]) => {
4487
4527
  status.workflow = { ...(status.workflow ?? { emits: [], console: [] }), trace };
4488
- for (const entry of trace.filter((candidate) => candidate.operation === "run")) {
4489
- const existing = status.steps?.find((step) => step.workflowKey === entry.key);
4528
+ const rebuild = trace.length < projectedTraceLength
4529
+ || (projectedTraceLength > 0 && trace[projectedTraceLength - 1] !== projectedTraceTail);
4530
+ if (rebuild) {
4531
+ workflowSteps.clear();
4532
+ for (const step of status.steps ?? []) {
4533
+ if (step.workflowKey) workflowSteps.set(step.workflowKey, step);
4534
+ }
4535
+ projectedTraceLength = 0;
4536
+ }
4537
+ for (let index = projectedTraceLength; index < trace.length; index += 1) {
4538
+ const entry = trace[index]!;
4539
+ if (entry.operation !== "run") continue;
4540
+ const existing = workflowSteps.get(entry.key);
4490
4541
  if (entry.state === "reused" && existing) continue;
4491
4542
  const mapped = entry.state === "started" || entry.state === "reused" ? "running" : entry.state === "completed" ? "completed" : "failed";
4492
4543
  if (existing) {
@@ -4497,9 +4548,13 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4497
4548
  if (entry.durationMs === undefined) delete existing.durationMs;
4498
4549
  else existing.durationMs = entry.durationMs;
4499
4550
  } else {
4500
- status.steps?.push({ agent: entry.agent ?? entry.key, label: entry.key, workflowKey: entry.key, parentWorkflowRunId: workflowRunId, status: mapped, startedAt: Date.now() });
4551
+ const step: NonNullable<AsyncStatus["steps"]>[number] = { agent: entry.agent ?? entry.key, label: entry.key, workflowKey: entry.key, parentWorkflowRunId: workflowRunId, status: mapped, startedAt: Date.now() };
4552
+ status.steps?.push(step);
4553
+ workflowSteps.set(entry.key, step);
4501
4554
  }
4502
4555
  }
4556
+ projectedTraceLength = trace.length;
4557
+ projectedTraceTail = trace.at(-1);
4503
4558
  projectWorkflowActivity();
4504
4559
  persist();
4505
4560
  appendWorkflowEvent({ type: "subagent.workflow.trace", trace });
@@ -5089,6 +5144,12 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
5089
5144
  try {
5090
5145
  const location = resolveAsyncRunLocation(paramsWithResolvedCwd, DIRS.async, DIRS.results);
5091
5146
  const runId = location.resolvedId ?? targetRunId ?? path.basename(location.asyncDir ?? paramsWithResolvedCwd.dir);
5147
+ const directoryStatus = location.asyncDir ? readStatus(location.asyncDir) : null;
5148
+ if (directoryStatus?.mode === "workflow") {
5149
+ const route = resolveWorkflowForegroundSteeringTarget({ state: deps.state, workflowRunId: directoryStatus.runId || runId, asyncDirRoot: DIRS.async });
5150
+ if (!route.ok) return { content: [{ type: "text", text: route.message }], isError: true, details: { mode: "management", results: [] } };
5151
+ return steerWorkflowForegroundTarget({ target: route.target, message, mode: paramsWithResolvedCwd.mode, index: paramsWithResolvedCwd.index, signal });
5152
+ }
5092
5153
  if (location.asyncDir) {
5093
5154
  const unsupported = externalRunnerControlError(location.asyncDir, "steer");
5094
5155
  if (unsupported) return unsupported;
@@ -5124,8 +5185,18 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
5124
5185
  return { content: [{ type: "text", text }], isError: true, details: { mode: "management", results: [] } };
5125
5186
  }
5126
5187
  if (resolved?.kind === "nested") return steerNestedRun(omitUndefinedProperties({ target: resolved, message, mode: paramsWithResolvedCwd.mode, index: paramsWithResolvedCwd.index, signal }));
5127
- if (resolved?.kind === "foreground") return { content: [{ type: "text", text: "action='steer' currently supports live async Pi child sessions only; use action='interrupt' or action='resume' for foreground runs." }], isError: true, details: { mode: "management", results: [] } };
5188
+ if (resolved?.kind === "foreground") {
5189
+ const route = resolveWorkflowForegroundSteeringTarget({ state: deps.state, childRunId: resolved.id, asyncDirRoot: DIRS.async });
5190
+ if (!route.ok) return { content: [{ type: "text", text: route.message }], isError: true, details: { mode: "management", results: [] } };
5191
+ return steerWorkflowForegroundTarget({ target: route.target, message, mode: paramsWithResolvedCwd.mode, index: paramsWithResolvedCwd.index, signal });
5192
+ }
5128
5193
  if (resolved?.kind !== "async") return { content: [{ type: "text", text: `No async run found for '${targetRunId}'.` }], isError: true, details: { mode: "management", results: [] } };
5194
+ const resolvedStatus = resolved.location.asyncDir ? readStatus(resolved.location.asyncDir) : null;
5195
+ if (resolvedStatus?.mode === "workflow") {
5196
+ const route = resolveWorkflowForegroundSteeringTarget({ state: deps.state, workflowRunId: resolvedStatus.runId || resolved.id, asyncDirRoot: DIRS.async });
5197
+ if (!route.ok) return { content: [{ type: "text", text: route.message }], isError: true, details: { mode: "management", results: [] } };
5198
+ return steerWorkflowForegroundTarget({ target: route.target, message, mode: paramsWithResolvedCwd.mode, index: paramsWithResolvedCwd.index, signal });
5199
+ }
5129
5200
  if (resolved.location.asyncDir) {
5130
5201
  const unsupported = externalRunnerControlError(resolved.location.asyncDir, "steer");
5131
5202
  if (unsupported) return unsupported;
@@ -5432,9 +5503,9 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
5432
5503
  if (externalAgent && (!effectiveAsync || effectiveParams.foregroundOnly === true)) {
5433
5504
  return buildRequestedModeError(effectiveParams, `Agent '${externalAgent.name}' uses runner.type='external-cli', which currently supports async/background execution only. Omit async or pass async:true; clarify and foregroundOnly are unsupported.`);
5434
5505
  }
5435
- const foregroundTimeout = resolveForegroundTimeout(
5506
+ const foregroundTimeout = resolveSingleAgentLaunchTimeout(
5436
5507
  effectiveParams,
5437
- effectiveAsync ? undefined : DEFAULT_FOREGROUND_TIMEOUT_MS,
5508
+ effectiveAsync,
5438
5509
  );
5439
5510
  if (foregroundTimeout.error) return buildRequestedModeError(effectiveParams, foregroundTimeout.error);
5440
5511
  const controlConfig = resolveControlConfig(deps.config.control, effectiveParams.control);
@@ -5584,6 +5655,17 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
5584
5655
  const foregroundDescription = effectiveParams.task?.trim()
5585
5656
  || effectiveParams.tasks?.[0]?.task?.trim()
5586
5657
  || (effectiveParams.chain ? firstRawChainTask(effectiveParams.chain)?.trim() : undefined);
5658
+ const parentWorkflowStatus = effectiveParams.workflowParentRunId
5659
+ ? readStatus(path.join(DIRS.async, effectiveParams.workflowParentRunId))
5660
+ : null;
5661
+ const workflowSteeringDir = effectiveParams.workflowParentRunId
5662
+ && requestSessionId
5663
+ && deps.state.workflowControllers?.has(effectiveParams.workflowParentRunId)
5664
+ && parentWorkflowStatus?.mode === "workflow"
5665
+ && (parentWorkflowStatus.state === "running" || parentWorkflowStatus.state === "queued")
5666
+ && parentWorkflowStatus.sessionId === requestSessionId
5667
+ ? workflowForegroundSteeringDir(DIRS.async, effectiveParams.workflowParentRunId, runId)
5668
+ : undefined;
5587
5669
  const foregroundControl: ForegroundRunControl | undefined = effectiveAsync
5588
5670
  ? undefined
5589
5671
  : compactOptional<ForegroundRunControl>({
@@ -5592,6 +5674,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
5592
5674
  mode: foregroundMode,
5593
5675
  ...(effectiveParams.workflowParentRunId ? { parentWorkflowRunId: effectiveParams.workflowParentRunId } : {}),
5594
5676
  ...(effectiveParams.workflowKey ? { workflowKey: effectiveParams.workflowKey } : {}),
5677
+ workflowSteeringDir,
5595
5678
  startedAt: Date.now(),
5596
5679
  updatedAt: Date.now(),
5597
5680
  cwd: effectiveCwd,
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
+ import type { Details, ForegroundRunControl, SubagentState } from "../../shared/types.ts";
6
+ import { readStatus } from "../../shared/utils.ts";
7
+ import {
8
+ consumeSteerAckFromDir,
9
+ readSteerCapability,
10
+ steerAcksDir,
11
+ steerCapabilityPath,
12
+ stepSteerInboxDir,
13
+ writeSteerRequestToExistingDir,
14
+ type SteerDeliveryMode,
15
+ type SteerRequest,
16
+ } from "../background/control-channel.ts";
17
+
18
+ export interface WorkflowForegroundSteeringTarget {
19
+ control: ForegroundRunControl;
20
+ workflowRunId: string;
21
+ sourceRunId: string;
22
+ }
23
+
24
+ export type WorkflowForegroundSteeringResolution =
25
+ | { ok: true; target: WorkflowForegroundSteeringTarget }
26
+ | { ok: false; message: string };
27
+
28
+ function activeWorkflowError(state: SubagentState, workflowRunId: string, asyncDirRoot: string): string | undefined {
29
+ if (!state.currentSessionId) return "Workflow steering requires an active parent session.";
30
+ if (!state.workflowControllers?.has(workflowRunId)) return `Workflow '${workflowRunId}' has no live foreground child.`;
31
+ const status = readStatus(path.join(asyncDirRoot, workflowRunId));
32
+ if (!status || status.mode !== "workflow" || (status.state !== "running" && status.state !== "queued")) {
33
+ return `Workflow '${workflowRunId}' has no live foreground child.`;
34
+ }
35
+ if (status.sessionId !== state.currentSessionId) return `Workflow '${workflowRunId}' was not found in the active session.`;
36
+ return undefined;
37
+ }
38
+
39
+ function controlIsLiveInWorkflow(control: ForegroundRunControl, workflowRunId: string, sessionId: string): boolean {
40
+ return control.parentWorkflowRunId === workflowRunId
41
+ && control.sessionId === sessionId
42
+ && Boolean(control.workflowSteeringDir)
43
+ && (control.activeChildren?.size ?? 0) > 0;
44
+ }
45
+
46
+ export function resolveWorkflowForegroundSteeringTarget(input: {
47
+ state: SubagentState;
48
+ childRunId?: string;
49
+ workflowRunId?: string;
50
+ asyncDirRoot: string;
51
+ }): WorkflowForegroundSteeringResolution {
52
+ const { state, childRunId, asyncDirRoot } = input;
53
+ if (childRunId) {
54
+ const control = state.foregroundControls.get(childRunId);
55
+ if (!control?.parentWorkflowRunId) return { ok: false, message: `Foreground run '${childRunId}' is not a live workflow-owned child.` };
56
+ const workflowRunId = control.parentWorkflowRunId;
57
+ const workflowError = activeWorkflowError(state, workflowRunId, asyncDirRoot);
58
+ if (workflowError) return { ok: false, message: workflowError };
59
+ if (!controlIsLiveInWorkflow(control, workflowRunId, state.currentSessionId!)) {
60
+ return { ok: false, message: `Foreground run '${childRunId}' is not a live workflow-owned child in the active session.` };
61
+ }
62
+ return { ok: true, target: { control, workflowRunId, sourceRunId: childRunId } };
63
+ }
64
+
65
+ const workflowRunId = input.workflowRunId;
66
+ if (!workflowRunId) return { ok: false, message: "Workflow steering requires a workflow or child run id." };
67
+ const workflowError = activeWorkflowError(state, workflowRunId, asyncDirRoot);
68
+ if (workflowError) return { ok: false, message: workflowError };
69
+ const controls = [...state.foregroundControls.values()].filter((control) => controlIsLiveInWorkflow(control, workflowRunId, state.currentSessionId!));
70
+ if (controls.length === 0) return { ok: false, message: `Workflow '${workflowRunId}' has no live foreground child.` };
71
+ if (controls.length > 1) return { ok: false, message: `Workflow '${workflowRunId}' has ${controls.length} live foreground children; steer a child run id instead.` };
72
+ return { ok: true, target: { control: controls[0]!, workflowRunId, sourceRunId: workflowRunId } };
73
+ }
74
+
75
+ function managementError(message: string): AgentToolResult<Details> {
76
+ return { content: [{ type: "text", text: message }], isError: true, details: { mode: "management", results: [] } };
77
+ }
78
+
79
+ export async function steerWorkflowForegroundTarget(input: {
80
+ target: WorkflowForegroundSteeringTarget;
81
+ message: string;
82
+ mode?: SteerDeliveryMode;
83
+ index?: number;
84
+ signal?: AbortSignal;
85
+ ackTimeoutMs?: number;
86
+ }): Promise<AgentToolResult<Details>> {
87
+ const { control, sourceRunId } = input.target;
88
+ const routeDir = control.workflowSteeringDir;
89
+ if (!routeDir || !fs.existsSync(routeDir)) return managementError(`Foreground run '${control.runId}' has no live workflow steering route.`);
90
+ const activeIndexes = [...(control.activeChildren?.keys() ?? [])].sort((left, right) => left - right);
91
+ const index = input.index ?? (activeIndexes.length === 1 ? activeIndexes[0] : undefined);
92
+ if (index === undefined) {
93
+ return managementError(activeIndexes.length === 0
94
+ ? `Foreground run '${control.runId}' has no live child session.`
95
+ : `Foreground run '${control.runId}' has ${activeIndexes.length} live child sessions; provide index.`);
96
+ }
97
+ if (!activeIndexes.includes(index)) return managementError(`Foreground run '${control.runId}' child ${index} is not live.`);
98
+ const capability = readSteerCapability(routeDir, index);
99
+ if (capability?.supported === false) return managementError(`Foreground run '${control.runId}' child ${index} does not support steering.`);
100
+
101
+ const request: SteerRequest = {
102
+ type: "steer",
103
+ id: randomUUID(),
104
+ ts: Date.now(),
105
+ message: input.message.trim(),
106
+ ...(input.mode && input.mode !== "steer" ? { mode: input.mode } : {}),
107
+ targetIndex: index,
108
+ source: "steer-action",
109
+ };
110
+ try {
111
+ writeSteerRequestToExistingDir(stepSteerInboxDir(routeDir, index), request);
112
+ } catch (error) {
113
+ if (typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
114
+ return managementError(`Foreground run '${control.runId}' has no live workflow steering route.`);
115
+ }
116
+ return managementError(`Failed to queue steering for foreground run ${control.runId}: ${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+
119
+ const deadline = Date.now() + (input.ackTimeoutMs ?? 3_000);
120
+ let ack;
121
+ let routeRemoved = false;
122
+ while (Date.now() <= deadline) {
123
+ ack = consumeSteerAckFromDir(steerAcksDir(routeDir, index), request.id);
124
+ if (ack || input.signal?.aborted) break;
125
+ if (!fs.existsSync(routeDir)) {
126
+ routeRemoved = true;
127
+ break;
128
+ }
129
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, deadline - Date.now()))));
130
+ }
131
+ if (routeRemoved || (!ack && !input.signal?.aborted && !fs.existsSync(routeDir))) {
132
+ return managementError(`Foreground run '${control.runId}' has no live child session.`);
133
+ }
134
+ const target = ack?.state === "delivered"
135
+ ? { index, state: "delivered" as const, deliveredAt: ack.ts }
136
+ : ack?.state === "queued"
137
+ ? { index, state: "queued" as const }
138
+ : ack?.state === "failed"
139
+ ? { index, state: "failed" as const, reason: ack.message }
140
+ : { index, state: "pending" as const };
141
+ const steering = {
142
+ requestId: request.id,
143
+ state: ack?.state === "delivered" ? "delivered" as const : ack?.state === "failed" ? "failed" as const : "pending" as const,
144
+ deliveryStatus: ack?.state === "delivered" ? "delivered" as const : "queued" as const,
145
+ sourceRunId,
146
+ targets: [target],
147
+ };
148
+ if (input.signal?.aborted) {
149
+ return { content: [{ type: "text", text: `Steering pending for foreground run ${control.runId} (request ${request.id}); caller aborted before acknowledgment.` }], details: { mode: "management", results: [], steering } };
150
+ }
151
+ if (ack?.state === "delivered") {
152
+ return { content: [{ type: "text", text: `Steering delivered for foreground run ${control.runId} (request ${request.id}).` }], details: { mode: "management", results: [], steering } };
153
+ }
154
+ if (ack?.state === "queued") {
155
+ return { content: [{ type: "text", text: `Steering queued for foreground run ${control.runId} (request ${request.id}).` }], details: { mode: "management", results: [], steering } };
156
+ }
157
+ if (ack?.state === "failed") {
158
+ return { content: [{ type: "text", text: `Steering failed for foreground run ${control.runId} (request ${request.id}): ${ack.message}` }], isError: true, details: { mode: "management", results: [], steering } };
159
+ }
160
+ return { content: [{ type: "text", text: `Steering pending for foreground run ${control.runId} (request ${request.id}); no acknowledgment was received.` }], details: { mode: "management", results: [], steering } };
161
+ }
162
+
163
+ export function workflowForegroundSteeringDir(asyncDirRoot: string, workflowRunId: string, childRunId: string): string {
164
+ return path.join(asyncDirRoot, workflowRunId, "control", "workflow-foreground", childRunId);
165
+ }
166
+
167
+ export function removeWorkflowForegroundSteeringRoute(control: ForegroundRunControl): void {
168
+ if (!control.workflowSteeringDir) return;
169
+ try {
170
+ fs.rmSync(control.workflowSteeringDir, { recursive: true, force: true });
171
+ } catch (error) {
172
+ console.warn(`[pi-subagents] Failed to remove workflow foreground steering route '${control.workflowSteeringDir}': ${error instanceof Error ? error.message : String(error)}`);
173
+ }
174
+ }
175
+
176
+ export function workflowForegroundSteeringLaunchOptions(control: ForegroundRunControl | undefined, index: number): Pick<import("../../shared/types.ts").RunSyncOptions, "steerInboxDir" | "steerCapabilityPath" | "steerAckDir"> {
177
+ if (!control?.workflowSteeringDir) return {};
178
+ const steerInboxDir = stepSteerInboxDir(control.workflowSteeringDir, index);
179
+ const steerAckDir = steerAcksDir(control.workflowSteeringDir, index);
180
+ fs.mkdirSync(steerInboxDir, { recursive: true });
181
+ fs.mkdirSync(steerAckDir, { recursive: true });
182
+ return {
183
+ steerInboxDir,
184
+ steerCapabilityPath: steerCapabilityPath(control.workflowSteeringDir, index),
185
+ steerAckDir,
186
+ };
187
+ }
@@ -49,7 +49,7 @@ const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
49
49
  const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "toolBudget", "acceptance", "agentContract", "gateOn"]);
50
50
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
51
51
  ...DYNAMIC_PARALLEL_KEYS,
52
- "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "waitToolEnabled",
52
+ "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "timeoutMs", "waitToolEnabled",
53
53
  "structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "capabilityCeiling", "completionGuard", "systemPrompt",
54
54
  "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId", "launchResolvedExtensions",
55
55
  ]);
@@ -191,7 +191,8 @@ function defaultScopeWarn(violation: ModelScopeViolation): void {
191
191
  *
192
192
  * An explicitly requested model string is resolved via {@link resolveModelCandidate}.
193
193
  * When `options.scope.enforce` is on, an out-of-scope resolved model throws for
194
- * an explicit (`source: "explicit"`) request and warns for an inherited one.
194
+ * an explicit (`source: "explicit"`) request and warns for an inherited one,
195
+ * unless strict scope enforcement makes inherited violations hard errors.
195
196
  */
196
197
  export function resolveSubagentModelOverride(
197
198
  requestedModel: string | boolean | undefined,
@@ -245,7 +246,7 @@ export function resolveEffectiveSubagentModel(
245
246
  }
246
247
 
247
248
  export interface BuildModelCandidatesOptions {
248
- /** Fallback models are inherited agent config and warn, rather than error, when out of scope. */
249
+ /** Fallback models warn by default and throw when strict scope enforcement is enabled. */
249
250
  scope?: ModelScopeConfig;
250
251
  onWarn?: (violation: ModelScopeViolation) => void;
251
252
  }
@@ -265,9 +266,12 @@ export function buildModelCandidates(
265
266
  if (!raw) continue;
266
267
  const normalized = resolveModelCandidate(raw.trim(), availableModels, preferredProvider);
267
268
  if (!normalized || seen.has(normalized)) continue;
268
- if (index > 0 && options?.scope?.enforce) {
269
+ if ((index > 0 || options?.scope?.strict === true) && options?.scope?.enforce) {
269
270
  const violation = checkModelScope(normalized, options.scope, "inherited");
270
- if (violation) (options.onWarn ?? defaultScopeWarn)(violation);
271
+ if (violation) {
272
+ if (violation.severity === "error") throw new Error(violation.message);
273
+ (options.onWarn ?? defaultScopeWarn)(violation);
274
+ }
271
275
  }
272
276
  seen.add(normalized);
273
277
  candidates.push(normalized);
@@ -6,7 +6,8 @@
6
6
  * where the model came from: an explicit caller-supplied model (`--model`,
7
7
  * tool-call `model`, or a TUI clarify pick) is a hard error, while a model
8
8
  * inherited from agent frontmatter / `defaultModel` / the parent session only
9
- * emits a warning so existing configurations keep working.
9
+ * emits a warning so existing configurations keep working. Optional strict
10
+ * enforcement makes inherited models hard errors too.
10
11
  *
11
12
  * The decision logic ({@link checkModelScope}) is a pure function of its
12
13
  * inputs so it can be unit-tested without touching the filesystem or config.
@@ -16,6 +17,8 @@ import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
16
17
 
17
18
  export interface ModelScopeConfig {
18
19
  enforce?: boolean;
20
+ /** Reject inherited and fallback models outside the allowlist instead of warning. */
21
+ strict?: boolean;
19
22
  /** Glob-style allow patterns (only `*` is special), matched against `provider/id`. */
20
23
  allow?: string[];
21
24
  }
@@ -67,7 +70,7 @@ export function checkModelScope(
67
70
  if (allow.some((pattern) => matchesScopePattern(model, pattern))) return undefined;
68
71
 
69
72
  const baseModel = stripThinkingSuffix(model);
70
- const severity: ModelScopeViolation["severity"] = source === "explicit" ? "error" : "warn";
73
+ const severity: ModelScopeViolation["severity"] = source === "explicit" || scope.strict === true ? "error" : "warn";
71
74
  return {
72
75
  model: baseModel,
73
76
  severity,
@@ -102,6 +105,13 @@ export function parseModelScopeConfig(
102
105
  config.enforce = input.enforce;
103
106
  }
104
107
 
108
+ if ("strict" in input) {
109
+ if (typeof input.strict !== "boolean") {
110
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.strict'; expected a boolean.`);
111
+ }
112
+ config.strict = input.strict;
113
+ }
114
+
105
115
  if ("allow" in input) {
106
116
  if (!Array.isArray(input.allow)) {
107
117
  throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
@@ -40,6 +40,7 @@ export interface RunnerSubagentStep {
40
40
  outputMode?: "inline" | "file-only";
41
41
  sessionFile?: string;
42
42
  maxSubagentDepth?: number;
43
+ timeoutMs?: number;
43
44
  waitToolEnabled?: boolean;
44
45
  structuredOutput?: {
45
46
  schema: import("../../shared/types.ts").JsonSchemaObject;
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { resolveAuthorityDecision, type AuthorityPolicyConfig } from "../../policy/authority.ts";
6
+ import { PROJECT_SUBAGENTS_RELATIVE_DIR } from "../../shared/artifacts.ts";
6
7
 
7
8
  export interface WorktreeSetup {
8
9
  cwd: string;
@@ -135,9 +136,9 @@ function resolveRepoState(cwd: string): RepoState {
135
136
  const cwdRelative = resolveRepoCwdRelative(cwd);
136
137
  const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
137
138
 
138
- // pi-subagents writes durable runtime state under .pi-subagents/ by default;
139
+ // pi-subagents writes durable runtime state under .pi/subagents/ by default;
139
140
  // that state must not make managed isolation unusable for later runs.
140
- const status = runGitChecked(toplevel, ["status", "--porcelain", "--", ":!.pi-subagents"]);
141
+ const status = runGitChecked(toplevel, ["status", "--porcelain", "--", `:!${PROJECT_SUBAGENTS_RELATIVE_DIR}`]);
141
142
  if (status.trim().length > 0) {
142
143
  throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
143
144
  }
@@ -3,19 +3,19 @@ import * as path from "node:path";
3
3
  import { CHAIN_RUNS_DIR, TEMP_ARTIFACTS_DIR, type ArtifactPaths, type ArtifactDirPreference } from "./types.ts";
4
4
  import { getAgentDir } from "./utils.ts";
5
5
  const CLEANUP_MARKER_FILE = ".last-cleanup";
6
- const PROJECT_ARTIFACT_ROOT = ".pi-subagents";
6
+ export const PROJECT_SUBAGENTS_RELATIVE_DIR = ".pi/subagents";
7
7
 
8
8
  const PROJECT_ARTIFACT_PATHS = [
9
- `${PROJECT_ARTIFACT_ROOT}/artifacts/output.md`,
10
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_input.md`,
11
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_output.md`,
12
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker.jsonl`,
13
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_transcript.jsonl`,
14
- `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_meta.json`,
15
- `${PROJECT_ARTIFACT_ROOT}/artifacts/progress/run/progress.md`,
16
- `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/output.md`,
17
- `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/run/output.md`,
18
- `${PROJECT_ARTIFACT_ROOT}/chain-runs/run.json`,
9
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/output.md`,
10
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker_input.md`,
11
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker_output.md`,
12
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker.jsonl`,
13
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker_transcript.jsonl`,
14
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/run_worker_meta.json`,
15
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/progress/run/progress.md`,
16
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/outputs/output.md`,
17
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/artifacts/outputs/run/output.md`,
18
+ `${PROJECT_SUBAGENTS_RELATIVE_DIR}/chain-runs/run.json`,
19
19
  ];
20
20
 
21
21
  function globMatchesPath(pattern: string, filePath: string): boolean {
@@ -59,7 +59,7 @@ function normalizePattern(pattern: string): string {
59
59
 
60
60
  function patternMatchesArtifactPath(pattern: string, artifactPath: string): boolean {
61
61
  const normalized = normalizePattern(pattern);
62
- return normalized === PROJECT_ARTIFACT_ROOT
62
+ return normalized === PROJECT_SUBAGENTS_RELATIVE_DIR
63
63
  || normalized === "*"
64
64
  || artifactPath.startsWith(`${normalized}/`)
65
65
  || globMatchesPath(normalized, artifactPath);
@@ -127,11 +127,11 @@ export function getProjectArtifactPackagingWarning(cwd: string): string | undefi
127
127
  const ignorePath = fs.existsSync(npmIgnorePath) ? npmIgnorePath : path.join(cwd, ".gitignore");
128
128
  if (filesIncludeArtifacts === undefined && ignoreFileExcludesProjectArtifacts(ignorePath)) return undefined;
129
129
 
130
- return "Project-scoped subagent artifacts can be included when this package is published. Add '.pi-subagents/' to .npmignore, restrict package.json files, or set artifactDir to 'session' or 'temp'.";
130
+ return "Project-scoped subagent artifacts can be included when this package is published. Add '.pi/subagents/' to .npmignore, restrict package.json files, or set artifactDir to 'session' or 'temp'.";
131
131
  }
132
132
 
133
133
  export function getProjectSubagentsDir(cwd: string): string {
134
- return path.join(cwd, PROJECT_ARTIFACT_ROOT);
134
+ return path.join(cwd, PROJECT_SUBAGENTS_RELATIVE_DIR);
135
135
  }
136
136
 
137
137
  export function getProjectArtifactsDir(cwd: string): string {