@henryqw/pi-subagent 18.0.0 → 22.0.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.
Files changed (47) hide show
  1. package/CONTEXT.md +76 -24
  2. package/README.md +116 -142
  3. package/dist/composition.d.ts +11 -0
  4. package/dist/composition.js +107 -0
  5. package/dist/direct-herdr.d.ts +17 -0
  6. package/dist/direct-herdr.js +219 -0
  7. package/dist/ephemeral.d.ts +2 -2
  8. package/dist/ephemeral.js +7 -7
  9. package/dist/git-runtime.d.ts +110 -0
  10. package/dist/git-runtime.js +472 -0
  11. package/dist/herdr-runtime.d.ts +137 -0
  12. package/dist/herdr-runtime.js +1228 -0
  13. package/dist/index.d.ts +11 -5
  14. package/dist/index.js +20 -16
  15. package/dist/integration-git.d.ts +59 -0
  16. package/dist/integration-git.js +484 -0
  17. package/dist/launch-runtime.d.ts +54 -0
  18. package/dist/launch-runtime.js +195 -0
  19. package/dist/process.d.ts +9 -0
  20. package/dist/process.js +11 -0
  21. package/dist/runner.d.ts +356 -0
  22. package/dist/runner.js +2710 -0
  23. package/dist/schema.d.ts +2234 -0
  24. package/dist/schema.js +1236 -0
  25. package/dist/store.d.ts +50 -0
  26. package/dist/store.js +288 -0
  27. package/dist/working-evidence.d.ts +25 -0
  28. package/dist/working-evidence.js +261 -0
  29. package/docs/adr/001-composable-ephemeral-execution.md +3 -3
  30. package/docs/delegation-routing.html +89 -0
  31. package/docs/delegation-routing.svg +78 -0
  32. package/docs/orchestration-architecture.html +238 -0
  33. package/docs/orchestration-architecture.svg +189 -0
  34. package/docs/orchestration.md +122 -331
  35. package/example.png +0 -0
  36. package/examples/roles/implementer.md +2 -3
  37. package/extensions/admission.ts +66 -0
  38. package/extensions/config.ts +51 -37
  39. package/extensions/isolated.ts +638 -0
  40. package/extensions/result-transport.ts +13 -103
  41. package/extensions/role-tools.ts +11 -11
  42. package/extensions/subagent-command.ts +167 -0
  43. package/extensions/subagent.ts +264 -540
  44. package/extensions/task-name.ts +1 -2
  45. package/extensions/workflow.ts +60 -96
  46. package/package.json +19 -11
  47. package/skills/pi-subagent/SKILL.md +35 -0
@@ -1,10 +1,4 @@
1
- import type { Usage } from "@earendil-works/pi-ai";
2
- import {
3
- addUsage,
4
- capEphemeralSubagentOutput,
5
- type EphemeralSubagentResult,
6
- type WorktreePayload,
7
- } from "@henryqw/pi-subagent";
1
+ import { capEphemeralSubagentOutput } from "@henryqw/pi-subagent";
8
2
  import type { WorkflowEntry, WorkflowMode } from "./workflow.ts";
9
3
 
10
4
  const EVIDENCE_PREVIEW_CODE_POINTS = 256;
@@ -18,17 +12,11 @@ type TransportEntryBase = {
18
12
  role: WorkflowEntry["delegation"]["role"];
19
13
  model?: string;
20
14
  thinkingLevel?: string;
21
- worktreePayload?: WorktreePayload;
22
- usage?: Usage;
23
15
  };
24
16
 
25
17
  export type WorkflowTransportEntry =
26
18
  | TransportEntryBase & { status: "pending" | "skipped"; assistantOutput?: never; failure?: never }
27
- | TransportEntryBase & {
28
- status: "running" | "succeeded";
29
- assistantOutput: EphemeralSubagentResult["output"];
30
- failure?: never;
31
- }
19
+ | TransportEntryBase & { status: "running" | "succeeded"; assistantOutput: string; failure?: never }
32
20
  | TransportEntryBase & { status: "failed" | "rejected"; assistantOutput?: never; failure: string };
33
21
 
34
22
  export type WorkflowTransportEntryDetails = {
@@ -40,7 +28,6 @@ export type WorkflowTransportEntryDetails = {
40
28
  summary?: string;
41
29
  model?: string;
42
30
  thinkingLevel?: string;
43
- worktree?: WorktreePayload;
44
31
  };
45
32
 
46
33
  export type WorkflowTransportDetails = {
@@ -52,17 +39,14 @@ export type BackgroundWorkflowTransportDetails = WorkflowTransportDetails & {
52
39
  taskId: string;
53
40
  outcome: "completed" | "failed" | "aborted";
54
41
  recovery?: true;
55
- usage?: Usage;
56
42
  };
57
43
 
58
44
  export type WorkflowTransport = {
59
45
  text: string;
60
46
  details: WorkflowTransportDetails;
61
- usage?: Usage;
62
47
  failed: boolean;
63
48
  };
64
49
 
65
- type TransportKind = "result" | "update" | "background" | "abort";
66
50
  type Evidence = { heading: string; preview: string; remainder: string };
67
51
 
68
52
  function compareEntries(left: WorkflowTransportEntry, right: WorkflowTransportEntry): number {
@@ -80,25 +64,18 @@ function workflowTitle(mode: WorkflowMode): string {
80
64
  return "Delegation chain";
81
65
  }
82
66
 
83
- function statusCounts(entries: readonly WorkflowTransportEntry[], kind: TransportKind): string[] {
67
+ function statusCounts(entries: readonly WorkflowTransportEntry[]): string[] {
84
68
  const count = (statuses: WorkflowTransportStatus[]) => entries.filter(({ status }) => statuses.includes(status)).length;
85
- const labels = kind === "update"
86
- ? [["running"], ["succeeded"], ["failed", "rejected"], ["pending"], ["skipped"]] as const
87
- : [["failed", "rejected"], ["succeeded"], ["skipped"]] as const;
88
- const words = kind === "update" ? ["running", "completed", "failed", "queued", "skipped"] : ["failed", "completed", "skipped"];
89
- return labels.flatMap((statuses, index) => {
69
+ return ([
70
+ [["failed", "rejected"], "failed"],
71
+ [["succeeded"], "completed"],
72
+ [["skipped"], "skipped"],
73
+ ] as const).flatMap(([statuses, word]) => {
90
74
  const total = count([...statuses]);
91
- return total ? [`${total} ${words[index]}`] : [];
75
+ return total ? [`${total} ${word}`] : [];
92
76
  });
93
77
  }
94
78
 
95
- function heading(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[], kind: TransportKind, failed: boolean): string {
96
- const title = kind === "background" ? `Background ${workflowTitle(mode).toLowerCase()}` : workflowTitle(mode);
97
- if (kind === "update") return [title, ...statusCounts(entries, kind)].join(" · ");
98
- const outcome = kind === "abort" ? "stopped" : failed ? "failed" : "completed";
99
- return [`${title} ${outcome}`, ...statusCounts(entries, kind)].join(" · ");
100
- }
101
-
102
79
  export function displaySummary(text: string): string {
103
80
  const line = text.split(/\r?\n/).find((candidate) => candidate.trim()) ?? "";
104
81
  const normalized = line.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().replace(/^[-*+]\s+/, "").split(/\s+/).join(" ");
@@ -136,37 +113,24 @@ function evidenceFor(entry: WorkflowTransportEntry, position: number, total: num
136
113
  };
137
114
  }
138
115
 
139
- function formatWorkflowTransport(
140
- mode: WorkflowMode,
141
- entries: readonly WorkflowTransportEntry[],
142
- kind: TransportKind,
143
- ): WorkflowTransport {
116
+ export function formatWorkflowResult(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[]): WorkflowTransport {
144
117
  const ordered = [...entries].sort(compareEntries);
145
- if (kind !== "update" && ordered.some(({ status }) => status === "pending" || status === "running")) {
118
+ if (ordered.some(({ status }) => status === "pending" || status === "running")) {
146
119
  throw new TypeError("Final workflow transport requires terminal entry states.");
147
120
  }
148
121
  const failed = ordered.some(({ status }) => status === "failed" || status === "rejected");
149
122
  const positioned = ordered.map((entry, index) => ({ entry, position: index + 1 }));
150
- const recoveries = positioned.flatMap(({ entry, position }) => {
151
- const worktree = entry.worktreePayload;
152
- return worktree === undefined || worktree.outcome === "pruned" ? [] : [{ entry, position, worktree }];
153
- });
154
- const evidence = kind === "update" ? [] : positioned.flatMap(({ entry, position }) => {
123
+ const evidence = positioned.flatMap(({ entry, position }) => {
155
124
  const value = evidenceFor(entry, position, ordered.length);
156
125
  return value ? [value] : [];
157
126
  });
158
127
  const lines = [
159
- heading(mode, ordered, kind, failed),
128
+ [`${workflowTitle(mode)} ${failed ? "failed" : "completed"}`, ...statusCounts(ordered)].join(" · "),
160
129
  ...positioned.map(({ entry, position }) => {
161
130
  const { glyph, fallback } = presentWorkflowEntryStatus(entry.status);
162
131
  const summary = displaySummary(sourceFor(entry) ?? "") || fallback;
163
132
  return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role} — ${summary}`;
164
133
  }),
165
- ...(recoveries.length ? [
166
- "Recovery:",
167
- ...recoveries.map(({ entry, position, worktree }) =>
168
- `- [${position}/${ordered.length}] ${entry.name} · worktree ${JSON.stringify(worktree.path)} · branch ${JSON.stringify(worktree.branch)}`),
169
- ] : []),
170
134
  ...(evidence.length ? [
171
135
  "Results:",
172
136
  ...evidence.flatMap(({ heading, preview }) => [heading, preview]),
@@ -176,8 +140,6 @@ function formatWorkflowTransport(
176
140
  ] : []),
177
141
  ] : []),
178
142
  ];
179
- let usage: Usage | undefined;
180
- for (const entry of ordered) if (entry.usage) usage = addUsage(usage, entry.usage);
181
143
  return {
182
144
  text: capEphemeralSubagentOutput(lines.join("\n")),
183
145
  details: {
@@ -193,61 +155,9 @@ function formatWorkflowTransport(
193
155
  ...(source === undefined ? {} : { summary: displaySummary(source) }),
194
156
  ...(entry.model === undefined ? {} : { model: entry.model }),
195
157
  ...(entry.thinkingLevel === undefined ? {} : { thinkingLevel: entry.thinkingLevel }),
196
- ...(entry.worktreePayload === undefined ? {} : { worktree: { ...entry.worktreePayload } }),
197
158
  };
198
159
  }),
199
160
  },
200
- ...(usage === undefined ? {} : { usage }),
201
161
  failed,
202
162
  };
203
163
  }
204
-
205
- export function formatWorkflowResult(
206
- mode: WorkflowMode,
207
- entries: readonly WorkflowTransportEntry[],
208
- ): WorkflowTransport {
209
- return formatWorkflowTransport(mode, entries, "result");
210
- }
211
-
212
- export function formatWorkflowUpdate(
213
- mode: WorkflowMode,
214
- entries: readonly WorkflowTransportEntry[],
215
- ): WorkflowTransport {
216
- return formatWorkflowTransport(mode, entries, "update");
217
- }
218
-
219
- export function formatBackgroundWorkflowResult(
220
- mode: WorkflowMode,
221
- entries: readonly WorkflowTransportEntry[],
222
- ): WorkflowTransport {
223
- return formatWorkflowTransport(mode, entries, "background");
224
- }
225
-
226
- export class WorkflowFailureError extends Error {
227
- override name = "WorkflowFailureError";
228
- readonly details: WorkflowTransportDetails;
229
- readonly usage?: Usage;
230
- readonly failed = true;
231
-
232
- constructor(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[]) {
233
- const transport = formatWorkflowResult(mode, entries);
234
- if (!transport.failed) throw new TypeError("WorkflowFailureError requires a failed or rejected entry.");
235
- super(transport.text);
236
- this.details = transport.details;
237
- this.usage = transport.usage;
238
- }
239
- }
240
-
241
- export class WorkflowAbortedError extends Error {
242
- override name = "AbortError";
243
- readonly details: WorkflowTransportDetails;
244
- readonly usage?: Usage;
245
- readonly failed = true;
246
-
247
- constructor(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[], cause: unknown) {
248
- const transport = formatWorkflowTransport(mode, entries, "abort");
249
- super(transport.text, { cause });
250
- this.details = transport.details;
251
- this.usage = transport.usage;
252
- }
253
- }
@@ -4,7 +4,7 @@ import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-codin
4
4
  import {
5
5
  CHILD_EXCLUDED_TOOL_NAMES,
6
6
  EXECUTION_BUDGET_ENV,
7
- PI_ORCHESTRATOR_PROCESS_LEASE,
7
+ PI_SUBAGENT_PROCESS_LEASE,
8
8
  ROLE_TOOL_POLICY_FLAG,
9
9
  type EphemeralSubagentExecutionBudget,
10
10
  } from "@henryqw/pi-subagent";
@@ -12,7 +12,7 @@ import {
12
12
  const childExcludedTools: ReadonlySet<string> = new Set(CHILD_EXCLUDED_TOOL_NAMES);
13
13
  const WARNING_RATIO = 0.8;
14
14
  const WARNING_MESSAGE_TYPE = "pi-subagent-execution-budget";
15
- const PROCESS_LEASE_ERROR = `${PI_ORCHESTRATOR_PROCESS_LEASE} must be a nonempty NUL/newline-free absolute path naming a regular non-symlink file owned by the current uid with mode 0600.`;
15
+ const PROCESS_LEASE_ERROR = `${PI_SUBAGENT_PROCESS_LEASE} must be a nonempty NUL/newline-free absolute path naming a regular non-symlink file owned by the current uid with mode 0600.`;
16
16
  const FINAL_HANDOFF_MESSAGE = {
17
17
  customType: "pi-subagent-final-handoff",
18
18
  content: "**Final handoff required.** Tools are disabled. If your assigned task or Role requires exact output, reply only with that output instead; it takes precedence over this decision packet. Otherwise, reply only with this decision packet:\n\n**Status:** completed | blocked | incomplete\n**Outcome:** one sentence describing what is now true\n**Evidence:** up to three concrete findings, changes, or checks; include an attempted approach only when it prevents Main from repeating failed work\n**Blocker:** none or the exact blocker\n**Risk:** none or one material risk\n**Suggested next:** none or one concrete action",
@@ -64,14 +64,14 @@ function executionBudget(value: string | undefined): EphemeralSubagentExecutionB
64
64
  if (Object.keys(budget).some((key) => !["maxTurns", "maxMs", "startedAt", "maxTokens"].includes(key))
65
65
  || !("maxTurns" in budget) || !("maxMs" in budget) || !("startedAt" in budget)
66
66
  || !Number.isSafeInteger(budget.maxTurns) || (budget.maxTurns as number) < 1
67
- || typeof budget.maxMs !== "number" || !Number.isFinite(budget.maxMs) || budget.maxMs <= 0
67
+ || (budget.maxMs !== null && (typeof budget.maxMs !== "number" || !Number.isFinite(budget.maxMs) || budget.maxMs <= 0))
68
68
  || !Number.isSafeInteger(budget.startedAt) || (budget.startedAt as number) < 0
69
69
  || !maxTokensValid) {
70
70
  throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
71
71
  }
72
72
  return {
73
73
  maxTurns: budget.maxTurns as number,
74
- maxMs: budget.maxMs,
74
+ maxMs: budget.maxMs as number | null,
75
75
  startedAt: budget.startedAt as number,
76
76
  ...(budget.maxTokens === undefined ? {} : { maxTokens: budget.maxTokens as number }),
77
77
  };
@@ -94,11 +94,11 @@ function messageTokens(message: unknown): number | undefined {
94
94
  }
95
95
 
96
96
  function joinBudgetParts(parts: string[]): string {
97
- return `${parts.slice(0, -1).join(", ")}${parts.length > 2 ? "," : ""} and ${parts.at(-1)}`;
97
+ return parts.length === 1 ? parts[0]! : `${parts.slice(0, -1).join(", ")}${parts.length > 2 ? "," : ""} and ${parts.at(-1)}`;
98
98
  }
99
99
 
100
100
  export default function roleTools(pi: ExtensionAPI): void {
101
- const processLease = process.env[PI_ORCHESTRATOR_PROCESS_LEASE];
101
+ const processLease = process.env[PI_SUBAGENT_PROCESS_LEASE];
102
102
  if (processLease !== undefined) validateProcessLease(processLease);
103
103
  pi.registerFlag(ROLE_TOOL_POLICY_FLAG, {
104
104
  description: "Internal Pi Subagent Role tool policy",
@@ -133,7 +133,7 @@ export default function roleTools(pi: ExtensionAPI): void {
133
133
  while : 2>/dev/null <&"$__pi_subagent_process_lease_fd" || : 2>/dev/null >&"$__pi_subagent_process_lease_fd"; do
134
134
  __pi_subagent_process_lease_fd=$((__pi_subagent_process_lease_fd + 1))
135
135
  done
136
- eval 'exec '"$__pi_subagent_process_lease_fd"'>>"$${PI_ORCHESTRATOR_PROCESS_LEASE}"' || exit $?
136
+ eval 'exec '"$__pi_subagent_process_lease_fd"'>>"$${PI_SUBAGENT_PROCESS_LEASE}"' || exit $?
137
137
  unset __pi_subagent_process_lease_fd
138
138
  ${event.input.command}`;
139
139
  });
@@ -166,18 +166,18 @@ ${event.input.command}`;
166
166
  const elapsedMs = Math.max(0, Date.now() - budget.startedAt);
167
167
  const turnWarningDue = !turnWarningSent && completedTurns >= warningTurn;
168
168
  const tokenWarningDue = warningTokens !== undefined && !tokenWarningSent && completedTokens >= warningTokens;
169
- const runtimeWarningDue = !runtimeWarningSent && elapsedMs >= budget.maxMs * WARNING_RATIO;
169
+ const runtimeWarningDue = budget.maxMs !== null && !runtimeWarningSent && elapsedMs >= budget.maxMs * WARNING_RATIO;
170
170
  if (!turnWarningDue && !tokenWarningDue && !runtimeWarningDue) return;
171
171
  if (turnWarningDue) turnWarningSent = true;
172
172
  if (tokenWarningDue) tokenWarningSent = true;
173
173
  if (runtimeWarningDue) runtimeWarningSent = true;
174
174
  const remainingTurns = Math.max(0, budget.maxTurns - completedTurns);
175
- const remainingMinutes = Math.max(0, Math.ceil((budget.maxMs - elapsedMs) / 60_000));
176
- const maxMinutes = budget.maxMs / 60_000;
175
+ const runtimeRemaining = budget.maxMs === null ? []
176
+ : [`approximately ${Math.max(0, Math.ceil((budget.maxMs - elapsedMs) / 60_000))} of ${budget.maxMs / 60_000} minutes`];
177
177
  const parts = [
178
178
  `${remainingTurns} of ${budget.maxTurns} turns`,
179
179
  ...(budget.maxTokens === undefined ? [] : [`${Math.max(0, budget.maxTokens - completedTokens)} of ${budget.maxTokens} tokens`]),
180
- `approximately ${remainingMinutes} of ${maxMinutes} minutes`,
180
+ ...runtimeRemaining,
181
181
  ];
182
182
  pi.sendMessage({
183
183
  customType: WARNING_MESSAGE_TYPE,
@@ -0,0 +1,167 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Read-only branch records; the adapter groups all tabs of a direct workflow. */
4
+ export interface DirectTask {
5
+ id: string;
6
+ name: string;
7
+ status: string;
8
+ tabs: readonly { entryId: string; name: string; tabId: string; paneId: string; sessionFile: string }[];
9
+ }
10
+
11
+ export interface IsolatedTask {
12
+ id: string;
13
+ name: string;
14
+ status: string;
15
+ kind: string;
16
+ }
17
+ export interface IsolatedRequest {
18
+ id: string;
19
+ name: string;
20
+ status: string;
21
+ tasks: readonly IsolatedTask[];
22
+ }
23
+ export interface IsolatedInventory {
24
+ root: string;
25
+ requests: readonly IsolatedRequest[];
26
+ invalidIds: readonly string[];
27
+ }
28
+
29
+ /** Mutation adapters MUST check current() synchronously alongside their live ownership
30
+ * validation and queue operation. They must not await between those operations. */
31
+ export interface SubagentCommandAdapter {
32
+ direct(ctx: ExtensionContext): readonly DirectTask[];
33
+ isolated(cwd: string): Promise<IsolatedInventory>;
34
+ inspect(root: string, requestId: string): Promise<readonly string[]>;
35
+ canFollowup(root: string, requestId: string, taskId: string): boolean;
36
+ enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
37
+ drain(root: string, requestId: string, taskId: string, current: () => boolean): readonly string[];
38
+ /** Session start/shutdown epoch; prevents same-session reload after an awaited dialog. */
39
+ epoch(): number;
40
+ }
41
+
42
+ const clean = (text: string, max = 110, multiline = false): string => [...text.replace(multiline ? /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g : /[\u0000-\u001f\u007f-\u009f]/g, " ")].slice(0, max).join("");
43
+ const errorText = (error: unknown): string => clean(error instanceof Error ? error.message : String(error), 500);
44
+ const completed = (status: string) => ["completed", "success", "failed", "failure", "aborted", "integrated"].includes(status);
45
+ const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
46
+
47
+ type Pick<T> = { label: string; value: T };
48
+ async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Pick<T>[]): Promise<T | undefined> {
49
+ // Numeric suffixes are UI-only. Never parse display text into an identity.
50
+ const labels = items.map((item, index) => `${clean(item.label, 145)} [${index + 1}]`);
51
+ const selected = await ctx.ui.select(title, labels);
52
+ const index = labels.indexOf(selected ?? "");
53
+ return index < 0 ? undefined : items[index]!.value;
54
+ }
55
+
56
+ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentCommandAdapter): void {
57
+ pi.registerCommand("subagent", {
58
+ description: "Inspect direct and isolated work; send or edit pending isolated instructions",
59
+ handler: async (args, ctx) => {
60
+ if (args.trim()) throw new Error("Usage: /subagent (no arguments)");
61
+ if (!ctx.hasUI) throw new Error("/subagent requires interactive UI (TUI or RPC); use the subagent agent tools in non-interactive mode.");
62
+ const manager = ctx.sessionManager;
63
+ const session = manager.getSessionId();
64
+ const file = manager.getSessionFile();
65
+ const branch = manager.getBranch().map((entry) => entry.id);
66
+ // A branch may grow during a dialog, but navigating to a descendant that
67
+ // already existed when it opened must not retarget its pending actions.
68
+ const existing = new Set(manager.getEntries().map((entry) => entry.id));
69
+ const epoch = adapter.epoch();
70
+ const current = () => {
71
+ if (adapter.epoch() !== epoch || manager.getSessionId() !== session || manager.getSessionFile() !== file) return false;
72
+ const now = manager.getBranch();
73
+ return now.length >= branch.length && branch.every((id, index) => now[index]?.id === id)
74
+ && now.slice(branch.length).every((entry) => !existing.has(entry.id));
75
+ };
76
+ const valid = () => {
77
+ if (current()) return true;
78
+ ctx.ui.notify("Session or branch changed; reopen /subagent in the current context.", "warning");
79
+ return false;
80
+ };
81
+ let history = false;
82
+ for (;;) {
83
+ if (!valid()) return;
84
+ // Direct recovery is independent of Git and isolated config/inventory failures.
85
+ const direct = adapter.direct(ctx);
86
+ let inventory: IsolatedInventory | undefined;
87
+ let unavailable: string | undefined;
88
+ try { inventory = await adapter.isolated(ctx.cwd); }
89
+ catch (error) { unavailable = errorText(error); }
90
+ if (!valid()) return;
91
+ if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
92
+ 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");
93
+ type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "close" };
94
+ const selections: Pick<Selection>[] = [
95
+ ...direct.filter((task) => history || !completed(task.status)).map((task) => ({ label: `Direct · ${task.name} · ${task.status} · ${task.id}`, value: { kind: "direct" as const, task } })),
96
+ ...(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 } })),
97
+ { label: "Refresh", value: { kind: "refresh" as const } },
98
+ { label: history ? "Active work" : "Completed / history (including retained work)", value: { kind: "history" as const } },
99
+ { label: "Close", value: { kind: "close" as const } },
100
+ ];
101
+ const taskChoices = selections.splice(0, selections.length - 3);
102
+ const rank = ({ value }: Pick<Selection>) => value.kind === "direct" ? priority(value.task.status)
103
+ : value.kind === "isolated" ? priority(value.request.status) : 3;
104
+ taskChoices.sort((a, b) => rank(a) - rank(b));
105
+ selections.unshift(...taskChoices);
106
+ if (!unavailable && !direct.length && !inventory?.requests.length && !inventory?.invalidIds.length) ctx.ui.notify("No direct tasks recorded on this session branch or isolated requests in this checkout. Ask Main to start delegation.", "info");
107
+ const selected = await choose(ctx, history ? "Subagent · completed / history" : "Subagent · current work", selections);
108
+ if (!valid() || !selected || selected.kind === "close") return;
109
+ if (selected.kind === "refresh") continue;
110
+ if (selected.kind === "history") { history = !history; continue; }
111
+ if (selected.kind === "direct") {
112
+ ctx.ui.notify(`Direct workflow ${clean(selected.task.name)} (${JSON.stringify(selected.task.id)}) · ${clean(selected.task.status)}`, "info");
113
+ for (const tab of selected.task.tabs) {
114
+ 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");
115
+ }
116
+ continue;
117
+ }
118
+ const { root, request } = selected;
119
+ for (;;) {
120
+ if (!valid()) return;
121
+ type Action = "inspect" | "send" | "edit" | "back";
122
+ const eligible = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
123
+ const actions: Pick<Action>[] = [
124
+ { label: "Inspect status and recovery", value: "inspect" },
125
+ ...(eligible.length ? [{ label: "Send follow-up instructions", value: "send" as const }, { label: "Edit queued instructions (withdraw ALL pending; Cancel leaves withdrawn)", value: "edit" as const }] : []),
126
+ { label: "Back / refresh", value: "back" },
127
+ ];
128
+ const action = await choose(ctx, `Isolated · ${clean(request.name)} · ${request.id}`, actions);
129
+ if (!valid() || !action) return;
130
+ if (action === "back") break;
131
+ if (action === "inspect") {
132
+ try {
133
+ const notices = await adapter.inspect(root, request.id);
134
+ if (valid()) for (const notice of notices) ctx.ui.notify(notice, "info");
135
+ }
136
+ catch (error) { if (valid()) ctx.ui.notify(`Inspection failed: ${errorText(error)}`, "error"); }
137
+ continue;
138
+ }
139
+ const live = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
140
+ if (!live.length) { ctx.ui.notify("No locally active unsealed changeset remains. Refresh to see current status.", "warning"); continue; }
141
+ const task = await choose(ctx, `Select task for ${action === "edit" ? "withdrawal and edit" : "follow-up"}`, live.map((item) => ({ label: `${item.name} · ${item.status} · ${item.id}`, value: item })));
142
+ if (!valid() || !task) return;
143
+ let prefill = "";
144
+ if (action === "edit") {
145
+ try {
146
+ const withdrawn = adapter.drain(root, request.id, task.id, current);
147
+ if (!withdrawn.length) { ctx.ui.notify("No pending instructions remain for this task; already-claimed instructions cannot be recalled.", "info"); continue; }
148
+ prefill = withdrawn.join("\n\n");
149
+ } catch (error) { if (valid()) ctx.ui.notify(`Cannot withdraw: ${errorText(error)}`, "error"); continue; }
150
+ }
151
+ for (;;) {
152
+ const text = await ctx.ui.editor(action === "edit" ? "Edit withdrawn instructions · submit ONE replacement; Cancel leaves originals withdrawn" : "Send follow-up instructions", prefill);
153
+ if (!valid() || text === undefined) return;
154
+ try {
155
+ ctx.ui.notify(adapter.enqueue(root, request.id, task.id, text, current), "info");
156
+ break;
157
+ } catch (error) {
158
+ if (!valid()) return;
159
+ ctx.ui.notify(`Not queued: ${errorText(error)}. Submitted text will reopen in this editor for editing or copying; Cancel does not queue it.`, "error");
160
+ prefill = text;
161
+ }
162
+ }
163
+ }
164
+ }
165
+ },
166
+ });
167
+ }