@henryqw/pi-subagent 26.3.0 → 26.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
@@ -6,7 +6,7 @@ Delegate work to configured Pi Roles with [Herdr](https://herdr.dev/). Authorize
6
6
 
7
7
  ## Install
8
8
 
9
- Requires Pi 1.1.0 or later. Development and automated checks use Pi 1.1.0. Older Pi releases are no longer supported.
9
+ Requires Pi 1.1.0 or later in the 1.x series. Development and automated checks use Pi 1.1.0. Older Pi releases are no longer supported.
10
10
 
11
11
  ```bash
12
12
  pi install npm:@henryqw/pi-task-models
@@ -39,7 +39,7 @@ Commands are for you. Delegation and state tools and the packaged skill are for
39
39
  | `subagent_abort` | tool | Abort an isolated request only when no retained candidates or integration worktrees remain; cannot discard them. |
40
40
  | `git_read` | tool | For Role-declared children (not Main): inspect bounded local Git evidence without Bash, tests, network access, or writes. |
41
41
  | `pi-subagent` | skill | Guide Main through delegation, authorization, checks, integration, and recovery. |
42
- | Direct status widget (`D`) | ui | For you in the TUI: show direct worker status, Role badges, route, elapsed time, and measured session tokens. |
42
+ | Direct status widget (`D`) | ui | For you in the TUI: show direct worker references (such as `D7.1`), status, Role badges, names, route, task elapsed time, measured session tokens, pending tools, and the latest tool's recorded time. |
43
43
  | Isolated status widget (`I`) | ui | For you in the TUI: show recorded task state, Role/model-class badges, and retained workspaces; readiness does not mean promotion. |
44
44
 
45
45
  ![Main, its coordinator extension, and a Herdr subagent through checked work, worker failure, and coordinator interruption](./docs/worker-lifecycle.svg)
@@ -50,10 +50,18 @@ The coordinator is extension code running in Main's Pi process, not another agen
50
50
 
51
51
  The TUI has two separate compact status widgets, each with one row per visible worker or task. `D` marks direct work; `I` marks isolated work. Badges such as `[S]` and `[I3]` identify the Role initial. Isolated badges also include the model class: `1` fast, `2` balanced, `3` frontier, `*` fav.
52
52
 
53
- The direct widget shows the launched model, thinking level, elapsed time, and measured session tokens (`— tok` until usage is available). Failed rows appear before working and completed rows. When rows overflow, `… N more` includes counts by status. The isolated widget shows the recorded task state and any retained workspace, not reliable live model, thinking, or token readings from Herdr. Attention appears before ordinary work; overflow uses `+N more · /subagent`. An aborted request with visible tasks has its own `request aborted` row and does not change their recorded statuses. A committed worker without a checked candidate can still show attention rather than readiness. Status glyphs use the active TUI theme, while the text still names the status without color.
53
+ Each direct row starts with a saved reference such as `D7.1`. `D7` is the workflow number in this Pi session, and `.1` is the worker's position in the original request. `/subagent` uses the same reference and name; the Herdr tab label shows them at launch. Numbers continue after reload and are never reused in the session; if a session switch carries a workflow into a session that already uses its number, it gets a new number there. An existing Herdr tab keeps its original launch label. The direct widget shows the launched model, thinking level, elapsed time, and measured session tokens (`— tok` until usage is available). Failed rows appear before working and completed rows. When rows overflow, `… N more` includes counts by status. The isolated widget shows the recorded task state and any retained workspace, not reliable live model, thinking, or token readings from Herdr. Attention appears before ordinary work; overflow uses `+N more · /subagent`. An aborted request with visible tasks has its own `request aborted` row and does not change their recorded statuses. A committed worker without a checked candidate can still show attention rather than readiness. Status glyphs use the active TUI theme, while the text still names the status without color.
54
54
 
55
55
  A ready isolated candidate is **not promoted** to Main. Choose **Refresh** in `/subagent` to reload both the menu and isolated widget from saved state; it does not recover or change a request. Use `/subagent` or `subagent_status` for exact identities, failures, and recovery.
56
56
 
57
+ ### Direct tool activity
58
+
59
+ Direct rows can show `pending read, bash` and `last read 125ms recorded`. Pending means Pi saved the call but has no matching result in this task's session. It does not prove that execution started. Parallel calls remain pending until their own results arrive.
60
+
61
+ Recorded time comes only from the latest matched tool result's `durationMs`. It is not calculated from assistant timestamps. If the latest result has no timing, no tool time is shown, even if an earlier result had timing. Older sessions and calls rejected before execution can have no recorded time. Task elapsed time remains a separate metric.
62
+
63
+ Activity uses the existing session sample and file-size cache. A partial trailing JSONL entry is ignored until it is complete. Tool names have terminal control characters removed for display. Narrow rows drop activity before other metrics to keep worker identity and failure state visible. Activity and time are display information, not proof of task completion, worker ownership, or a timeout.
64
+
57
65
  ### How Main routes delegation
58
66
 
59
67
  Main chooses the mode from your request; you do not need to select one. Words like “direct” and “isolated” can signal your intent, but the work determines the route. Main declares the chosen mode in every `delegate_task` call, and the extension never falls back between modes.
@@ -88,13 +96,13 @@ Chain: { mode: "direct", chain: [{ role, name, task, ... }] }
88
96
 
89
97
  Direct tasks use the selected Role's declared tools, extensions, Skills, and MCP servers, including write-capable resources. Authorization comes from your request and Main's bounded task packet, not the Role's capabilities: name allowed paths/actions and exclusions, preserve unrelated edits and staged changes, and authorize commits or external actions explicitly. For example, a configured Git operator with `bash` can stage named pending files and commit them on the `fast` route. Read-only Roles retain their tool restrictions. Use isolated mode for a checked changeset.
90
98
 
91
- Direct writes affect Main's checkout immediately. No clean-checkout prerequisite, automatic checks, rollback, or isolated integration guarantee applies. Requests containing potential writers (including any extensions or MCP servers) can start only when no direct writer owns the checkout and no Main potentially-writing call is in flight there. They run their tasks serially and hold checkout admission after the initial handle returns; competing direct writers and Main writes fail until all owned workers are proved stopped. Use `mode: isolated` to keep Main free to write, with worktree checks and explicit integration instead of immediate shared-checkout changes. Pure read-only parallel requests still run concurrently. A failure in a potential-writer request stops further dispatch; an uncertain worker may still be running. Chains replace each literal `{previous}` with the preceding successful answer and stop on failure. Task outcome and worker termination are separate: after settlement the coordinator closes exact owned writer panes and releases admission only after pane absence and two empty private process-lease scans. Native `lsof` is required for this proof; no Git files, commits, or saved sessions are removed. Failure remains failure even when admission is released. If closing fails, identity changes, a leased child survives, or inspection is uncertain, admission stays held. Open `/subagent`, select the exact direct task, and choose **Close/cancel-and-release owned workers** to cancel dispatch and retry termination. The action checks session/branch/epoch and live ownership; recorded-only tasks cannot unlock anything. Resolve the reported blocker (including any surviving owned process), then repeat the same action. Restarting Pi is not termination proof. Inspect partial changes before new writes; never blindly retry a commit or delete work to recover.
99
+ Direct writes affect Main's checkout immediately. No clean-checkout prerequisite, automatic checks, rollback, or isolated integration guarantee applies. Requests containing potential writers (including any extensions or MCP servers) can start only when no direct writer owns the checkout and no Main potentially-writing call is in flight there. They run their tasks serially and hold checkout admission after the initial handle returns; competing direct writers and Main writes fail until all owned workers are proved stopped. Use `mode: isolated` to keep Main free to write, with worktree checks and explicit integration instead of immediate shared-checkout changes. Pure read-only parallel requests still run concurrently. A failure in a potential-writer request stops further dispatch; an uncertain worker may still be running. Chains replace each literal `{previous}` with the preceding successful answer and stop on failure. Task outcome and worker termination are separate: after settlement the coordinator closes exact owned writer panes and releases admission only after pane absence and two empty private process-lease scans. Native `lsof` is required for this proof; no Git files, commits, or saved sessions are removed. Failure remains failure even when admission is released. If closing fails, identity changes, a leased child survives, or inspection is uncertain, admission stays held. Open `/subagent`, select the exact direct workflow, and choose **Close/cancel-and-release ALL owned workers in this workflow** to cancel dispatch and retry termination. It stops every owned worker of that workflow, not one selected worker. The action checks session/branch/epoch and live ownership; recorded-only tasks cannot unlock anything. Resolve the reported blocker (including any surviving owned process), then repeat the same action. Restarting Pi is not termination proof. Inspect partial changes before new writes; never blindly retry a commit or delete work to recover.
92
100
 
93
101
  If tab creation times out or returns incomplete or mismatched identity, `/subagent` retains the allocation intent: workspace, checkout, label, private lease/session paths, and any returned tab/pane IDs. No worker is started until creation identity is verified. These records are evidence, not ownership: Close is unavailable for unverified allocation, and admission remains held pending manual reconciliation against Herdr. Do not recreate a tab, guess missing IDs, or restart to bypass this blocker. A Close begun while a worker is active keeps its branch/session/epoch guards through cancellation settlement; stale dialogs cannot close panes or release admission.
94
102
 
95
103
  Potential-writer tasks must return one JSON completion object, such as `{"outcome":"succeeded","answer":"Scoped commit completed."}`. `outcome` must be `succeeded`, `failed`, or `blocked`, and `answer` must be non-empty. A normal final Pi turn is not enough: failure, blocked work, plain text, or malformed completion stops later dispatch, but admission depends on proved termination, not the reported outcome. Success is the worker's explicit report, not independent validation of its changes.
96
104
 
97
- The tool returns a task ID and first Herdr tab after launch, not the answer. The extension observes each worker and sends one result to Main when the workflow finishes. If Main is busy, Pi queues it after the current turn; if idle, it starts a turn. A blocked, stalled, unknown, or truncated result is not reported as success. Session switch or shutdown stops observation and preserves tab identities for recovery. Open `/subagent` on the session branch to inspect exact tabs, agents, and Pi session files, including tabs launched after the first. A recorded tab may no longer be running; locally observed work is labelled separately.
105
+ The tool returns a task ID and first Herdr tab after launch, not the answer. The extension observes each worker and sends one result to Main when the workflow finishes. If Main is busy, Pi queues it after the current turn; if idle, it starts a turn. A blocked, stalled, unknown, or truncated result is not reported as success. Session switch or shutdown stops observation and preserves tab identities for recovery. Open `/subagent` on the session branch to inspect exact tabs, agents, and Pi session files, including tabs launched after the first. The menu has one row per workflow: a single worker shows its reference, Role, and name (`D7.1 · worker · Search audit`); a multi-worker workflow shows its reference and worker count (`D8 · 3 workers`), and selecting it lists each recorded worker by reference, Role, and name with its exact IDs. References and names help you match rows; they are never used to decide ownership. Older records without saved references show their saved allocation label or exact task ID. A `recorded only` row is not proof that its workers are running or finished; locally observed work is labelled separately.
98
106
 
99
107
  ### Isolated checked graphs
100
108
 
@@ -149,7 +157,7 @@ Completed text answers are saved independently of cleanup. Status and terminal f
149
157
 
150
158
  Isolated workers can start while Main has staged, unstaged, or untracked changes: they use only Main's pinned committed branch, HEAD, and tree. Those local edits are neither copied into worker worktrees nor stashed, staged, reset, or merged automatically. Main must remain at that committed identity while workers run; branch or HEAD drift blocks new waves. The worker produces a committed candidate without tracked or untracked changes, then runs focused preliminary checks and optional judgment. Ignored generated files do not block candidacy, but prevent automatic worktree cleanup; inspect them before deleting retained work. The runner records each ready candidate immediately but **does not integrate it automatically**. Main can inspect `subagent_status` and use `subagent_stage` to select the exact candidate in an owned integration worktree while siblings are still working; conflicts are resolved there. Stage and resolve are available during an active wave, but rejection and revision wait for the wave to settle. `subagent_integrate` can advance dependent tasks from a staged snapshot, validate the combined tip with full root checks and optional final judgment after the wave settles, and promote it to Main only after exact validation. A definitive failed combined check/review allows one committed correction in the integration checkout followed by another validation. Main must explicitly promote; promotion and cleanup never silently discard retained work.
151
159
 
152
- Open `/subagent` without arguments to pick work by label. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout (including requests launched in other sessions). Pick an isolated request and **Inspect status and recovery** to launch a non-focused Herdr tab with a read-only `scout` Role on the configured `fast` route. Unlike ordinary direct delegation, status inspection rejects Roles with extensions or MCP servers because their tools are not verified read-only. Its prompt contains bounded saved status (including retained paths, but not raw launch environment or session transcripts); the analysis stays in that tab and does not queue a Main turn. The tab and session file are recorded for recovery even if launch becomes uncertain. The read-only `subagent_status` tool remains Main's authority for exact identities before any stage or recovery action. If Herdr or the read-only fast route is unavailable, inspection fails without changing the request; Main can use `subagent_status`. Refresh or choose completed/history for older work. Invalid state IDs are warned about and preserved. Outside Git or when isolated inventory is unavailable, direct recovery still works. Browsing does not resume work or change resources; direct **Close/cancel-and-release** is an explicit termination action.
160
+ Open `/subagent` without arguments to pick work by label. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout (including requests launched in other sessions). Pick an isolated request and **Inspect status and recovery** to launch a non-focused Herdr tab with a read-only `scout` Role on the configured `fast` route. Unlike ordinary direct delegation, status inspection rejects Roles with extensions or MCP servers because their tools are not verified read-only. Its prompt contains bounded saved status (including retained paths, but not raw launch environment or session transcripts); the analysis stays in that tab and does not queue a Main turn. The tab appears in the direct list with its own reference and name, such as `D9.1 · scout · Inspect <request ID>`. The tab and session file are recorded for recovery even if launch becomes uncertain. The read-only `subagent_status` tool remains Main's authority for exact identities before any stage or recovery action. If Herdr or the read-only fast route is unavailable, inspection fails without changing the request; Main can use `subagent_status`. Refresh or choose completed/history for older work. Invalid state IDs are warned about and preserved. Outside Git or when isolated inventory is unavailable, direct recovery still works. Browsing does not resume work or change resources; direct **Close/cancel-and-release** is an explicit termination action.
153
161
 
154
162
  For a locally active unsealed isolated changeset, choose **Send follow-up instructions** to open a native multiline editor; submission queues the instruction, not a completed revision. Choose **Edit queued instructions** to withdraw *all still-pending* instructions for that selected task before the editor opens. Their full text appears in FIFO order separated by blank lines. Explicit submission queues **one replacement** at the tail. Cancel leaves the withdrawn instructions removed; already-claimed instructions, initial prompts, and automatic corrections cannot be recalled. A worker may seal during editing: failed submission reopens the full submitted text for deliberate editing or copying, never automatically requeues it. Combined text above the single-instruction limit is preserved in the editor but rejected until shortened. Cancelling before withdrawal makes no change. Neither action touches Main's editor or Pi's global Option+Up queue shortcut. No-UI sessions can run `/subagent recover` or use agent tools for individual inspection and recovery.
155
163
 
@@ -229,7 +237,7 @@ Main's nested calls pass admission by their own tool names, not by script or anc
229
237
 
230
238
  To let a Role run `codemode` scripts, list `builtin:codemode` in `extensions` and `codemode` in `tools`; both are required, because the built-in registers the tool inactive and the Role tool policy activates it. `codemode` alone in `tools` fails the launch as an unavailable tool. Pi's `codemode.mode` and `codemode.inlineBudget` settings apply in the child unchanged, so the Role never forces codemode-only operation, and MCP configuration never activates `codemode` in a child. Scripts can call only the Role's active tools plus `codemode`/`deferred` extension and MCP tools; inactive base tools such as `bash` stay unreachable.
231
239
 
232
- The servers named in `mcps` come from the global `~/.pi/agent/mcp.json` only. In a Role that activates `codemode`, each selected server keeps its configured `exposure` and `toolExposure` (Pi's default `codemode` exposure makes tools callable from scripts without declaring them, `direct` declares them, `deferred` tools stay reachable from scripts, `hidden` tools stay unreachable), and an invalid exposure value fails the launch. In every other Role, selected servers are forced to `direct` exposure and `toolExposure` is ignored, because that child cannot reach undeclared tools. Declared tools appear to the model as `mcp__<server>__<tool>` once the server connects. Pi replaces `-` in server and tool names with `_` and appends a hash suffix when two tool names collide or a name exceeds 64 characters. Each selected entry is checked against Pi's documented `mcpServers` rules before launch, in Main and again in the child: a disabled, malformed, or unknown-transport entry, an invalid server name, two selected names that differ only in `-` and `_`, `auth.provider` over plain HTTP outside `localhost`, `127.0.0.1`, or `[::1]`, or a non-HTTPS `oauth.authServerMetadataUrl` outside those hosts fails the launch with the file path and field. The file is never modified; fix the entry or choose another server. Valid `auth`, `oauth`, and `description` fields are passed through so Pi's native provider auth and OAuth apply. `oauth.clientRegistration` must be `dcr` or `cimd`; `cimd` requires Pi 1.0.1 or later; Pi 1.0.0 rejects it before child launch. It cannot use `clientId` or `clientName`, and any `callbackUrl` must use localhost or 127.0.0.1 with path `/callback`. Project MCP overrides remain ignored: a project cannot change the Role's global server allowlist or exposure. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. Main-only delegation and recovery tools plus `ask_question` are excluded from children.
240
+ The servers named in `mcps` come from the global `~/.pi/agent/mcp.json` only. In a Role that activates `codemode`, each selected server keeps its configured `exposure` and `toolExposure` (Pi's default `codemode` exposure makes tools callable from scripts without declaring them, `direct` declares them, `deferred` tools stay reachable from scripts, `hidden` tools stay unreachable), and an invalid exposure value fails the launch. In every other Role, selected servers are forced to `direct` exposure and `toolExposure` is ignored, because that child cannot reach undeclared tools. Declared tools appear to the model as `mcp__<server>__<tool>` once the server connects. Pi replaces `-` in server and tool names with `_` and appends a hash suffix when two tool names collide or a name exceeds 64 characters. Each selected entry is checked against Pi's documented `mcpServers` rules before launch, in Main and again in the child: a disabled, malformed, or unknown-transport entry, an invalid server name, two selected names that differ only in `-` and `_`, `auth.provider` over plain HTTP outside `localhost`, `127.0.0.1`, or `[::1]`, or a non-HTTPS `oauth.authServerMetadataUrl` outside those hosts fails the launch with the file path and field. The file is never modified; fix the entry or choose another server. Valid `auth`, `oauth`, and `description` fields are passed through so Pi's native provider auth and OAuth apply. `oauth.clientRegistration` must be `dcr` or `cimd`. A `cimd` entry cannot use `clientId` or `clientName`, and any `callbackUrl` must use localhost or 127.0.0.1 with path `/callback`. Project MCP overrides remain ignored: a project cannot change the Role's global server allowlist or exposure. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work starts. Main-only delegation and recovery tools plus `ask_question` are excluded from children.
233
241
 
234
242
  ## API
235
243
 
@@ -9,10 +9,23 @@ export interface DirectHandle {
9
9
  prompt: string;
10
10
  answer(maxBytes: number): Promise<string>;
11
11
  usageTokens(): Promise<number | undefined>;
12
+ /** Display-only activity from the last usageTokens() sample. */
13
+ readonly activity?: DirectToolActivity;
12
14
  cancel(): Promise<void>;
13
15
  }
14
16
  export declare function exactDirectTerminalTurn(jsonl: string, prompt: string): boolean;
15
17
  export declare function exactDirectAnswer(jsonl: string, prompt: string, maxBytes?: number, requireSuccess?: boolean): string;
18
+ export interface DirectToolActivity {
19
+ pending: string[];
20
+ latest?: {
21
+ name: string;
22
+ durationMs?: number;
23
+ };
24
+ }
25
+ export declare function directSessionUsage(jsonl: string, prompt: string): {
26
+ tokens?: number;
27
+ activity?: DirectToolActivity;
28
+ };
16
29
  export declare function directSessionTokens(jsonl: string, prompt: string): number | undefined;
17
30
  export type DirectTab = Pick<DirectHandle, "name" | "tabId" | "paneId" | "sessionFile"> & {
18
31
  leasePath: string;
@@ -101,10 +101,12 @@ export function exactDirectAnswer(jsonl, prompt, maxBytes = ANSWER_LIMIT, requir
101
101
  return completion.answer;
102
102
  }
103
103
  // Ignore a partial trailing JSONL entry while the child is writing it.
104
- export function directSessionTokens(jsonl, prompt) {
104
+ export function directSessionUsage(jsonl, prompt) {
105
105
  let active = false;
106
106
  let total = 0;
107
107
  let observed = false;
108
+ const pending = new Map();
109
+ let latest;
108
110
  for (const line of jsonl.split("\n")) {
109
111
  if (!line.trim())
110
112
  continue;
@@ -115,7 +117,7 @@ export function directSessionTokens(jsonl, prompt) {
115
117
  catch {
116
118
  break;
117
119
  }
118
- if (entry.type !== "message" || !entry.message || typeof entry.message !== "object")
120
+ if (!entry || entry.type !== "message" || !entry.message || typeof entry.message !== "object")
119
121
  continue;
120
122
  const message = entry.message;
121
123
  if (message.role === "user") {
@@ -124,18 +126,42 @@ export function directSessionTokens(jsonl, prompt) {
124
126
  if (active) {
125
127
  total = 0;
126
128
  observed = false;
129
+ pending.clear();
130
+ latest = undefined;
131
+ }
132
+ }
133
+ else if (active && message.role === "assistant") {
134
+ if (message.usage && typeof message.usage === "object") {
135
+ const usage = message.usage;
136
+ const values = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
137
+ if (values.every((value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0)) {
138
+ total += values.reduce((sum, value) => sum + value, 0);
139
+ observed = true;
140
+ }
127
141
  }
142
+ if (Array.isArray(message.content))
143
+ for (const part of message.content) {
144
+ if (part?.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string") {
145
+ pending.set(part.id, part.name);
146
+ }
147
+ }
128
148
  }
129
- else if (active && message.role === "assistant" && message.usage && typeof message.usage === "object") {
130
- const usage = message.usage;
131
- const values = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
132
- if (values.every((value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0)) {
133
- total += values.reduce((sum, value) => sum + value, 0);
134
- observed = true;
149
+ else if (active && message.role === "toolResult" && typeof message.toolCallId === "string") {
150
+ const name = pending.get(message.toolCallId);
151
+ if (name === undefined)
152
+ continue;
153
+ pending.delete(message.toolCallId);
154
+ latest = { name };
155
+ if (typeof message.durationMs === "number" && Number.isFinite(message.durationMs) && message.durationMs >= 0) {
156
+ latest.durationMs = message.durationMs;
135
157
  }
136
158
  }
137
159
  }
138
- return observed ? total : undefined;
160
+ return { tokens: observed ? total : undefined,
161
+ activity: pending.size || latest ? { pending: [...pending.values()], latest } : undefined };
162
+ }
163
+ export function directSessionTokens(jsonl, prompt) {
164
+ return directSessionUsage(jsonl, prompt).tokens;
139
165
  }
140
166
  export class DirectAllocationError extends Error {
141
167
  allocation;
@@ -277,18 +303,21 @@ export function createDirectHerdr(pi, cwd, idleMs) {
277
303
  if (state === "blocked" || state === "unknown")
278
304
  throw new Error(`Herdr agent became ${state}; inspect it before retrying.`);
279
305
  let lastUsageSize = -1;
280
- let lastTokens;
306
+ let lastUsage = {};
281
307
  return {
282
308
  name, tabId, paneId, sessionFile, prompt,
309
+ get activity() { return lastUsage.activity; },
283
310
  async usageTokens() {
284
311
  const info = await stat(sessionFile);
285
- if (info.size > SESSION_LIMIT)
312
+ if (info.size > SESSION_LIMIT) {
313
+ lastUsage = {};
286
314
  return undefined;
315
+ }
287
316
  if (info.size !== lastUsageSize) {
288
- lastTokens = directSessionTokens(await readFile(sessionFile, "utf8"), prompt);
317
+ lastUsage = directSessionUsage(await readFile(sessionFile, "utf8"), prompt);
289
318
  lastUsageSize = info.size;
290
319
  }
291
- return lastTokens;
320
+ return lastUsage.tokens;
292
321
  },
293
322
  async answer(maxBytes) {
294
323
  const readAnswer = async () => {
@@ -1,4 +1,4 @@
1
- import { type LoadedMcpConfig } from "@earendil-works/pi-coding-agent";
1
+ import type { LoadedMcpConfig } from "@earendil-works/pi-coding-agent";
2
2
  export declare function parseRoleMcpAllowlist(value: unknown): string[];
3
3
  /**
4
4
  * Select the Role's allowlisted servers from the global `mcp.json` in Pi's native `mcpServers`
@@ -9,5 +9,4 @@ export declare function parseRoleMcpAllowlist(value: unknown): string[];
9
9
  */
10
10
  export declare function loadRoleMcpConfig(agentDir: string, allowlist: readonly string[], options?: {
11
11
  codemode: boolean;
12
- piVersion?: string;
13
12
  }): LoadedMcpConfig;
package/dist/mcp-role.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { VERSION } from "@earendil-works/pi-coding-agent";
4
3
  export function parseRoleMcpAllowlist(value) {
5
4
  if (typeof value !== "string")
6
5
  throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
@@ -72,7 +71,7 @@ const EXPOSURE_CHECKS = {
72
71
  toolExposure: [(value) => isRecord(value) && Object.values(value).every(isExposure), "an object mapping tool names to exposures"],
73
72
  };
74
73
  /** Validate one selected server against Pi's documented `mcpServers` rules; the native loader is bypassed by `loadConfig`. */
75
- function validateRoleMcpServer(path, name, value, codemode, piVersion) {
74
+ function validateRoleMcpServer(path, name, value, codemode) {
76
75
  const fail = (message) => { throw new Error(`${path}: MCP server "${name}" ${message}`); };
77
76
  if (!SERVER_NAME.test(name))
78
77
  fail("has an invalid name (use letters, digits, \"_\" and \"-\").");
@@ -90,8 +89,6 @@ function validateRoleMcpServer(path, name, value, codemode, piVersion) {
90
89
  fail(`field "${field}" must be ${shape}.`);
91
90
  }
92
91
  if (http && isRecord(value.oauth) && value.oauth.clientRegistration === "cimd") {
93
- if (piVersion === "1.0.0")
94
- fail('field "oauth.clientRegistration" "cimd" requires Pi 1.0.1 or later; current Pi is 1.0.0.');
95
92
  if (value.oauth.clientId !== undefined || value.oauth.clientName !== undefined) {
96
93
  fail('field "oauth.clientRegistration" "cimd" cannot be combined with "oauth.clientId" or "oauth.clientName".');
97
94
  }
@@ -146,7 +143,7 @@ export function loadRoleMcpConfig(agentDir, allowlist, options = { codemode: fal
146
143
  if (clash)
147
144
  throw new Error(`${path}: Role MCP server "${name}" conflicts with "${clash}": names that differ only in "-" and "_" share one tool namespace.`);
148
145
  namespaces.set(mcpNamespace(name), name);
149
- return { name, config: validateRoleMcpServer(path, name, configured[name], options.codemode, options.piVersion ?? VERSION), source: path, scope: "global" };
146
+ return { name, config: validateRoleMcpServer(path, name, configured[name], options.codemode), source: path, scope: "global" };
150
147
  });
151
148
  return { servers, autoEnableCodemode: false, errors: [] };
152
149
  }
@@ -1,15 +1,34 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ /** Saved before launch for user matching only; never ownership or mutation authority. */
4
+ export interface DirectDisplay {
5
+ /** Session-local workflow number. */
6
+ workflow: number;
7
+ /** One-based original workflow entry index. */
8
+ worker: number;
9
+ workers: number;
10
+ title: string;
11
+ role: string;
12
+ }
13
+ export const directRef = ({ workflow, worker }: DirectDisplay): string => `D${workflow}.${worker}`;
14
+
3
15
  /** Read-only branch records; the adapter groups all tabs of a direct workflow. */
4
16
  export interface DirectTask {
5
17
  id: string;
18
+ /** Legacy label: saved allocation label, otherwise the exact task ID. */
6
19
  name: string;
7
20
  status: string;
8
21
  canClose?: boolean;
9
22
  recovery?: string;
10
- tabs: readonly { entryId: string; name: string; tabId: string; paneId: string; sessionFile: string }[];
23
+ /** Recorded workers by original index; empty for legacy records. */
24
+ workers: readonly DirectDisplay[];
25
+ tabs: readonly { entryId: string; name: string; tabId: string; paneId: string; sessionFile: string; display?: DirectDisplay }[];
11
26
  }
12
27
 
28
+ const workerLabel = (display: DirectDisplay) => `${directRef(display)} · ${display.role} · ${display.title}`;
29
+ const directLabel = ({ name, workers: [first] }: DirectTask): string => !first ? name
30
+ : first.workers === 1 ? workerLabel(first) : `D${first.workflow} · ${first.workers} workers`;
31
+
13
32
  export interface IsolatedTask {
14
33
  id: string;
15
34
  name: string;
@@ -35,7 +54,7 @@ export interface SubagentCommandAdapter {
35
54
  closeDirect(task: DirectTask, current: () => boolean): Promise<string>;
36
55
  isolated(cwd: string, current: () => boolean): Promise<IsolatedInventory>;
37
56
  recover(cwd: string): Promise<string>;
38
- inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ tabId: string; name: string; sessionFile: string }>;
57
+ inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ ref: string; tabId: string; name: string; sessionFile: string }>;
39
58
  canFollowup(root: string, requestId: string, taskId: string): boolean;
40
59
  enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
41
60
  drain(root: string, requestId: string, taskId: string, current: () => boolean): readonly string[];
@@ -104,7 +123,8 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
104
123
  if (inventory?.invalidIds.length) for (const id of inventory.invalidIds) ctx.ui.notify(`Unreadable isolated state ID: ${JSON.stringify(id)}. Preserve this file; inspect the state store before making changes.`, "warning");
105
124
  type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "close" };
106
125
  const selections: Choice<Selection>[] = [
107
- ...direct.filter((task) => history || !completed(task.status)).map((task) => ({ label: `Direct · ${task.name} · ${task.status} · ${task.id}`, value: { kind: "direct" as const, task } })),
126
+ // One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
127
+ ...direct.filter((task) => history || !completed(task.status)).map((task) => ({ label: `Direct · ${directLabel(task)} · ${task.status}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct" as const, task } })),
108
128
  ...(inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status)).map((request) => ({ label: `Isolated · ${request.name} · ${request.status} · ${request.id}`, value: { kind: "isolated" as const, request, root: inventory!.root } })),
109
129
  { label: "Refresh", value: { kind: "refresh" as const } },
110
130
  { label: history ? "Active work" : "Completed / history (including retained work)", value: { kind: "history" as const } },
@@ -121,20 +141,25 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
121
141
  if (selected.kind === "refresh") continue;
122
142
  if (selected.kind === "history") { history = !history; continue; }
123
143
  if (selected.kind === "direct") {
124
- ctx.ui.notify(`Direct workflow ${clean(selected.task.name)} (${JSON.stringify(selected.task.id)}) · ${clean(selected.task.status)}`, "info");
125
- if (selected.task.recovery) ctx.ui.notify(clean(selected.task.recovery, 4000), "warning");
126
- for (const tab of selected.task.tabs) {
127
- ctx.ui.notify(`Task ${JSON.stringify(tab.entryId)} · agent ${JSON.stringify(tab.name)} · tab ${JSON.stringify(tab.tabId)} · pane ${JSON.stringify(tab.paneId)} · session ${JSON.stringify(tab.sessionFile)}`, "info");
144
+ const { task } = selected;
145
+ const label = clean(directLabel(task));
146
+ ctx.ui.notify(`Direct workflow ${label} · ${clean(task.status)} · ID ${JSON.stringify(task.id)}`, "info");
147
+ if (task.recovery) ctx.ui.notify(clean(task.recovery, 4000), "warning");
148
+ for (const tab of task.tabs) {
149
+ ctx.ui.notify(`${tab.display ? `${clean(workerLabel(tab.display))} · ` : ""}task ${JSON.stringify(tab.entryId)} · agent ${JSON.stringify(tab.name)} · tab ${JSON.stringify(tab.tabId)} · pane ${JSON.stringify(tab.paneId)} · session ${JSON.stringify(tab.sessionFile)}`, "info");
128
150
  }
129
- if (selected.task.canClose) {
130
- const action = await choose(ctx, `Direct · ${clean(selected.task.name)} · ${selected.task.id}`, [
131
- { label: "Close/cancel-and-release owned workers (preserve files and commits)", value: "close" },
151
+ const untabbed = task.workers.filter((worker) => !task.tabs.some((tab) => tab.display?.worker === worker.worker));
152
+ for (const worker of untabbed) ctx.ui.notify(`${clean(workerLabel(worker))} · no verified tab recorded`, "info");
153
+ if (task.workers[0] && task.workers.length < task.workers[0].workers) ctx.ui.notify(`${task.workers[0].workers - task.workers.length} other worker(s) in D${task.workers[0].workflow} have no recorded tab.`, "info");
154
+ if (task.canClose) {
155
+ const action = await choose(ctx, `Direct · ${label}`, [
156
+ { label: "Close/cancel-and-release ALL owned workers in this workflow (preserve files and commits)", value: "close" },
132
157
  { label: "Back / refresh", value: "back" },
133
158
  ]);
134
159
  if (!valid()) return;
135
160
  if (action === "close") {
136
161
  try {
137
- const report = await adapter.closeDirect(selected.task, current);
162
+ const report = await adapter.closeDirect(task, current);
138
163
  if (valid()) ctx.ui.notify(report, "info");
139
164
  } catch (error) { if (valid()) ctx.ui.notify(`Admission retained: ${errorText(error)}. Inspect the exact tab/session; retry Close/cancel-and-release after resolving termination.`, "error"); }
140
165
  }
@@ -157,7 +182,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
157
182
  if (action === "inspect") {
158
183
  try {
159
184
  const tab = await adapter.inspectInTab(root, request.id, ctx, current);
160
- if (valid()) ctx.ui.notify(`Status inspection started in Herdr tab ${tab.tabId} · agent ${tab.name} · session ${tab.sessionFile}.`, "info");
185
+ if (valid()) ctx.ui.notify(`Status inspection ${tab.ref} started in Herdr tab ${tab.tabId} · agent ${tab.name} · session ${tab.sessionFile}.`, "info");
161
186
  }
162
187
  catch (error) { if (valid()) ctx.ui.notify(`Inspection failed: ${errorText(error)}`, "error"); }
163
188
  continue;
@@ -6,11 +6,11 @@ import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecuto
6
6
  import { readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
7
7
  import { registerCheckoutAdmission, roleCanWrite, roleIsReadOnlyScout } from "./admission.ts";
8
8
  import { registerIsolatedExtension } from "./isolated.ts";
9
- import { registerSubagentCommand, type DirectTask } from "./subagent-command.ts";
9
+ import { directRef, registerSubagentCommand, type DirectDisplay, type DirectTask } from "./subagent-command.ts";
10
10
  import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
11
11
  import { ENTRY_STATUS_PRESENTATION, formatWorkflowResult, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
12
12
  import { DelegateTaskParameters, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, type Delegation, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
13
- import { createDirectHerdr, DirectAllocationError, type DirectAllocation, type DirectHandle, type DirectTab } from "../dist/direct-herdr.js";
13
+ import { createDirectHerdr, DirectAllocationError, type DirectAllocation, type DirectHandle, type DirectTab, type DirectToolActivity } from "../dist/direct-herdr.js";
14
14
  import { materializeTransientLaunch } from "../dist/launch-runtime.js";
15
15
  const WIDGET_KEY = "subagent-status";
16
16
  const WIDGET_INTERVAL_MS = 1_000;
@@ -20,6 +20,7 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
20
20
 
21
21
  type WidgetStatus = "working" | "success" | "failure";
22
22
  type WidgetItem = {
23
+ ref: string;
23
24
  role: string;
24
25
  model: string;
25
26
  thinkingLevel: string;
@@ -29,6 +30,7 @@ type WidgetItem = {
29
30
  status: WidgetStatus;
30
31
  finishedAt?: number;
31
32
  tokens?: number;
33
+ activity?: DirectToolActivity;
32
34
  };
33
35
 
34
36
  function roleBadge(role: string): string {
@@ -56,6 +58,18 @@ function tokenLabel(tokens: number | undefined): string {
56
58
  return tokens === undefined ? "— tok" : `${tokens < 1_000 ? tokens : `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`} tok`;
57
59
  }
58
60
 
61
+ function activityLabel(activity: DirectToolActivity | undefined): string | undefined {
62
+ if (!activity) return undefined;
63
+ const name = (value: string) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 40);
64
+ const parts: string[] = [];
65
+ if (activity.pending.length) parts.push(`pending ${activity.pending.map(name).join(", ")}`);
66
+ if (activity.latest?.durationMs !== undefined) {
67
+ const ms = activity.latest.durationMs;
68
+ parts.push(`last ${name(activity.latest.name)} ${ms < 1_000 ? `${Math.round(ms)}ms` : formatDuration(ms)} recorded`);
69
+ }
70
+ return parts.length ? parts.join(" · ") : undefined;
71
+ }
72
+
59
73
  function renderWidgetRows(
60
74
  items: WidgetItem[],
61
75
  width: number,
@@ -68,12 +82,14 @@ function renderWidgetRows(
68
82
  - (b.status === "failure" ? 0 : b.status === "working" ? 1 : 2));
69
83
  const visible = ordered.slice(0, ordered.length > MAX_WIDGET_LINES ? MAX_WIDGET_LINES - 1 : MAX_WIDGET_LINES);
70
84
  const lines = visible.map((item) => {
71
- const prefix = `${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", `D ${item.role}`)} ${item.status === "working" ? "" : `${statusLabel(item.status)} · `}`;
85
+ const prefix = `${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", `${item.ref} ${item.role}`)} ${item.status === "working" ? "" : `${statusLabel(item.status)} · `}`;
72
86
  const metrics = [
73
87
  `${item.model}/${item.thinkingLevel}`,
74
88
  tokenLabel(item.tokens),
75
89
  formatDuration((item.finishedAt ?? now) - item.startedAt),
76
90
  ];
91
+ const activity = activityLabel(item.activity);
92
+ if (activity) metrics.push(activity); // Activity is the first metric removed on narrow rows.
77
93
  while (metrics.length && width - visibleWidth(prefix) - visibleWidth(` · ${metrics.join(" · ")}`) < 8) {
78
94
  if (metrics.length === 2) metrics.shift(); // Preserve measured tokens ahead of route details.
79
95
  else metrics.pop();
@@ -111,8 +127,9 @@ function replaceRouteModel(ctx: ExtensionContext, reference: string, route: Reso
111
127
  const DIRECT_RESULT_TYPE = "subagent-direct-result";
112
128
  const DIRECT_TAB_TYPE = "subagent-direct-tab";
113
129
  const DIRECT_ALLOCATION_TYPE = "subagent-direct-allocation";
114
- type DirectAllocationRecord = DirectAllocation & { taskId: string; entryId: string; failure: string };
115
- type DirectTabRecord = DirectTab & { taskId: string; entryId: string };
130
+ // Legacy records have no display metadata.
131
+ type DirectAllocationRecord = DirectAllocation & { taskId: string; entryId: string; failure: string; display?: DirectDisplay };
132
+ type DirectTabRecord = DirectTab & { taskId: string; entryId: string; display?: DirectDisplay };
116
133
 
117
134
  function boundedError(error: unknown): Error {
118
135
  const message = capOutput(error instanceof Error ? error.message : String(error));
@@ -193,25 +210,46 @@ export default function subagentExtension(pi: ExtensionAPI): void {
193
210
  const directTasks = new Map<string, { controller: AbortController; settled: Promise<void>; handles: DirectHandle[]; tabs: DirectTabRecord[]; close?: (current: () => boolean) => Promise<void>; closeScope?: () => boolean; allocation?: DirectAllocationRecord; recovery?: string }>();
194
211
  let latestCtx: ExtensionContext | undefined;
195
212
  let sessionEpoch = 0;
213
+ // Shutdown still saves outgoing identities; only a session start defers records to its carry loop.
214
+ let directRecordEpoch = 0;
215
+ let directWorkflow = 0;
216
+ /** Saved display workflow numbers anywhere in this session, by owning task. */
217
+ const savedWorkflows = (ctx: ExtensionContext) => {
218
+ const saved = new Map<number, string>();
219
+ for (const entry of ctx.sessionManager.getEntries()) {
220
+ if (entry.type !== "custom" || (entry.customType !== DIRECT_TAB_TYPE && entry.customType !== DIRECT_ALLOCATION_TYPE)) continue;
221
+ const record = entry.data as DirectTabRecord | undefined;
222
+ if (record?.display) saved.set(record.display.workflow, record.taskId);
223
+ }
224
+ return saved;
225
+ };
226
+ // The in-memory counter also covers launches that have not saved a record yet.
227
+ const nextWorkflow = (ctx: ExtensionContext) => (directWorkflow = Math.max(directWorkflow, ...savedWorkflows(ctx).keys()) + 1);
196
228
  registerSubagentCommand(pi, {
197
229
  direct(ctx): DirectTask[] {
198
- const grouped = new Map<string, DirectTask & { tabs: Array<DirectTask["tabs"][number]> }>();
230
+ const grouped = new Map<string, DirectTask & { workers: DirectDisplay[]; tabs: Array<DirectTask["tabs"][number]> }>();
199
231
  for (const entry of ctx.sessionManager.getBranch()) {
200
- if (entry.type === "custom" && entry.customType === DIRECT_ALLOCATION_TYPE && entry.data) {
201
- const allocation = entry.data as DirectAllocationRecord;
202
- grouped.set(allocation.taskId, { id: allocation.taskId, name: allocation.name,
203
- status: "allocation unverified; admission retained", recovery: allocation.failure,
204
- tabs: grouped.get(allocation.taskId)?.tabs ?? [] });
232
+ if (entry.type !== "custom" || !entry.data || (entry.customType !== DIRECT_TAB_TYPE && entry.customType !== DIRECT_ALLOCATION_TYPE)) continue;
233
+ const record = entry.data as DirectTabRecord | DirectAllocationRecord;
234
+ const local = directTasks.get(record.taskId);
235
+ let task = grouped.get(record.taskId);
236
+ if (!task) grouped.set(record.taskId, task = { id: record.taskId, name: record.taskId,
237
+ status: local?.recovery ? "admission retained" : local ? "observed locally" : "recorded only; not proof of running or completion",
238
+ ...(local?.recovery ? { recovery: local.recovery } : {}), canClose: Boolean(local?.close), workers: [], tabs: [] });
239
+ const { display } = record;
240
+ if (display && !task.workers.some(({ worker }) => worker === display.worker)) task.workers.push(display);
241
+ if (entry.customType === DIRECT_ALLOCATION_TYPE) {
242
+ const { label, failure } = record as DirectAllocationRecord;
243
+ if (!display) task.name = label;
244
+ task.status = "allocation unverified; admission retained";
245
+ task.recovery = failure;
246
+ task.canClose = false;
247
+ } else if (!task.tabs.some(({ tabId }) => tabId === record.tabId)) {
248
+ const { entryId, name, tabId, paneId, sessionFile } = record as DirectTabRecord;
249
+ task.tabs.push({ entryId, name, tabId, paneId, sessionFile, ...(display ? { display } : {}) });
205
250
  }
206
- if (entry.type !== "custom" || entry.customType !== DIRECT_TAB_TYPE || !entry.data) continue;
207
- const tab = entry.data as DirectTabRecord;
208
- const local = directTasks.get(tab.taskId);
209
- const existing = grouped.get(tab.taskId);
210
- const record = { entryId: tab.entryId, name: tab.name, tabId: tab.tabId, paneId: tab.paneId, sessionFile: tab.sessionFile };
211
- if (existing) {
212
- if (!existing.tabs.some((item) => item.tabId === tab.tabId)) existing.tabs.push(record);
213
- } else grouped.set(tab.taskId, { id: tab.taskId, name: tab.name, status: local?.recovery ? `admission retained: ${local.recovery}` : local ? "observed locally" : "recorded (not observed)", canClose: Boolean(local?.close), tabs: [record] });
214
251
  }
252
+ for (const task of grouped.values()) task.workers.sort((a, b) => a.worker - b.worker);
215
253
  return [...grouped.values()];
216
254
  },
217
255
  async closeDirect(task, current) {
@@ -230,7 +268,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
230
268
  // Automatic settlement may already have proved termination and released it.
231
269
  if (!current()) throw new Error("Direct task scope changed; reopen /subagent.");
232
270
  if (directTasks.has(task.id)) await local.close(owned);
233
- return "Owned workers stopped; checkout admission released. Dirty files, commits and session evidence preserved. Inspect changes before retrying work.";
271
+ return "All owned workers in this workflow stopped; checkout admission released. Dirty files, commits and session evidence preserved. Inspect changes before retrying work.";
234
272
  },
235
273
  isolated: isolatedSurface.inventory,
236
274
  recover: (cwd) => isolatedSurface.recover(cwd),
@@ -245,6 +283,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
245
283
  const herdr = createDirectHerdr(pi, ctx.cwd, currentPolicy().childIdleMs);
246
284
  const controller = new AbortController();
247
285
  const taskId = `inspect-${id}-${randomUUID()}`;
286
+ const display: DirectDisplay = { workflow: nextWorkflow(ctx), worker: 1, workers: 1, title: `Inspect ${id}`, role: role.name };
287
+ const recordEpoch = directRecordEpoch;
248
288
  const handles: DirectHandle[] = [];
249
289
  const tabs: DirectTabRecord[] = [];
250
290
  let resolveSettled!: () => void;
@@ -257,10 +297,10 @@ export default function subagentExtension(pi: ExtensionAPI): void {
257
297
  try {
258
298
  handle = await herdr.start(
259
299
  { ...prepared, args: [...transient.launch.args] }, `i-${randomUUID().replaceAll("-", "").slice(0, 24)}`,
260
- `Inspect ${id}`, `Analyze this saved, read-only status snapshot for request ${id}. Summarize blockers and safe next decisions; do not infer missing evidence or mutate resources. The subagent_status tool in Main remains authoritative for exact action identities.\n\n${snapshot}`,
300
+ `${directRef(display)} ${display.title}`, `Analyze this saved, read-only status snapshot for request ${id}. Summarize blockers and safe next decisions; do not infer missing evidence or mutate resources. The subagent_status tool in Main remains authoritative for exact action identities.\n\n${snapshot}`,
261
301
  controller.signal, (tab) => {
262
- const record = { taskId, entryId: id, ...tab };
263
- pi.appendEntry(DIRECT_TAB_TYPE, record);
302
+ const record = { taskId, entryId: id, display, ...tab };
303
+ if (recordEpoch === directRecordEpoch) pi.appendEntry(DIRECT_TAB_TYPE, record);
264
304
  tabs.push(record);
265
305
  });
266
306
  } catch (error) {
@@ -272,7 +312,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
272
312
  await handle.cancel();
273
313
  throw new Error(`Session changed during inspection launch; inspect Herdr tab ${handle.tabId}.`);
274
314
  }
275
- return { tabId: handle.tabId, name: handle.name, sessionFile: handle.sessionFile };
315
+ return { ref: directRef(display), tabId: handle.tabId, name: handle.name, sessionFile: handle.sessionFile };
276
316
  } finally {
277
317
  directTasks.delete(taskId);
278
318
  resolveSettled();
@@ -307,8 +347,9 @@ export default function subagentExtension(pi: ExtensionAPI): void {
307
347
  samplingTokens.add(id);
308
348
  void handle.usageTokens().then((tokens) => {
309
349
  const item = widgetItems.get(id);
310
- if (tokens !== undefined && item?.status === "working" && tokenHandles.get(id) === handle) {
350
+ if (item?.status === "working" && tokenHandles.get(id) === handle) {
311
351
  item.tokens = tokens;
352
+ item.activity = handle.activity;
312
353
  requestWidgetRender();
313
354
  }
314
355
  }).catch(() => { /* Usage is optional; never interrupt work for telemetry. */ })
@@ -333,10 +374,9 @@ export default function subagentExtension(pi: ExtensionAPI): void {
333
374
  const startWidgetItem = (
334
375
  id: string,
335
376
  taskId: string,
336
- role: string,
377
+ display: DirectDisplay,
337
378
  model: string,
338
379
  thinkingLevel: string | undefined,
339
- name: string,
340
380
  ctx: ExtensionContext,
341
381
  ) => {
342
382
  if (!ctx.hasUI) return;
@@ -349,11 +389,12 @@ export default function subagentExtension(pi: ExtensionAPI): void {
349
389
  }
350
390
  }
351
391
  widgetItems.set(id, {
352
- role: roleBadge(role),
392
+ ref: directRef(display),
393
+ role: roleBadge(display.role),
353
394
  model,
354
395
  thinkingLevel: thinkingLevel ?? "default",
355
396
  taskId,
356
- name,
397
+ name: display.title,
357
398
  startedAt: Date.now(),
358
399
  status: "working",
359
400
  });
@@ -373,6 +414,9 @@ export default function subagentExtension(pi: ExtensionAPI): void {
373
414
 
374
415
  pi.on("session_start", async (_event, ctx) => {
375
416
  sessionEpoch += 1;
417
+ directRecordEpoch += 1;
418
+ // Numbers are session-local; saved records rebuild the next number.
419
+ directWorkflow = 0;
376
420
  if (latestCtx) {
377
421
  const previous = [...directTasks.values()];
378
422
  for (const { controller, handles } of previous) {
@@ -382,17 +426,30 @@ export default function subagentExtension(pi: ExtensionAPI): void {
382
426
  await Promise.allSettled(previous.map(({ settled }) => settled));
383
427
  // A switch discards the old session's ordinary follow-up. Carry exact
384
428
  // recovery identities into the new branch as well as the original one.
385
- const recorded = new Set(ctx.sessionManager.getBranch().flatMap((entry) =>
429
+ const branch = ctx.sessionManager.getBranch();
430
+ const recorded = new Set(branch.flatMap((entry) =>
386
431
  entry.type === "custom" && entry.customType === DIRECT_TAB_TYPE && entry.data
387
432
  ? [(entry.data as DirectTabRecord).tabId] : []));
388
- for (const { allocation } of previous) if (allocation && !ctx.sessionManager.getBranch().some((entry) =>
389
- entry.type === "custom" && entry.customType === DIRECT_ALLOCATION_TYPE && (entry.data as DirectAllocationRecord)?.taskId === allocation.taskId)) {
390
- pi.appendEntry(DIRECT_ALLOCATION_TYPE, allocation);
391
- }
392
- for (const { tabs } of previous) for (const tab of tabs) {
393
- if (!recorded.has(tab.tabId)) {
394
- pi.appendEntry(DIRECT_TAB_TYPE, tab);
395
- recorded.add(tab.tabId);
433
+ for (const local of previous) {
434
+ const carryAllocation = local.allocation && !branch.some((entry) => entry.type === "custom"
435
+ && entry.customType === DIRECT_ALLOCATION_TYPE && (entry.data as DirectAllocationRecord)?.taskId === local.allocation!.taskId);
436
+ const records = [...local.tabs, ...(local.allocation ? [local.allocation] : [])];
437
+ const display = records.find((record) => record.display)?.display;
438
+ const owner = display && savedWorkflows(ctx).get(display.workflow);
439
+ if (owner !== undefined && owner !== records[0]!.taskId) {
440
+ // Another workflow owns this number here; renumber display only, never exact identity.
441
+ const workflow = nextWorkflow(ctx);
442
+ const renumber = <T extends DirectTabRecord | DirectAllocationRecord>(record: T): T =>
443
+ record.display ? { ...record, display: { ...record.display, workflow } } : record;
444
+ local.tabs.splice(0, local.tabs.length, ...local.tabs.map(renumber));
445
+ if (local.allocation) local.allocation = renumber(local.allocation);
446
+ }
447
+ if (carryAllocation) pi.appendEntry(DIRECT_ALLOCATION_TYPE, local.allocation);
448
+ for (const tab of local.tabs) {
449
+ if (!recorded.has(tab.tabId)) {
450
+ pi.appendEntry(DIRECT_TAB_TYPE, tab);
451
+ recorded.add(tab.tabId);
452
+ }
396
453
  }
397
454
  }
398
455
  }
@@ -445,6 +502,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
445
502
  const reportDirect = (
446
503
  launchEpoch: number,
447
504
  taskId: string,
505
+ ref: string,
448
506
  mode: ParsedWorkflow["mode"],
449
507
  entries: readonly WorkflowTransportEntry[],
450
508
  tabs: readonly DirectTabRecord[],
@@ -454,8 +512,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
454
512
  if (stale) return;
455
513
  const transport = formatWorkflowResult(mode, entries);
456
514
  const outcome: BackgroundWorkflowTransportDetails["outcome"] = transport.failed ? "failed" : "completed";
457
- const content = [transport.text, ...(recovery ? [`Checkout admission retained: ${recovery}. Open /subagent, select ${taskId}, and follow its recovery guidance (Close/cancel-and-release only when offered); never blindly retry work.`] : []), ...(tabs.length ? ["Recovery (also available via /subagent):", ...tabs.map(({ taskId, entryId, name, tabId, paneId, sessionFile }) =>
458
- `- ${taskId} · ${entryId} · tab ${tabId} · pane ${paneId} · agent ${name} · session ${sessionFile}`)] : [])].join("\n");
515
+ const content = [transport.text, ...(recovery ? [`Checkout admission retained: ${recovery}. Open /subagent, select ${ref} (${taskId}), and follow its recovery guidance (Close/cancel-and-release of all its owned workers only when offered); never blindly retry work.`] : []), ...(tabs.length ? ["Recovery (also available via /subagent):", ...tabs.map(({ taskId, entryId, name, tabId, paneId, sessionFile, display }) =>
516
+ `- ${display ? `${directRef(display)} · ` : ""}${taskId} · ${entryId} · tab ${tabId} · pane ${paneId} · agent ${name} · session ${sessionFile}`)] : [])].join("\n");
459
517
  const details: BackgroundWorkflowTransportDetails & { tabs: readonly DirectTabRecord[] } = {
460
518
  ...transport.details,
461
519
  taskId,
@@ -523,8 +581,14 @@ export default function subagentExtension(pi: ExtensionAPI): void {
523
581
  id: entry.id, index: entry.index, name: entry.delegation.name, role: entry.delegation.role, status: "pending",
524
582
  }]));
525
583
  const taskId = `direct-${++directSequence}-${Date.now().toString(36)}`;
584
+ const workflowNumber = nextWorkflow(ctx);
585
+ const ref = `D${workflowNumber}`;
586
+ const displayOf = ({ index, delegation }: WorkflowEntry): DirectDisplay => ({
587
+ workflow: workflowNumber, worker: index + 1, workers: entries.length, title: delegation.name, role: delegation.role,
588
+ });
526
589
  const controller = new AbortController();
527
590
  const launchEpoch = sessionEpoch;
591
+ const recordEpoch = directRecordEpoch;
528
592
  const handles: DirectHandle[] = [];
529
593
  const tabs: DirectTabRecord[] = [];
530
594
  const tabByEntry = new Map<string, DirectTabRecord>();
@@ -566,23 +630,24 @@ export default function subagentExtension(pi: ExtensionAPI): void {
566
630
  ...resources.prompts.flatMap((path) => ["--prompt-template", path]),
567
631
  ...resources.themes.flatMap((path) => ["--theme", path]));
568
632
  states.set(entry.id, { ...states.get(entry.id)!, model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel, status: "running", assistantOutput: "" });
569
- startWidgetItem(entry.id, taskId, role.name, prepared.model.id, prepared.thinkingLevel, entry.delegation.name, ctx);
633
+ const display = displayOf(entry);
634
+ startWidgetItem(entry.id, taskId, display, prepared.model.id, prepared.thinkingLevel, ctx);
570
635
  const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
571
636
  promptArgIndex: prepared.promptArgIndex }, activeSignal);
572
637
  let handle: DirectHandle;
573
638
  try {
574
639
  handle = await herdr.start({ ...prepared, args: [...transient.launch.args] },
575
- `d-${randomUUID().replaceAll("-", "").slice(0, 24)}`, entry.delegation.name, entry.delegation.task, activeSignal, (tab) => {
576
- const record = { taskId, entryId: entry.id, ...tab };
577
- // Persist before agent start, including launches interrupted by a session switch.
578
- pi.appendEntry(DIRECT_TAB_TYPE, record);
640
+ `d-${randomUUID().replaceAll("-", "").slice(0, 24)}`, `${directRef(display)} ${display.title}`, entry.delegation.task, activeSignal, (tab) => {
641
+ const record = { taskId, entryId: entry.id, display, ...tab };
642
+ // Persist before agent start, or let the new session carry the exact identity after settlement.
643
+ if (recordEpoch === directRecordEpoch) pi.appendEntry(DIRECT_TAB_TYPE, record);
579
644
  tabs.push(record);
580
645
  tabByEntry.set(entry.id, record);
581
646
  }, writes);
582
647
  } catch (error) {
583
648
  if (error instanceof DirectAllocationError) {
584
- local.allocation = { taskId, entryId: entry.id, ...error.allocation, failure: error.message };
585
- pi.appendEntry(DIRECT_ALLOCATION_TYPE, local.allocation);
649
+ local.allocation = { taskId, entryId: entry.id, display, ...error.allocation, failure: error.message };
650
+ if (recordEpoch === directRecordEpoch) pi.appendEntry(DIRECT_ALLOCATION_TYPE, local.allocation);
586
651
  }
587
652
  // A failed start can still be in flight. Keep its private prompt for recovery.
588
653
  throw new Error(`${error instanceof Error ? error.message : String(error)}. Direct Role prompt retained at ${transient.launch.args[prepared.promptArgIndex + 1]} after uncertain start.`, { cause: error });
@@ -633,7 +698,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
633
698
  const answer = await handle.answer(Math.floor(40 * 1024 / entries.length));
634
699
  try {
635
700
  const tokens = await handle.usageTokens();
636
- if (tokens !== undefined && widgetItems.has(entry.id)) widgetItems.get(entry.id)!.tokens = tokens;
701
+ const item = widgetItems.get(entry.id);
702
+ if (item) { item.tokens = tokens; item.activity = handle.activity; }
637
703
  } catch { /* Optional telemetry. */ }
638
704
  states.set(entry.id, { ...states.get(entry.id)!, status: "succeeded", assistantOutput: answer });
639
705
  finishWidgetItem(entry.id, "success");
@@ -662,12 +728,12 @@ export default function subagentExtension(pi: ExtensionAPI): void {
662
728
  try { await local.close!(() => directTasks.get(taskId) === local); }
663
729
  catch { /* Retain ownership and admission for an explicit guarded retry. */ }
664
730
  } else directTasks.delete(taskId);
665
- if (!controller.signal.aborted) reportDirect(launchEpoch, taskId, workflow.mode, [...states.values()], tabs, local.recovery);
731
+ if (!controller.signal.aborted) reportDirect(launchEpoch, taskId, ref, workflow.mode, [...states.values()], tabs, local.recovery);
666
732
  resolveSettled();
667
733
  }
668
734
  })();
669
735
  return {
670
- content: [{ type: "text" as const, text: capOutput(`Direct delegation started · ${taskId}\nHerdr tab: ${first.tabId} · pane: ${first.paneId} · agent: ${first.name}\nExact session: ${first.sessionFile}\nAll launched tabs: /subagent (on this session branch).\n${entries.length} task(s); result will arrive in one follow-up message. Direct work shares this checkout; completion is an exact answer, not checked integration. ${writes ? "Potential writers run serially; competing Pi writes stay blocked until owned workers are proved stopped. If termination is uncertain, use /subagent Close/cancel-and-release; changes are not rolled back." : "Concurrent changes may make reads stale."}`) }],
736
+ content: [{ type: "text" as const, text: capOutput(`Direct delegation started · ${ref} · ${taskId}\nHerdr tab: ${first.tabId} · pane: ${first.paneId} · agent: ${first.name}\nExact session: ${first.sessionFile}\nAll launched tabs: /subagent (on this session branch).\n${entries.length} task(s); result will arrive in one follow-up message. Direct work shares this checkout; completion is an exact answer, not checked integration. ${writes ? "Potential writers run serially; competing Pi writes stay blocked until owned workers are proved stopped. If termination is uncertain, use /subagent ${ref} Close/cancel-and-release (stops ALL its owned workers); changes are not rolled back." : "Concurrent changes may make reads stale."}`) }],
671
737
  details: { taskId, mode: workflow.mode, tabId: first.tabId, sessionFile: first.sessionFile,
672
738
  entries: entries.map(({ id, index, delegation }) => ({ id, index, name: delegation.name, role: delegation.role })) },
673
739
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "26.3.0",
3
+ "version": "26.4.0",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",