@ryan_nookpi/pi-extension-subagent 0.3.3 → 0.4.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/README.md CHANGED
@@ -1,55 +1,21 @@
1
1
  # subagent
2
2
 
3
- Asynchronous subagent delegation for [pi](https://github.com/earendil-works/pi). Run specialist agents in dedicated child sessions, optionally pass selected main-session context, and receive results as follow-up messages.
4
-
5
- The primary interface is **CLI-style**: one `subagent` tool accepts a command string with verbs, options, and a `--` task separator, such as `subagent run worker --isolated -- review this change`. This provides one consistent grammar for single runs, continuation, parallel batches, sequential chains, inspection, and cleanup. These strings are tool input, not shell commands.
3
+ Asynchronous subagent delegation for [pi](https://github.com/earendil-works/pi). Run specialist agents in dedicated child sessions, share main-session context when needed, and receive results as follow-up messages.
6
4
 
7
5
  > [!WARNING]
8
- > Subagents run headlessly without approval prompts. Claude-runtime agents use permission bypass, and pi-runtime agents can use every tool listed in their agent definition. This extension is not a sandbox. Use it only in trusted repositories with trusted prompts and agent definitions.
9
-
10
- ## Requirements
11
-
12
- - pi 0.80.6 or later (tested with 0.80.7)
13
- - For `runtime: claude` with the default `claudeRuntime: "sdk"`: supported Anthropic authentication such as `ANTHROPIC_API_KEY`; see the [official Claude Agent SDK documentation](https://platform.claude.com/docs/en/agent-sdk/overview)
14
- - For `runtime: claude` with `claudeRuntime: "cli"`: the `claude` executable on `PATH` and an authenticated Claude Code installation
6
+ > Subagents run headlessly without approval prompts. Claude-runtime agents bypass permissions, and pi-runtime agents can use every tool declared by their agent definition. This extension is not a sandbox. Use trusted repositories, prompts, and agent definitions only.
15
7
 
16
8
  ## Install
17
9
 
10
+ Requires pi 0.80.6 or later. Compatibility is tested with pi 0.80.7.
11
+
18
12
  ```bash
19
13
  pi install npm:@ryan_nookpi/pi-extension-subagent
20
14
  ```
21
15
 
22
16
  ## Quick start
23
17
 
24
- ### 1. Discover or seed agents
25
-
26
- Run this from the interactive pi UI:
27
-
28
- ```text
29
- /subagents
30
- ```
31
-
32
- If no agent definitions exist in any discovery location, the extension offers an optional starter pack containing:
33
-
34
- - Nine portable English agents: `browser`, `challenger`, `code-cleaner`, `reviewer`, `searcher`, `security-auditor`, `simplifier`, `verifier`, and `worker`
35
- - The `stress-interview` and `self-healing` skills, written in English and validated against the [Agent Skills specification](https://agentskills.io/specification)
36
- - Missing global `subagent` settings: `defaultAgent: "worker"`, `claudeRuntime: "cli"`, and symbol mappings for searcher, challenger, and browser
37
-
38
- Seeded agents intentionally omit model IDs and inherit the user's Pi model. Existing files and configured setting values are never overwritten. If the offer is declined, nothing is recorded or written, so the extension asks again the next time the list is still empty.
39
-
40
- Agents and subagent settings are available immediately after installation. Run `/reload` or start a new Pi session to activate the two newly copied skills. Headless sessions never install automatically; they return instructions to run `/subagents` interactively.
41
-
42
- The same offer is available from either agent-list tool:
43
-
44
- ```json
45
- { "command": "subagent agents" }
46
- ```
47
-
48
- The separate `list-agents` tool behaves the same way. The `subagent ...` examples in this README are **tool command strings**, not terminal commands. Do not run them in Bash.
49
-
50
- ### 2. Or create an agent manually
51
-
52
- Agents are Markdown files with YAML frontmatter. Create `~/.pi/agent/agents/worker.md` for a global agent, or `.pi/agents/worker.md` inside one project:
18
+ Create a global agent at `~/.pi/agent/agents/worker.md`, or a project agent at `.pi/agents/worker.md`:
53
19
 
54
20
  ```markdown
55
21
  ---
@@ -63,18 +29,7 @@ runtime: pi
63
29
  Implement the requested changes and verify them.
64
30
  ```
65
31
 
66
- `name` and `description` are required. Optional fields are:
67
-
68
- - `runtime`: `pi` (default) or `claude`
69
- - `model`: runtime-compatible model ID
70
- - `thinking`: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`
71
- - `tools`: comma-separated tool names
72
-
73
- Omitted model, thinking, and tools values use that runtime's defaults.
74
-
75
- ### 3. Launch a run
76
-
77
- Interactive user command:
32
+ Then launch it from pi:
78
33
 
79
34
  ```text
80
35
  /sub:isolate worker implement the requested change and run tests
@@ -86,36 +41,54 @@ Equivalent AI tool call:
86
41
  { "command": "subagent run worker --isolated -- implement the requested change and run tests" }
87
42
  ```
88
43
 
89
- Runs are asynchronous in interactive mode. Wait for the automatic completion or failure follow-up instead of immediately polling `status` or `detail`.
44
+ Tool launches are asynchronous. Wait for the automatic completion or failure follow-up instead of polling immediately.
45
+
46
+ ## Agent definitions
47
+
48
+ Agent files use YAML frontmatter followed by the system prompt. `name` and `description` are required.
90
49
 
91
- ## Agent discovery
50
+ | Field | Description |
51
+ | --- | --- |
52
+ | `runtime` | `pi` (default) or `claude` |
53
+ | `model` | Runtime-compatible model ID |
54
+ | `thinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` |
55
+ | `tools` | Comma-separated tool names |
56
+
57
+ Omitted optional fields use runtime defaults.
92
58
 
93
- Definitions are loaded from the following locations. Later sources override earlier agents with the same name:
59
+ Agents are discovered in this order; later definitions override earlier agents with the same name:
94
60
 
95
61
  1. `$PI_CODING_AGENT_DIR/agents/*.md` (normally `~/.pi/agent/agents/*.md`)
96
62
  2. Nearest `.claude/agents/**/*.md`
97
63
  3. Nearest `.pi/agents/*.md`
98
64
 
99
- Project `.claude/agents` files are discovered recursively. Project `.pi/agents` files are discovered only in the selected directory.
65
+ `.claude/agents` is searched recursively. `.pi/agents` is not.
66
+
67
+ Run `/subagents`, `subagent agents`, or the `list-agents` tool to inspect discovered agents.
68
+
69
+ ### Optional starter pack
100
70
 
101
- ## Context modes and lifecycle
71
+ If no agents are found, `/subagents` can offer an optional, opinionated starter pack. It copies nine agent templates, two example workflow skills, and missing global `subagent` settings. Existing files and configured values are not overwritten.
102
72
 
103
- - `--isolated` starts a dedicated child session without copying the main conversation. It is the default for `subagent` tool launches.
104
- - `--main` adds selected main-session context to the child task.
105
- - `/sub:isolate` selects isolated context; `/sub:main` selects main context.
106
- - `>>` and `>` use main-session context.
107
- - Continuing a run preserves its original context mode and child session. Supplying `--main` or `--isolated` to `continue` does not retroactively change it.
73
+ The starter pack is not required. Decline it if you prefer to define agents manually. It fills a missing `claudeRuntime` setting with `cli`; without that setting, the extension default is `sdk`.
108
74
 
109
- Pi replaces and invalidates extension runtimes during `/new`, `/resume`, `/fork`, and reload. Active child processes are therefore aborted during `session_shutdown`, and the old session records why they stopped. Wait for active runs before replacing the parent session. This follows pi's [official extension lifecycle guidance](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md#long-lived-resources-and-shutdown).
75
+ ## Context modes
110
76
 
111
- ## CLI-style tool interface
77
+ - `--isolated` starts without copying the main conversation. It is the default for tool launches.
78
+ - `--main` passes selected main-session context to the child.
79
+ - `/sub:isolate` and `/sub:main` provide the same choice for interactive commands.
80
+ - Continuing a run preserves its original child session and context mode.
112
81
 
113
- Instead of registering a separate tool for every operation, the extension exposes a compact CLI-style grammar through one `subagent` tool. The model passes the full command as the tool's `command` parameter; it must not invoke `subagent` from Bash or another shell.
82
+ Active child processes stop when pi replaces or reloads the parent extension runtime. This follows pi's [official extension lifecycle guidance](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md#long-lived-resources-and-shutdown).
114
83
 
115
- The extension registers two main-session tools:
84
+ ## Tool interface
116
85
 
117
- - `list-agents`: return discovered agent definitions and runtime settings
118
- - `subagent`: accept one CLI-style command string
86
+ The extension registers two tools:
87
+
88
+ - `list-agents` — list discovered agents and runtime settings
89
+ - `subagent` — execute a CLI-style command string
90
+
91
+ These strings are tool input, not shell commands. Do not run them in Bash.
119
92
 
120
93
  ```text
121
94
  subagent help
@@ -123,15 +96,15 @@ subagent agents
123
96
  subagent runs
124
97
  subagent run <agent> [--main|--isolated] -- <task>
125
98
  subagent continue <runId> [--agent <agent>] [--main|--isolated] -- <task>
126
- subagent batch [--main|--isolated] --agent <agent> --task <task> [--agent <agent> --task <task> ...]
127
- subagent chain [--main|--isolated] --agent <agent> --task <task> [--agent <agent> --task <task> ...]
99
+ subagent batch [--main|--isolated] --agent <agent> --task <task> ...
100
+ subagent chain [--main|--isolated] --agent <agent> --task <task> ...
128
101
  subagent status <runId>
129
102
  subagent detail <runId>
130
103
  subagent abort <runId|runId,runId|all>
131
104
  subagent remove <runId|runId,runId|all>
132
105
  ```
133
106
 
134
- `batch` runs independent tasks in parallel. Quote tasks containing spaces:
107
+ `batch` runs independent tasks in parallel:
135
108
 
136
109
  ```json
137
110
  {
@@ -147,46 +120,51 @@ subagent remove <runId|runId,runId|all>
147
120
  }
148
121
  ```
149
122
 
150
- Use `status` and `detail` only for explicit, one-off inspection. Repeated polling is unnecessary because completion is delivered automatically.
123
+ Use `status` and `detail` for one-off inspection, not polling loops.
151
124
 
152
- ## Slash commands
125
+ ## Interactive commands
153
126
 
154
- - `/subagents` — list discovered agents and settings
155
- - `/sub:main [agent|alias|runId] <task>` — launch with main-session context or continue a run
156
- - `/sub:isolate [agent|alias|runId] <task>` — launch in isolated context or continue a run
157
- - `/sub:peek [runId]` — show the latest response; defaults to the latest run
158
- - `/sub:open [runId]` — open session replay; defaults to the latest run
159
- - `/sub:history` — show all run history, including removed runs
160
- - `/sub:rm [runId]` — remove a run; defaults to the latest and aborts it if necessary
161
- - `/sub:clear [all]` — clear finished runs, or every run with `all`
162
- - `/sub:abort [runId|all]` — abort the latest running run, one run, or all running runs
127
+ | Command | Description |
128
+ | --- | --- |
129
+ | `/subagents` | List agents and offer the starter pack when none exist |
130
+ | `/sub:main [agent\|runId] <task>` | Launch or continue with main-session context |
131
+ | `/sub:isolate [agent\|runId] <task>` | Launch or continue with isolated context |
132
+ | `/sub:peek [runId]` | Show the latest result |
133
+ | `/sub:open [runId]` | Open the child session replay |
134
+ | `/sub:history` | Show run history, including removed runs |
135
+ | `/sub:abort [runId\|all]` | Abort running work |
136
+ | `/sub:rm [runId]` | Remove a run, aborting it first if necessary |
137
+ | `/sub:clear [all]` | Clear finished runs, or all runs |
163
138
 
164
139
  When an agent is omitted, launch commands use `defaultAgent`.
165
140
 
166
- ## Interactive shortcuts
141
+ ### Shortcuts
167
142
 
168
- | Shortcut | Behavior |
143
+ | Shortcut | Description |
169
144
  | --- | --- |
170
- | `>> [agent\|runId] <task>` | Visible run using main-session context |
171
- | `> [agent\|runId] <task>` | Hidden run using main-session context; interactive UI only |
145
+ | `>> [agent\|runId] <task>` | Visible run with main-session context |
146
+ | `> [agent\|runId] <task>` | Hidden run with main-session context |
172
147
  | `#<runId> <task>` | Continue a run |
173
- | `>><symbol> <task>` | Visible run using the agent mapped in `symbolMap` |
174
- | `><symbol> <task>` | Hidden run using the mapped agent |
175
- | `<>runId` | Compact form of `/sub:peek runId` |
176
- | `<< [runId\|runId,runId]` | Abort selected running runs or clear selected finished runs; without arguments, abort the latest running run |
177
- | `<<< [all]` | Clear finished runs; use `all` to clear every run |
148
+ | `>><symbol> <task>` / `><symbol> <task>` | Run an agent from `symbolMap` |
149
+ | `<>runId` | Peek at a result |
150
+ | `<< [runId\|runId,runId]` | Abort running or clear finished runs |
151
+ | `<<< [all]` | Clear finished runs, or all runs |
178
152
 
179
- Hidden runs do not add start or completion messages to the main transcript. Read their output with `/sub:peek`, `<>runId`, or `/sub:open`. A plain `>` shortcut requires a space before its task; configured symbol shortcuts do not.
153
+ Hidden runs are available only in the interactive UI and do not add start or completion messages to the main transcript.
180
154
 
181
- ## Escalation from pi-runtime agents
155
+ ### Prompt mentions
182
156
 
183
- Pi-runtime subagent sessions receive an `ask_master` tool. It lets a child report a decision that the parent must make, then immediately terminates that child run. The parent receives the escalation as a follow-up.
157
+ Use `>agent-name` inside a prompt to reference a discovered agent. Exact names are highlighted and rewritten to `subagent:agent-name` before the main LLM receives the prompt; they do not launch a run directly. Unknown names remain unchanged.
184
158
 
185
- Use `ask_master` only when the child cannot safely proceed, such as before a destructive operation or an unresolved architecture decision. Claude-runtime agents do not receive this tool; they report blockers in their final text instead.
159
+ ```text
160
+ Delegate implementation to >worker and review to >reviewer.
161
+ ```
162
+
163
+ A mention has no space after `>`. Launch shortcuts remain separate, such as `> worker implement this`.
186
164
 
187
165
  ## Configuration
188
166
 
189
- Global configuration belongs under `subagent` in `$PI_CODING_AGENT_DIR/settings.json` (normally `~/.pi/agent/settings.json`):
167
+ Global settings belong under `subagent` in `$PI_CODING_AGENT_DIR/settings.json`:
190
168
 
191
169
  ```json
192
170
  {
@@ -212,65 +190,44 @@ A nearest project `.pi/subagent.json` overrides global values:
212
190
  }
213
191
  ```
214
192
 
215
- - `claudeRuntime`: `sdk` (default) or `cli`; applies only to agents with `runtime: claude`
216
- - `defaultAgent`: agent used when a launch omits its agent; defaults to `worker` and must match a discovered definition
217
- - `symbolMap`: one-character shortcuts mapped to non-empty agent names; a valid project map replaces the global map as a whole, while a malformed project map falls back to the valid global map
193
+ - `claudeRuntime`: `sdk` (default) or `cli`; applies only to `runtime: claude`
194
+ - `defaultAgent`: used when an interactive launch omits the agent; defaults to `worker`
195
+ - `symbolMap`: one-character shortcuts mapped to agent names; a valid project map replaces the global map
218
196
 
219
- ### Context guard override
220
-
221
- `PI_SUBAGENT_CONTEXT_GUARD_TOKENS` overrides the proactive context limit for pi-runtime children. Set it to a positive integer to apply that ceiling to every pi model. Set it to `0` or an empty value to disable the proactive guard and rely on native compaction or provider overflow handling.
222
-
223
- ## Troubleshooting
197
+ Set `PI_SUBAGENT_CONTEXT_GUARD_TOKENS` to a positive integer to override the proactive context limit for pi-runtime children. Set it to `0` or an empty value to disable the guard.
224
198
 
225
- ### `Configured defaultAgent "worker" was not found`
199
+ ## Claude runtime
226
200
 
227
- Create a `worker` definition from the quick start, choose an existing agent explicitly, or change `defaultAgent`. Run `/subagents` to verify discovery before launching.
201
+ The default Claude runtime uses the Claude Agent SDK and requires supported Anthropic authentication such as `ANTHROPIC_API_KEY`; see the [official Claude Agent SDK documentation](https://platform.claude.com/docs/en/agent-sdk/overview).
228
202
 
229
- ### Claude SDK authentication failure
203
+ For `claudeRuntime: "cli"`, install the `claude` executable, ensure it is on `PATH`, and authenticate Claude Code.
230
204
 
231
- Confirm the environment used to start pi has valid Anthropic authentication, such as `ANTHROPIC_API_KEY`. The SDK runtime does not require the Claude Code CLI.
205
+ ## Escalation
232
206
 
233
- ### `spawn claude ENOENT`
207
+ Pi-runtime children receive an `ask_master` tool. Calling it reports a decision or blocker to the parent and immediately terminates the child run. Use it only when the child cannot proceed safely.
234
208
 
235
- `claudeRuntime` is set to `cli`, but the `claude` executable is not on `PATH`. Install and authenticate Claude Code, or switch back to `claudeRuntime: "sdk"`.
209
+ Claude-runtime children do not receive `ask_master`; they report blockers in their final response.
236
210
 
237
- ### Hidden shortcut produces no transcript message
238
-
239
- That is intentional. Hidden `>` runs are human-only UI jobs. Inspect them with `/sub:peek`, `<>runId`, or `/sub:open`.
240
-
241
- ### Debug runner termination
242
-
243
- The extension records process lifecycle diagnostics as `subagent-runner-diagnostic` custom entries in the parent session JSONL. They include run/batch IDs, parent and child PID/PGID, abort reason, internal kill cause, `exit`/`close` code and signal, settle reason, shutdown reason, and process-error stacks. Custom entries are not sent to the LLM and remain hidden unless an entry renderer is registered, as documented by pi's [`appendEntry` API](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md#piappendentrycustomtype-data).
244
-
245
- Run `/session` to find the current session file, set `SESSION_FILE` to that path, then extract only the diagnostic entries:
246
-
247
- ```bash
248
- jq -c 'select(.type == "custom" and .customType == "subagent-runner-diagnostic") | {timestamp, data}' "$SESSION_FILE"
249
- ```
250
-
251
- For batch failures, compare entries by `batchId` and `runId`, then follow each run from `spawn` through `kill_intent`, `exit`, `close`, and `settled`. Share only the selected diagnostic lines—not the full session file, which may contain prompts or tool output.
252
-
253
- To report a reproducible extension bug, create `subagent-issue.md` with the pi and extension versions, OS and Node version, launch mode/command, reproduction steps, expected and actual behavior, and the sanitized diagnostic lines. Then submit it with:
211
+ ## Troubleshooting
254
212
 
255
- ```bash
256
- gh issue create --repo Jonghakseo/pi-extension \
257
- --title "subagent: unexpected child termination" \
258
- --body-file subagent-issue.md
259
- ```
213
+ - **`Configured defaultAgent "worker" was not found`** — create a matching agent, choose another agent explicitly, or update `defaultAgent`.
214
+ - **Claude SDK authentication failure** — confirm the environment that starts pi contains valid Anthropic authentication.
215
+ - **`spawn claude ENOENT`** — install Claude Code or switch `claudeRuntime` to `sdk`.
216
+ - **Hidden run shows no transcript message** — inspect it with `/sub:peek`, `<>runId`, or `/sub:open`.
260
217
 
261
- Without GitHub CLI, use the repository's [new issue page](https://github.com/Jonghakseo/pi-extension/issues/new). Remove secrets and sensitive paths before attaching diagnostics.
218
+ When reporting a bug, include the pi and extension versions, OS and Node version, launch command, reproduction steps, and sanitized error output. Do not attach full session files because they may contain prompts, tool output, or secrets.
262
219
 
263
- ## Security and trust boundary
220
+ ## Security
264
221
 
265
- - Claude SDK execution uses `permissionMode: "bypassPermissions"` with `allowDangerouslySkipPermissions`; Claude CLI execution uses `--dangerously-skip-permissions`.
266
- - Pi-runtime children are headless and have unrestricted access to the tools declared by their agent.
267
- - Project agent definitions are repository-controlled instructions. Review `.pi/agents` and `.claude/agents` before running this extension in an unfamiliar repository.
268
- - Restrict each agent's `tools` list to what it needs. Avoid broad shell or write access for read-only review agents.
269
- - `--isolated` separates conversation context; it does not provide filesystem, process, credential, or network isolation.
222
+ - Claude SDK uses permission bypass; Claude CLI uses `--dangerously-skip-permissions`.
223
+ - Pi-runtime children can use every tool declared by their agent.
224
+ - Project agent definitions are repository-controlled instructions. Review `.pi/agents` and `.claude/agents` in unfamiliar repositories.
225
+ - Restrict each agent's `tools` list to the minimum required.
226
+ - `--isolated` separates conversation context, not filesystem, process, credential, or network access.
270
227
 
271
228
  ## Stability
272
229
 
273
- This is a `0.1.x` release. The commands, configuration keys, agent frontmatter, and behaviors documented here are the supported surface. Internal TypeScript modules included in the npm tarball are implementation details and may change during the `0.x` series. Compatibility is currently tested against pi 0.80.7.
230
+ This package is pre-1.0. Documented commands, configuration, and agent frontmatter are the supported surface; internal TypeScript modules may change between releases.
274
231
 
275
232
  ## License
276
233
 
package/cli.ts CHANGED
@@ -53,8 +53,9 @@ export const SUBAGENT_CLI_HELP_TEXT = [
53
53
  " subagent help",
54
54
  " subagent agents",
55
55
  " subagent runs",
56
- " subagent status <runId>",
57
- " subagent detail <runId>",
56
+ " subagent status <runId|groupId>",
57
+ " subagent detail <runId|groupId>",
58
+ " (groupId = the b_.../p_... id returned by batch/chain launches; finished groups are retained briefly)",
58
59
  "",
59
60
  " Execution:",
60
61
  " subagent run <agent> [--main|--isolated] -- <task>",
@@ -167,6 +168,11 @@ function parseInteger(raw: string): number | null {
167
168
  return Number.isInteger(value) ? value : null;
168
169
  }
169
170
 
171
+ /** Group IDs returned by batch (`b_...`) and chain (`p_...`) launches. */
172
+ function isGroupId(raw: string): boolean {
173
+ return /^[bp]_/.test(raw);
174
+ }
175
+
170
176
  function parseRunTarget(
171
177
  raw: string,
172
178
  knownRunIds: number[] | undefined,
@@ -462,33 +468,35 @@ export function parseSubagentToolCommand(
462
468
  return { type: "params", params: { asyncAction: "list" } };
463
469
 
464
470
  case "status": {
465
- const runIdRaw = args[0];
466
- if (!runIdRaw)
471
+ const idRaw = args[0];
472
+ if (!idRaw)
467
473
  return {
468
474
  type: "error",
469
- message: `❌ status requires <runId>\n\n✓ Example: subagent status 22\n\nSee all runs with: subagent runs`,
475
+ message: `❌ status requires <runId|groupId>\n\n✓ Example: subagent status 22\n✓ Example: subagent status b_1712... (batch/chain)\n\nSee all runs with: subagent runs`,
470
476
  };
471
- const runId = parseInteger(runIdRaw);
477
+ if (isGroupId(idRaw)) return { type: "params", params: { asyncAction: "status", groupId: idRaw } };
478
+ const runId = parseInteger(idRaw);
472
479
  if (runId === null)
473
480
  return {
474
481
  type: "error",
475
- message: `❌ Invalid runId: "${runIdRaw}"\n\nThe runId must be a number. See all runs with: subagent runs`,
482
+ message: `❌ Invalid id: "${idRaw}"\n\nUse a numeric runId or a batch/chain groupId (b_.../p_...). See all runs with: subagent runs`,
476
483
  };
477
484
  return { type: "params", params: { asyncAction: "status", runId } };
478
485
  }
479
486
 
480
487
  case "detail": {
481
- const runIdRaw = args[0];
482
- if (!runIdRaw)
488
+ const idRaw = args[0];
489
+ if (!idRaw)
483
490
  return {
484
491
  type: "error",
485
- message: `❌ detail requires <runId>\n\n✓ Example: subagent detail 22\n\nSee all runs with: subagent runs`,
492
+ message: `❌ detail requires <runId|groupId>\n\n✓ Example: subagent detail 22\n✓ Example: subagent detail b_1712... (batch/chain)\n\nSee all runs with: subagent runs`,
486
493
  };
487
- const runId = parseInteger(runIdRaw);
494
+ if (isGroupId(idRaw)) return { type: "params", params: { asyncAction: "detail", groupId: idRaw } };
495
+ const runId = parseInteger(idRaw);
488
496
  if (runId === null)
489
497
  return {
490
498
  type: "error",
491
- message: `❌ Invalid runId: "${runIdRaw}"\n\nThe runId must be a number. See all runs with: subagent runs`,
499
+ message: `❌ Invalid id: "${idRaw}"\n\nUse a numeric runId or a batch/chain groupId (b_.../p_...). See all runs with: subagent runs`,
492
500
  };
493
501
  return { type: "params", params: { asyncAction: "detail", runId } };
494
502
  }
package/commands.ts CHANGED
@@ -42,11 +42,16 @@ import {
42
42
  upsertPendingGroupCompletion,
43
43
  } from "./group-pending.js";
44
44
  import { enqueueSubagentInvocation } from "./invocation-queue.js";
45
+ import {
46
+ createAgentMentionAutocompleteProvider,
47
+ registerAgentMentionHighlighting,
48
+ replaceAgentMentions,
49
+ } from "./mentions.js";
45
50
  import { appendDisplayTaskUpdate, getSessionFileSize } from "./persisted-session.js";
46
51
  import { SUBAGENT_COMMANDS, type SubagentCommandName } from "./registration-manifest.js";
47
52
  import { readSessionReplayItems, SubagentSessionReplayOverlay } from "./replay.js";
48
53
  import { invokeWithAutoRetry, MAX_SUBAGENT_AUTO_RETRIES } from "./retry.js";
49
- import { getLatestRun, removeRun, trimCommandRunHistory } from "./run-utils.js";
54
+ import { evictStaleFinishedGroups, getLatestRun, removeRun, trimCommandRunHistory } from "./run-utils.js";
50
55
  import {
51
56
  getFinalOutput,
52
57
  getLastNonEmptyLine,
@@ -819,6 +824,7 @@ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAP
819
824
  }
820
825
 
821
826
  evictStalePendingGroupCompletions(STALE_PENDING_COMPLETION_MS);
827
+ evictStaleFinishedGroups(store);
822
828
 
823
829
  // Fallback: if this session has no subagent markers at all, but we recently
824
830
  // had in-memory runs for the same session file, reuse that snapshot so
@@ -904,6 +910,17 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
904
910
  commandDefinitions.set(name, definition);
905
911
  };
906
912
 
913
+ // Prompt mentions are references for the main LLM, not direct launch shortcuts.
914
+ // Register this transform before the legacy `>` handlers so `>worker` cannot
915
+ // be mistaken for a configured one-character symbol shortcut.
916
+ pi.on("input", (event, ctx) => {
917
+ if (event.source === "extension") return { action: "continue" as const };
918
+
919
+ const transformedText = replaceAgentMentions(event.text ?? "", discoverAgents(ctx.cwd).agents);
920
+ if (transformedText === event.text) return { action: "continue" as const };
921
+ return { action: "transform" as const, text: transformedText, images: event.images };
922
+ });
923
+
907
924
  pi.registerTool({
908
925
  name: "list-agents",
909
926
  label: "List Agents",
@@ -2300,6 +2317,19 @@ export async function handleBeforeAgentStart(
2300
2317
  export function handleSessionStart(pi: ExtensionAPI, store: SubagentStore, ctx: ExtensionContext): void {
2301
2318
  restoreRunsFromSession(store, ctx, pi);
2302
2319
  registerTerminalInputRedirect(ctx);
2320
+
2321
+ let cachedAgents = discoverAgents(ctx.cwd).agents;
2322
+ let discoveredAt = Date.now();
2323
+ const getAgents = () => {
2324
+ if (Date.now() - discoveredAt >= 1_000) {
2325
+ cachedAgents = discoverAgents(ctx.cwd).agents;
2326
+ discoveredAt = Date.now();
2327
+ }
2328
+ return cachedAgents;
2329
+ };
2330
+
2331
+ ctx.ui.addAutocompleteProvider((current) => createAgentMentionAutocompleteProvider(current, getAgents));
2332
+ registerAgentMentionHighlighting(ctx, getAgents);
2303
2333
  }
2304
2334
 
2305
2335
  /** session_shutdown handler; index.ts invokes this after shutting down runs. */
package/constants.ts CHANGED
@@ -30,6 +30,12 @@ export const SUBAGENT_STRONG_WAIT_MESSAGE =
30
30
  /** Maximum age (ms) for pending cross-session completions before eviction. */
31
31
  export const STALE_PENDING_COMPLETION_MS = 30 * 60 * 1_000;
32
32
 
33
+ /** Max number of finished batch/chain group snapshots retained for `status`/`detail` queries. */
34
+ export const MAX_FINISHED_GROUPS = 20;
35
+
36
+ /** Max age (ms) a finished group snapshot is retained before eviction. */
37
+ export const FINISHED_GROUP_TTL_MS = 30 * 60 * 1_000;
38
+
33
39
  /** Short label shown in the widget when inside a child session. */
34
40
  export const PARENT_HINT = "↩ parent (><)";
35
41
 
package/lifecycle.ts CHANGED
@@ -114,6 +114,7 @@ export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, rea
114
114
  store.globalLiveRuns.clear();
115
115
  store.batchGroups.clear();
116
116
  store.pipelines.clear();
117
+ store.finishedGroups.clear();
117
118
  store.recentLaunchTimestamps.clear();
118
119
  store.commandRuns.clear();
119
120
  store.commandWidgetCtx = null;
package/mentions.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { CustomEditor, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ AutocompleteProvider,
4
+ AutocompleteSuggestions,
5
+ EditorComponent,
6
+ EditorTheme,
7
+ TUI,
8
+ } from "@earendil-works/pi-tui";
9
+ import type { AgentConfig } from "./agents.js";
10
+ import { COMMAND_COMPLETION_LIMIT } from "./constants.js";
11
+
12
+ const MENTION_PREFIX_PATTERN = /(?:^|[\s([{])>(?!>)([^\s>]*)$/;
13
+ const MENTION_LEADING_BOUNDARY = "[\\s([{]";
14
+ const MENTION_TRAILING_BOUNDARY = "(?=$|[\\s.,!?;:)}\\]])";
15
+ const MENTION_HIGHLIGHT_WRAPPED = Symbol.for("pi-extension-subagent.mentionHighlightWrapped");
16
+ const ANSI_CYAN = "\x1b[36m";
17
+ const ANSI_RESET_FOREGROUND = "\x1b[39m";
18
+
19
+ type HighlightableEditor = EditorComponent & Record<PropertyKey, unknown>;
20
+ type RichEditorTheme = EditorTheme & {
21
+ fg?: (color: string, text: string) => string;
22
+ };
23
+
24
+ function escapeRegExp(value: string): string {
25
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
26
+ }
27
+
28
+ function buildAgentMentionPattern(agents: AgentConfig[]): {
29
+ pattern: RegExp;
30
+ canonicalNames: Map<string, string>;
31
+ } | null {
32
+ const canonicalNames = new Map(agents.map((agent) => [agent.name.toLowerCase(), agent.name]));
33
+ const names = Array.from(canonicalNames.values())
34
+ .filter((name) => name.length > 0 && !/\s/.test(name))
35
+ .sort((left, right) => right.length - left.length)
36
+ .map(escapeRegExp);
37
+ if (names.length === 0) return null;
38
+
39
+ return {
40
+ pattern: new RegExp(`(^|${MENTION_LEADING_BOUNDARY})>(${names.join("|")})${MENTION_TRAILING_BOUNDARY}`, "gim"),
41
+ canonicalNames,
42
+ };
43
+ }
44
+
45
+ function styleAgentMention(text: string, theme: EditorTheme): string {
46
+ const richTheme = theme as RichEditorTheme;
47
+ for (const color of ["subagentMention", "syntaxType", "toolTitle"]) {
48
+ try {
49
+ const styled = richTheme.fg?.(color, text);
50
+ if (styled && styled !== text) return styled;
51
+ } catch {
52
+ // Custom theme keys are optional.
53
+ }
54
+ }
55
+ return theme.selectList?.selectedText?.(text) ?? `${ANSI_CYAN}${text}${ANSI_RESET_FOREGROUND}`;
56
+ }
57
+
58
+ /** Return the incomplete agent name after a mention marker at the cursor. */
59
+ export function extractAgentMentionQuery(textBeforeCursor: string): string | undefined {
60
+ return MENTION_PREFIX_PATTERN.exec(textBeforeCursor)?.[1];
61
+ }
62
+
63
+ /** Filter mention candidates by case-insensitive containment, preferring prefix matches. */
64
+ export function filterAgentMentionCandidates(agents: AgentConfig[], query: string): AgentConfig[] {
65
+ const normalizedQuery = query.toLowerCase();
66
+ return agents
67
+ .filter((agent) => agent.name.toLowerCase().includes(normalizedQuery))
68
+ .sort((left, right) => {
69
+ const leftName = left.name.toLowerCase();
70
+ const rightName = right.name.toLowerCase();
71
+ const leftStartsWith = leftName.startsWith(normalizedQuery);
72
+ const rightStartsWith = rightName.startsWith(normalizedQuery);
73
+ if (leftStartsWith !== rightStartsWith) return leftStartsWith ? -1 : 1;
74
+ return leftName.localeCompare(rightName);
75
+ })
76
+ .slice(0, COMMAND_COMPLETION_LIMIT);
77
+ }
78
+
79
+ /** Replace exact, discovered `>agent` mentions with the main-LLM-friendly `subagent:agent` form. */
80
+ export function replaceAgentMentions(text: string, agents: AgentConfig[]): string {
81
+ const mentionPattern = buildAgentMentionPattern(agents);
82
+ if (!mentionPattern) return text;
83
+
84
+ return text.replace(mentionPattern.pattern, (_match, boundary: string, name: string) => {
85
+ const canonicalName = mentionPattern.canonicalNames.get(name.toLowerCase()) ?? name;
86
+ return `${boundary}subagent:${canonicalName}`;
87
+ });
88
+ }
89
+
90
+ /** Highlight only exact mentions for agents that are currently discoverable. */
91
+ export function highlightAgentMentions(
92
+ text: string,
93
+ agents: AgentConfig[],
94
+ style: (mention: string) => string,
95
+ ): string {
96
+ const mentionPattern = buildAgentMentionPattern(agents);
97
+ if (!mentionPattern) return text;
98
+
99
+ return text.replace(mentionPattern.pattern, (_match, boundary: string, name: string) => {
100
+ return `${boundary}${style(`>${name}`)}`;
101
+ });
102
+ }
103
+
104
+ export function createAgentMentionHighlightEditor(
105
+ baseEditor: EditorComponent,
106
+ getAgents: () => AgentConfig[],
107
+ theme: EditorTheme,
108
+ ): EditorComponent {
109
+ const highlightableEditor = baseEditor as HighlightableEditor;
110
+ if (highlightableEditor[MENTION_HIGHLIGHT_WRAPPED]) return baseEditor;
111
+
112
+ return new Proxy(highlightableEditor, {
113
+ get(target, property) {
114
+ if (property === MENTION_HIGHLIGHT_WRAPPED) return true;
115
+ if (property === "render") {
116
+ return (width: number) => {
117
+ const agents = getAgents();
118
+ return target
119
+ .render(width)
120
+ .map((line) => highlightAgentMentions(line, agents, (mention) => styleAgentMention(mention, theme)));
121
+ };
122
+ }
123
+
124
+ const value = Reflect.get(target, property, target);
125
+ return typeof value === "function" ? value.bind(target) : value;
126
+ },
127
+ set(target, property, value) {
128
+ return Reflect.set(target, property, value, target);
129
+ },
130
+ }) as EditorComponent;
131
+ }
132
+
133
+ export function registerAgentMentionHighlighting(ctx: ExtensionContext, getAgents: () => AgentConfig[]): void {
134
+ if (ctx.mode !== "tui") return;
135
+
136
+ const previousEditorFactory = ctx.ui.getEditorComponent();
137
+ ctx.ui.setEditorComponent((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
138
+ const baseEditor = previousEditorFactory?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings);
139
+ return createAgentMentionHighlightEditor(baseEditor, getAgents, theme);
140
+ });
141
+ }
142
+
143
+ export function createAgentMentionAutocompleteProvider(
144
+ current: AutocompleteProvider,
145
+ getAgents: () => AgentConfig[],
146
+ ): AutocompleteProvider {
147
+ return {
148
+ triggerCharacters: Array.from(new Set([...(current.triggerCharacters ?? []), ">"])),
149
+ async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
150
+ const currentLine = lines[cursorLine] ?? "";
151
+ const query = extractAgentMentionQuery(currentLine.slice(0, cursorCol));
152
+ if (query === undefined) {
153
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
154
+ }
155
+
156
+ const candidates = filterAgentMentionCandidates(getAgents(), query);
157
+ if (options.signal.aborted || candidates.length === 0) return null;
158
+
159
+ return {
160
+ prefix: `>${query}`,
161
+ items: candidates.map((agent) => ({
162
+ value: `>${agent.name}`,
163
+ label: `>${agent.name}`,
164
+ description: agent.description,
165
+ })),
166
+ };
167
+ },
168
+
169
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
170
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
171
+ },
172
+
173
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
174
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
175
+ },
176
+ };
177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-extension-subagent",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,6 +40,7 @@
40
40
  "lifecycle.ts",
41
41
  "invocation-queue.ts",
42
42
  "live-preview.ts",
43
+ "mentions.ts",
43
44
  "persisted-session.ts",
44
45
  "replay.ts",
45
46
  "registration-manifest.ts",
package/run-utils.ts CHANGED
@@ -7,8 +7,15 @@
7
7
  */
8
8
 
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { FINISHED_GROUP_TTL_MS, MAX_FINISHED_GROUPS, STATUS_OUTPUT_PREVIEW_MAX_CHARS } from "./constants.js";
10
11
  import type { SubagentStore } from "./store.js";
11
- import type { CommandRunState } from "./types.js";
12
+ import type {
13
+ BatchGroupState,
14
+ CommandRunState,
15
+ FinishedGroupMember,
16
+ FinishedGroupSnapshot,
17
+ PipelineState,
18
+ } from "./types.js";
12
19
  import { updateCommandRunsWidget, type WidgetRenderCtx } from "./widget.js";
13
20
 
14
21
  export interface RemoveRunOptions {
@@ -201,3 +208,115 @@ export function trimCommandRunHistory(
201
208
 
202
209
  return removedRunIds;
203
210
  }
211
+
212
+ // ── Finished-group retention ────────────────────────────────────────────────
213
+ // Batch/chain groups are deleted from the live store once their completion is
214
+ // delivered. To keep `subagent status/detail <groupId>` working for a short
215
+ // window afterward, we retain an immutable snapshot per finished group.
216
+
217
+ function memberOutput(run: CommandRunState, fallback?: string): string {
218
+ return run.lastOutput?.trim() || run.lastLine?.trim() || fallback?.trim() || "(no output)";
219
+ }
220
+
221
+ /** Build a finished-group snapshot from a completed batch group. */
222
+ export function snapshotBatchGroup(
223
+ store: SubagentStore,
224
+ batch: BatchGroupState,
225
+ terminalStatus: FinishedGroupSnapshot["terminalStatus"],
226
+ ): FinishedGroupSnapshot {
227
+ let failed = 0;
228
+ const members: FinishedGroupMember[] = batch.runIds.map((runId) => {
229
+ const run = store.commandRuns.get(runId);
230
+ if (!run) {
231
+ if (batch.failedRunIds.has(runId)) failed++;
232
+ return {
233
+ summaryLine: `#${runId} [gone] (run no longer available)`,
234
+ output: batch.pendingResults.get(runId)?.trim() || "(no output)",
235
+ };
236
+ }
237
+ if (run.status === "error" || batch.failedRunIds.has(runId)) failed++;
238
+ return { summaryLine: formatCommandRunSummary(run), output: memberOutput(run, batch.pendingResults.get(runId)) };
239
+ });
240
+ return {
241
+ groupId: batch.batchId,
242
+ kind: "batch",
243
+ terminalStatus,
244
+ finishedAt: Date.now(),
245
+ total: batch.runIds.length,
246
+ failed,
247
+ members,
248
+ };
249
+ }
250
+
251
+ /** Build a finished-group snapshot from a completed pipeline. */
252
+ export function snapshotPipeline(
253
+ pipeline: PipelineState,
254
+ terminalStatus: FinishedGroupSnapshot["terminalStatus"],
255
+ ): FinishedGroupSnapshot {
256
+ const members: FinishedGroupMember[] = pipeline.stepResults.map((step, index) => ({
257
+ summaryLine: `Step ${index + 1} · #${step.runId} ${step.agent} · ${step.status}`,
258
+ output: step.output?.trim() || "(no output)",
259
+ task: step.task,
260
+ }));
261
+ return {
262
+ groupId: pipeline.pipelineId,
263
+ kind: "chain",
264
+ terminalStatus,
265
+ finishedAt: Date.now(),
266
+ total: pipeline.stepResults.length,
267
+ failed: pipeline.stepResults.filter((step) => step.status === "error").length,
268
+ members,
269
+ };
270
+ }
271
+
272
+ /** Retain a finished-group snapshot, evicting the oldest entries past the cap. */
273
+ export function retireFinishedGroup(store: SubagentStore, snapshot: FinishedGroupSnapshot): void {
274
+ // Re-insert to refresh insertion order (most-recent last).
275
+ store.finishedGroups.delete(snapshot.groupId);
276
+ store.finishedGroups.set(snapshot.groupId, snapshot);
277
+ while (store.finishedGroups.size > MAX_FINISHED_GROUPS) {
278
+ const oldest = store.finishedGroups.keys().next().value;
279
+ if (oldest === undefined) break;
280
+ store.finishedGroups.delete(oldest);
281
+ }
282
+ }
283
+
284
+ /** Evict finished-group snapshots older than the retention TTL. Returns count removed. */
285
+ export function evictStaleFinishedGroups(store: SubagentStore, now = Date.now()): number {
286
+ let removed = 0;
287
+ for (const [groupId, snapshot] of store.finishedGroups) {
288
+ if (now - snapshot.finishedAt > FINISHED_GROUP_TTL_MS) {
289
+ store.finishedGroups.delete(groupId);
290
+ removed++;
291
+ }
292
+ }
293
+ return removed;
294
+ }
295
+
296
+ function formatFinishedAge(finishedAt: number, now = Date.now()): string {
297
+ const seconds = Math.max(0, Math.round((now - finishedAt) / 1000));
298
+ if (seconds < 60) return `${seconds}s ago`;
299
+ const minutes = Math.round(seconds / 60);
300
+ return `${minutes}m ago`;
301
+ }
302
+
303
+ /** Render a retained finished-group snapshot for `status` (summary) or `detail` (with output). */
304
+ export function formatFinishedGroupStatus(snapshot: FinishedGroupSnapshot, detailed: boolean): string {
305
+ const label = snapshot.kind === "batch" ? "subagent-batch" : "subagent-chain";
306
+ const failedSuffix = snapshot.failed > 0 ? `, ${snapshot.failed} failed` : "";
307
+ const header = `[${label}#${snapshot.groupId}] ${snapshot.terminalStatus} · ${snapshot.total} ${
308
+ snapshot.kind === "batch" ? "runs" : "steps"
309
+ }${failedSuffix} · finished ${formatFinishedAge(snapshot.finishedAt)}`;
310
+ const body = snapshot.members
311
+ .map((member) => {
312
+ if (!detailed) return member.summaryLine;
313
+ const output =
314
+ member.output.length > STATUS_OUTPUT_PREVIEW_MAX_CHARS
315
+ ? `${member.output.slice(0, STATUS_OUTPUT_PREVIEW_MAX_CHARS)}\n\n... [truncated]`
316
+ : member.output;
317
+ const taskLine = member.task ? `Task: ${member.task}\n` : "";
318
+ return `${member.summaryLine}\n${taskLine}${output}`;
319
+ })
320
+ .join(detailed ? "\n\n" : "\n");
321
+ return `${header}\n\n${body}`;
322
+ }
package/store.ts CHANGED
@@ -5,7 +5,14 @@
5
5
  import type { Message } from "@earendil-works/pi-ai";
6
6
  import { visibleWidth } from "@earendil-works/pi-tui";
7
7
  import { getDisplayItems, getFinalOutput, getLastNonEmptyLine, getLatestActivityPreview } from "./runner.js";
8
- import type { BatchGroupState, CommandRunState, GlobalRunEntry, PipelineState, SingleResult } from "./types.js";
8
+ import type {
9
+ BatchGroupState,
10
+ CommandRunState,
11
+ FinishedGroupSnapshot,
12
+ GlobalRunEntry,
13
+ PipelineState,
14
+ SingleResult,
15
+ } from "./types.js";
9
16
  import type { WidgetRenderCtx } from "./widget.js";
10
17
 
11
18
  export const COLLAPSED_ITEM_COUNT = 10;
@@ -40,6 +47,8 @@ export interface SubagentStore {
40
47
  batchGroups: Map<string, BatchGroupState>;
41
48
  /** In-memory sequential pipelines launched via the tool. */
42
49
  pipelines: Map<string, PipelineState>;
50
+ /** Retained snapshots of finished batch/chain groups, keyed by groupId (insertion-ordered). */
51
+ finishedGroups: Map<string, FinishedGroupSnapshot>;
43
52
  }
44
53
 
45
54
  export function createStore(): SubagentStore {
@@ -59,6 +68,7 @@ export function createStore(): SubagentStore {
59
68
  recentLaunchTimestamps: new Map(),
60
69
  batchGroups: new Map(),
61
70
  pipelines: new Map(),
71
+ finishedGroups: new Map(),
62
72
  };
63
73
  }
64
74
 
package/tool-execute.ts CHANGED
@@ -41,7 +41,17 @@ import {
41
41
  import { clearPendingGroupCompletion, upsertPendingGroupCompletion } from "./group-pending.js";
42
42
  import { enqueueSubagentInvocation } from "./invocation-queue.js";
43
43
  import { appendDisplayTaskUpdate, getSessionFileSize } from "./persisted-session.js";
44
- import { clearFinishedRuns, formatCommandRunSummary, removeRun, trimCommandRunHistory } from "./run-utils.js";
44
+ import {
45
+ clearFinishedRuns,
46
+ evictStaleFinishedGroups,
47
+ formatCommandRunSummary,
48
+ formatFinishedGroupStatus,
49
+ removeRun,
50
+ retireFinishedGroup,
51
+ snapshotBatchGroup,
52
+ snapshotPipeline,
53
+ trimCommandRunHistory,
54
+ } from "./run-utils.js";
45
55
  import { getFinalOutput, getLastNonEmptyLine, runSingleAgent } from "./runner.js";
46
56
  import {
47
57
  buildMainContextText,
@@ -54,10 +64,12 @@ import {
54
64
  import { formatStarterPackNotice, offerStarterPackIfEmpty } from "./starter-pack.js";
55
65
  import { type SubagentStore, updateRunFromResult } from "./store.js";
56
66
  import type {
67
+ BatchGroupState,
57
68
  BatchOrChainItem,
58
69
  CommandRunState,
59
70
  OnUpdateCallback,
60
71
  PendingCompletion,
72
+ PipelineState,
61
73
  PipelineStepResult,
62
74
  SingleResult,
63
75
  SubagentDetails,
@@ -583,6 +595,47 @@ function formatPipelineSummary(
583
595
  return `[subagent-chain#${pipelineId}] ${terminalStatus}\n\n${steps}`;
584
596
  }
585
597
 
598
+ function previewOutput(output: string): string {
599
+ const trimmed = output.trim() || "(no output yet)";
600
+ return trimmed.length > STATUS_OUTPUT_PREVIEW_MAX_CHARS
601
+ ? `${trimmed.slice(0, STATUS_OUTPUT_PREVIEW_MAX_CHARS)}\n\n... [truncated]`
602
+ : trimmed;
603
+ }
604
+
605
+ function formatBatchGroupStatus(store: SubagentStore, batch: BatchGroupState, detailed: boolean): string {
606
+ const total = batch.runIds.length;
607
+ const done = batch.completedRunIds.size;
608
+ const failed = batch.failedRunIds.size;
609
+ const header = `[subagent-batch#${batch.batchId}] running · ${done}/${total} finished${
610
+ failed > 0 ? `, ${failed} failed` : ""
611
+ }`;
612
+ const body = batch.runIds
613
+ .map((runId) => {
614
+ const run = store.commandRuns.get(runId);
615
+ if (!run) return `#${runId} (unavailable)`;
616
+ const summary = formatCommandRunSummary(run);
617
+ if (!detailed) return summary;
618
+ return `${summary}\n${previewOutput(run.lastOutput ?? run.lastLine ?? "")}`;
619
+ })
620
+ .join(detailed ? "\n\n" : "\n");
621
+ return `${header}\n\n${body}`;
622
+ }
623
+
624
+ function formatPipelineGroupStatus(store: SubagentStore, pipeline: PipelineState, detailed: boolean): string {
625
+ const finishedCount = pipeline.stepResults.length;
626
+ const header = `[subagent-chain#${pipeline.pipelineId}] running · step ${pipeline.currentIndex + 1} (${finishedCount} finished)`;
627
+ const parts = pipeline.stepResults.map((step, index) => {
628
+ const line = `Step ${index + 1} · #${step.runId} ${step.agent} · ${step.status}`;
629
+ return detailed ? `${line}\nTask: ${step.task}\n${previewOutput(step.output)}` : line;
630
+ });
631
+ const runningRunId = pipeline.stepRunIds[finishedCount];
632
+ if (runningRunId !== undefined) {
633
+ const run = store.commandRuns.get(runningRunId);
634
+ if (run) parts.push(`Step ${finishedCount + 1} · ${formatCommandRunSummary(run)}`);
635
+ }
636
+ return `${header}\n\n${parts.join(detailed ? "\n\n" : "\n")}`;
637
+ }
638
+
586
639
  function toLaunchSummary(
587
640
  runState: Pick<CommandRunState, "agent" | "id" | "batchId" | "pipelineId" | "pipelineStepIndex">,
588
641
  mode: SubagentLaunchSummary["mode"],
@@ -842,6 +895,43 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
842
895
  };
843
896
  }
844
897
 
898
+ if ((asyncAction === "status" || asyncAction === "detail") && typeof cmdParams.groupId === "string") {
899
+ evictStaleFinishedGroups(store);
900
+ const groupId = cmdParams.groupId;
901
+ const detailed = asyncAction === "detail";
902
+ const finished = store.finishedGroups.get(groupId);
903
+ if (finished) {
904
+ return {
905
+ content: [{ type: "text", text: withIdleRunWarning(formatFinishedGroupStatus(finished, detailed)) }],
906
+ details: makeDetails("single"),
907
+ };
908
+ }
909
+ const batch = store.batchGroups.get(groupId);
910
+ if (batch) {
911
+ return {
912
+ content: [{ type: "text", text: withIdleRunWarning(formatBatchGroupStatus(store, batch, detailed)) }],
913
+ details: makeDetails("single"),
914
+ };
915
+ }
916
+ const pipeline = store.pipelines.get(groupId);
917
+ if (pipeline) {
918
+ return {
919
+ content: [{ type: "text", text: withIdleRunWarning(formatPipelineGroupStatus(store, pipeline, detailed)) }],
920
+ details: makeDetails("single"),
921
+ };
922
+ }
923
+ return {
924
+ content: [
925
+ {
926
+ type: "text",
927
+ text: `Unknown subagent group "${groupId}". Finished groups are retained only briefly; use \`subagent runs\` to inspect individual runs.`,
928
+ },
929
+ ],
930
+ details: makeDetails("single"),
931
+ isError: true,
932
+ };
933
+ }
934
+
845
935
  const rawRunIds = Array.isArray(cmdParams.runIds) ? cmdParams.runIds : undefined;
846
936
  const invalidRunIds = (rawRunIds ?? []).filter((value) => !Number.isInteger(value));
847
937
  if (invalidRunIds.length > 0) {
@@ -1471,6 +1561,9 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1471
1561
  const orderedRuns = runStates.map(({ runState }) => runState);
1472
1562
  const hasError = finalizedRuns.some((finalized) => finalized.isError);
1473
1563
  const content = formatBatchSummary(batchId, orderedRuns, hasError ? "error" : "completed");
1564
+ const batchForSnapshot = store.batchGroups.get(batchId);
1565
+ if (batchForSnapshot)
1566
+ retireFinishedGroup(store, snapshotBatchGroup(store, batchForSnapshot, hasError ? "error" : "completed"));
1474
1567
  for (const { runState } of runStates) cleanupRunAfterFinalDelivery(runState.id);
1475
1568
  clearPendingGroupCompletion("batch", batchId);
1476
1569
  store.batchGroups.delete(batchId);
@@ -1525,6 +1618,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1525
1618
  runSummaries: orderedRuns.map((run) => buildRunAnalyticsSummary(run)),
1526
1619
  },
1527
1620
  };
1621
+ retireFinishedGroup(store, snapshotBatchGroup(store, batch, batchTerminalStatus));
1528
1622
  if (isInOriginSession(ctx, batch.originSessionFile)) {
1529
1623
  pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
1530
1624
  clearPendingGroupCompletion("batch", batchId);
@@ -1579,6 +1673,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1579
1673
  runSummaries: orderedRuns.map((run) => buildRunAnalyticsSummary(run)),
1580
1674
  },
1581
1675
  };
1676
+ retireFinishedGroup(store, snapshotBatchGroup(store, batch, "error"));
1582
1677
  if (isInOriginSession(ctx, batch.originSessionFile)) {
1583
1678
  pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
1584
1679
  clearPendingGroupCompletion("batch", batchId);
@@ -1754,6 +1849,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1754
1849
  const hasError = pipeline.stepResults.some((step) => step.status === "error");
1755
1850
  if (terminalStatus === "completed" && hasError) terminalStatus = "error";
1756
1851
  const content = formatPipelineSummary(pipelineId, pipeline.stepResults, terminalStatus);
1852
+ retireFinishedGroup(store, snapshotPipeline(pipeline, terminalStatus));
1757
1853
  for (const runId of pipeline.stepRunIds) cleanupRunAfterFinalDelivery(runId);
1758
1854
  clearPendingGroupCompletion("chain", pipelineId);
1759
1855
  store.pipelines.delete(pipelineId);
@@ -1892,6 +1988,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1892
1988
  runSummaries: orderedRuns.map((run) => buildRunAnalyticsSummary(run)),
1893
1989
  },
1894
1990
  };
1991
+ retireFinishedGroup(store, snapshotPipeline(pipeline, terminalStatus));
1895
1992
  if (isInOriginSession(ctx, pipeline.originSessionFile)) {
1896
1993
  pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
1897
1994
  clearPendingGroupCompletion("chain", pipelineId);
package/types.ts CHANGED
@@ -183,6 +183,30 @@ export interface PipelineState {
183
183
  pendingCompletion?: PendingCompletion;
184
184
  }
185
185
 
186
+ /** A single member (batch run or chain step) captured in a finished-group snapshot. */
187
+ export interface FinishedGroupMember {
188
+ /** Pre-rendered one-line summary, frozen at retirement time. */
189
+ summaryLine: string;
190
+ /** Full member output, truncated only when rendered. */
191
+ output: string;
192
+ /** Chain step task (omitted for batch runs). */
193
+ task?: string;
194
+ }
195
+
196
+ /**
197
+ * Immutable snapshot of a completed batch/chain group, retained briefly so
198
+ * `subagent status/detail <groupId>` still works after the live group is gone.
199
+ */
200
+ export interface FinishedGroupSnapshot {
201
+ groupId: string;
202
+ kind: "batch" | "chain";
203
+ terminalStatus: "completed" | "error" | "stopped";
204
+ finishedAt: number;
205
+ total: number;
206
+ failed: number;
207
+ members: FinishedGroupMember[];
208
+ }
209
+
186
210
  export const ListAgentsParams = Type.Object({});
187
211
 
188
212
  export const SubagentParams = Type.Object({