@herbertgao/pi-subagents 0.15.5 → 0.16.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 +6 -0
- package/README.md +30 -22
- package/examples/agent-tool-description.md +5 -5
- package/package.json +7 -7
- package/src/agent-file-toggle.ts +7 -1
- package/src/agent-manager.ts +48 -19
- package/src/agent-runner.ts +17 -13
- package/src/custom-agents.ts +26 -2
- package/src/index.ts +219 -40
- package/src/invocation-config.ts +101 -3
- package/src/nested-tools.ts +18 -4
- package/src/settings.ts +99 -0
- package/src/types.ts +22 -3
- package/src/ui/agent-widget.ts +51 -3
- package/src/ui/conversation-viewer.ts +20 -2
- package/src/ui/fleet-list.ts +21 -6
- package/src/usage.ts +129 -1
- package/src/worktree.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#90](https://github.com/HerbertGao/pi-extensions/pull/90) [`74b7bce`](https://github.com/HerbertGao/pi-extensions/commit/74b7bceac14c36961d25af9cbc6aecc502bdaf3b) Thanks [@HerbertGao](https://github.com/HerbertGao)! - Selectively sync pi-subagents with upstream 0.18.0, including worktree controls, background-by-default top-level agents, usage and cost reporting, programmatic activity tracking, child-session shutdown, and nested print-mode coverage.
|
|
8
|
+
|
|
3
9
|
## 0.15.5
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Spawn specialized agents that run in isolated sessions — each with its own too
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
|
|
16
|
-
- **Parallel background agents** —
|
|
16
|
+
- **Parallel background agents** — top-level agents run detached by default, with automatic queuing (configurable concurrency limit, default 10) and smart group join; nested delegation still defaults to foreground
|
|
17
17
|
- **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons. Configurable via `/agents → Settings → Widget`: `all` (every agent), `background` (default — hides foreground runs, which already render inline as the `Agent` tool result), or `off`
|
|
18
18
|
- **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view`
|
|
19
19
|
- **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Steer a running agent inline by pressing `Enter` to open a composer, typing, then `Enter` to send (`Esc` or an empty submit returns) — the message appears as a user message and redirects the agent after its current tool. Stop a still-running agent by pressing `x` (then `x` again to confirm) — both work for background agents too
|
|
@@ -26,7 +26,8 @@ Spawn specialized agents that run in isolated sessions — each with its own too
|
|
|
26
26
|
- **Fuzzy model selection** — specify models by name (`"haiku"`, `"sonnet"`) instead of full IDs, with automatic filtering to only available/configured models
|
|
27
27
|
- **Context inheritance** — optionally fork the parent conversation into a sub-agent so it knows what's been discussed
|
|
28
28
|
- **Persistent agent memory** — three scopes (project, local, user) with automatic read-only fallback for agents without write tools
|
|
29
|
-
- **Git worktree isolation** — run agents in isolated repo copies;
|
|
29
|
+
- **Git worktree isolation** — run agents in isolated repo copies; `isolation: off` and the project-wide `worktreeIsolation` switch keep work in the current checkout, while preservation commits bypass interactive signing
|
|
30
|
+
- **Usage and cost reporting** — optionally include subagent spend in the parent session's stats (`reportUsage`) and show estimated USD cost across subagent UI surfaces (`showCost`)
|
|
30
31
|
- **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `<name>/SKILL.md` directory layout supported)
|
|
31
32
|
- **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
|
|
32
33
|
- **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
|
|
@@ -60,7 +61,7 @@ Agent({
|
|
|
60
61
|
})
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
Agents run in the background by default: calls return an ID immediately and notify on completion. Pass `run_in_background: false` to block and return the full result inline. Nested spawns keep the safer foreground default unless explicitly overridden.
|
|
64
65
|
|
|
65
66
|
### Scheduling
|
|
66
67
|
|
|
@@ -227,7 +228,7 @@ All fields are optional — sensible defaults for everything.
|
|
|
227
228
|
| `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload (see [Skill Preloading](#skill-preloading) for discovery locations) |
|
|
228
229
|
| `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
|
|
229
230
|
| `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
|
|
230
|
-
| `isolation` | — |
|
|
231
|
+
| `isolation` | — | `worktree` runs in an isolated git worktree; `off` vetoes a caller-requested worktree |
|
|
231
232
|
| `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp are interchangeable) and falls back to the same model under another provider if the named one doesn't have it |
|
|
232
233
|
| `thinking` | inherit | off, minimal, low, medium, high, xhigh, max — actual availability depends on your pi version and model; pi clamps unsupported levels down |
|
|
233
234
|
| `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
|
|
@@ -237,7 +238,7 @@ All fields are optional — sensible defaults for everything.
|
|
|
237
238
|
| `allowed_subagents` | none | Opt in to scoped nested `Agent`, `get_subagent_result`, and `steer_subagent` tools. Omitted / empty / `none` / `false` = no nesting; `all` (or `"*"` / `true`) = any enabled agent; comma-separated list = only those agent types |
|
|
238
239
|
| `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" — inherits parent's AGENTS.md / CLAUDE.md) |
|
|
239
240
|
| `inherit_context` | `false` | Fork parent conversation into agent |
|
|
240
|
-
| `run_in_background` | `
|
|
241
|
+
| `run_in_background` | `backgroundByDefault` | Pin this agent to background (`true`) or foreground (`false`); omit to follow the project default |
|
|
241
242
|
| `isolated` | `false` | Hermetic specialist mode: forces `extensions: false` + `skills: false` + drops `ext:` selectors. Only built-in tools. Distinct from `isolation: worktree` (filesystem) |
|
|
242
243
|
| `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
|
|
243
244
|
|
|
@@ -298,6 +299,7 @@ A few rules the examples don't make obvious:
|
|
|
298
299
|
- Any `ext:` entry flips extension tools to an explicit allowlist — unnamed extensions still load (handlers fire) but expose no tools. So `tools: "*, ext:mcp/search"` exposes only `search` from `mcp`, nothing from any other extension.
|
|
299
300
|
- Extension names match case-insensitively (`[Mcp]` = `[mcp]`); tool names in `ext:foo/bar` stay case-sensitive.
|
|
300
301
|
- Extensions that register tools **lazily** work too. MCP-backed extensions typically can't enumerate their tools until their servers connect, so they register from `session_start` or `before_agent_start` rather than at load. Subagent scoping is re-derived as tools appear, so these surface normally — including under `ext:` selectors, which keep narrowing correctly no matter when a tool shows up.
|
|
302
|
+
- Extensions bound into a child session receive `session_shutdown` before disposal, including record eviction and root-session shutdown; release per-session timers, watchers, sockets, and temp resources there.
|
|
301
303
|
- An installed **package** extension matches by its package short name (`@scope/pi-subagents` → `[pi-subagents]`), in addition to its path-derived name (a package whose entry is `src/index.ts` also answers to `[src]`). Prefer the package name — the path-derived one is incidental.
|
|
302
304
|
- Plain `tools:` typos fail loudly: `tools: reed, grep` fires `tools-error:…` instead of silently producing an under-tooled agent.
|
|
303
305
|
- `exclude_extensions:` wins over `extensions:` and over `ext:` selectors — an excluded extension never loads and a `tools: ext:` entry can't pull it back. Plain names only (no paths, no `*`); a name matching nothing fires an `extension-error:…` warning.
|
|
@@ -310,19 +312,19 @@ A few rules the examples don't make obvious:
|
|
|
310
312
|
|
|
311
313
|
Launch a sub-agent.
|
|
312
314
|
|
|
313
|
-
| Parameter | Type
|
|
314
|
-
| ------------------- |
|
|
315
|
-
| `prompt` | string
|
|
316
|
-
| `description` | string
|
|
317
|
-
| `subagent_type` | string
|
|
318
|
-
| `model` | string
|
|
319
|
-
| `thinking` | string
|
|
320
|
-
| `max_turns` | number
|
|
321
|
-
| `run_in_background` | boolean
|
|
322
|
-
| `resume` | string
|
|
323
|
-
| `isolated` | boolean
|
|
324
|
-
| `isolation` | `"worktree"` | no |
|
|
325
|
-
| `inherit_context` | boolean
|
|
315
|
+
| Parameter | Type | Required | Description |
|
|
316
|
+
| ------------------- | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
317
|
+
| `prompt` | string | yes | The task for the agent |
|
|
318
|
+
| `description` | string | yes | Short 3-5 word summary (shown in UI) |
|
|
319
|
+
| `subagent_type` | string | yes | Agent type (built-in or custom) |
|
|
320
|
+
| `model` | string | no | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp interchangeable) with provider fallback |
|
|
321
|
+
| `thinking` | string | no | Thinking level: off, minimal, low, medium, high, xhigh, max (availability depends on pi version and model) |
|
|
322
|
+
| `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) |
|
|
323
|
+
| `run_in_background` | boolean | no | Defaults to `true`; `false` blocks and returns the result inline |
|
|
324
|
+
| `resume` | string | no | Agent ID to resume a previous session |
|
|
325
|
+
| `isolated` | boolean | no | No extension/MCP tools |
|
|
326
|
+
| `isolation` | `"off"` \| `"worktree"` | no | `worktree` isolates; `off` keeps the current checkout. Omitted when worktree isolation is disabled |
|
|
327
|
+
| `inherit_context` | boolean | no | Fork parent conversation into agent |
|
|
326
328
|
|
|
327
329
|
### `get_subagent_result`
|
|
328
330
|
|
|
@@ -388,7 +390,7 @@ Instead of hard-aborting at the turn limit, agents get a graceful shutdown:
|
|
|
388
390
|
|
|
389
391
|
## Concurrency
|
|
390
392
|
|
|
391
|
-
Background agents are subject to a configurable concurrency limit (default:
|
|
393
|
+
Background agents are subject to a configurable concurrency limit (default: 10). Excess agents are automatically queued and start as running agents complete. The widget shows queued agents as a collapsed count.
|
|
392
394
|
|
|
393
395
|
Foreground agents bypass the queue — they block the parent anyway.
|
|
394
396
|
|
|
@@ -432,12 +434,18 @@ When on, each subagent spawn's effective model is validated against pi's own `en
|
|
|
432
434
|
|
|
433
435
|
## Persistent Settings
|
|
434
436
|
|
|
435
|
-
Runtime tuning values set via `/agents` → Settings (
|
|
437
|
+
Runtime tuning values set via `/agents` → Settings (including background default, worktree isolation, usage reporting, and cost display) persist across pi restarts. Two files, merged on load:
|
|
436
438
|
|
|
437
439
|
- **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
|
|
438
440
|
- **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
|
|
439
441
|
|
|
440
|
-
**Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `
|
|
442
|
+
**Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `10`, background by default enabled, default max turns unlimited, grace turns `5`, nested depth `2`, join mode `smart`, worktree isolation enabled, usage/cost reporting disabled, strict agent files disabled, defaults enabled).
|
|
443
|
+
|
|
444
|
+
**Background by default** (`backgroundByDefault`, default `true`): unqualified top-level `Agent` calls run detached. Set `false` to restore foreground-by-default behavior. Explicit tool or agent-file `run_in_background` values win. Nested calls always default to foreground so a parent cannot finish and stop an uncollected child.
|
|
445
|
+
|
|
446
|
+
**Worktree isolation** (`worktreeIsolation`, default `true`): set `false` to reject worktree creation on every spawn path and remove the `isolation` parameter and guidance on the next Pi session. `isolation: off` in agent frontmatter vetoes a caller's `worktree` request.
|
|
447
|
+
|
|
448
|
+
**Usage and cost** (`reportUsage` and `showCost`, both default `false`): `reportUsage` attaches child usage to parent tool results so Pi session stats include delegated work; `showCost` renders Pi's estimated USD totals in subagent surfaces. They are independent.
|
|
441
449
|
|
|
442
450
|
**Strict agent files** (`strictAgentFiles`, default `false`): fail extension startup when any discovered agent file is unreadable or malformed. Enable via `/agents → Settings → Strict agent files` or set `true` in `subagents.json`. Strictness applies only to startup; reloads before later Agent calls remain tolerant so a file edited incorrectly mid-session is skipped with a warning instead of aborting the call.
|
|
443
451
|
|
|
@@ -460,7 +468,7 @@ Launch an autonomous agent. Available types:
|
|
|
460
468
|
Custom agents live in .pi/agents/ or {{agentDir}}/agents/.
|
|
461
469
|
```
|
|
462
470
|
|
|
463
|
-
Placeholders: `{{typeList}}` (full per-agent descriptions), `{{compactTypeList}}` (first sentence each), `{{agentDir}}`, `{{scheduleGuideline}}` (expands with its own leading newline + `-` bullet when scheduling is on — place it directly after your last rule line; empty when scheduling is off). Unknown placeholders are left verbatim with a stderr warning; a missing or empty file falls back to `"full"` with a warning. Note the usual trust umbrella: a project-level file shapes the orchestrator's prompt, same as project agents and extensions do.
|
|
471
|
+
Placeholders: `{{typeList}}` (full per-agent descriptions), `{{compactTypeList}}` (first sentence each), `{{agentDir}}`, `{{isolationGuideline}}` (empty when worktrees are disabled), `{{scheduleGuideline}}` (expands with its own leading newline + `-` bullet when scheduling is on — place it directly after your last rule line; empty when scheduling is off). Unknown placeholders are left verbatim with a stderr warning; a missing or empty file falls back to `"full"` with a warning. Note the usual trust umbrella: a project-level file shapes the orchestrator's prompt, same as project agents and extensions do.
|
|
464
472
|
|
|
465
473
|
**Starting point:** copy [`examples/agent-tool-description.md`](examples/agent-tool-description.md) — it reproduces the default full description exactly (a CI test keeps it in sync), so you can trim from a known-good baseline instead of writing from scratch.
|
|
466
474
|
|
|
@@ -14,19 +14,19 @@ If the target is already known, use a direct tool — `read` for a known path, `
|
|
|
14
14
|
## Usage notes
|
|
15
15
|
|
|
16
16
|
- Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
|
|
17
|
-
- When you launch multiple agents for independent work, send them in a single message with multiple tool uses
|
|
17
|
+
- When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.
|
|
18
18
|
- When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
|
|
19
19
|
- Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
|
|
20
|
-
-
|
|
21
|
-
-
|
|
20
|
+
- Agents run in the background by default. You will be notified when one completes — do NOT poll or sleep waiting for it.
|
|
21
|
+
- Pass `run_in_background: false` only when your very next action depends on the result and nothing else could usefully happen while it runs.
|
|
22
|
+
- Never fabricate or predict a pending agent's results; if asked before completion, say it is still running.
|
|
22
23
|
- Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
|
|
23
24
|
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
24
25
|
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
|
|
25
26
|
- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
|
|
26
27
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
27
28
|
- Use thinking to control extended thinking level.
|
|
28
|
-
- Use inherit_context if the agent needs the parent conversation history.
|
|
29
|
-
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.{{scheduleGuideline}}
|
|
29
|
+
- Use inherit_context if the agent needs the parent conversation history.{{isolationGuideline}}{{scheduleGuideline}}
|
|
30
30
|
|
|
31
31
|
## Writing the prompt
|
|
32
32
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@herbertgao/pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Claude Code-style autonomous subagents for Pi, with HerbertGao-maintained UI extensions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -46,15 +46,15 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@sinclair/typebox": "^0.34.49",
|
|
48
48
|
"croner": "^10.0.1",
|
|
49
|
-
"nanoid": "^5.
|
|
49
|
+
"nanoid": "^5.1.16"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@vitest/coverage-istanbul": "^4.1.10"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@earendil-works/pi-ai": ">=0.
|
|
56
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
57
|
-
"@earendil-works/pi-tui": ">=0.
|
|
55
|
+
"@earendil-works/pi-ai": ">=0.81.0",
|
|
56
|
+
"@earendil-works/pi-coding-agent": ">=0.81.0",
|
|
57
|
+
"@earendil-works/pi-tui": ">=0.81.0"
|
|
58
58
|
},
|
|
59
59
|
"engines": {
|
|
60
60
|
"node": ">=22.19.0"
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
},
|
|
69
69
|
"x-upstream": {
|
|
70
70
|
"package": "@tintinweb/pi-subagents",
|
|
71
|
-
"version": "0.
|
|
71
|
+
"version": "0.18.0",
|
|
72
72
|
"repository": "https://github.com/tintinweb/pi-subagents",
|
|
73
|
-
"commit": "
|
|
73
|
+
"commit": "3f9d35cd078d18a141eb5a6d8f4fc5010d756280"
|
|
74
74
|
}
|
|
75
75
|
}
|
package/src/agent-file-toggle.ts
CHANGED
|
@@ -245,7 +245,13 @@ export function serializeAgentFile(cfg: AgentConfig): string {
|
|
|
245
245
|
if (cfg.disallowedTools?.length)
|
|
246
246
|
fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`)
|
|
247
247
|
if (cfg.inheritContext) fmFields.push("inherit_context: true")
|
|
248
|
-
|
|
248
|
+
// Both cases, not just `true`: with `backgroundByDefault` on, omitting the
|
|
249
|
+
// field means background, so `false` is the only way to pin an agent file to
|
|
250
|
+
// foreground and is no longer interchangeable with absence. No caller can
|
|
251
|
+
// reach it yet — Eject only handles built-in defaults, which omit the field —
|
|
252
|
+
// so this keeps the writer symmetric with the loader, nothing more.
|
|
253
|
+
if (cfg.runInBackground !== undefined)
|
|
254
|
+
fmFields.push(`run_in_background: ${cfg.runInBackground}`)
|
|
249
255
|
if (cfg.outputTranscript === false) fmFields.push("output_transcript: false")
|
|
250
256
|
if (cfg.isolated) fmFields.push("isolated: true")
|
|
251
257
|
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`)
|
package/src/agent-manager.ts
CHANGED
|
@@ -24,19 +24,25 @@ import type {
|
|
|
24
24
|
SubagentType,
|
|
25
25
|
ThinkingLevel,
|
|
26
26
|
} from "./types.js"
|
|
27
|
-
import { addUsage } from "./usage.js"
|
|
28
|
-
import {
|
|
27
|
+
import { addUsage, type LifetimeUsage } from "./usage.js"
|
|
28
|
+
import {
|
|
29
|
+
cleanupWorktree,
|
|
30
|
+
createWorktree,
|
|
31
|
+
isWorktreeIsolationEnabled,
|
|
32
|
+
pruneWorktrees,
|
|
33
|
+
} from "./worktree.js"
|
|
29
34
|
|
|
30
35
|
export type OnAgentComplete = (record: AgentRecord) => void
|
|
31
36
|
export type OnAgentStart = (record: AgentRecord) => void
|
|
32
37
|
export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void
|
|
38
|
+
export type OnAgentUsage = (record: AgentRecord, usage: LifetimeUsage) => void
|
|
33
39
|
export type CompactionInfo = {
|
|
34
40
|
reason: "manual" | "threshold" | "overflow"
|
|
35
41
|
tokensBefore: number
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/** Default max concurrent background agents. */
|
|
39
|
-
const DEFAULT_MAX_CONCURRENT =
|
|
45
|
+
const DEFAULT_MAX_CONCURRENT = 10
|
|
40
46
|
|
|
41
47
|
/**
|
|
42
48
|
* Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
|
|
@@ -125,11 +131,7 @@ interface SpawnOptions {
|
|
|
125
131
|
/** Called at the end of each agentic turn with the cumulative count. */
|
|
126
132
|
onTurnEnd?: (turnCount: number) => void
|
|
127
133
|
/** Called once per assistant message_end with that message's usage delta. */
|
|
128
|
-
onAssistantUsage?: (usage:
|
|
129
|
-
input: number
|
|
130
|
-
output: number
|
|
131
|
-
cacheWrite: number
|
|
132
|
-
}) => void
|
|
134
|
+
onAssistantUsage?: (usage: LifetimeUsage) => void
|
|
133
135
|
/** Called when the session successfully compacts. */
|
|
134
136
|
onCompaction?: (info: CompactionInfo) => void
|
|
135
137
|
/** Nesting depth: top-level subagent = 1. */
|
|
@@ -158,11 +160,7 @@ interface ResumeOptions {
|
|
|
158
160
|
/** Called at the end of each resumed agentic turn with the cumulative count. */
|
|
159
161
|
onTurnEnd?: (turnCount: number) => void
|
|
160
162
|
/** Called once per assistant message_end with that message's usage delta. */
|
|
161
|
-
onAssistantUsage?: (usage:
|
|
162
|
-
input: number
|
|
163
|
-
output: number
|
|
164
|
-
cacheWrite: number
|
|
165
|
-
}) => void
|
|
163
|
+
onAssistantUsage?: (usage: LifetimeUsage) => void
|
|
166
164
|
/** Called when the session successfully compacts. */
|
|
167
165
|
onCompaction?: (info: CompactionInfo) => void
|
|
168
166
|
/**
|
|
@@ -176,12 +174,38 @@ interface ResumeOptions {
|
|
|
176
174
|
onStarted?: () => void
|
|
177
175
|
}
|
|
178
176
|
|
|
177
|
+
const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000
|
|
178
|
+
|
|
179
|
+
async function shutdownChildSession(
|
|
180
|
+
session: AgentSession | undefined,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
try {
|
|
183
|
+
const runner = session?.extensionRunner
|
|
184
|
+
if (runner?.hasHandlers?.("session_shutdown")) {
|
|
185
|
+
await Promise.race([
|
|
186
|
+
runner.emit({ type: "session_shutdown", reason: "quit" }),
|
|
187
|
+
new Promise<void>((resolve) =>
|
|
188
|
+
setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS).unref(),
|
|
189
|
+
),
|
|
190
|
+
])
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
/* best-effort lifecycle cleanup */
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
session?.dispose?.()
|
|
197
|
+
} catch {
|
|
198
|
+
/* ignore */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
179
202
|
export class AgentManager {
|
|
180
203
|
private agents = new Map<string, AgentRecord>()
|
|
181
204
|
private cleanupInterval: ReturnType<typeof setInterval>
|
|
182
205
|
private onComplete?: OnAgentComplete
|
|
183
206
|
private onStart?: OnAgentStart
|
|
184
207
|
private onCompact?: OnAgentCompact
|
|
208
|
+
private onUsage?: OnAgentUsage
|
|
185
209
|
private maxConcurrent: number
|
|
186
210
|
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
187
211
|
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
@@ -197,10 +221,12 @@ export class AgentManager {
|
|
|
197
221
|
maxConcurrent = DEFAULT_MAX_CONCURRENT,
|
|
198
222
|
onStart?: OnAgentStart,
|
|
199
223
|
onCompact?: OnAgentCompact,
|
|
224
|
+
onUsage?: OnAgentUsage,
|
|
200
225
|
) {
|
|
201
226
|
this.onComplete = onComplete
|
|
202
227
|
this.onStart = onStart
|
|
203
228
|
this.onCompact = onCompact
|
|
229
|
+
this.onUsage = onUsage
|
|
204
230
|
this.maxConcurrent = maxConcurrent
|
|
205
231
|
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
206
232
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000)
|
|
@@ -302,7 +328,7 @@ export class AgentManager {
|
|
|
302
328
|
// fail loud if not possible (no silent fallback to main tree). Done
|
|
303
329
|
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
304
330
|
let worktreeCwd: string | undefined
|
|
305
|
-
if (options.isolation === "worktree") {
|
|
331
|
+
if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) {
|
|
306
332
|
const wt = createWorktree(baseCwd, id)
|
|
307
333
|
if (!wt) {
|
|
308
334
|
throw new Error(
|
|
@@ -365,6 +391,7 @@ export class AgentManager {
|
|
|
365
391
|
onTextDelta: options.onTextDelta,
|
|
366
392
|
onAssistantUsage: (usage) => {
|
|
367
393
|
addUsage(record.lifetimeUsage, usage)
|
|
394
|
+
this.onUsage?.(record, usage)
|
|
368
395
|
options.onAssistantUsage?.(usage)
|
|
369
396
|
},
|
|
370
397
|
onCompaction: (info) => {
|
|
@@ -662,6 +689,7 @@ export class AgentManager {
|
|
|
662
689
|
onTurnEnd: options?.onTurnEnd,
|
|
663
690
|
onAssistantUsage: (usage) => {
|
|
664
691
|
addUsage(record.lifetimeUsage, usage)
|
|
692
|
+
this.onUsage?.(record, usage)
|
|
665
693
|
options?.onAssistantUsage?.(usage)
|
|
666
694
|
},
|
|
667
695
|
onCompaction: (info) => {
|
|
@@ -766,6 +794,7 @@ export class AgentManager {
|
|
|
766
794
|
onTurnEnd: options.onTurnEnd,
|
|
767
795
|
onAssistantUsage: (usage) => {
|
|
768
796
|
addUsage(record.lifetimeUsage, usage)
|
|
797
|
+
this.onUsage?.(record, usage)
|
|
769
798
|
options.onAssistantUsage?.(usage)
|
|
770
799
|
},
|
|
771
800
|
onCompaction: (info) => {
|
|
@@ -851,9 +880,10 @@ export class AgentManager {
|
|
|
851
880
|
|
|
852
881
|
/** Dispose a record's session and remove it from the map. */
|
|
853
882
|
private removeRecord(id: string, record: AgentRecord): void {
|
|
854
|
-
record.session
|
|
883
|
+
const session = record.session
|
|
855
884
|
record.session = undefined
|
|
856
885
|
this.agents.delete(id)
|
|
886
|
+
void shutdownChildSession(session)
|
|
857
887
|
}
|
|
858
888
|
|
|
859
889
|
private cleanup() {
|
|
@@ -926,14 +956,13 @@ export class AgentManager {
|
|
|
926
956
|
}
|
|
927
957
|
}
|
|
928
958
|
|
|
929
|
-
dispose() {
|
|
959
|
+
async dispose(): Promise<void> {
|
|
930
960
|
clearInterval(this.cleanupInterval)
|
|
931
961
|
// Clear queue
|
|
932
962
|
this.queue = []
|
|
933
|
-
|
|
934
|
-
record.session?.dispose()
|
|
935
|
-
}
|
|
963
|
+
const sessions = [...this.agents.values()].map((record) => record.session)
|
|
936
964
|
this.agents.clear()
|
|
965
|
+
await Promise.all(sessions.map((session) => shutdownChildSession(session)))
|
|
937
966
|
// Prune any orphaned git worktrees (crash recovery)
|
|
938
967
|
try {
|
|
939
968
|
pruneWorktrees(process.cwd())
|
package/src/agent-runner.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js"
|
|
42
42
|
import { preloadSkills } from "./skill-loader.js"
|
|
43
43
|
import type { SubagentType, ThinkingLevel } from "./types.js"
|
|
44
|
+
import type { LifetimeUsage } from "./usage.js"
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Tool names registered by THIS extension. Single source of truth so the
|
|
@@ -349,6 +350,15 @@ export function setDefaultMaxTurns(n: number | undefined): void {
|
|
|
349
350
|
defaultMaxTurns = normalizeMaxTurns(n)
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
export function resolveEffectiveMaxTurns(
|
|
354
|
+
type: string,
|
|
355
|
+
explicit?: number,
|
|
356
|
+
): number | undefined {
|
|
357
|
+
return normalizeMaxTurns(
|
|
358
|
+
explicit ?? getAgentConfig(type)?.maxTurns ?? defaultMaxTurns,
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
352
362
|
/** Additional turns allowed after the soft limit steer message. */
|
|
353
363
|
let graceTurns = 5
|
|
354
364
|
|
|
@@ -442,11 +452,7 @@ export interface RunOptions {
|
|
|
442
452
|
* Lets callers maintain a lifetime accumulator that survives compaction
|
|
443
453
|
* (which replaces session.state.messages and resets stats-derived sums).
|
|
444
454
|
*/
|
|
445
|
-
onAssistantUsage?: (usage:
|
|
446
|
-
input: number
|
|
447
|
-
output: number
|
|
448
|
-
cacheWrite: number
|
|
449
|
-
}) => void
|
|
455
|
+
onAssistantUsage?: (usage: LifetimeUsage) => void
|
|
450
456
|
/**
|
|
451
457
|
* Called when the session successfully compacts. `tokensBefore` is upstream's
|
|
452
458
|
* pre-compaction context size estimate. Aborted compactions don't fire.
|
|
@@ -1030,9 +1036,7 @@ export async function runAgent(
|
|
|
1030
1036
|
|
|
1031
1037
|
// Track turns for graceful max_turns enforcement
|
|
1032
1038
|
let turnCount = 0
|
|
1033
|
-
const maxTurns =
|
|
1034
|
-
options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns,
|
|
1035
|
-
)
|
|
1039
|
+
const maxTurns = resolveEffectiveMaxTurns(type, options.maxTurns)
|
|
1036
1040
|
let softLimitReached = false
|
|
1037
1041
|
let aborted = false
|
|
1038
1042
|
|
|
@@ -1079,6 +1083,8 @@ export async function runAgent(
|
|
|
1079
1083
|
input: u.input ?? 0,
|
|
1080
1084
|
output: u.output ?? 0,
|
|
1081
1085
|
cacheWrite: u.cacheWrite ?? 0,
|
|
1086
|
+
cacheRead: u.cacheRead ?? 0,
|
|
1087
|
+
cost: u.cost?.total ?? 0,
|
|
1082
1088
|
})
|
|
1083
1089
|
}
|
|
1084
1090
|
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
@@ -1133,11 +1139,7 @@ export async function resumeAgent(
|
|
|
1133
1139
|
onToolActivity?: (activity: ToolActivity) => void
|
|
1134
1140
|
/** Called at the end of each resumed agentic turn with the 1-based count. */
|
|
1135
1141
|
onTurnEnd?: (turnCount: number) => void
|
|
1136
|
-
onAssistantUsage?: (usage:
|
|
1137
|
-
input: number
|
|
1138
|
-
output: number
|
|
1139
|
-
cacheWrite: number
|
|
1140
|
-
}) => void
|
|
1142
|
+
onAssistantUsage?: (usage: LifetimeUsage) => void
|
|
1141
1143
|
onCompaction?: (info: {
|
|
1142
1144
|
reason: "manual" | "threshold" | "overflow"
|
|
1143
1145
|
tokensBefore: number
|
|
@@ -1180,6 +1182,8 @@ export async function resumeAgent(
|
|
|
1180
1182
|
input: u.input ?? 0,
|
|
1181
1183
|
output: u.output ?? 0,
|
|
1182
1184
|
cacheWrite: u.cacheWrite ?? 0,
|
|
1185
|
+
cacheRead: u.cacheRead ?? 0,
|
|
1186
|
+
cost: u.cost?.total ?? 0,
|
|
1183
1187
|
})
|
|
1184
1188
|
}
|
|
1185
1189
|
if (
|
package/src/custom-agents.ts
CHANGED
|
@@ -6,7 +6,12 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
|
6
6
|
import { basename, join } from "node:path"
|
|
7
7
|
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"
|
|
8
8
|
import { BUILTIN_TOOL_NAMES } from "./agent-types.js"
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
AgentConfig,
|
|
11
|
+
IsolationMode,
|
|
12
|
+
MemoryScope,
|
|
13
|
+
ThinkingLevel,
|
|
14
|
+
} from "./types.js"
|
|
10
15
|
|
|
11
16
|
interface WarningState {
|
|
12
17
|
previous: Set<string>
|
|
@@ -124,7 +129,7 @@ function loadFromDir(
|
|
|
124
129
|
: undefined,
|
|
125
130
|
isolated: fm.isolated != null ? fm.isolated === true : undefined,
|
|
126
131
|
memory: parseMemory(fm.memory),
|
|
127
|
-
isolation: fm.isolation
|
|
132
|
+
isolation: parseIsolation(fm.isolation),
|
|
128
133
|
enabled: fm.enabled !== false, // default true; explicitly false disables
|
|
129
134
|
source,
|
|
130
135
|
sourcePath: path,
|
|
@@ -270,6 +275,25 @@ function parseMemory(val: unknown): MemoryScope | undefined {
|
|
|
270
275
|
return undefined
|
|
271
276
|
}
|
|
272
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Parse the `isolation` frontmatter field.
|
|
280
|
+
*
|
|
281
|
+
* `off` is kept as a value rather than folded into `undefined` because the two
|
|
282
|
+
* do not mean the same thing here: agent config outranks tool-call params, so
|
|
283
|
+
* `off` vetoes a caller's `worktree` while an absent field lets it through.
|
|
284
|
+
*
|
|
285
|
+
* pi's frontmatter parser is not YAML 1.1 — bare `off` and `no` arrive as
|
|
286
|
+
* strings and only `false` becomes a boolean — so all three spellings are
|
|
287
|
+
* accepted rather than leaving an author's intent silently dropped. Anything
|
|
288
|
+
* else stays `undefined`, as before.
|
|
289
|
+
*/
|
|
290
|
+
function parseIsolation(val: unknown): IsolationMode | undefined {
|
|
291
|
+
if (val === "worktree") return "worktree"
|
|
292
|
+
if (val === "off" || val === "none" || val === "no" || val === false)
|
|
293
|
+
return "off"
|
|
294
|
+
return undefined
|
|
295
|
+
}
|
|
296
|
+
|
|
273
297
|
/**
|
|
274
298
|
* Parse an inherit field (extensions, skills).
|
|
275
299
|
* omitted/true → true (inherit all); false/"none"/empty → false; csv → listed names.
|