@henryqw/pi-subagent 26.1.2 → 26.1.3

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/CONTEXT.md CHANGED
@@ -41,7 +41,7 @@ Own Role-configured Pi delegation through one `delegate_task` surface:
41
41
 
42
42
  ### Direct evidence
43
43
 
44
- - Direct mode admits declared write-capable resources for explicitly authorized scope while preserving read-only Role restrictions. Potential-writer requests serialize tasks and retain Pi checkout admission through exact completion; uncertain outcomes retain admission for manual recovery. Potential-writer answers require an explicit structured `succeeded` outcome; failed, blocked, or malformed completion stops dispatch and retains admission. This is a worker report, not proof of validation, cleanliness, rollback, or checked integration. Direct changesets require isolated mode.
44
+ - Direct mode admits declared write-capable resources for explicitly authorized scope while preserving read-only Role restrictions. Potential-writer requests serialize tasks and retain Pi checkout admission until exact owned worker termination is proved by pane absence and two empty private process-lease scans; uncertain termination retains admission and live recovery ownership. Potential-writer answers require an explicit structured `succeeded` outcome; failed, blocked, or malformed completion stops dispatch but does not prevent release after proved termination. `/subagent` Close/cancel-and-release retries termination under session, branch, epoch and exact ownership guards without replaying work or removing checkout/session evidence. This is a worker report, not proof of validation, cleanliness, rollback, or checked integration. Direct changesets require isolated mode.
45
45
  - Checkout admission in Main serializes write-capable Pi tool calls per checkout by their model-issued root call: a `codemode` script's nested writes share the script's ownership, independent writers wait, and reads never wait.
46
46
  - Each direct worker has a recorded Herdr tab and Pi session identity. The first handle returns before completion; subsequent tab identities remain recoverable on the session branch.
47
47
  - An exact settled worker with a bounded final Pi answer produces a follow-up to Main. Blocked, unknown, idle-stalled, truncated, or ambiguous outcomes retain actionable recovery identity. Session replacement never delivers to the wrong Main session.
package/README.md CHANGED
@@ -29,7 +29,7 @@ Commands are for you; tools and the packaged skill are for Main. You do not need
29
29
 
30
30
  | Surface | Type | Purpose |
31
31
  | --- | --- | --- |
32
- | `/subagent` | command | Browse work, launch isolated status inspection in a separate Herdr tab with the `fast` model, or send/edit pending instructions. Requires interactive TUI or RPC; inspection also requires a Herdr-managed pane and a read-only `scout` Role. |
32
+ | `/subagent` | command | Browse work, Close/cancel-and-release locally owned direct workers, launch isolated status inspection in a separate Herdr tab with the `fast` model, or send/edit pending instructions. Requires interactive TUI or RPC; inspection also requires a Herdr-managed pane and a read-only `scout` Role. |
33
33
  | `/subagent recover` | command | Classify orphaned isolated work in the current canonical repository and send Main one follow-up with request IDs, blockers, and reported next actions. No UI or Herdr pane required. |
34
34
  | `delegate_task` | tool | Start authorized shared-checkout work or a durable isolated checked graph. |
35
35
  | `subagent_status` | tool | Read durable isolated state and the exact allowed continuation without replaying work. Declares an output schema, so `codemode` scripts receive the same bounded public projection as structured content. |
@@ -81,9 +81,11 @@ Chain: { mode: "direct", chain: [{ role, name, task, ... }] }
81
81
 
82
82
  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.
83
83
 
84
- 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) run their tasks serially and hold checkout admission after the initial handle returns; competing Pi writes fail until exact successful completion. 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. On failure, cancellation, or uncertain launch after tab creation, admission stays held: inspect the exact tabs/session files through `/subagent`, stop any remaining owned workers, inspect and preserve their changes, then restart Pi before new writes. Never blindly retry a commit or delete work to recover.
84
+ 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) run their tasks serially and hold checkout admission after the initial handle returns; competing Pi writes fail until all owned workers are proved stopped. 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.
85
85
 
86
- 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 and retains admission. Success is the worker's explicit report, not independent validation of its changes.
86
+ 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.
87
+
88
+ 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.
87
89
 
88
90
  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.
89
91
 
@@ -140,7 +142,7 @@ Completed text answers are saved independently of cleanup. Status and terminal f
140
142
 
141
143
  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.
142
144
 
143
- 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.
145
+ 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.
144
146
 
145
147
  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.
146
148
 
@@ -14,7 +14,24 @@ export interface DirectHandle {
14
14
  export declare function exactDirectTerminalTurn(jsonl: string, prompt: string): boolean;
15
15
  export declare function exactDirectAnswer(jsonl: string, prompt: string, maxBytes?: number, requireSuccess?: boolean): string;
16
16
  export declare function directSessionTokens(jsonl: string, prompt: string): number | undefined;
17
- export type DirectTab = Pick<DirectHandle, "name" | "tabId" | "paneId" | "sessionFile">;
17
+ export type DirectTab = Pick<DirectHandle, "name" | "tabId" | "paneId" | "sessionFile"> & {
18
+ leasePath: string;
19
+ };
20
+ /** Allocation evidence is not ownership and must never authorize pane mutation. */
21
+ export type DirectAllocation = Omit<DirectTab, "tabId" | "paneId"> & {
22
+ workspaceId: string;
23
+ cwd: string;
24
+ label: string;
25
+ tabId?: string;
26
+ paneId?: string;
27
+ };
28
+ export declare class DirectAllocationError extends Error {
29
+ readonly allocation: DirectAllocation;
30
+ constructor(allocation: DirectAllocation, cause: unknown);
31
+ }
18
32
  export declare function createDirectHerdr(pi: Pick<ExtensionAPI, "exec">, cwd: string, idleMs: number): {
33
+ /** Close only an exact owned pane, then prove it and its leased children stopped.
34
+ * A task result or Ctrl-C acknowledgement is not termination evidence. */
35
+ stop(tab: DirectTab, current?: () => boolean): Promise<void>;
19
36
  start(launch: ResolvedRoleLaunch, name: string, label: string, task: string, signal: AbortSignal, onTab: (tab: DirectTab) => void, requireSuccess?: boolean): Promise<DirectHandle>;
20
37
  };
@@ -1,9 +1,11 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
- import { readFile, mkdtemp, stat } from "node:fs/promises";
3
+ import { readFile, mkdtemp, stat, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
6
7
  import { createHerdrClient, hasHerdrErrorCode, herdrCommandFailure, startPiAgent } from "@henryqw/pi-herdr";
8
+ import { assertPrivateLease, scanProcessLease } from "./process-lease.js";
7
9
  const OPERATION_MS = 30_000;
8
10
  const SESSION_LIMIT = 16 * 1024 * 1024;
9
11
  const ANSWER_LIMIT = 50 * 1024;
@@ -132,6 +134,13 @@ export function directSessionTokens(jsonl, prompt) {
132
134
  }
133
135
  return observed ? total : undefined;
134
136
  }
137
+ export class DirectAllocationError extends Error {
138
+ allocation;
139
+ constructor(allocation, cause) {
140
+ super(`Direct tab allocation is unverified: ${cause instanceof Error ? cause.message : String(cause)}. No agent start was dispatched. Inspect /subagent allocation evidence: workspace ${allocation.workspaceId}, cwd ${allocation.cwd}, label ${JSON.stringify(allocation.label)}, tab ${allocation.tabId ?? "unknown"}, pane ${allocation.paneId ?? "unknown"}, agent ${allocation.name}, session ${allocation.sessionFile}, lease ${allocation.leasePath}. Manual allocation reconciliation is required; do not replay creation or unlock admission.`, { cause });
141
+ this.allocation = allocation;
142
+ }
143
+ }
135
144
  export function createDirectHerdr(pi, cwd, idleMs) {
136
145
  if (process.env.HERDR_ENV !== "1")
137
146
  throw new Error("Direct delegation requires a Herdr-managed Pi pane (HERDR_ENV=1).");
@@ -149,6 +158,56 @@ export function createDirectHerdr(pi, cwd, idleMs) {
149
158
  return field(agent.agent_status, "agent state");
150
159
  };
151
160
  return {
161
+ /** Close only an exact owned pane, then prove it and its leased children stopped.
162
+ * A task result or Ctrl-C acknowledgement is not termination evidence. */
163
+ async stop(tab, current = () => true) {
164
+ const requireCurrent = () => { if (!current())
165
+ throw new Error("Direct task scope changed; reopen /subagent."); };
166
+ requireCurrent();
167
+ await assertPrivateLease(tab.leasePath, false);
168
+ const listed = result(await herdr.json(["agent", "list"], options()), "agent_list");
169
+ if (!Array.isArray(listed.agents))
170
+ throw new Error("Malformed Herdr agent list.");
171
+ const related = listed.agents.map((value) => {
172
+ const agent = object(value, "listed agent");
173
+ for (const key of ["pane_id", "tab_id"])
174
+ field(agent[key], `listed agent ${key}`);
175
+ return agent;
176
+ }).filter((agent) => agent.name === tab.name || agent.pane_id === tab.paneId || agent.tab_id === tab.tabId);
177
+ const panes = async () => {
178
+ const list = result(await herdr.json(["pane", "list", "--workspace", workspaceId], options()), "pane_list");
179
+ if (!Array.isArray(list.panes))
180
+ throw new Error("Malformed Herdr pane list.");
181
+ return list.panes.map((value) => {
182
+ const pane = object(value, "listed pane");
183
+ for (const key of ["pane_id", "tab_id"])
184
+ field(pane[key], `listed pane ${key}`);
185
+ if (pane.workspace_id !== workspaceId)
186
+ throw new Error("Herdr pane list escaped the owned workspace.");
187
+ return pane;
188
+ });
189
+ };
190
+ if (related.length) {
191
+ const agent = related[0];
192
+ if (related.length !== 1 || agent.name !== tab.name || agent.pane_id !== tab.paneId
193
+ || agent.tab_id !== tab.tabId || agent.workspace_id !== workspaceId || agent.cwd !== workingDir
194
+ || tab.paneId === callerPane)
195
+ throw new Error("Worker termination requires one exact owned agent, workspace, checkout, tab and pane match.");
196
+ requireCurrent();
197
+ result(await herdr.json(["pane", "close", tab.paneId], options()), "ok");
198
+ }
199
+ if ((await panes()).some((pane) => pane.pane_id === tab.paneId || pane.tab_id === tab.tabId)) {
200
+ throw new Error(`Owned pane ${tab.paneId} is still present; termination is unproved.`);
201
+ }
202
+ for (let scan = 0; scan < 2; scan++) {
203
+ const holders = await scanProcessLease(tab.leasePath, (args) => pi.exec("lsof", args, { cwd: workingDir, timeout: 3_000 }));
204
+ if (holders.length)
205
+ throw new Error(`Owned process lease ${tab.leasePath} still has holders: ${holders.join(", ")}. Stop those owned processes, then retry Close/cancel-and-release.`);
206
+ if (scan === 0)
207
+ await sleep(50);
208
+ }
209
+ requireCurrent();
210
+ },
152
211
  async start(launch, name, label, task, signal, onTab, requireSuccess = false) {
153
212
  const caller = result(await herdr.json(["pane", "current", "--current"], options(signal)), "pane_current");
154
213
  const pane = object(caller.pane, "calling pane");
@@ -158,6 +217,8 @@ export function createDirectHerdr(pi, cwd, idleMs) {
158
217
  throw new Error("Direct Herdr launch cannot transfer Role environment overrides.");
159
218
  const sessionDir = await mkdtemp(join(tmpdir(), "pi-subagent-direct-"));
160
219
  const sessionFile = join(sessionDir, "session.jsonl");
220
+ const leasePath = join(sessionDir, "process.lease");
221
+ await writeFile(leasePath, "", { flag: "wx", mode: 0o600 });
161
222
  // Pi's native session is the exact answer channel. Herdr screen output is diagnostic only.
162
223
  const sessionArgs = launch.args.filter((arg) => arg !== "--no-session");
163
224
  if (sessionArgs.length !== launch.args.length - 1)
@@ -165,18 +226,34 @@ export function createDirectHerdr(pi, cwd, idleMs) {
165
226
  signal.throwIfAborted();
166
227
  // Tab creation is bounded but not session-cancellable: Herdr may create it
167
228
  // before the CLI responds, and aborting the CLI loses the exact tab ID.
168
- const created = result(await herdr.json(["tab", "create", "--workspace", workspaceId, "--cwd", workingDir, "--label", label, "--no-focus"], options()), "tab_created");
169
- const tab = object(created.tab, "created tab");
170
- const workerPane = object(created.root_pane, "created pane");
171
- const tabId = field(tab.tab_id, "tab id");
172
- const paneId = field(workerPane.pane_id, "pane id");
173
- if (tab.workspace_id !== workspaceId || workerPane.workspace_id !== workspaceId || workerPane.tab_id !== tabId
174
- || workerPane.cwd !== workingDir || tab.focused !== false || workerPane.focused !== false || paneId === callerPane) {
175
- throw new Error(`Herdr tab ${tabId} has unverified identity, cwd or focus; inspect it before retrying.`);
229
+ const allocation = { name, workspaceId, cwd: workingDir, label, sessionFile, leasePath };
230
+ let tabId;
231
+ let paneId;
232
+ try {
233
+ const response = await herdr.json(["tab", "create", "--workspace", workspaceId, "--cwd", workingDir, "--label", label, "--env", `PI_SUBAGENT_PROCESS_LEASE=${leasePath}`, "--no-focus"], options());
234
+ const created = object(response.result, "tab allocation result");
235
+ // Capture every returned ID before validation can fail. These are evidence,
236
+ // not owned tabs; the caller retains this intent even without either ID.
237
+ for (const [source, key, target] of [[created.tab, "tab_id", "tabId"], [created.root_pane, "pane_id", "paneId"]]) {
238
+ if (source && typeof source === "object" && typeof source[key] === "string")
239
+ allocation[target] = source[key];
240
+ }
241
+ result(response, "tab_created");
242
+ const tab = object(created.tab, "created tab");
243
+ const workerPane = object(created.root_pane, "created pane");
244
+ tabId = field(tab.tab_id, "tab id");
245
+ paneId = field(workerPane.pane_id, "pane id");
246
+ if (tab.workspace_id !== workspaceId || workerPane.workspace_id !== workspaceId || workerPane.tab_id !== tabId
247
+ || workerPane.cwd !== workingDir || tab.focused !== false || workerPane.focused !== false || paneId === callerPane) {
248
+ throw new Error(`Herdr tab ${tabId} has unverified identity, cwd or focus; inspect it before retrying.`);
249
+ }
250
+ }
251
+ catch (error) {
252
+ throw new DirectAllocationError(allocation, error);
176
253
  }
177
254
  try {
178
255
  // Record the tab before the agent start can outlive an aborted launch.
179
- onTab({ name, tabId, paneId, sessionFile });
256
+ onTab({ name, tabId, paneId, sessionFile, leasePath });
180
257
  signal.throwIfAborted();
181
258
  const started = await startPiAgent(herdr, { name, pane: paneId,
182
259
  args: [...sessionArgs, "--session", sessionFile], options: options(signal), shouldRetry: () => false });
@@ -9,6 +9,7 @@ import { isCleanCommitted, } from "./schema.js";
9
9
  import { buildChangesetTaskPrompt, withTransientLaunch, } from "./runner.js";
10
10
  import { runProcess as defaultRunProcess } from "./process.js";
11
11
  import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
12
+ import { assertPrivateLease, scanProcessLease } from "./process-lease.js";
12
13
  import { exactDirectTerminalTurn } from "./direct-herdr.js";
13
14
  const MIN_HERDR_VERSION = [0, 9, 0];
14
15
  const MIN_HERDR_PROTOCOL = 22;
@@ -1193,42 +1194,10 @@ export class HerdrHostRuntime {
1193
1194
  }
1194
1195
  }
1195
1196
  async assertPrivateLease(path, allowMissing) {
1196
- let info;
1197
- try {
1198
- info = await lstat(path);
1199
- }
1200
- catch (error) {
1201
- if (allowMissing && error.code === "ENOENT")
1202
- return false;
1203
- throw new Error(`Exact process lease cannot be inspected: ${path}`, { cause: error });
1204
- }
1205
- const uid = process.getuid?.();
1206
- if (info.isSymbolicLink() || !info.isFile() || (info.mode & 0o777) !== LEASE_MODE || (uid !== undefined && info.uid !== uid)) {
1207
- throw new Error("Exact process lease must be a current-user regular non-symlink mode-0600 file.");
1208
- }
1209
- return true;
1197
+ return assertPrivateLease(path, allowMissing);
1210
1198
  }
1211
1199
  async scanLease(path, cwd, context, pid, allowMissing = false) {
1212
- if (!await this.assertPrivateLease(path, allowMissing))
1213
- return [];
1214
- const args = ["-nP", "-a", ...(pid === undefined ? [] : ["-p", String(pid)]), "-F", "p", "--", path];
1215
- const result = await this.execute(this.lsofCommand, args, this.processOptions(cwd, context, LSOF_OPERATION_CAP_MS));
1216
- if (result.killed || ![0, 1].includes(result.code) || (result.code === 1 && (result.stdout.trim() || result.stderr.trim()))) {
1217
- throw new Error("Exact process lease lsof scan failed or was ambiguous.");
1218
- }
1219
- if (result.code === 1)
1220
- return [];
1221
- if (result.stderr.trim())
1222
- throw new Error("Exact process lease lsof scan returned unexpected diagnostics.");
1223
- const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean);
1224
- if (!lines.length || lines.some((line) => !/^p[1-9]\d*$/.test(line))) {
1225
- throw new Error("Exact process lease lsof scan returned malformed PID fields.");
1226
- }
1227
- const holders = [...new Set(lines.map((line) => Number(line.slice(1))))];
1228
- if (holders.some((holder) => !Number.isSafeInteger(holder) || holder <= 0) || (pid !== undefined && holders.some((holder) => holder !== pid))) {
1229
- throw new Error("Exact process lease lsof scan returned an unexpected PID.");
1230
- }
1231
- return holders;
1200
+ return scanProcessLease(path, (args) => this.execute(this.lsofCommand, args, this.processOptions(cwd, context, LSOF_OPERATION_CAP_MS)), pid, allowMissing);
1232
1201
  }
1233
1202
  async signalExactHolders(holders, allocation, signal, context) {
1234
1203
  for (const pid of holders) {
@@ -0,0 +1,4 @@
1
+ import type { HerdrExecResult } from "@henryqw/pi-herdr";
2
+ export declare function assertPrivateLease(path: string, allowMissing: boolean): Promise<boolean>;
3
+ /** Shared exact process-lease evidence for direct and isolated workers. */
4
+ export declare function scanProcessLease(path: string, execute: (args: string[]) => Promise<HerdrExecResult>, pid?: number, allowMissing?: boolean): Promise<number[]>;
@@ -0,0 +1,40 @@
1
+ import { lstat } from "node:fs/promises";
2
+ export async function assertPrivateLease(path, allowMissing) {
3
+ let info;
4
+ try {
5
+ info = await lstat(path);
6
+ }
7
+ catch (error) {
8
+ if (allowMissing && error.code === "ENOENT")
9
+ return false;
10
+ throw new Error(`Exact process lease cannot be inspected: ${path}`, { cause: error });
11
+ }
12
+ const uid = process.getuid?.();
13
+ if (info.isSymbolicLink() || !info.isFile() || (info.mode & 0o777) !== 0o600 || (uid !== undefined && info.uid !== uid)) {
14
+ throw new Error("Exact process lease must be a current-user regular non-symlink mode-0600 file.");
15
+ }
16
+ return true;
17
+ }
18
+ /** Shared exact process-lease evidence for direct and isolated workers. */
19
+ export async function scanProcessLease(path, execute, pid, allowMissing = false) {
20
+ if (!await assertPrivateLease(path, allowMissing))
21
+ return [];
22
+ const args = ["-nP", "-a", ...(pid === undefined ? [] : ["-p", String(pid)]), "-F", "p", "--", path];
23
+ const result = await execute(args);
24
+ if (result.killed || ![0, 1].includes(result.code) || (result.code === 1 && (result.stdout.trim() || result.stderr.trim()))) {
25
+ throw new Error("Exact process lease lsof scan failed or was ambiguous.");
26
+ }
27
+ if (result.code === 1)
28
+ return [];
29
+ if (result.stderr.trim())
30
+ throw new Error("Exact process lease lsof scan returned unexpected diagnostics.");
31
+ const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean);
32
+ if (!lines.length || lines.some((line) => !/^p[1-9]\d*$/.test(line))) {
33
+ throw new Error("Exact process lease lsof scan returned malformed PID fields.");
34
+ }
35
+ const holders = [...new Set(lines.map((line) => Number(line.slice(1))))];
36
+ if (holders.some((holder) => !Number.isSafeInteger(holder) || holder <= 0) || (pid !== undefined && holders.some((holder) => holder !== pid))) {
37
+ throw new Error("Exact process lease lsof scan returned an unexpected PID.");
38
+ }
39
+ return holders;
40
+ }
@@ -83,14 +83,11 @@ export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (inp
83
83
  pi.on("tool_result", (event) => release(event.toolCallId));
84
84
  pi.on("tool_execution_end", (event) => release(event.toolCallId));
85
85
  pi.on("session_shutdown", () => {
86
- ownerByCheckout.clear();
87
- checkoutByRoot.clear();
88
- writersByRoot.clear();
89
- rootByCall.clear();
90
- heldCalls.clear();
86
+ // Session replacement is not proof that a held direct worker stopped.
87
+ for (const call of rootByCall.keys()) release(call);
91
88
  });
92
89
  // A direct handle settles the tool call before the worker settles. Keep its
93
- // admission until exact completion; uncertain workers require manual recovery.
90
+ // admission until proved termination; uncertain workers require guarded recovery.
94
91
  return (toolCallId) => {
95
92
  const root = rootByCall.get(toolCallId);
96
93
  if (root === undefined || !writersByRoot.get(root)?.has(toolCallId)) {
@@ -5,6 +5,8 @@ export interface DirectTask {
5
5
  id: string;
6
6
  name: string;
7
7
  status: string;
8
+ canClose?: boolean;
9
+ recovery?: string;
8
10
  tabs: readonly { entryId: string; name: string; tabId: string; paneId: string; sessionFile: string }[];
9
11
  }
10
12
 
@@ -30,6 +32,7 @@ export interface IsolatedInventory {
30
32
  * validation and queue operation. They must not await between those operations. */
31
33
  export interface SubagentCommandAdapter {
32
34
  direct(ctx: ExtensionContext): readonly DirectTask[];
35
+ closeDirect(task: DirectTask, current: () => boolean): Promise<string>;
33
36
  isolated(cwd: string, current: () => boolean): Promise<IsolatedInventory>;
34
37
  recover(cwd: string): Promise<string>;
35
38
  inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ tabId: string; name: string; sessionFile: string }>;
@@ -119,9 +122,23 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
119
122
  if (selected.kind === "history") { history = !history; continue; }
120
123
  if (selected.kind === "direct") {
121
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");
122
126
  for (const tab of selected.task.tabs) {
123
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");
124
128
  }
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" },
132
+ { label: "Back / refresh", value: "back" },
133
+ ]);
134
+ if (!valid()) return;
135
+ if (action === "close") {
136
+ try {
137
+ const report = await adapter.closeDirect(selected.task, current);
138
+ if (valid()) ctx.ui.notify(report, "info");
139
+ } 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
+ }
141
+ }
125
142
  continue;
126
143
  }
127
144
  const { root, request } = selected;
@@ -10,7 +10,7 @@ import { registerSubagentCommand, 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, type DirectHandle, type DirectTab } from "../dist/direct-herdr.js";
13
+ import { createDirectHerdr, DirectAllocationError, type DirectAllocation, type DirectHandle, type DirectTab } 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;
@@ -110,6 +110,8 @@ function replaceRouteModel(ctx: ExtensionContext, reference: string, route: Reso
110
110
 
111
111
  const DIRECT_RESULT_TYPE = "subagent-direct-result";
112
112
  const DIRECT_TAB_TYPE = "subagent-direct-tab";
113
+ const DIRECT_ALLOCATION_TYPE = "subagent-direct-allocation";
114
+ type DirectAllocationRecord = DirectAllocation & { taskId: string; entryId: string; failure: string };
113
115
  type DirectTabRecord = DirectTab & { taskId: string; entryId: string };
114
116
 
115
117
  function boundedError(error: unknown): Error {
@@ -136,7 +138,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
136
138
  if (!details?.entries) return new Text(content, outputPad, 0);
137
139
  const count = details.entries.length;
138
140
  const subject = count === 1 ? "Direct subagent" : `${count} direct subagents`;
139
- const state = details.recovery ? "stopped; recovery needed" : details.outcome;
141
+ const state = details.recovery ? "termination unverified; recovery needed" : details.outcome;
140
142
  const glyph = details.recovery ? "■" : details.outcome === "completed" ? "✓" : "✗";
141
143
  const color = details.recovery ? "warning" : details.outcome === "completed" ? "success" : "error";
142
144
  const rows = details.entries.map(({ name, role, status, summary }) => {
@@ -188,24 +190,48 @@ export default function subagentExtension(pi: ExtensionAPI): void {
188
190
  currentPolicy,
189
191
  });
190
192
  let directSequence = 0;
191
- const directTasks = new Map<string, { controller: AbortController; settled: Promise<void>; handles: DirectHandle[]; tabs: DirectTabRecord[] }>();
193
+ 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 }>();
192
194
  let latestCtx: ExtensionContext | undefined;
193
195
  let sessionEpoch = 0;
194
196
  registerSubagentCommand(pi, {
195
197
  direct(ctx): DirectTask[] {
196
198
  const grouped = new Map<string, DirectTask & { tabs: Array<DirectTask["tabs"][number]> }>();
197
199
  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 ?? [] });
205
+ }
198
206
  if (entry.type !== "custom" || entry.customType !== DIRECT_TAB_TYPE || !entry.data) continue;
199
207
  const tab = entry.data as DirectTabRecord;
200
- const observed = directTasks.has(tab.taskId);
208
+ const local = directTasks.get(tab.taskId);
201
209
  const existing = grouped.get(tab.taskId);
202
210
  const record = { entryId: tab.entryId, name: tab.name, tabId: tab.tabId, paneId: tab.paneId, sessionFile: tab.sessionFile };
203
211
  if (existing) {
204
212
  if (!existing.tabs.some((item) => item.tabId === tab.tabId)) existing.tabs.push(record);
205
- } else grouped.set(tab.taskId, { id: tab.taskId, name: tab.name, status: observed ? "observed locally" : "recorded (not observed)", tabs: [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] });
206
214
  }
207
215
  return [...grouped.values()];
208
216
  },
217
+ async closeDirect(task, current) {
218
+ const local = directTasks.get(task.id);
219
+ const owned = () => current() && directTasks.get(task.id) === local;
220
+ if (!owned() || !local?.close || task.tabs.length !== local.tabs.length || task.tabs.some((tab, index) => {
221
+ const saved = local.tabs[index]!;
222
+ return tab.entryId !== saved.entryId || tab.name !== saved.name || tab.tabId !== saved.tabId
223
+ || tab.paneId !== saved.paneId || tab.sessionFile !== saved.sessionFile;
224
+ })) throw new Error("Direct task ownership changed; reopen /subagent. Recorded-only tasks cannot release admission.");
225
+ // Cancellation settles through the background finalizer; it must retain this
226
+ // command's authority across every awaited identity/termination lookup too.
227
+ local.closeScope = owned;
228
+ local.controller.abort();
229
+ await local.settled;
230
+ // Automatic settlement may already have proved termination and released it.
231
+ if (!current()) throw new Error("Direct task scope changed; reopen /subagent.");
232
+ 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.";
234
+ },
209
235
  isolated: isolatedSurface.inventory,
210
236
  recover: (cwd) => isolatedSurface.recover(cwd),
211
237
  async inspectInTab(root, id, ctx, current) {
@@ -359,6 +385,10 @@ export default function subagentExtension(pi: ExtensionAPI): void {
359
385
  const recorded = new Set(ctx.sessionManager.getBranch().flatMap((entry) =>
360
386
  entry.type === "custom" && entry.customType === DIRECT_TAB_TYPE && entry.data
361
387
  ? [(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
+ }
362
392
  for (const { tabs } of previous) for (const tab of tabs) {
363
393
  if (!recorded.has(tab.tabId)) {
364
394
  pi.appendEntry(DIRECT_TAB_TYPE, tab);
@@ -394,7 +424,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
394
424
  await Promise.allSettled(handles.map((handle) => handle.cancel()));
395
425
  }
396
426
  await Promise.allSettled(tasks.map(({ settled }) => settled));
397
- directTasks.clear();
427
+ // Unproved workers keep their local ownership and admission across a session switch.
398
428
  });
399
429
  // btw-style context refresh: model_select carries the new model on the event,
400
430
  // agent_settled delivers the freshest full context after each turn.
@@ -418,17 +448,19 @@ export default function subagentExtension(pi: ExtensionAPI): void {
418
448
  mode: ParsedWorkflow["mode"],
419
449
  entries: readonly WorkflowTransportEntry[],
420
450
  tabs: readonly DirectTabRecord[],
451
+ recovery?: string,
421
452
  ): void => {
422
453
  const stale = launchEpoch !== sessionEpoch;
423
454
  if (stale) return;
424
455
  const transport = formatWorkflowResult(mode, entries);
425
456
  const outcome: BackgroundWorkflowTransportDetails["outcome"] = transport.failed ? "failed" : "completed";
426
- const content = [transport.text, ...(tabs.length ? ["Recovery (also available via /subagent):", ...tabs.map(({ taskId, entryId, name, tabId, paneId, sessionFile }) =>
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 }) =>
427
458
  `- ${taskId} · ${entryId} · tab ${tabId} · pane ${paneId} · agent ${name} · session ${sessionFile}`)] : [])].join("\n");
428
459
  const details: BackgroundWorkflowTransportDetails & { tabs: readonly DirectTabRecord[] } = {
429
460
  ...transport.details,
430
461
  taskId,
431
462
  outcome,
463
+ ...(recovery ? { recovery: true as const } : {}),
432
464
  tabs: [...tabs],
433
465
  };
434
466
  try {
@@ -501,7 +533,21 @@ export default function subagentExtension(pi: ExtensionAPI): void {
501
533
  // Register before Herdr can create a tab: session switches must await this
502
534
  // launch, including its identity callback, before copying recovery records.
503
535
  const releaseAdmission = writes ? holdAdmission(toolCallId) : () => {};
504
- directTasks.set(taskId, { controller, settled, handles, tabs });
536
+ const local: NonNullable<ReturnType<typeof directTasks.get>> = { controller, settled, handles, tabs };
537
+ local.close = async (current) => {
538
+ const inScope = () => current() && (local.closeScope?.() ?? true);
539
+ try {
540
+ if (local.allocation) throw new Error(local.allocation.failure);
541
+ for (const tab of tabs) await herdr.stop(tab, inScope);
542
+ if (!inScope()) throw new Error("Direct task scope changed; reopen /subagent.");
543
+ releaseAdmission();
544
+ directTasks.delete(taskId);
545
+ } catch (error) {
546
+ local.recovery = boundedError(error).message;
547
+ throw error;
548
+ }
549
+ };
550
+ directTasks.set(taskId, local);
505
551
  const launch = async (entry: WorkflowEntry, activeSignal: AbortSignal) => {
506
552
  activeSignal.throwIfAborted();
507
553
  const role = loadRoles().find((candidate) => candidate.name === entry.delegation.role);
@@ -534,6 +580,10 @@ export default function subagentExtension(pi: ExtensionAPI): void {
534
580
  tabByEntry.set(entry.id, record);
535
581
  }, writes);
536
582
  } catch (error) {
583
+ if (error instanceof DirectAllocationError) {
584
+ local.allocation = { taskId, entryId: entry.id, ...error.allocation, failure: error.message };
585
+ pi.appendEntry(DIRECT_ALLOCATION_TYPE, local.allocation);
586
+ }
537
587
  // A failed start can still be in flight. Keep its private prompt for recovery.
538
588
  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 });
539
589
  }
@@ -558,9 +608,9 @@ export default function subagentExtension(pi: ExtensionAPI): void {
558
608
  } catch (error) {
559
609
  controller.abort();
560
610
  finishWidgetItem(entries[0]!.id, "failure");
561
- directTasks.delete(taskId);
611
+ if (!tabs.length && !local.allocation) { releaseAdmission(); directTasks.delete(taskId); }
612
+ else local.recovery = local.allocation?.failure ?? "Launch outcome uncertain; inspect the exact recorded tab before Close/cancel-and-release";
562
613
  resolveSettled();
563
- if (!tabs.length) releaseAdmission();
564
614
  throw boundedError(error);
565
615
  } finally { signal?.removeEventListener("abort", abortLaunch); }
566
616
  void (async () => {
@@ -608,14 +658,16 @@ export default function subagentExtension(pi: ExtensionAPI): void {
608
658
  status: "skipped",
609
659
  });
610
660
  }
611
- if ([...states.values()].every(({ status }) => status === "succeeded")) releaseAdmission();
612
- if (!controller.signal.aborted) reportDirect(launchEpoch, taskId, workflow.mode, [...states.values()], tabs);
613
- directTasks.delete(taskId);
661
+ if (writes || controller.signal.aborted) {
662
+ try { await local.close!(() => directTasks.get(taskId) === local); }
663
+ catch { /* Retain ownership and admission for an explicit guarded retry. */ }
664
+ } else directTasks.delete(taskId);
665
+ if (!controller.signal.aborted) reportDirect(launchEpoch, taskId, workflow.mode, [...states.values()], tabs, local.recovery);
614
666
  resolveSettled();
615
667
  }
616
668
  })();
617
669
  return {
618
- 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 exact completion. On failure or cancellation, inspect owned tabs before restarting Pi; changes are not rolled back." : "Concurrent changes may make reads stale."}`) }],
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."}`) }],
619
671
  details: { taskId, mode: workflow.mode, tabId: first.tabId, sessionFile: first.sessionFile,
620
672
  entries: entries.map(({ id, index, delegation }) => ({ id, index, name: delegation.name, role: delegation.role })) },
621
673
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "26.1.2",
3
+ "version": "26.1.3",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -24,7 +24,7 @@ After delegation acknowledges a handle or durable request, do other work or end
24
24
 
25
25
  # Direct requests
26
26
 
27
- Use one compact `role`/`name`/`task` packet, `tasks` for independent packets, or `chain` for dependent packets. Only answer-returning tasks (`kind: text`) are accepted; writes and commits are allowed within explicit task authorization, but `kind: changeset`, structured checks, and judgment require isolated mode. State scope, exclusions, commit policy, and external-action permission; preserve unrelated files and staged changes. Direct writes take effect immediately with no automatic validation, rollback, or isolated integration guarantees. Requests with potential writers run serially and block competing Pi writes until exact completion. Main may read or do work elsewhere while waiting, not mutate the reserved checkout. On failure/cancellation or an uncertain launched tab, inspect `/subagent` identities, stop remaining owned workers, preserve and inspect changes, then restart Pi before writing or retrying. A failed commit may already have happened; never replay it blindly. The tool returns a handle after launching Herdr tabs in the current workspace. Main receives the verified result as a follow-up after the workflow finishes, without interrupting an active turn. If observation stops, open `/subagent` to inspect exact recorded tabs and session files on this session branch; recorded does not prove still running.
27
+ Use one compact `role`/`name`/`task` packet, `tasks` for independent packets, or `chain` for dependent packets. Only answer-returning tasks (`kind: text`) are accepted; writes and commits are allowed within explicit task authorization, but `kind: changeset`, structured checks, and judgment require isolated mode. State scope, exclusions, commit policy, and external-action permission; preserve unrelated files and staged changes. Direct writes take effect immediately with no automatic validation, rollback, or isolated integration guarantees. Requests with potential writers run serially and block competing Pi writes until owned workers are proved stopped. Main may read or do work elsewhere while waiting, not mutate the reserved checkout. Task failure is not proof a worker stopped. The coordinator closes exact owned writer panes and releases admission only after pane absence and two empty private process-lease scans. If termination remains uncertain, inspect `/subagent` identities and choose **Close/cancel-and-release owned workers** for the exact locally owned task. Resolve the reported blocker, then retry that guarded action; it never replays the prompt or removes dirty files, commits, or sessions. Recorded-only tasks have no live release authority; restarting Pi is not termination proof. An unverified tab allocation retains its workspace, checkout, label, lease/session paths and any returned IDs for manual reconciliation; it cannot authorize Close, guessed identities or replayed creation. Close keeps its branch/session/epoch guards while an active task cancels and settles. Preserve and inspect changes before writing or retrying. A failed commit may already have happened; never replay it blindly. The tool returns a handle after launching Herdr tabs in the current workspace. Main receives the verified result as a follow-up after the workflow finishes, without interrupting an active turn. If observation stops, open `/subagent` to inspect exact recorded tabs and session files on this session branch; recorded does not prove still running.
28
28
 
29
29
  # Isolated requests
30
30