@henryqw/pi-subagent 26.3.1 → 26.5.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 +14 -6
- package/dist/direct-herdr.d.ts +13 -0
- package/dist/direct-herdr.js +42 -13
- package/dist/herdr-runtime.js +3 -3
- package/extensions/subagent-command.ts +37 -12
- package/extensions/subagent.ts +116 -50
- package/package.json +2 -2
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 in the 1.x series. 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. Isolated work requires Herdr 0.9.3 or later, with a running Herdr server on API protocol 22 or later; Pi Subagent checks both before it starts.
|
|
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,
|
|
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
|

|
|
@@ -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
|
|
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
|
|
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
|
|
package/dist/direct-herdr.d.ts
CHANGED
|
@@ -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;
|
package/dist/direct-herdr.js
CHANGED
|
@@ -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
|
|
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 === "
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
-
|
|
317
|
+
lastUsage = directSessionUsage(await readFile(sessionFile, "utf8"), prompt);
|
|
289
318
|
lastUsageSize = info.size;
|
|
290
319
|
}
|
|
291
|
-
return
|
|
320
|
+
return lastUsage.tokens;
|
|
292
321
|
},
|
|
293
322
|
async answer(maxBytes) {
|
|
294
323
|
const readAnswer = async () => {
|
package/dist/herdr-runtime.js
CHANGED
|
@@ -11,7 +11,7 @@ import { runProcess as defaultRunProcess } from "./process.js";
|
|
|
11
11
|
import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
|
|
12
12
|
import { assertPrivateLease, scanProcessLease } from "./process-lease.js";
|
|
13
13
|
import { exactDirectTerminalTurn } from "./direct-herdr.js";
|
|
14
|
-
const MIN_HERDR_VERSION = [0, 9,
|
|
14
|
+
const MIN_HERDR_VERSION = [0, 9, 3];
|
|
15
15
|
const MIN_HERDR_PROTOCOL = 22;
|
|
16
16
|
const HERDR_OPERATION_CAP_MS = 30_000;
|
|
17
17
|
const LSOF_OPERATION_CAP_MS = 3_000;
|
|
@@ -276,7 +276,7 @@ export class HerdrHostRuntime {
|
|
|
276
276
|
const versionOutput = await this.herdr.run(["--version"], this.processOptions(root, context, HERDR_OPERATION_CAP_MS));
|
|
277
277
|
const versionMatch = /^herdr (\S+)\s*$/.exec(versionOutput);
|
|
278
278
|
if (!versionMatch || !compareVersion(versionMatch[1], MIN_HERDR_VERSION)) {
|
|
279
|
-
throw new Error("Herdr client version must be at least 0.9.
|
|
279
|
+
throw new Error("Herdr client version must be at least 0.9.3.");
|
|
280
280
|
}
|
|
281
281
|
const status = statusFields(await this.herdr.run(["status", "server"], this.processOptions(root, context, HERDR_OPERATION_CAP_MS)));
|
|
282
282
|
if (status.get("status") !== "running"
|
|
@@ -285,7 +285,7 @@ export class HerdrHostRuntime {
|
|
|
285
285
|
|| status.get("private_protocol_compatible") !== "yes"
|
|
286
286
|
|| !/^\d+$/.test(status.get("private_protocol") ?? "")
|
|
287
287
|
|| Number(status.get("private_protocol")) < MIN_HERDR_PROTOCOL) {
|
|
288
|
-
throw new Error("Herdr server must be compatible version >=0.9.
|
|
288
|
+
throw new Error("Herdr server must be compatible version >=0.9.3 with private protocol >=22.");
|
|
289
289
|
}
|
|
290
290
|
const schema = await this.herdr.json(["api", "schema", "--json"], this.processOptions(root, context, HERDR_OPERATION_CAP_MS));
|
|
291
291
|
requireSchemaCapabilities(schema);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
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;
|
package/extensions/subagent.ts
CHANGED
|
@@ -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",
|
|
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
|
-
|
|
115
|
-
type
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 "
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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)}`,
|
|
576
|
-
const record = { taskId, entryId: entry.id, ...tab };
|
|
577
|
-
// Persist before agent start,
|
|
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
|
-
|
|
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 ?
|
|
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
|
+
"version": "26.5.0",
|
|
4
4
|
"description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@henryqw/pi-config-store": "^3.0.0",
|
|
70
|
-
"@henryqw/pi-herdr": "^0.
|
|
70
|
+
"@henryqw/pi-herdr": "^0.5.0",
|
|
71
71
|
"@henryqw/pi-multi-codex": "^3.0.0",
|
|
72
72
|
"@henryqw/pi-process": "^0.2.0",
|
|
73
73
|
"@henryqw/pi-task-models": "^8.0.0",
|