pi-subagents 0.47.1 → 0.48.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 (46) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +2 -0
  3. package/docs/configuration.md +46 -2
  4. package/docs/observability.md +1 -1
  5. package/docs/tool-reference.md +1 -1
  6. package/package.json +1 -1
  7. package/src/agents/agents.ts +9 -3
  8. package/src/extension/config.ts +6 -0
  9. package/src/extension/doctor.ts +40 -0
  10. package/src/extension/index.ts +21 -0
  11. package/src/extension/public-execution.ts +5 -0
  12. package/src/extension/rpc.ts +3 -9
  13. package/src/extension/schemas.ts +2 -2
  14. package/src/intercom/intercom-bridge.ts +4 -1
  15. package/src/missions/lifecycle.ts +4 -7
  16. package/src/missions/store.ts +4 -4
  17. package/src/missions/workflow-state.ts +6 -2
  18. package/src/runs/background/active-async-capacity.ts +374 -0
  19. package/src/runs/background/active-run-index.ts +9 -5
  20. package/src/runs/background/async-execution.ts +117 -29
  21. package/src/runs/background/async-resume.ts +7 -1
  22. package/src/runs/background/async-status.ts +11 -5
  23. package/src/runs/background/chain-append.ts +33 -15
  24. package/src/runs/background/owned-process-tree.ts +104 -0
  25. package/src/runs/background/process-terminal.ts +17 -3
  26. package/src/runs/background/run-status.ts +5 -1
  27. package/src/runs/background/stale-run-reconciler.ts +3 -3
  28. package/src/runs/background/subagent-runner.ts +56 -31
  29. package/src/runs/foreground/chain-execution.ts +37 -2
  30. package/src/runs/foreground/execution.ts +90 -19
  31. package/src/runs/foreground/foreground-control.ts +12 -0
  32. package/src/runs/foreground/prompt-audit.ts +171 -0
  33. package/src/runs/foreground/subagent-executor.ts +614 -187
  34. package/src/runs/shared/acceptance.ts +13 -4
  35. package/src/runs/shared/llm-intent-arbiter.ts +286 -0
  36. package/src/runs/shared/parallel-utils.ts +2 -0
  37. package/src/runs/shared/pi-args.ts +44 -1
  38. package/src/runs/shared/run-fanout-budget.ts +280 -0
  39. package/src/runs/shared/single-output.ts +4 -2
  40. package/src/runs/shared/task-intent.ts +19 -3
  41. package/src/runs/shared/worktree.ts +17 -5
  42. package/src/shared/types.ts +92 -0
  43. package/src/shared/utils.ts +3 -1
  44. package/src/tui/fleet-status.ts +7 -5
  45. package/src/tui/fleet.ts +225 -12
  46. package/src/workflows/scripted-workflow.ts +22 -5
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.48.0] - 2026-08-13
6
+
7
+ ### Added
8
+ - Add a durable per-run child fan-out budget with a default cap of 64 across static, dynamic, workflow, and nested child admissions. Thanks to @asjer for #1031.
9
+ - Add an opt-in per-session cap for concurrently active top-level async runs, with atomic admission, resume transfer, status/Fleet/RPC/doctor visibility, and release gated by the verified process-terminal behavior from #1030. Thanks to @asjer for #1029.
10
+ - Add a live Prompt Audit drawer to Fleet for current-session foreground children. Prompt text is visible in the drawer, kept outside serializable Fleet state, and redacted from foreground input, transcript, metadata, result, progress, and run-history artifacts (#1021).
11
+ - Add a global `timeoutMs` config option that sets the default run deadline for single, parallel, and chain launches (foreground, plus plain single-agent async) when neither the call nor the selected agent provides a timeout. It reaches parallel (`tasks: [...]`) and chain launches, which never adopt an agent's frontmatter `timeoutMs` (that default applies to single-agent launches only), so a long fan-out no longer falls back to the built-in 30-minute default and gets killed mid-run. Explicit call `timeoutMs`/`maxRuntimeMs` and agent frontmatter defaults still win; composite async runs stay unbounded at the top level by design. Thanks to @shaharmor for #1018.
12
+ - Add a `PI_SUBAGENT_TASK_DELIVERY` environment setting (`auto` | `file`, default `auto`) controlling how the task text reaches child Pi processes. `file` writes the task to a temp `task.md` referenced as `@<path>` instead of embedding it in argv, for hosts where endpoint protection (EDR) pre-execution command-line scanning denies children whose argv embeds a long natural-language task. Thanks to @yanqianglu for #1028.
13
+ - Escalate startup retries to file task delivery after an unexplained zero-activity `SIGKILL` child exit, so EDR-denied launches self-heal on retry in both foreground and background runs. Thanks to @yanqianglu for #1028.
14
+
15
+ ### Fixed
16
+ - Open Fleet Prompt Audit with the authored task visible by default and show a short live task summary in the normal Fleet detail pane (#1021).
17
+ - Use full task-text hashes for LLM intent arbiter memoization so same-prefix review and implementation tasks cannot share a cached verdict.
18
+ - Terminate async Pi writers as owned POSIX process groups on stop and timeout, and keep terminal process proof unknown until process-tree exit is verified. Thanks to @asjer for #1030.
19
+ - Explain when a requested mission is scoped to another worktree by naming the current project root and mission directory (#1024).
20
+ - Preserve the configured output reference when explicit acceptance rejects an otherwise completed foreground child, so useful reports remain available (#1023).
21
+ - Reject configured worktree base directories inside the agent extensions directory, including symlink aliases (#1014).
22
+ - Align unnamed intercom fallback orchestrator targets with pi-intercom's 18-character registered presence names so subagents without an explicit session name can reach their orchestrator. Thanks to @mystery4f for #1017.
23
+ - Stop reading hyphenated adjectives like "must-fix items" or "should-fix tests" as implementation intent, which made the completion mutation guard hard-fail read-only review runs with a false "completed without making edits" error. Severity compounds (must|should|needs + dash + verb) are stripped before verb matching across every mutation pattern (incl. update/add/apply/make/do siblings), the acceptance-level write-capability check, and the patch-scope pattern, while CLI flags ("eslint --fix", "prettier --write") and clause-level dashes ("branch—fix it") keep their write intent. Thanks to @MarcusNeufeldt for #1020.
24
+ - Add an optional LLM intent arbiter: when the completion guard is about to hard-fail a run that made no edits, a model decides — from the task text alone, never the child's own report — whether the task actually instructed file changes; only a confident read-only verdict rescues the run, before any failure state is published. Covers single, parallel, and chain foreground runs; enabled by default; set `PI_SUBAGENTS_LLM_INTENT_ARBITER=0` to disable. Thanks to @MarcusNeufeldt for #1020.
25
+ - Tolerate empty-string entries in acceptance-report string-array fields instead of rejecting the whole report. Thanks to @hjiang for #1015.
26
+ - Let single external-cli workflow children ignore inherited Pi models so model-less external runners start instead of failing preflight. Thanks to @twosunnus for #1016.
27
+
5
28
  ## [0.47.1] - 2026-08-12
6
29
 
7
30
  ### Fixed
package/README.md CHANGED
@@ -93,6 +93,8 @@ In the TUI, a persistent FleetView below the editor keeps active work visible. `
93
93
 
94
94
  Details, keybindings, and the machine-readable run artifacts are in [Observability](https://github.com/nicobailon/pi-subagents/blob/main/docs/observability.md).
95
95
 
96
+ For bounded orchestration, `maxSubagentSpawnsPerRun` limits cumulative logical children in one run tree. It defaults to 64 and stays separate from active concurrency and the session-wide cumulative spawn budget. See [Configuration](https://github.com/nicobailon/pi-subagents/blob/main/docs/configuration.md#maxsubagentspawnsperrun).
97
+
96
98
  ## If something feels off
97
99
 
98
100
  ```text
@@ -115,6 +115,18 @@ This is different from `waitTool.enabled=false`, which returns immediately witho
115
115
 
116
116
  Forces depth-0 internal single, parallel, and chain runs into background mode and bypasses launch UI by forcing `clarify: false`. Nested calls keep their own inherited settings.
117
117
 
118
+ ## `timeoutMs`
119
+
120
+ ```json
121
+ { "timeoutMs": 3600000 }
122
+ ```
123
+
124
+ Global default runtime deadline, in milliseconds, for subagent runs. It replaces the built-in 30-minute backstop for foreground launches (single, parallel, chain, and workflowScript) and plain single-agent async runs whenever no call-level `timeoutMs`/`maxRuntimeMs` applies. For single-agent launches, selected agent frontmatter `timeoutMs` still wins. This only moves the *default*.
125
+
126
+ Use it when foreground orchestration or plain async single-agent runs need a longer default than 30 minutes. It does not set async composite top-level deadlines, and it does not replace async fan-out child deadlines.
127
+
128
+ Composite async runs (async chains, parallel tasks, and scripted workflows) stay unbounded at the top level by design. Their runner children are bounded individually by their own agent or runner defaults, so this value does not cap them. Must be a positive integer no greater than `2147483647` (the largest delay a Node.js timer can honor, roughly 24.8 days); invalid or out-of-range values are ignored and the built-in defaults apply.
129
+
118
130
  ## `globalConcurrencyLimit`
119
131
 
120
132
  ```json
@@ -131,9 +143,31 @@ Caps simultaneously running children inside existing durable legacy multi-child
131
143
 
132
144
  Optionally caps the total number of child subagent launches during one parent session, including completed and failed children, parallel task counts, static chain steps, and bounded dynamic fanout children. Sessions are unlimited by default. Set this value to `0` to disable a configured cap. `PI_SUBAGENT_MAX_SPAWNS_PER_SESSION` overrides the config for a process and follows the same positive-cap/zero-unlimited semantics.
133
145
 
134
- `subagent({ action: "status" })`, fleet status, and `subagent({ action: "doctor" })` expose used, effective limit, remaining capacity, grants, and the remaining grant allowance. Static chains and parallel calls fail before creating run artifacts or starting partial work when their declared capacity cannot fit. Later retries or unbounded dynamic work are not guaranteed by that preflight.
146
+ `subagent({ action: "status" })`, fleet status, and `subagent({ action: "doctor" })` expose used, effective limit, remaining capacity, grants, and the remaining grant allowance for this budget. A user may explicitly call `subagent({ action: "grant-spawn-budget", additional: 10 })` from the root interactive parent after all children settle and confirm the native prompt. Grants are additive: they never erase cumulative usage, are rejected for unlimited sessions and child/headless callers, and total granted capacity cannot exceed the original configured cap. Compaction remains part of the same logical parent session and does not reset usage or grants; starting a new parent session does.
147
+
148
+ ## `maxSubagentSpawnsPerRun`
149
+
150
+ ```json
151
+ { "maxSubagentSpawnsPerRun": 64 }
152
+ ```
153
+
154
+ Caps cumulative logical child admissions in one top-level run tree. The default is `64`. `PI_SUBAGENT_MAX_SPAWNS_PER_RUN` overrides the config when it is a positive integer. Invalid, zero, or missing values fall back to the configured positive value or `64`.
155
+
156
+ The budget counts single launches, expanded `tasks`/`count`, static chain steps and parallel groups, actual dynamic `expand` items, appended chain steps, workflow children, and nested child calls. Static and materialized dynamic groups are admitted atomically. Startup retries, model fallback, and retained-child resume reuse the original logical child claim. Claims are never released or refunded. This cap is independent from the session-wide cumulative spawn budget and `globalConcurrencyLimit`.
135
157
 
136
- A user may explicitly call `subagent({ action: "grant-spawn-budget", additional: 10 })` from the root interactive parent after all children settle and confirm the native prompt. Grants are additive: they never erase cumulative usage, are rejected for unlimited sessions and child/headless callers, and total granted capacity cannot exceed the original configured cap. Compaction remains part of the same logical parent session and does not reset usage or grants; starting a new parent session does.
158
+ ## `maxActiveAsyncRunsPerSession`
159
+
160
+ ```json
161
+ { "maxActiveAsyncRunsPerSession": 4 }
162
+ ```
163
+
164
+ Optionally caps concurrently active top-level async runs owned by one parent session. Unset or `0` keeps the existing unlimited behavior. A positive integer reserves one slot before an async single, parallel, chain, or workflow creates run artifacts or starts children. Foreground runs and nested/workflow children do not reserve another slot.
165
+
166
+ Queued, running, paused, and needs-attention runs retain capacity. Runner-backed slots release only after terminal logical state and matching observed process-terminal proof from #1030. Missing, malformed, or unknown cleanup proof retains the slot. A terminal async workflow releases after its controller is gone and every launched child is accounted for: awaited foreground children are covered by workflow settlement, while actual background children still require observed process-terminal proof. Resume transfers the source slot without a second charge. Dismissal and history cleanup do not release capacity.
167
+
168
+ This limit bounds current top-level async load. It is separate from cumulative `maxSubagentSpawnsPerSession`, `maxSubagentSpawnsPerRun`, and `globalConcurrencyLimit`.
169
+
170
+ `subagent({ action: "status" })`, fleet status, and `subagent({ action: "doctor" })` expose used, effective limit, and remaining active capacity. Static chains and parallel calls fail before creating run artifacts or starting partial work when their declared capacity cannot fit. Later retries or unbounded dynamic work are not guaranteed by that preflight.
137
171
 
138
172
  ## `scheduledRuns`
139
173
 
@@ -196,6 +230,16 @@ export PI_SUBAGENT_PI_BINARY=/path/to/pi-or-wrapper
196
230
 
197
231
  Overrides the command used to launch child Pi processes. Package wrappers can set this to their own `pi`/agent binary so subagents inherit wrapper flags, environment setup, and bundled resources without relying on `PATH` ordering. Empty or whitespace-only values are ignored.
198
232
 
233
+ ## `PI_SUBAGENT_TASK_DELIVERY`
234
+
235
+ ```bash
236
+ export PI_SUBAGENT_TASK_DELIVERY=file # auto | file (default: auto)
237
+ ```
238
+
239
+ Controls how the task text reaches the child Pi process. `auto` (default) passes short tasks as an inline argv token and writes tasks longer than 8000 characters to a temp `task.md` referenced as `@<path>`. `file` always uses a temp file, keeping the task out of argv entirely.
240
+
241
+ Use `file` on hosts where endpoint protection (EDR) pre-execution scanning denies child processes whose command line embeds a long natural-language task — that denial surfaces as an immediate zero-activity `SIGKILL`. Independently of this setting, startup retries automatically escalate to file delivery after an unexplained zero-activity `SIGKILL`. Empty, whitespace-only, or unrecognized values fall back to `auto`.
242
+
199
243
  ## `intercomBridge`
200
244
 
201
245
  ```json
@@ -4,7 +4,7 @@ Where running subagents show up, how to inspect them, and the files and events t
4
4
 
5
5
  ## Foreground runs
6
6
 
7
- Foreground runs stream progress in the conversation while they run. They default to a generous 30-minute wall-clock timeout when neither the call nor the selected agent provides a timeout; explicit `timeoutMs`/`maxRuntimeMs` and agent defaults win.
7
+ Foreground runs stream progress in the conversation while they run. They default to a generous 30-minute wall-clock timeout when neither the call nor the selected agent provides a timeout; a global [`timeoutMs`](configuration.md#timeoutms) config replaces that default, and explicit `timeoutMs`/`maxRuntimeMs` and agent defaults win.
8
8
 
9
9
  Live progress shows compact detail for single, chain, and parallel modes: current tool, recent output, token counts, aggregate cost, duration, activity freshness, current-tool duration, and chain graph metadata when available.
10
10
 
@@ -43,7 +43,7 @@ Parameters and actions for the `subagent` tool. These are what the LLM passes wh
43
43
  | `agentScope` | `user \| project \| both` | `both` | Agent discovery scope. Project wins on collisions. |
44
44
  | `async` | boolean | default-on | Background execution. Workflows default to background and accept `async:false` as an explicit foreground escape hatch. |
45
45
  | `chatProgress` | `auto \| off \| live-card` | `auto` | WorkflowScript chat projection. `auto` renders a live in-chat card only for watched foreground workflows in the same Git repository, including managed worktrees; it is off otherwise. Explicit `live-card` requires `async:false` and the same Git repository. |
46
- | `timeoutMs` / `maxRuntimeMs` | number | 30 min foreground; none async | Optional run-level max runtime in milliseconds. Foreground uses 30 minutes when omitted. Async runs have no default timeout, including async workflows. |
46
+ | `timeoutMs` / `maxRuntimeMs` | number | config `timeoutMs`, else 30 min foreground / single-agent async | Optional run-level max runtime in milliseconds. When omitted, the global [`timeoutMs`](configuration.md#timeoutms) config provides the default; absent that, foreground and plain single-agent async runs fall back to 30 minutes, while composite async runs (chains, parallel tasks, workflows) stay unbounded at the top level. |
47
47
  | `turnBudget` | object | none | Optional assistant-turn budget `{ maxTurns, graceTurns }`. At `maxTurns` the child is warned to wrap up. After the grace window (default 1), termination occurs at the next assistant boundary; a response that starts tool work records `termination-deferred` until a later boundary. Partial output is returned on abort. |
48
48
  | `toolBudget` | object | none | Optional child tool-call budget `{ soft?, hard, block? }`. At `soft` the child is nudged to finalize. After `hard`, configured tools are blocked; `block` defaults to `read`, `grep`, `find`, and `ls`, while `"*"` blocks every tool call. Final assistant text is never blocked. |
49
49
  | `usageBudget` | object | none | Optional root-only reported-usage budget `{ tokens?: { soft?, hard }, costUsd?: { soft?, hard } }`. Soft limits are status-only. Hard limits prevent later child launches after reported usage is reconciled; already-running children are not stopped and no reservations are made. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.47.1",
3
+ "version": "0.48.0",
4
4
  "description": "Pi extension for single-agent delegation and scripted multi-agent workflows",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -1015,7 +1015,10 @@ function applyBuiltinOverride(
1015
1015
  };
1016
1016
 
1017
1017
  if (override.description !== undefined) next.description = override.description;
1018
- if (override.model !== undefined) { if (override.model === false) delete next.model; else next.model = override.model; }
1018
+ if (override.model !== undefined) {
1019
+ if (override.model === false) delete next.model; else next.model = override.model;
1020
+ delete next.modelSource;
1021
+ }
1019
1022
  if (override.fallbackModels !== undefined) { if (override.fallbackModels === false) delete next.fallbackModels; else next.fallbackModels = [...override.fallbackModels]; }
1020
1023
  if (override.thinking !== undefined) { if (override.thinking === false) delete next.thinking; else next.thinking = override.thinking; }
1021
1024
  if (override.systemPromptMode !== undefined) next.systemPromptMode = override.systemPromptMode;
@@ -1133,8 +1136,11 @@ function applyCustomAgentOverride(
1133
1136
  mutable().description = override.description;
1134
1137
  anyFilled = true;
1135
1138
  }
1136
- if (override.model !== undefined) {
1137
- fill("model", ["model"], override.model === false ? undefined : override.model);
1139
+ if (override.model !== undefined && !agentHasFrontmatterField(agent, "model")) {
1140
+ const target = mutable();
1141
+ if (override.model === false) delete target.model; else target.model = override.model;
1142
+ delete target.modelSource;
1143
+ anyFilled = true;
1138
1144
  }
1139
1145
  if (override.fallbackModels !== undefined) {
1140
1146
  fill(
@@ -53,6 +53,12 @@ function validateConfig(config: Record<string, unknown>): void {
53
53
  if (config.legacyChainControls !== undefined && typeof config.legacyChainControls !== "boolean") {
54
54
  throw new Error("config.legacyChainControls must be a boolean");
55
55
  }
56
+ if (config.maxActiveAsyncRunsPerSession !== undefined
57
+ && (typeof config.maxActiveAsyncRunsPerSession !== "number"
58
+ || !Number.isInteger(config.maxActiveAsyncRunsPerSession)
59
+ || config.maxActiveAsyncRunsPerSession < 0)) {
60
+ throw new Error("config.maxActiveAsyncRunsPerSession must be a non-negative integer");
61
+ }
56
62
  validateMissionStoreConfig(config.missions);
57
63
  validateAuthorityPolicy(config.authorityPolicy);
58
64
  validatePermissionConfig(config.permissions);
@@ -3,6 +3,8 @@ import * as path from "node:path";
3
3
  import { discoverAgentsAll, type AgentSource } from "../agents/agents.ts";
4
4
  import { isAsyncAvailable } from "../runs/background/async-execution.ts";
5
5
  import { formatSpawnBudgetSummary, getSpawnBudgetSnapshot } from "../runs/shared/spawn-budget.ts";
6
+ import { getActiveAsyncCapacitySnapshot, resolveMaxActiveAsyncRunsPerSession } from "../runs/background/active-async-capacity.ts";
7
+ import { decodeRunFanoutBudgetDescriptor, formatRunFanoutBudget, getRunFanoutBudgetSnapshot, RUN_FANOUT_BUDGET_ENV } from "../runs/shared/run-fanout-budget.ts";
6
8
  import { diagnoseIntercomBridge, type IntercomBridgeDiagnostic } from "../intercom/intercom-bridge.ts";
7
9
  import { discoverAvailableSkills, type SkillSource } from "../agents/skills.ts";
8
10
  import {
@@ -11,6 +13,8 @@ import {
11
13
  TEMP_ROOT_DIR,
12
14
  type ExtensionConfig,
13
15
  type SubagentState,
16
+ normalizeMaxSubagentSpawnsPerRun,
17
+ resolveMaxSubagentSpawnsPerRun,
14
18
  } from "../shared/types.ts";
15
19
 
16
20
  interface DoctorPaths {
@@ -175,6 +179,36 @@ function formatSpawnBudgetSection(input: DoctorReportInput): string[] {
175
179
  ];
176
180
  }
177
181
 
182
+ function formatRunFanoutSection(input: DoctorReportInput): string[] {
183
+ try {
184
+ const inherited = decodeRunFanoutBudgetDescriptor(process.env[RUN_FANOUT_BUDGET_ENV]);
185
+ if (inherited) {
186
+ return [`- usage: ${formatRunFanoutBudget(getRunFanoutBudgetSnapshot(inherited)).replace(/^Run fan-out: /, "")}`, `- root run: ${inherited.rootRunId}`, "- reset boundary: cumulative claims are never released; a new top-level run creates a new budget"];
187
+ }
188
+ } catch (error) {
189
+ return [`- inherited budget: invalid — ${errorText(error)}`];
190
+ }
191
+ const configured = resolveMaxSubagentSpawnsPerRun(input.config.maxSubagentSpawnsPerRun);
192
+ const source = normalizeMaxSubagentSpawnsPerRun(process.env.PI_SUBAGENT_MAX_SPAWNS_PER_RUN) !== undefined
193
+ ? "environment"
194
+ : normalizeMaxSubagentSpawnsPerRun(input.config.maxSubagentSpawnsPerRun) !== undefined ? "config" : "default";
195
+ return [`- configured limit: ${configured} (${source})`, "- usage: available after a run starts", "- reset boundary: cumulative claims are never released; a new top-level run creates a new budget"];
196
+ }
197
+
198
+ function formatActiveAsyncCapacitySection(input: DoctorReportInput): string[] {
199
+ const limit = resolveMaxActiveAsyncRunsPerSession(input.config.maxActiveAsyncRunsPerSession);
200
+ const sessionId = input.currentSessionId ?? input.state.currentSessionId;
201
+ const snapshot = sessionId
202
+ ? getActiveAsyncCapacitySnapshot(sessionId, limit, { liveWorkflowRunIds: new Set(input.state.workflowControllers?.keys() ?? []) })
203
+ : { used: 0, limit: limit ?? 0 };
204
+ input.state.activeAsyncCapacity = snapshot;
205
+ return [
206
+ `- usage: ${snapshot.used}/${snapshot.limit || "unlimited"} used`,
207
+ "- scope: top-level async runs in the current parent session; foreground and nested workflow children are not charged again",
208
+ "- release: terminal logical state plus verified process exit; missing or unknown cleanup proof retains capacity",
209
+ ];
210
+ }
211
+
178
212
  function formatPermissionSystemSection(): string[] {
179
213
  const lines: string[] = [];
180
214
  const parentSession = process.env["PI_SUBAGENT_PARENT_SESSION"] ?? "";
@@ -215,6 +249,12 @@ export function buildDoctorReport(input: DoctorReportInput): string {
215
249
  "Spawn budget",
216
250
  ...formatSpawnBudgetSection(input),
217
251
  "",
252
+ "Run fan-out budget",
253
+ ...formatRunFanoutSection(input),
254
+ "",
255
+ "Active async capacity",
256
+ ...formatActiveAsyncCapacitySection(input),
257
+ "",
218
258
  "Permission system",
219
259
  ...formatPermissionSystemSection(),
220
260
  "",
@@ -30,6 +30,7 @@ import { SubagentFleetStatus, resolveFleetViewPlacement } from "../tui/fleet-sta
30
30
  import { createSubagentParamsSchema } from "./schemas.ts";
31
31
  import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
32
32
  import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
33
+ import { getActiveAsyncCapacitySnapshot, resolveMaxActiveAsyncRunsPerSession } from "../runs/background/active-async-capacity.ts";
33
34
  import { createResultWatcher } from "../runs/background/result-watcher.ts";
34
35
  import { createScheduledRunManager } from "../runs/background/scheduled-runs.ts";
35
36
  import { registerSlashCommands } from "../slash/slash-commands.ts";
@@ -65,6 +66,7 @@ import {
65
66
  SLASH_TEXT_RESULT_TYPE,
66
67
  SUBAGENT_ASYNC_COMPLETE_EVENT,
67
68
  SUBAGENT_ASYNC_STARTED_EVENT,
69
+ SUBAGENT_PROCESS_TERMINAL_EVENT,
68
70
  SUBAGENT_CONTROL_EVENT,
69
71
  SUBAGENT_STEERING_NOTICE_EVENT,
70
72
  WIDGET_KEY,
@@ -384,6 +386,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
384
386
  granted: 0,
385
387
  grantHistory: [],
386
388
  },
389
+ activeAsyncCapacity: { used: 0, limit: resolveMaxActiveAsyncRunsPerSession(config.maxActiveAsyncRunsPerSession) ?? 0 },
387
390
  asyncJobs: new Map(),
388
391
  fleetJobs: new Map(),
389
392
  foregroundRuns: new Map(),
@@ -677,12 +680,17 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
677
680
  };
678
681
  const asyncCompleteHandler = (payload: unknown) => {
679
682
  handleComplete(payload);
683
+ refreshActiveAsyncCapacity();
680
684
  scheduledRunManager.handleAsyncCompletion(payload);
681
685
  fleetStatus?.refresh();
682
686
  };
683
687
  const eventUnsubscribes = [
684
688
  pi.events.on(SUBAGENT_ASYNC_STARTED_EVENT, asyncStartedHandler),
685
689
  pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, asyncCompleteHandler),
690
+ pi.events.on(SUBAGENT_PROCESS_TERMINAL_EVENT, () => {
691
+ refreshActiveAsyncCapacity();
692
+ fleetStatus?.refresh();
693
+ }),
686
694
  pi.events.on(SUBAGENT_CONTROL_EVENT, controlEventHandler),
687
695
  pi.events.on(SUBAGENT_STEERING_NOTICE_EVENT, steeringNoticeHandler),
688
696
  herdrStatusBridge.dispose,
@@ -728,6 +736,18 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
728
736
  fleetStatus?.refresh();
729
737
  };
730
738
 
739
+ const refreshActiveAsyncCapacity = () => {
740
+ if (!state.currentSessionId) {
741
+ state.activeAsyncCapacity = { used: 0, limit: resolveMaxActiveAsyncRunsPerSession(config.maxActiveAsyncRunsPerSession) ?? 0 };
742
+ return;
743
+ }
744
+ state.activeAsyncCapacity = getActiveAsyncCapacitySnapshot(
745
+ state.currentSessionId,
746
+ resolveMaxActiveAsyncRunsPerSession(config.maxActiveAsyncRunsPerSession),
747
+ { liveWorkflowRunIds: new Set(state.workflowControllers?.keys() ?? []) },
748
+ );
749
+ };
750
+
731
751
  const resetSessionState = (ctx: ExtensionContext, recovering: boolean) => {
732
752
  state.widgetsSuspended = false;
733
753
  state.baseCwd = ctx.cwd;
@@ -753,6 +773,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
753
773
  }
754
774
  }
755
775
  state.lastUiContext = ctx;
776
+ refreshActiveAsyncCapacity();
756
777
  cleanupSessionArtifacts(ctx);
757
778
  state.foregroundControls.clear();
758
779
  state.lastForegroundControlId = null;
@@ -11,6 +11,8 @@ export interface PublicSubagentExecutionParams {
11
11
  workflowScript?: unknown;
12
12
  resume?: unknown;
13
13
  clarify?: unknown;
14
+ runFanoutBudget?: unknown;
15
+ runFanoutAdmitted?: unknown;
14
16
  }
15
17
 
16
18
  export type PublicSubagentExecutionMode = "workflow" | "management";
@@ -24,6 +26,9 @@ export type PublicSubagentExecutionNormalization<T> =
24
26
  * Internal runs.run children and structured owned delegation bypass this boundary.
25
27
  */
26
28
  export function normalizePublicSubagentExecution<T extends PublicSubagentExecutionParams>(params: T): PublicSubagentExecutionNormalization<T> {
29
+ if (params.runFanoutBudget !== undefined || params.runFanoutAdmitted !== undefined) {
30
+ return { ok: false, error: "Public execution does not accept internal run fan-out fields.", mode: params.workflowScript !== undefined ? "workflow" : "management" };
31
+ }
27
32
  const action = params.action;
28
33
  if (action !== undefined && (typeof action !== "string" || !action.trim())) {
29
34
  return { ok: false, error: "action must be a non-empty management/control action, or omit action and use workflowScript.", mode: "management" };
@@ -19,7 +19,6 @@ import {
19
19
  import { sanitizeDisplayText, truncateDisplayText } from "../shared/display-text.ts";
20
20
  import { readStatus } from "../shared/utils.ts";
21
21
  import { SubagentParams } from "./schemas.ts";
22
- import { formatWorkflowJsonPreview } from "../workflows/scripted-workflow.ts";
23
22
  import { normalizePublicSubagentExecution } from "./public-execution.ts";
24
23
 
25
24
  export const SUBAGENT_RPC_PROTOCOL_VERSION = 1;
@@ -91,6 +90,7 @@ export interface SubagentRpcFleetStatus {
91
90
  entries: SubagentRpcFleetEntry[];
92
91
  /** Total active children before the bounded entries window. */
93
92
  totalActive: number;
93
+ topLevelAsyncCapacity: { used: number; limit: number };
94
94
  omitted: number;
95
95
  }
96
96
 
@@ -154,7 +154,7 @@ function buildFleetStatus(
154
154
  }
155
155
  if (!state || !authoritativeSessionId || state.currentSessionId !== authoritativeSessionId) {
156
156
  keyState.keys.clear();
157
- return { version: 1, entries: [], totalActive: 0, omitted: 0 };
157
+ return { version: 1, entries: [], totalActive: 0, topLevelAsyncCapacity: { used: 0, limit: 0 }, omitted: 0 };
158
158
  }
159
159
 
160
160
  let totalActive = 0;
@@ -173,7 +173,6 @@ function buildFleetStatus(
173
173
  effort: child.thinking,
174
174
  startedAt: child.startedAt,
175
175
  tokens: { input: child.inputTokens ?? 0, output: child.outputTokens ?? 0, total: child.tokens ?? 0 },
176
- goal: child.description ?? control.description,
177
176
  });
178
177
  } else {
179
178
  addCandidate({
@@ -183,7 +182,6 @@ function buildFleetStatus(
183
182
  effort: control.thinking,
184
183
  startedAt: control.startedAt,
185
184
  tokens: { input: control.inputTokens ?? 0, output: control.outputTokens ?? 0, total: control.tokens ?? 0 },
186
- goal: control.description,
187
185
  });
188
186
  }
189
187
  }
@@ -191,13 +189,11 @@ function buildFleetStatus(
191
189
  if (job.sessionId !== authoritativeSessionId || !activeState(job.status)) continue;
192
190
  const startedAt = job.startedAt ?? job.updatedAt;
193
191
  if (job.mode === "workflow") {
194
- const latestEmit = job.workflow?.emits?.length ? formatWorkflowJsonPreview(job.workflow.emits.at(-1), 120) : undefined;
195
192
  addCandidate({
196
193
  internalKey: `async:${job.asyncId}`,
197
194
  agent: "workflow",
198
195
  startedAt,
199
196
  tokens: job.totalTokens,
200
- goal: latestEmit !== undefined ? `latest emit: ${latestEmit}` : job.description,
201
197
  });
202
198
  continue;
203
199
  }
@@ -214,7 +210,6 @@ function buildFleetStatus(
214
210
  agent: job.mode ?? "subagent",
215
211
  startedAt,
216
212
  tokens: job.totalTokens,
217
- goal: job.description,
218
213
  });
219
214
  continue;
220
215
  }
@@ -230,7 +225,6 @@ function buildFleetStatus(
230
225
  effort: step.thinking,
231
226
  startedAt: step.startedAt ?? startedAt,
232
227
  tokens: step.tokens ?? (steps.length === 1 ? job.totalTokens : undefined),
233
- goal: job.description,
234
228
  });
235
229
  }
236
230
  }
@@ -271,7 +265,7 @@ function buildFleetStatus(
271
265
  if (!activeKeys.has(internalKey)) keyState.keys.delete(internalKey);
272
266
  }
273
267
  const omitted = Math.max(0, totalActive - entries.length);
274
- return { version: 1, entries, totalActive, omitted };
268
+ return { version: 1, entries, totalActive, topLevelAsyncCapacity: state.activeAsyncCapacity ?? { used: 0, limit: 0 }, omitted };
275
269
  }
276
270
 
277
271
  interface RegisterSubagentRpcBridgeOptions {
@@ -324,8 +324,8 @@ const SubagentParamProperties = {
324
324
  description: "'fresh' or 'fork' to branch from parent session. Explicit context overrides every child in the invocation. If omitted, each requested agent uses its own defaultContext; agents without defaultContext: 'fork' run fresh.",
325
325
  })),
326
326
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
327
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Run timeout. Foreground runs and async children default to 30m; async composites have no default parent deadline. Alias maxRuntimeMs." })),
328
- maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias timeoutMs. Foreground runs and async children default to 30m; async composites have no default parent deadline." })),
327
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Timeout. Foreground and single async runs use config timeoutMs, else 30m; async composites have no default parent deadline. Alias maxRuntimeMs." })),
328
+ maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias timeoutMs. Foreground and single async runs use config timeoutMs, else 30m; async composites have no default parent deadline." })),
329
329
  turnBudget: Type.Optional(TurnBudgetOverride),
330
330
  toolBudget: Type.Optional(ToolBudgetOverride),
331
331
  usageBudget: Type.Optional(UsageBudgetOverride),
@@ -63,7 +63,10 @@ export function resolveIntercomSessionTarget(sessionName: string | undefined, se
63
63
  if (trimmedName) return trimmedName;
64
64
  const fallbackSessionId = intercomSessionId?.trim() || sessionId;
65
65
  const normalizedSessionId = fallbackSessionId.startsWith("session-") ? fallbackSessionId.slice("session-".length) : fallbackSessionId;
66
- return `${DEFAULT_INTERCOM_TARGET_PREFIX}-${normalizedSessionId.slice(0, 8)}`;
66
+ // NOTE: keep slice length in sync with pi-intercom's resolveIntercomPresenceName
67
+ // (index.ts: DEFAULT_UNNAMED_SESSION_ALIAS_PREFIX + slice(0, 18)); mismatched lengths
68
+ // make fallback orchestrator targets unresolvable ("Session not found").
69
+ return `${DEFAULT_INTERCOM_TARGET_PREFIX}-${normalizedSessionId.slice(0, 18)}`;
67
70
  }
68
71
 
69
72
  function sanitizeIntercomTargetPart(value: string): string {
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
5
+ import { PROMPT_REDACTED } from "../shared/utils.ts";
5
6
  import type { Details, SubagentRunMode } from "../shared/types.ts";
6
7
  import { validateMissionLaunch } from "./actions.ts";
7
8
  import type { MissionArtifact, MissionRecord, MissionRunLink, MissionRunMode, MissionStatus, MissionStoreConfig, MissionStoreLocation } from "./types.ts";
@@ -47,11 +48,6 @@ function workflowObjective(params: MissionLaunchParams): string | undefined {
47
48
  return undefined;
48
49
  }
49
50
 
50
- function conciseTitle(objective: string): string {
51
- const firstLine = objective.split(/\r?\n/, 1)[0]?.trim() || objective.trim();
52
- return firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
53
- }
54
-
55
51
  export function prepareMissionLaunch(input: {
56
52
  params: MissionLaunchParams;
57
53
  projectRoot: string;
@@ -73,10 +69,11 @@ export function prepareMissionLaunch(input: {
73
69
  return { missionId, location, autoCreated: false, announceInContent: true };
74
70
  }
75
71
  const mission = input.params.mission !== undefined ? validateMissionLaunch(input.params.mission) : undefined;
76
- const title = mission?.title || conciseTitle(objective!);
72
+ const promptDerivedObjective = mission?.objective ?? (mission ? mission.title : objective ? PROMPT_REDACTED : undefined);
73
+ const title = mission?.title || PROMPT_REDACTED;
77
74
  const record = createMission(location, {
78
75
  title,
79
- objective: mission?.objective || objective || title,
76
+ objective: promptDerivedObjective || title,
80
77
  ...(mission?.goal === true ? { goal: true as const } : {}),
81
78
  ...(mission?.budget ? { budget: mission.budget } : {}),
82
79
  status: "active",
@@ -396,11 +396,11 @@ export class MissionNotFoundError extends Error {
396
396
  readonly missionId: string;
397
397
  readonly missionDir: string;
398
398
 
399
- constructor(missionId: string, missionDir: string) {
400
- super(`Mission '${missionId}' was not found in ${missionDir}`);
399
+ constructor(missionId: string, location: MissionStoreLocation) {
400
+ super(`Mission '${missionId}' was not found in mission directory '${location.missionDir}' for project root '${location.projectRoot}'. If it was created in another worktree, run the request from that worktree.`);
401
401
  this.name = "MissionNotFoundError";
402
402
  this.missionId = missionId;
403
- this.missionDir = missionDir;
403
+ this.missionDir = location.missionDir;
404
404
  }
405
405
  }
406
406
 
@@ -410,7 +410,7 @@ export function readMission(location: MissionStoreLocation, missionId: string):
410
410
  try {
411
411
  raw = fs.readFileSync(filePath, "utf-8");
412
412
  } catch (error) {
413
- if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new MissionNotFoundError(missionId, location.missionDir);
413
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new MissionNotFoundError(missionId, location);
414
414
  throw error;
415
415
  }
416
416
  try {
@@ -95,8 +95,12 @@ function stateLockIsStale(lockPath: string, now = Date.now()): boolean {
95
95
  const owner = readStateLockOwner(lockPath);
96
96
  if (owner) {
97
97
  if (!isProcessAlive(owner.pid)) return true;
98
- const currentProcessKey = processStartKey(owner.pid);
99
- return Boolean(owner.processKey && currentProcessKey && owner.processKey !== currentProcessKey);
98
+ if (owner.processKey) {
99
+ const currentProcessKey = owner.pid === process.pid ? CURRENT_PROCESS_KEY : processStartKey(owner.pid);
100
+ if (currentProcessKey) return owner.processKey !== currentProcessKey;
101
+ if (owner.pid === process.pid) return true;
102
+ }
103
+ return false;
100
104
  }
101
105
  try {
102
106
  return now - fs.statSync(lockPath).mtimeMs > STATE_LOCK_STALE_MS;