pi-subagents 0.47.0 → 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 (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +2 -0
  3. package/agents/reviewer.md +3 -4
  4. package/docs/configuration.md +46 -2
  5. package/docs/observability.md +1 -1
  6. package/docs/tool-reference.md +1 -1
  7. package/package.json +1 -1
  8. package/src/agents/agents.ts +9 -3
  9. package/src/extension/config.ts +16 -0
  10. package/src/extension/doctor.ts +40 -0
  11. package/src/extension/index.ts +30 -11
  12. package/src/extension/public-execution.ts +5 -0
  13. package/src/extension/rpc.ts +7 -9
  14. package/src/extension/schemas.ts +4 -4
  15. package/src/intercom/intercom-bridge.ts +4 -1
  16. package/src/intercom/native-supervisor-channel.ts +102 -4
  17. package/src/missions/lifecycle.ts +4 -7
  18. package/src/missions/store.ts +4 -4
  19. package/src/missions/workflow-state.ts +6 -2
  20. package/src/runs/background/active-async-capacity.ts +374 -0
  21. package/src/runs/background/active-run-index.ts +46 -0
  22. package/src/runs/background/async-execution.ts +117 -29
  23. package/src/runs/background/async-job-tracker.ts +279 -134
  24. package/src/runs/background/async-resume.ts +7 -1
  25. package/src/runs/background/async-status.ts +33 -4
  26. package/src/runs/background/chain-append.ts +33 -15
  27. package/src/runs/background/control-channel.ts +55 -17
  28. package/src/runs/background/owned-process-tree.ts +104 -0
  29. package/src/runs/background/process-terminal.ts +17 -3
  30. package/src/runs/background/result-watcher.ts +87 -6
  31. package/src/runs/background/run-status.ts +23 -1
  32. package/src/runs/background/scheduled-runs.ts +23 -0
  33. package/src/runs/background/stale-run-reconciler.ts +10 -3
  34. package/src/runs/background/subagent-runner.ts +75 -33
  35. package/src/runs/foreground/async-dismiss-action.ts +85 -0
  36. package/src/runs/foreground/async-steering-action.ts +6 -7
  37. package/src/runs/foreground/chain-execution.ts +37 -2
  38. package/src/runs/foreground/execution.ts +90 -19
  39. package/src/runs/foreground/foreground-control.ts +12 -0
  40. package/src/runs/foreground/prompt-audit.ts +171 -0
  41. package/src/runs/foreground/subagent-executor.ts +648 -195
  42. package/src/runs/shared/acceptance.ts +13 -4
  43. package/src/runs/shared/llm-intent-arbiter.ts +286 -0
  44. package/src/runs/shared/parallel-utils.ts +2 -0
  45. package/src/runs/shared/pi-args.ts +44 -1
  46. package/src/runs/shared/run-fanout-budget.ts +280 -0
  47. package/src/runs/shared/single-output.ts +4 -2
  48. package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
  49. package/src/runs/shared/task-intent.ts +19 -3
  50. package/src/runs/shared/worktree.ts +17 -5
  51. package/src/shared/artifacts.ts +1 -1
  52. package/src/shared/file-coalescer.ts +9 -0
  53. package/src/shared/types.ts +97 -1
  54. package/src/shared/utils.ts +3 -1
  55. package/src/tui/fleet-status.ts +7 -5
  56. package/src/tui/fleet.ts +225 -12
  57. package/src/workflows/scripted-workflow.ts +26 -6
package/CHANGELOG.md CHANGED
@@ -2,15 +2,52 @@
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
+
28
+ ## [0.47.1] - 2026-08-12
29
+
30
+ ### Fixed
31
+ - Honor configured artifact cleanup retention days and let `0` disable artifact cleanup. Thanks to @elecnix for #1012.
32
+ - Add a display-only dismiss action for reload-recovered running workflows without claiming or attempting to stop their work (#1010).
33
+ - Stop the bundled reviewer from inheriting chain-only plan/progress reads in ad-hoc review runs. Thanks to @Ostii for #1000.
34
+ - Remove mutation-capable tools from the bundled reviewer so read-only review lanes have a hard launch-time tool boundary (#1007).
35
+ - Show the requested child agent in workflow started trace entries. Thanks to @albertgwo for #1001.
36
+
5
37
  ## [0.47.0] - 2026-08-11
6
38
 
7
39
  ### Changed
40
+ - Avoid fully parsing stale cross-session result files during watcher recovery and reduce healthy watcher safety scans.
41
+ - Index active async runs so status restoration no longer scans all historical run directories.
42
+ - Refresh active async job state from filesystem events while reserving polling for slow liveness repair.
8
43
  - Add optional strict model-scope enforcement that rejects inherited and fallback models outside the configured allowlist. Thanks to @antonioc-cl for #995.
9
44
  - Trim legacy chain-control schema fields and guidance by default, saving 1,319 `o200k_base` tokens from the serialized default tool schema plus description versus `legacyChainControls: true`. Thanks to @tajquitgenius for #977.
10
45
  - Move project-scoped pi-subagents storage from `.pi-subagents/` to `.pi/subagents/` for cleaner project roots. Thanks to @yceachan for #971.
11
46
  - Clarify the accepted mission launch object contract for tool callers.
12
47
  - Reduce repeated async status parsing, workflow trace projection, and constrained widget rendering work.
48
+ - Coalesce rapid running-status writes while keeping terminal and attention status changes durable immediately.
13
49
  - Keep parsed async statuses cached beyond 50 runs while preserving per-read freshness checks. Thanks to @bcanvural for #982.
50
+ - Prefer native control inbox watchers over per-process 250 ms polling, with polling retained as a fallback.
14
51
 
15
52
  ### Fixed
16
53
  - Omit missing configured read files from child task instructions.
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
@@ -1,12 +1,11 @@
1
1
  ---
2
2
  name: reviewer
3
3
  description: Versatile review specialist for code diffs, plans, proposed solutions, codebase health, and PR/issue validation
4
- tools: read, grep, find, ls, bash, edit, write, intercom
4
+ tools: read, grep, find, ls, intercom
5
5
  thinking: high
6
6
  systemPromptMode: replace
7
7
  inheritProjectContext: true
8
8
  inheritSkills: false
9
- defaultReads: plan.md, progress.md
10
9
  ---
11
10
 
12
11
  You are a disciplined review subagent. Your job is to inspect, evaluate, and report findings with evidence. You do not guess; you verify from the code, tests, docs, or requirements.
@@ -51,9 +50,9 @@ Review a PR or issue by understanding the context, then verifying:
51
50
  - Tests and docs are updated as needed.
52
51
 
53
52
  ## Working rules
54
- - Read the plan, progress, and relevant files first when available.
53
+ - Read the relevant files first. Read plan and progress when the task supplies them.
55
54
  - Repo-local `progress.md` files are allowed scratch/memory files. Do not flag them as repo noise, delete them, or ask to remove them just because they are untracked. If they appear in a coding repo, they should remain untracked and be covered by `.gitignore`.
56
- - Use `bash` only for read-only inspection (e.g., `git diff`, `git log`, `git show`, test runs).
55
+ - Do not use shell commands or write files. Report any test or Git command that a supervisor must run.
57
56
  - Do not invent issues. Only report problems you can justify from evidence.
58
57
  - Prefer small corrective edits over broad rewrites.
59
58
  - If everything looks good, say so plainly.
@@ -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.0",
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(
@@ -37,6 +37,15 @@ function validateFleetKeybindingsConfig(value: unknown): void {
37
37
  }
38
38
  }
39
39
 
40
+ function validateArtifactConfig(value: unknown): void {
41
+ if (value === undefined) return;
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("config.artifactConfig must be a JSON object");
43
+ const cleanupDays = (value as Record<string, unknown>).cleanupDays;
44
+ if (cleanupDays !== undefined && (typeof cleanupDays !== "number" || !Number.isInteger(cleanupDays) || cleanupDays < 0)) {
45
+ throw new Error("config.artifactConfig.cleanupDays must be a non-negative integer");
46
+ }
47
+ }
48
+
40
49
  function validateConfig(config: Record<string, unknown>): void {
41
50
  if (config.artifactDir !== undefined && !ARTIFACT_DIR_PREFERENCES.has(config.artifactDir as ArtifactDirPreference)) {
42
51
  throw new Error(`config.artifactDir must be "project", "session", or "temp"`);
@@ -44,11 +53,18 @@ function validateConfig(config: Record<string, unknown>): void {
44
53
  if (config.legacyChainControls !== undefined && typeof config.legacyChainControls !== "boolean") {
45
54
  throw new Error("config.legacyChainControls must be a boolean");
46
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
+ }
47
62
  validateMissionStoreConfig(config.missions);
48
63
  validateAuthorityPolicy(config.authorityPolicy);
49
64
  validatePermissionConfig(config.permissions);
50
65
  validateScheduledRunsConfig(config.scheduledRuns);
51
66
  validateFleetKeybindingsConfig(config.fleetKeybindings);
67
+ validateArtifactConfig(config.artifactConfig);
52
68
  }
53
69
 
54
70
  export function getConfigPath(): string {
@@ -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,
@@ -366,7 +368,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
366
368
  const asyncWidgetEnabled = config.asyncWidget !== false;
367
369
  const summaryInlineToolDisplay = config.inlineToolDisplay === "summary";
368
370
  const tempArtifactsDir = getArtifactsDir(null);
369
- cleanupAllArtifactDirs(DEFAULT_ARTIFACT_CONFIG.cleanupDays);
371
+ const artifactCleanupDays = config.artifactConfig?.cleanupDays ?? DEFAULT_ARTIFACT_CONFIG.cleanupDays;
372
+ cleanupAllArtifactDirs(artifactCleanupDays);
370
373
 
371
374
  const state: SubagentState = {
372
375
  baseCwd: "",
@@ -383,6 +386,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
383
386
  granted: 0,
384
387
  grantHistory: [],
385
388
  },
389
+ activeAsyncCapacity: { used: 0, limit: resolveMaxActiveAsyncRunsPerSession(config.maxActiveAsyncRunsPerSession) ?? 0 },
386
390
  asyncJobs: new Map(),
387
391
  fleetJobs: new Map(),
388
392
  foregroundRuns: new Map(),
@@ -430,6 +434,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
430
434
  },
431
435
  resolveCapabilityCeiling: (sessionId) => resolveCurrentSubagentCapabilityCeiling(sessionId),
432
436
  });
437
+ const { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs, dispose: disposeAsyncJobTracker } = createAsyncJobTracker(pi, state, DIRS.async, {
438
+ widgetEnabled: asyncWidgetEnabled,
439
+ });
433
440
  const { startResultWatcher, primeExistingResults, stopResultWatcher } = createResultWatcher(
434
441
  pi,
435
442
  state,
@@ -438,6 +445,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
438
445
  {
439
446
  notifier: completionNotifier,
440
447
  observeCompletion: (result) => scheduledRunManager.handleAsyncCompletion(result),
448
+ observedCompletionRunIds: () => scheduledRunManager.observedCompletionRunIds(),
441
449
  deliverIntercomResults: config.intercomBridge?.resultDelivery === true,
442
450
  },
443
451
  );
@@ -451,16 +459,10 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
451
459
  supervisorChannel.dispose();
452
460
  waitSubscriptionManager.dispose();
453
461
  fleetStatus?.dispose();
454
- if (state.poller) {
455
- clearInterval(state.poller);
456
- state.poller = null;
457
- }
462
+ disposeAsyncJobTracker();
458
463
  };
459
464
  globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
460
465
 
461
- const { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs } = createAsyncJobTracker(pi, state, DIRS.async, {
462
- widgetEnabled: asyncWidgetEnabled,
463
- });
464
466
  const executor = createSubagentExecutor({
465
467
  pi,
466
468
  state,
@@ -678,12 +680,17 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
678
680
  };
679
681
  const asyncCompleteHandler = (payload: unknown) => {
680
682
  handleComplete(payload);
683
+ refreshActiveAsyncCapacity();
681
684
  scheduledRunManager.handleAsyncCompletion(payload);
682
685
  fleetStatus?.refresh();
683
686
  };
684
687
  const eventUnsubscribes = [
685
688
  pi.events.on(SUBAGENT_ASYNC_STARTED_EVENT, asyncStartedHandler),
686
689
  pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, asyncCompleteHandler),
690
+ pi.events.on(SUBAGENT_PROCESS_TERMINAL_EVENT, () => {
691
+ refreshActiveAsyncCapacity();
692
+ fleetStatus?.refresh();
693
+ }),
687
694
  pi.events.on(SUBAGENT_CONTROL_EVENT, controlEventHandler),
688
695
  pi.events.on(SUBAGENT_STEERING_NOTICE_EVENT, steeringNoticeHandler),
689
696
  herdrStatusBridge.dispose,
@@ -708,7 +715,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
708
715
  try {
709
716
  const sessionFile = ctx.sessionManager.getSessionFile();
710
717
  if (sessionFile) {
711
- cleanupOldArtifacts(getArtifactsDir(sessionFile), DEFAULT_ARTIFACT_CONFIG.cleanupDays);
718
+ cleanupOldArtifacts(getArtifactsDir(sessionFile), artifactCleanupDays);
712
719
  }
713
720
  } catch {
714
721
  // Cleanup failures should not block session lifecycle events.
@@ -729,6 +736,18 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
729
736
  fleetStatus?.refresh();
730
737
  };
731
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
+
732
751
  const resetSessionState = (ctx: ExtensionContext, recovering: boolean) => {
733
752
  state.widgetsSuspended = false;
734
753
  state.baseCwd = ctx.cwd;
@@ -754,6 +773,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
754
773
  }
755
774
  }
756
775
  state.lastUiContext = ctx;
776
+ refreshActiveAsyncCapacity();
757
777
  cleanupSessionArtifacts(ctx);
758
778
  state.foregroundControls.clear();
759
779
  state.lastForegroundControlId = null;
@@ -823,8 +843,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
823
843
  delete globalStore[eventUnsubscribeStoreKey];
824
844
  }
825
845
  scheduledRunManager.stop();
826
- if (state.poller) clearInterval(state.poller);
827
- state.poller = null;
846
+ disposeAsyncJobTracker();
828
847
  for (const timer of state.cleanupTimers.values()) {
829
848
  clearTimeout(timer);
830
849
  }
@@ -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 {
@@ -490,6 +484,10 @@ function stopAsyncRun(
490
484
  throw new SubagentRpcError("not_found", `Async run '${initialRunId}' was not found in the active session.`);
491
485
  }
492
486
 
487
+ if (initialStatus.mode === "workflow" && initialStatus.state === "running") {
488
+ throw new SubagentRpcError("invalid_state", `Workflow ${initialRunId} is not controlled by this extension runtime; reload recovery cannot stop it safely.`);
489
+ }
490
+
493
491
  let status;
494
492
  try {
495
493
  status = reconcileAsyncRun(location.asyncDir, { resultsDir, kill: options.kill, now: options.now }).status;
@@ -263,10 +263,10 @@ const SubagentParamProperties = {
263
263
  })),
264
264
  name: Type.Optional(Type.String({ description: "Human-readable name for action='schedule.create'." })),
265
265
  id: Type.Optional(Type.String({
266
- description: "Run id or prefix for status, interrupt, stop, resume, steer, append-step, approve-checkpoint, reject-checkpoint, mission.attach-run, or the decision id for mission.resolve-decision."
266
+ description: "Run id or prefix for status, interrupt, stop, dismiss, resume, steer, append-step, approve-checkpoint, reject-checkpoint, mission.attach-run, or the decision id for mission.resolve-decision."
267
267
  })),
268
268
  runId: Type.Optional(Type.String({
269
- description: "Target run ID for interrupt, stop, resume, steer, append-step, approve-checkpoint, reject-checkpoint, or mission.attach-run. Prefer id for new calls."
269
+ description: "Target run ID for interrupt, stop, dismiss, resume, steer, append-step, approve-checkpoint, reject-checkpoint, or mission.attach-run. Prefer id for new calls."
270
270
  })),
271
271
  dir: Type.Optional(Type.String({
272
272
  description: "Async run directory for action='status', action='stop', action='resume', or action='steer'."
@@ -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 {