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.
- package/CHANGELOG.md +23 -0
- package/README.md +2 -0
- package/docs/configuration.md +46 -2
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +1 -1
- package/package.json +1 -1
- package/src/agents/agents.ts +9 -3
- package/src/extension/config.ts +6 -0
- package/src/extension/doctor.ts +40 -0
- package/src/extension/index.ts +21 -0
- package/src/extension/public-execution.ts +5 -0
- package/src/extension/rpc.ts +3 -9
- package/src/extension/schemas.ts +2 -2
- package/src/intercom/intercom-bridge.ts +4 -1
- package/src/missions/lifecycle.ts +4 -7
- package/src/missions/store.ts +4 -4
- package/src/missions/workflow-state.ts +6 -2
- package/src/runs/background/active-async-capacity.ts +374 -0
- package/src/runs/background/active-run-index.ts +9 -5
- package/src/runs/background/async-execution.ts +117 -29
- package/src/runs/background/async-resume.ts +7 -1
- package/src/runs/background/async-status.ts +11 -5
- package/src/runs/background/chain-append.ts +33 -15
- package/src/runs/background/owned-process-tree.ts +104 -0
- package/src/runs/background/process-terminal.ts +17 -3
- package/src/runs/background/run-status.ts +5 -1
- package/src/runs/background/stale-run-reconciler.ts +3 -3
- package/src/runs/background/subagent-runner.ts +56 -31
- package/src/runs/foreground/chain-execution.ts +37 -2
- package/src/runs/foreground/execution.ts +90 -19
- package/src/runs/foreground/foreground-control.ts +12 -0
- package/src/runs/foreground/prompt-audit.ts +171 -0
- package/src/runs/foreground/subagent-executor.ts +614 -187
- package/src/runs/shared/acceptance.ts +13 -4
- package/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/src/runs/shared/parallel-utils.ts +2 -0
- package/src/runs/shared/pi-args.ts +44 -1
- package/src/runs/shared/run-fanout-budget.ts +280 -0
- package/src/runs/shared/single-output.ts +4 -2
- package/src/runs/shared/task-intent.ts +19 -3
- package/src/runs/shared/worktree.ts +17 -5
- package/src/shared/types.ts +92 -0
- package/src/shared/utils.ts +3 -1
- package/src/tui/fleet-status.ts +7 -5
- package/src/tui/fleet.ts +225 -12
- 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
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
package/docs/observability.md
CHANGED
|
@@ -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
|
|
package/docs/tool-reference.md
CHANGED
|
@@ -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
|
|
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
package/src/agents/agents.ts
CHANGED
|
@@ -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) {
|
|
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
|
-
|
|
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(
|
package/src/extension/config.ts
CHANGED
|
@@ -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);
|
package/src/extension/doctor.ts
CHANGED
|
@@ -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
|
"",
|
package/src/extension/index.ts
CHANGED
|
@@ -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" };
|
package/src/extension/rpc.ts
CHANGED
|
@@ -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 {
|
package/src/extension/schemas.ts
CHANGED
|
@@ -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: "
|
|
328
|
-
maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias timeoutMs. Foreground
|
|
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
|
-
|
|
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
|
|
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:
|
|
76
|
+
objective: promptDerivedObjective || title,
|
|
80
77
|
...(mission?.goal === true ? { goal: true as const } : {}),
|
|
81
78
|
...(mission?.budget ? { budget: mission.budget } : {}),
|
|
82
79
|
status: "active",
|
package/src/missions/store.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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;
|