@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,101 +1,35 @@
1
- import { basename } from "node:path";
2
- import type { Usage } from "@earendil-works/pi-ai";
3
- import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
3
  import { type Component, Text, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
5
- import {
6
- availableTaskModels,
7
- loadTaskModelsConfig,
8
- modelReference,
9
- registerModelTask,
10
- resolveAvailableModel,
11
- type ResolvedTaskRoute,
12
- taskThinkingLevels,
13
- } from "@henryqw/pi-task-models";
14
- import {
15
- capEphemeralSubagentOutput as capOutput,
16
- createChildWorktree,
17
- createEphemeralSubagentExecutor,
18
- DELEGATE_TASK,
19
- DEFAULT_MAX_TURNS,
20
- EphemeralSubagentError,
21
- finalizeChildWorktree,
22
- finalizeRoleLaunch,
23
- formatDuration,
24
- loadRoles,
25
- prepareRoleLaunch,
26
- WorktreeSetupError,
27
- worktreeContextNote,
28
- type EphemeralSubagentActivityEvent,
29
- type EphemeralSubagentResult,
30
- type EphemeralSubagentTimeout,
31
- type Role,
32
- type WorktreeInfo,
33
- type WorktreePayload,
34
- } from "@henryqw/pi-subagent";
35
- import { DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
4
+ import { availableTaskModels, loadTaskModelsConfig, modelReference, registerModelTask, resolveAvailableModel, type ResolvedTaskRoute, taskThinkingLevels } from "@henryqw/pi-task-models";
5
+ import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, DELEGATE_TASK, formatDuration, loadRoles, prepareRoleLaunch, ROLE_TOOL_POLICY_FLAG, type EphemeralSubagentTimeout, type Role } from "@henryqw/pi-subagent";
6
+ import { DEFAULT_EXECUTION_POLICY, DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
7
+ import { createCheckoutAdmission, roleCanWrite } from "./admission.ts";
8
+ import { registerIsolatedExtension } from "./isolated.ts";
9
+ import { registerSubagentCommand, type DirectTask } from "./subagent-command.ts";
36
10
  import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
37
- import {
38
- formatBackgroundWorkflowResult,
39
- formatWorkflowResult,
40
- formatWorkflowUpdate,
41
- presentWorkflowEntryStatus,
42
- WorkflowAbortedError,
43
- WorkflowFailureError,
44
- type BackgroundWorkflowTransportDetails,
45
- type WorkflowTransportEntry,
46
- } from "./result-transport.ts";
47
- import {
48
- identifyWorkflowEntries,
49
- parseWorkflow,
50
- runForegroundWorkflow,
51
- WorkflowSchema,
52
- type Delegation,
53
- type ParsedWorkflow,
54
- type WorkflowEntry,
55
- } from "./workflow.ts";
11
+ import { formatWorkflowResult, presentWorkflowEntryStatus, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
12
+ import { DelegateTaskSchema, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, type Delegation, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
13
+ import { createDirectHerdr, type DirectHandle, type DirectTab } from "../src/direct-herdr.ts";
14
+ import { materializeTransientLaunch } from "../src/launch-runtime.ts";
56
15
  const WIDGET_KEY = "subagent-status";
57
16
  const WIDGET_INTERVAL_MS = 80;
58
17
  const MAX_WIDGET_ITEMS = 8;
59
18
  const MAX_WIDGET_LINES = 6;
60
19
  const MAX_WIDGET_GROUP_ROWS = 3;
61
- export const MAX_WIDGET_ACTIVE_TOOLS = 8;
62
- const DEFAULT_TIMEOUT_POLICY = {
63
- idleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
64
- maxMs: DEFAULT_TIMEOUT_CONFIG.maxMinutes * 60_000,
65
- };
66
20
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
67
21
 
68
22
  type TimeoutPolicy = EphemeralSubagentTimeout;
69
-
70
- /** Merge validated config-file timeout fields over defaults; absent keys keep defaults. */
71
- export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined): TimeoutPolicy {
72
- return {
73
- idleMs: partial?.idleMinutes === undefined ? DEFAULT_TIMEOUT_POLICY.idleMs : partial.idleMinutes * 60_000,
74
- maxMs: partial?.maxMinutes === undefined ? DEFAULT_TIMEOUT_POLICY.maxMs : partial.maxMinutes * 60_000,
75
- };
76
- }
77
23
  type WidgetStatus = "working" | "success" | "failure" | "aborted";
78
- type WidgetActiveTool = {
79
- toolName: string;
80
- path?: string;
81
- startedAt: number;
82
- order: number;
83
- };
84
24
  type WidgetItem = {
85
25
  role: string;
86
26
  model: string;
87
27
  thinkingLevel: string;
88
28
  taskId: string;
89
29
  name: string;
90
- tokens: number;
91
30
  startedAt: number;
92
31
  status: WidgetStatus;
93
32
  finishedAt?: number;
94
- completedAssistantTurns: number;
95
- startedToolCount: number;
96
- activeTools: Map<string, WidgetActiveTool>;
97
- activeToolId?: string;
98
- activityOrder: number;
99
33
  };
100
34
 
101
35
  function roleBadge(role: string): string {
@@ -103,13 +37,6 @@ function roleBadge(role: string): string {
103
37
  return `[${Array.from(initial)[0]!}]`;
104
38
  }
105
39
 
106
- function formatTokens(tokens: number): string {
107
- if (tokens < 1_000) return String(tokens);
108
- if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
109
- if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k`;
110
- return `${(tokens / 1_000_000).toFixed(1)}M`;
111
- }
112
-
113
40
  function statusGlyph(status: WidgetStatus, spinnerIndex: number, theme: Theme): string {
114
41
  switch (status) {
115
42
  case "working": return theme.fg("accent", SPINNER_FRAMES[spinnerIndex % SPINNER_FRAMES.length]!);
@@ -128,31 +55,15 @@ function statusLabel(status: WidgetStatus): string {
128
55
  }
129
56
  }
130
57
 
131
- function activityLabel(item: WidgetItem, now: number): string {
58
+ function activityLabel(item: WidgetItem): string {
132
59
  if (item.status === "success") return "Done";
133
60
  if (item.status === "failure") return "Failed";
134
61
  if (item.status === "aborted") return "Stopped";
135
- const activeTool = item.activeToolId === undefined ? undefined : item.activeTools.get(item.activeToolId);
136
- if (!activeTool) return "thinking…";
137
- return [
138
- activeTool.toolName,
139
- formatDuration(now - activeTool.startedAt),
140
- ...(activeTool.path === undefined ? [] : [activeTool.path]),
141
- ].join(" · ");
62
+ return "working…";
142
63
  }
143
64
 
144
65
  function activityMetrics(item: WidgetItem, now: number): string {
145
- return [
146
- ...(item.completedAssistantTurns === 0
147
- ? []
148
- : [`${item.completedAssistantTurns} turn${item.completedAssistantTurns === 1 ? "" : "s"}`]),
149
- ...(item.startedToolCount === 0
150
- ? []
151
- : [`${item.startedToolCount} tool${item.startedToolCount === 1 ? "" : "s"}`]),
152
- `${item.model}·${item.thinkingLevel}`,
153
- `${formatTokens(item.tokens)} tok`,
154
- formatDuration((item.finishedAt ?? now) - item.startedAt),
155
- ].join(" · ");
66
+ return `${item.model}·${item.thinkingLevel} · ${formatDuration((item.finishedAt ?? now) - item.startedAt)}`;
156
67
  }
157
68
 
158
69
  function renderWidgetRows(
@@ -186,7 +97,7 @@ function renderWidgetRows(
186
97
  for (const item of group.items.slice(0, childCount)) {
187
98
  visible.add(item);
188
99
  lines.push(truncateToWidth(
189
- ` ${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} ${theme.fg("text", activityLabel(item, now))} · ${theme.fg("muted", activityMetrics(item, now))}`,
100
+ ` ${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} ${theme.fg("text", activityLabel(item))} · ${theme.fg("muted", activityMetrics(item, now))}`,
190
101
  width,
191
102
  ));
192
103
  }
@@ -217,22 +128,15 @@ function replaceRouteModel(ctx: ExtensionContext, reference: string, route: Reso
217
128
  return { model, thinkingLevel: route.thinkingLevel };
218
129
  }
219
130
 
220
- const BACKGROUND_RESULT_TYPE = "subagent-background-result";
131
+ const DIRECT_RESULT_TYPE = "subagent-direct-result";
132
+ const DIRECT_TAB_TYPE = "subagent-direct-tab";
133
+ type DirectTabRecord = DirectTab & { taskId: string; entryId: string };
221
134
 
222
135
  function boundedError(error: unknown): Error {
223
136
  const message = capOutput(error instanceof Error ? error.message : String(error));
224
137
  return error instanceof Error && error.message === message ? error : new Error(message, { cause: error });
225
138
  }
226
139
 
227
- function failedToolPatch(error: WorkflowFailureError | WorkflowAbortedError) {
228
- return {
229
- content: [{ type: "text" as const, text: error.message }],
230
- details: error.details,
231
- isError: true as const,
232
- ...(error.usage === undefined ? {} : { usage: error.usage }),
233
- };
234
- }
235
-
236
140
  const roleSummary = (): string => {
237
141
  try {
238
142
  return loadRoles().map((role) => `${role.name}: ${role.description}${role.modelClass === undefined ? "" : ` (modelClass: ${role.modelClass})`}`).join("; ");
@@ -245,15 +149,16 @@ export default function subagentExtension(
245
149
  pi: ExtensionAPI,
246
150
  overrideTimeoutPolicy?: TimeoutPolicy,
247
151
  ): void {
152
+ if (process.argv.includes(`--${ROLE_TOOL_POLICY_FLAG}`)) return;
248
153
  registerModelTask(pi, DELEGATE_TASK);
249
- pi.registerMessageRenderer(BACKGROUND_RESULT_TYPE, (message, { expanded, outputPad }, theme) => {
250
- const details = message.details as BackgroundWorkflowTransportDetails | undefined;
154
+ pi.registerMessageRenderer(DIRECT_RESULT_TYPE, (message, { expanded, outputPad }, theme) => {
155
+ const details = message.details as (BackgroundWorkflowTransportDetails & { tabs?: readonly DirectTabRecord[] }) | undefined;
251
156
  const content = typeof message.content === "string"
252
157
  ? message.content
253
158
  : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
254
159
  if (!details?.entries) return new Text(content, outputPad, 0);
255
160
  const count = details.entries.length;
256
- const subject = count === 1 ? "Background subagent" : `${count} background subagents`;
161
+ const subject = count === 1 ? "Direct subagent" : `${count} direct subagents`;
257
162
  const state = details.recovery ? "stopped; recovery needed"
258
163
  : details.outcome === "completed" ? "completed"
259
164
  : details.outcome === "failed" ? "failed" : "stopped";
@@ -267,49 +172,83 @@ export default function subagentExtension(
267
172
  return new Text([
268
173
  theme.fg(color, `${glyph} ${subject} ${state}`),
269
174
  ...rows,
175
+ ...(details.tabs?.length ? ["Recovery: /subagent (exact tabs and sessions)"] : []),
270
176
  ...(expanded ? ["", raw] : []),
271
177
  ].join("\n"), outputPad, 0);
272
178
  });
273
179
  const widgetItems = new Map<string, WidgetItem>();
274
- // Each child is a full Pi process issuing its own model calls; cap parallel
275
- // spend. Precedence: PI_SUBAGENT_MAX_SUBAGENTS env > config/pi-subagent/config.json
276
- // maxSubagents > default 5. Invalid present config falls back to the default
277
- // and is reported at session start; an invalid env value fails fast.
278
180
  const loadedConfig = readSubagentConfig();
279
- let maxActiveSubagents = loadedConfig.config.maxSubagents ?? 5;
280
- const maxSubagentsRaw = process.env.PI_SUBAGENT_MAX_SUBAGENTS;
281
- if (maxSubagentsRaw !== undefined) {
282
- // Reject "2workers", "1.5", "1e3" — parseInt would silently accept prefixes —
283
- // and digit strings that overflow to Infinity, which would disable the cap.
284
- if (!/^\d+$/.test(maxSubagentsRaw) || !/^[1-9]\d*$/.test(maxSubagentsRaw)) {
285
- throw boundedError(new Error(`PI_SUBAGENT_MAX_SUBAGENTS must be a positive integer, got ${JSON.stringify(maxSubagentsRaw)}.`));
286
- }
287
- const parsed = Number.parseInt(maxSubagentsRaw, 10);
288
- if (!Number.isSafeInteger(parsed)) {
289
- throw boundedError(new Error(`PI_SUBAGENT_MAX_SUBAGENTS exceeds the supported range, got ${JSON.stringify(maxSubagentsRaw)}.`));
290
- }
291
- maxActiveSubagents = parsed;
181
+ let initialPolicy: EffectiveExecutionPolicy;
182
+ try {
183
+ initialPolicy = resolveExecutionPolicy(loadedConfig);
184
+ } catch {
185
+ initialPolicy = {
186
+ maxSubagents: DEFAULT_EXECUTION_POLICY.maxSubagents,
187
+ maxTurns: DEFAULT_EXECUTION_POLICY.maxTurns,
188
+ childIdleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
189
+ childMaxMs: DEFAULT_TIMEOUT_CONFIG.maxMinutes * 60_000,
190
+ maxCorrections: DEFAULT_EXECUTION_POLICY.maxCorrections,
191
+ };
292
192
  }
293
- let backgroundSequence = 0;
294
- // Explicit policy argument (tests/embedders) wins; otherwise resolve from
295
- // config file over defaults.
296
- const timeoutPolicy: TimeoutPolicy = overrideTimeoutPolicy ?? resolveTimeoutPolicy(loadedConfig.config.timeout);
193
+ const timeoutPolicy: TimeoutPolicy = overrideTimeoutPolicy ?? {
194
+ idleMs: initialPolicy.childIdleMs,
195
+ maxMs: initialPolicy.childMaxMs,
196
+ };
197
+ const effectiveInitialPolicy: EffectiveExecutionPolicy = {
198
+ ...initialPolicy,
199
+ childIdleMs: timeoutPolicy.idleMs,
200
+ childMaxMs: timeoutPolicy.maxMs ?? initialPolicy.childMaxMs,
201
+ };
297
202
  const executor = createEphemeralSubagentExecutor({
298
- maxConcurrency: maxActiveSubagents,
299
- maxTurns: loadedConfig.config.maxTurns ?? DEFAULT_MAX_TURNS,
300
- maxTokens: loadedConfig.config.maxTokens,
203
+ maxConcurrency: effectiveInitialPolicy.maxSubagents,
204
+ maxTurns: effectiveInitialPolicy.maxTurns,
205
+ maxTokens: effectiveInitialPolicy.maxTokens,
301
206
  timeout: timeoutPolicy,
302
207
  });
303
- // Background children outlive the launching tool call, so they get their own
304
- // abort signal: tied to the session, not to the turn that started them.
305
- const backgroundTasks = new Map<string, { controller: AbortController; settled: Promise<void> }>();
306
- const failedToolPatches = new Map<string, ReturnType<typeof failedToolPatch>>();
307
- // Latest known session context; refreshed on session lifecycle and model
308
- // changes so queued background launches resolve against effective state.
208
+ const currentPolicy = (): EffectiveExecutionPolicy => resolveExecutionPolicy(readSubagentConfig());
209
+ const canWrite = (input: unknown): boolean => {
210
+ try {
211
+ const parsed = parseDelegateTask(input);
212
+ if (parsed.mode === "isolated") return true;
213
+ const roles = new Map(loadRoles().map((role) => [role.name, role]));
214
+ return parsed.workflow.delegations.some((delegation) => roleCanWrite(roles.get(delegation.role) ?? { name: delegation.role } as Role));
215
+ } catch {
216
+ return true;
217
+ }
218
+ };
219
+ const admission = createCheckoutAdmission();
220
+ admission.register(pi, canWrite);
221
+ const isolatedSurface = registerIsolatedExtension(pi, {
222
+ executor,
223
+ policy: effectiveInitialPolicy,
224
+ currentPolicy,
225
+ });
226
+ let directSequence = 0;
227
+ const directTasks = new Map<string, { controller: AbortController; settled: Promise<void>; handles: DirectHandle[]; tabs: DirectTabRecord[] }>();
309
228
  let latestCtx: ExtensionContext | undefined;
310
- // Bumped by session_start and session_shutdown; background tasks may only
311
- // deliver into the exact session that launched them.
312
229
  let sessionEpoch = 0;
230
+ registerSubagentCommand(pi, {
231
+ direct(ctx): DirectTask[] {
232
+ const grouped = new Map<string, DirectTask & { tabs: Array<DirectTask["tabs"][number]> }>();
233
+ for (const entry of ctx.sessionManager.getBranch()) {
234
+ if (entry.type !== "custom" || entry.customType !== DIRECT_TAB_TYPE || !entry.data) continue;
235
+ const tab = entry.data as DirectTabRecord;
236
+ const observed = directTasks.has(tab.taskId);
237
+ const existing = grouped.get(tab.taskId);
238
+ const record = { entryId: tab.entryId, name: tab.name, tabId: tab.tabId, paneId: tab.paneId, sessionFile: tab.sessionFile };
239
+ if (existing) {
240
+ if (!existing.tabs.some((item) => item.tabId === tab.tabId)) existing.tabs.push(record);
241
+ } else grouped.set(tab.taskId, { id: tab.taskId, name: tab.name, status: observed ? "observed locally" : "recorded (not observed)", tabs: [record] });
242
+ }
243
+ return [...grouped.values()];
244
+ },
245
+ isolated: (cwd) => isolatedSurface.inventory(cwd),
246
+ inspect: (root, id) => isolatedSurface.inspect(root, id),
247
+ canFollowup: (root, id, task) => isolatedSurface.canFollowup(root, id, task),
248
+ enqueue: (root, id, task, text, current) => isolatedSurface.enqueue(root, id, task, text, current),
249
+ drain: (root, id, task, current) => isolatedSurface.drain(root, id, task, current),
250
+ epoch: () => sessionEpoch,
251
+ });
313
252
  let widgetInstalled = false;
314
253
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
315
254
  let spinnerIndex = 0;
@@ -367,80 +306,43 @@ export default function subagentExtension(
367
306
  thinkingLevel: thinkingLevel ?? "default",
368
307
  taskId,
369
308
  name,
370
- tokens: 0,
371
309
  startedAt: Date.now(),
372
310
  status: "working",
373
- completedAssistantTurns: 0,
374
- startedToolCount: 0,
375
- activeTools: new Map(),
376
- activityOrder: 0,
377
311
  });
378
312
  startWidgetTimer();
379
313
  requestWidgetRender();
380
314
  };
381
315
 
382
- const updateWidgetTokens = (id: string, tokens: number) => {
383
- const item = widgetItems.get(id);
384
- if (!item) return;
385
- item.tokens = tokens;
386
- requestWidgetRender();
387
- };
388
-
389
- const updateWidgetActivity = (id: string, event: EphemeralSubagentActivityEvent) => {
390
- const item = widgetItems.get(id);
391
- if (!item || item.status !== "working") return;
392
- switch (event.type) {
393
- case "tool_execution_start": {
394
- if (item.activeTools.has(event.toolCallId)) break;
395
- if (item.activeTools.size >= MAX_WIDGET_ACTIVE_TOOLS) {
396
- let oldest: [string, WidgetActiveTool] | undefined;
397
- for (const candidate of item.activeTools) {
398
- if (!oldest || candidate[1].order < oldest[1].order) oldest = candidate;
399
- }
400
- if (oldest) item.activeTools.delete(oldest[0]);
401
- }
402
- const path = event.path === undefined ? undefined : basename(event.path);
403
- item.startedToolCount += 1;
404
- item.activeTools.set(event.toolCallId, {
405
- toolName: event.toolName,
406
- ...(path ? { path } : {}),
407
- startedAt: Date.now(),
408
- order: ++item.activityOrder,
409
- });
410
- item.activeToolId = event.toolCallId;
411
- break;
412
- }
413
- case "tool_execution_end": {
414
- item.activeTools.delete(event.toolCallId);
415
- if (item.activeToolId === event.toolCallId) {
416
- let latest: [string, WidgetActiveTool] | undefined;
417
- for (const candidate of item.activeTools) {
418
- if (!latest || candidate[1].order > latest[1].order) latest = candidate;
419
- }
420
- item.activeToolId = latest?.[0];
421
- }
422
- break;
423
- }
424
- case "message_end":
425
- item.completedAssistantTurns += 1;
426
- break;
427
- }
428
- requestWidgetRender();
429
- };
430
-
431
316
  const finishWidgetItem = (id: string, status: Exclude<WidgetStatus, "working">) => {
432
317
  const item = widgetItems.get(id);
433
318
  if (!item) return;
434
319
  item.status = status;
435
320
  item.finishedAt = Date.now();
436
- item.activeTools.clear();
437
- item.activeToolId = undefined;
438
321
  if (![...widgetItems.values()].some(({ status }) => status === "working")) stopWidgetTimer();
439
322
  requestWidgetRender();
440
323
  };
441
324
 
442
- pi.on("session_start", (_event, ctx) => {
325
+ pi.on("session_start", async (_event, ctx) => {
443
326
  sessionEpoch += 1;
327
+ if (latestCtx) {
328
+ const previous = [...directTasks.values()];
329
+ for (const { controller, handles } of previous) {
330
+ controller.abort();
331
+ await Promise.allSettled(handles.map((handle) => handle.cancel()));
332
+ }
333
+ await Promise.allSettled(previous.map(({ settled }) => settled));
334
+ // A switch discards the old session's ordinary follow-up. Carry exact
335
+ // recovery identities into the new branch as well as the original one.
336
+ const recorded = new Set(ctx.sessionManager.getBranch().flatMap((entry) =>
337
+ entry.type === "custom" && entry.customType === DIRECT_TAB_TYPE && entry.data
338
+ ? [(entry.data as DirectTabRecord).tabId] : []));
339
+ for (const { tabs } of previous) for (const tab of tabs) {
340
+ if (!recorded.has(tab.tabId)) {
341
+ pi.appendEntry(DIRECT_TAB_TYPE, tab);
342
+ recorded.add(tab.tabId);
343
+ }
344
+ }
345
+ }
444
346
  latestCtx = ctx;
445
347
  ensureWidget(ctx);
446
348
  if (loadedConfig.error !== undefined) ctx.ui.notify(loadedConfig.error, "warning");
@@ -453,7 +355,6 @@ export default function subagentExtension(
453
355
  }
454
356
  });
455
357
  pi.on("session_shutdown", async (_event, ctx) => {
456
- failedToolPatches.clear();
457
358
  stopWidgetTimer();
458
359
  widgetItems.clear();
459
360
  activeTui = undefined;
@@ -462,10 +363,13 @@ export default function subagentExtension(
462
363
  // Invalidate ordinary outcomes, abort children, then let preserved isolated
463
364
  // work report into the outgoing session before Pi tears it down.
464
365
  sessionEpoch += 1;
465
- const tasks = [...backgroundTasks.values()];
466
- for (const { controller } of tasks) controller.abort();
366
+ const tasks = [...directTasks.values()];
367
+ for (const { controller, handles } of tasks) {
368
+ controller.abort();
369
+ await Promise.allSettled(handles.map((handle) => handle.cancel()));
370
+ }
467
371
  await Promise.allSettled(tasks.map(({ settled }) => settled));
468
- backgroundTasks.clear();
372
+ directTasks.clear();
469
373
  });
470
374
  // btw-style context refresh: model_select carries the new model on the event,
471
375
  // agent_settled delivers the freshest full context after each turn.
@@ -482,79 +386,39 @@ export default function subagentExtension(
482
386
  pi.on("agent_settled", (_event, ctx) => {
483
387
  latestCtx = ctx;
484
388
  });
485
- pi.on("tool_result", (event) => {
486
- if (event.toolName !== "delegate_task") return;
487
- const patch = failedToolPatches.get(event.toolCallId);
488
- if (!patch) return;
489
- failedToolPatches.delete(event.toolCallId);
490
- return patch;
491
- });
492
389
 
493
- const reportBackground = (
390
+ const reportDirect = (
494
391
  launchEpoch: number,
495
392
  taskId: string,
496
393
  mode: ParsedWorkflow["mode"],
497
394
  entries: readonly WorkflowTransportEntry[],
498
- setupRecoveries: ReadonlyMap<string, string>,
395
+ tabs: readonly DirectTabRecord[],
499
396
  ): void => {
500
397
  const stale = launchEpoch !== sessionEpoch;
501
- const recoveries = entries.flatMap((entry) => {
502
- const worktree = entry.worktreePayload;
503
- return worktree === undefined || worktree.outcome === "pruned" ? [] : [{ entry, worktree }];
504
- });
505
- if (stale && !recoveries.length && !setupRecoveries.size) return;
506
- const transport = formatBackgroundWorkflowResult(mode, entries);
507
- const outcome: BackgroundWorkflowTransportDetails["outcome"] = stale ? "aborted" : transport.failed ? "failed" : "completed";
508
- const content = stale
509
- ? capOutput([
510
- "Background workflow left recoverable isolated work after session shutdown.",
511
- `Task ID: ${taskId}`,
512
- `Mode: ${mode}`,
513
- "Recovery locations:",
514
- ...recoveries.map(({ entry, worktree }) =>
515
- `- [${entry.index}] worktree path=${JSON.stringify(worktree.path)} branch=${JSON.stringify(worktree.branch)}`),
516
- ...[...setupRecoveries].map(([id, recovery]) => {
517
- const entry = entries.find((candidate) => candidate.id === id)!;
518
- return `- [${entry.index}] setup state: ${recovery}`;
519
- }),
520
- "Evidence:",
521
- ...recoveries.flatMap(({ entry, worktree }) => {
522
- const measurements = [
523
- ...(worktree.commits === undefined ? [] : [`commits=${worktree.commits}`]),
524
- ...(worktree.dirty === undefined ? [] : [`dirty=${worktree.dirty}`]),
525
- ];
526
- return [
527
- `- [${entry.index}] ${worktree.outcome} worktree${measurements.length ? ` ${measurements.join(" ")}` : ""}`,
528
- ...(worktree.outcome === "recovery" ? [` ${worktree.note}`] : []),
529
- ];
530
- }),
531
- ...[...setupRecoveries].map(([id, recovery]) => {
532
- const entry = entries.find((candidate) => candidate.id === id)!;
533
- return `- [${entry.index}] recoverable WorktreeSetupError: ${recovery}`;
534
- }),
535
- ].join("\n"))
536
- : transport.text;
537
- const details: BackgroundWorkflowTransportDetails = {
398
+ if (stale) return;
399
+ const transport = formatWorkflowResult(mode, entries);
400
+ const outcome: BackgroundWorkflowTransportDetails["outcome"] = transport.failed ? "failed" : "completed";
401
+ const content = [transport.text, ...(tabs.length ? ["Recovery (also available via /subagent):", ...tabs.map(({ taskId, entryId, name, tabId, paneId, sessionFile }) =>
402
+ `- ${taskId} · ${entryId} · tab ${tabId} · pane ${paneId} · agent ${name} · session ${sessionFile}`)] : [])].join("\n");
403
+ const details: BackgroundWorkflowTransportDetails & { tabs: readonly DirectTabRecord[] } = {
538
404
  ...transport.details,
539
405
  taskId,
540
406
  outcome,
541
- ...(transport.usage === undefined ? {} : { usage: transport.usage }),
542
- ...(stale ? { recovery: true } : {}),
407
+ tabs: [...tabs],
543
408
  };
544
409
  try {
545
- // Custom messages convert to user-role LLM messages, so the parent agent
546
- // sees the aggregate on its next turn without forcing one now.
410
+ // Queue behind the current turn, then trigger one follow-up turn.
547
411
  pi.sendMessage({
548
- customType: BACKGROUND_RESULT_TYPE,
412
+ customType: DIRECT_RESULT_TYPE,
549
413
  content,
550
414
  display: true,
551
415
  details,
552
- }, { triggerTurn: false });
416
+ }, { triggerTurn: true, deliverAs: "followUp" });
553
417
  } catch (error) {
554
418
  // Delivery can disappear during teardown; only an active UI gets a visible failure.
555
- if (!stale && latestCtx?.hasUI) {
419
+ if (latestCtx?.hasUI) {
556
420
  latestCtx.ui.notify(boundedError(new Error(
557
- `Background workflow ${taskId} result delivery failed: ${error instanceof Error ? error.message : String(error)}`,
421
+ `Direct workflow ${taskId} result delivery failed: ${error instanceof Error ? error.message : String(error)}`,
558
422
  )).message, "error");
559
423
  }
560
424
  }
@@ -563,299 +427,159 @@ export default function subagentExtension(
563
427
  pi.registerTool({
564
428
  name: "delegate_task",
565
429
  label: "Subagent",
566
- description: `Delegate one selected single, parallel, or chain workflow of bounded tasks to isolated Pi Subagents. Roles: ${roleSummary()}.`,
567
- promptSnippet: "Delegate one bounded single, parallel, or chain workflow to isolated roles",
430
+ description: `Delegate read-only direct work in Herdr or a durable checked isolated graph to Pi Roles. Roles: ${roleSummary()}.`,
431
+ promptSnippet: "Delegate read-only direct work or a checked isolated task graph",
568
432
  promptGuidelines: [
569
- "Use delegate_task with exactly one mode: role+name+task for one task, tasks for 1–8 independent tasks, or chain for 1–8 dependent tasks using {previous}. Prefer parallel whenever at least two outcomes are independently deliverable and verifiable; never split one invariant.",
570
- "Each delegate_task entry needs one bounded outcome, scope and exclusions, context and constraints, deliverable, and focused validation. Discover unclear scope first; never pass the parent request unchanged. Parallel mutations need non-overlapping ownership; read-only tasks may overlap only for distinct questions. Keep integration and cross-cutting decisions in Main.",
433
+ "Keep trivial mechanically verifiable work in Main. Use mode direct only for read-only research, analysis, or review in the current workspace. Use mode isolated for any implementation, write-capable Role, or checked changeset; explicit mode never falls back.",
434
+ "Direct requests use one compact role/name/task packet, tasks for independent packets, or chain with {previous}. Direct work returns a Herdr handle after launch; results arrive as one follow-up message.",
435
+ "Isolation uses typed tasks and dependencies. Keep tightly coupled changes with one owner; do not split by file count. Failures, ambiguity, limits, and conflicts retain work and never waive checks or identity guards.",
571
436
  `For delegate_task, ${MODEL_CLASS_GUIDANCE} A direct model replaces only the selected route's model; its thinking level stays unchanged.`,
572
- "Use delegate_task background only when the user explicitly requests non-blocking work.",
573
437
  ],
574
- parameters: WorkflowSchema,
438
+ parameters: DelegateTaskSchema,
575
439
  prepareArguments(args) {
576
440
  try {
577
- const workflow = parseWorkflow(args);
578
- if (workflow.mode === "single") return { ...workflow.delegations[0], background: workflow.background };
579
- if (workflow.mode === "parallel") return { tasks: workflow.delegations, background: workflow.background };
580
- return { chain: workflow.delegations, background: workflow.background };
581
- } catch (error) {
582
- throw boundedError(error);
583
- }
441
+ const parsed = parseDelegateTask(args);
442
+ if (parsed.mode === "isolated") return parsed.request;
443
+ const workflow = parsed.workflow;
444
+ if (workflow.mode === "single") return { mode: "direct" as const, ...workflow.delegations[0] };
445
+ if (workflow.mode === "parallel") return { mode: "direct" as const, tasks: workflow.delegations };
446
+ return { mode: "direct" as const, chain: workflow.delegations };
447
+ } catch (error) { throw boundedError(error); }
584
448
  },
585
- async execute(toolCallId, params, signal, onUpdate, ctx) {
586
- const throwIfAborted = () => {
587
- if (signal?.aborted) throw new EphemeralSubagentError("aborted", "Subagent was aborted.", signal.reason);
588
- };
589
- throwIfAborted();
590
- let workflow: ParsedWorkflow;
591
- try {
592
- workflow = parseWorkflow(params);
593
- const roles = loadRoles();
594
- const knownRoles = new Set(roles.map(({ name }) => name));
595
- for (const { role } of workflow.delegations) {
596
- if (!knownRoles.has(role)) {
597
- throw new Error(`Unknown Subagent role: ${role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
598
- }
599
- }
600
- } catch (error) {
601
- throw boundedError(error);
449
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
450
+ signal?.throwIfAborted();
451
+ const parsed = parseDelegateTask(params);
452
+ if (parsed.mode === "isolated") return await isolatedSurface.execute(parsed.request, signal, ctx);
453
+ const workflow = parsed.workflow;
454
+ const roles = new Map(loadRoles().map((role) => [role.name, role]));
455
+ for (const delegation of workflow.delegations) {
456
+ const role = roles.get(delegation.role);
457
+ if (!role) throw boundedError(new Error(`Unknown Subagent role: ${delegation.role}. Available roles: ${[...roles.keys()].join(", ") || "none"}.`));
458
+ if (roleCanWrite(role)) throw new Error(`Role ${role.name} exposes write-capable or unverified resources. Use mode isolated for this Role; direct is read-only.`);
602
459
  }
603
- throwIfAborted();
604
- const reloadRole = (name: string): Role => {
605
- let freshRoles: Role[];
606
- try {
607
- freshRoles = loadRoles();
608
- } catch (error) {
609
- throw boundedError(new Error(
610
- `Couldn't reload Subagent role ${JSON.stringify(name)} after it waited for an executor permit. Fix the Role configuration and retry: ${error instanceof Error ? error.message : String(error)}`,
611
- { cause: error },
612
- ));
613
- }
614
- const role = freshRoles.find((candidate) => candidate.name === name);
615
- if (role) return role;
616
- throw boundedError(new Error(
617
- `Subagent role ${JSON.stringify(name)} disappeared while waiting for an executor permit. Restore it and retry. Available roles: ${freshRoles.map(({ name: available }) => available).join(", ") || "none"}.`,
618
- ));
619
- };
620
-
621
- // Resolve against the latest known session context after each FIFO permit.
622
- const launchCtx = () => latestCtx ?? ctx;
623
- const prepareLaunch = (role: Role, delegation: Delegation) => {
624
- const context = launchCtx();
625
- const launch = prepareRoleLaunch(pi, context, {
626
- role,
627
- task: DELEGATE_TASK,
628
- ...(delegation.modelClass === undefined ? {} : { modelClass: delegation.modelClass }),
629
- });
630
- if (delegation.model === undefined) return launch;
631
- return prepareRoleLaunch(pi, context, {
632
- role,
633
- route: replaceRouteModel(context, delegation.model, launch),
634
- });
635
- };
636
- const foregroundWorkflow: ParsedWorkflow = { ...workflow, background: false };
637
- const entries = identifyWorkflowEntries(toolCallId, foregroundWorkflow);
460
+ const policy = currentPolicy();
461
+ const herdr = createDirectHerdr(pi, ctx.cwd, policy.childIdleMs);
462
+ const entries = identifyWorkflowEntries(toolCallId, workflow);
638
463
  const states = new Map<string, WorkflowTransportEntry>(entries.map((entry) => [entry.id, {
639
- id: entry.id,
640
- index: entry.index,
641
- name: entry.delegation.name,
642
- role: entry.delegation.role,
643
- status: "pending",
464
+ id: entry.id, index: entry.index, name: entry.delegation.name, role: entry.delegation.role, status: "pending",
644
465
  }]));
645
- const setupRecoveries = new Map<string, string>();
646
- const emitUpdate = (enabled: boolean) => {
647
- if (!enabled) return;
648
- const update = formatWorkflowUpdate(workflow.mode, [...states.values()]);
649
- onUpdate?.({
650
- content: [{ type: "text", text: update.text }],
651
- details: update.details,
652
- ...(update.usage === undefined ? {} : { usage: update.usage }),
466
+ const taskId = `direct-${++directSequence}-${Date.now().toString(36)}`;
467
+ const controller = new AbortController();
468
+ const launchEpoch = sessionEpoch;
469
+ const handles: DirectHandle[] = [];
470
+ const tabs: DirectTabRecord[] = [];
471
+ const tabByEntry = new Map<string, DirectTabRecord>();
472
+ let resolveSettled!: () => void;
473
+ const settled = new Promise<void>((resolve) => { resolveSettled = resolve; });
474
+ // Register before Herdr can create a tab: session switches must await this
475
+ // launch, including its identity callback, before copying recovery records.
476
+ directTasks.set(taskId, { controller, settled, handles, tabs });
477
+ const launch = async (entry: WorkflowEntry, activeSignal: AbortSignal) => {
478
+ activeSignal.throwIfAborted();
479
+ const role = loadRoles().find((candidate) => candidate.name === entry.delegation.role);
480
+ if (!role || roleCanWrite(role)) throw new Error(`Role ${entry.delegation.role} disappeared or became write-capable; use mode isolated.`);
481
+ const context = latestCtx ?? ctx;
482
+ const route = prepareRoleLaunch(pi, context, { role, task: DELEGATE_TASK,
483
+ ...(entry.delegation.modelClass === undefined ? {} : { modelClass: entry.delegation.modelClass }) });
484
+ const prepared = entry.delegation.model === undefined ? route : prepareRoleLaunch(pi, context, {
485
+ role, route: replaceRouteModel(context, entry.delegation.model, route),
653
486
  });
654
- };
655
- const runWorkflow = async (workflowSignal: AbortSignal | undefined, emitToolUpdates: boolean) => {
487
+ const base = states.get(entry.id)!;
488
+ states.set(entry.id, { id: base.id, index: base.index, name: base.name, role: base.role,
489
+ model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel, status: "running", assistantOutput: "" });
490
+ startWidgetItem(entry.id, taskId, role.name, prepared.model.id, prepared.thinkingLevel, entry.delegation.name, ctx);
491
+ const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
492
+ promptArgIndex: prepared.promptArgIndex }, activeSignal);
493
+ let handle: DirectHandle;
656
494
  try {
657
- return await runForegroundWorkflow<EphemeralSubagentResult>(toolCallId, foregroundWorkflow, async (entry: WorkflowEntry) => {
658
- let model: string | undefined;
659
- let thinkingLevel: string | undefined;
660
- let worktree: WorktreeInfo | undefined;
661
- let worktreePayload: WorktreePayload | undefined;
662
- let child: EphemeralSubagentResult | undefined;
663
- let rejected: unknown;
664
- let rejectedUsage: Usage | undefined;
665
- let aborted = false;
666
- let status: "succeeded" | "failed" | "rejected" = "rejected";
667
- let text = "Subagent did not start.";
668
- const setState = (
669
- nextStatus: "running" | "succeeded" | "failed" | "rejected",
670
- nextText: string,
671
- ) => {
672
- const usage = child?.usage ?? rejectedUsage;
673
- const base = {
674
- id: entry.id,
675
- index: entry.index,
676
- name: entry.delegation.name,
677
- role: entry.delegation.role,
678
- ...(model === undefined ? {} : { model }),
679
- ...(thinkingLevel === undefined ? {} : { thinkingLevel }),
680
- ...(worktreePayload === undefined ? {} : { worktreePayload }),
681
- ...(usage === undefined ? {} : { usage }),
682
- };
683
- states.set(entry.id, nextStatus === "failed" || nextStatus === "rejected"
684
- ? { ...base, status: nextStatus, failure: nextText }
685
- : { ...base, status: nextStatus, assistantOutput: nextText });
686
- };
687
- try {
688
- child = await executor.run({
689
- signal: workflowSignal,
690
- onUpdate: (output) => {
691
- setState("running", output);
692
- emitUpdate(emitToolUpdates);
693
- },
694
- onTokens: (tokens) => updateWidgetTokens(entry.id, tokens),
695
- onActivity: (event) => updateWidgetActivity(entry.id, event),
696
- prepare: async () => {
697
- // Route and effective Role resources resolve only after this entry's
698
- // shared executor permit, before isolated state is created.
699
- const role = reloadRole(entry.delegation.role);
700
- const preparedLaunch = prepareLaunch(role, entry.delegation);
701
- model = modelReference(preparedLaunch.model);
702
- thinkingLevel = preparedLaunch.thinkingLevel;
703
- if (preparedLaunch.isolation === "worktree") {
704
- worktree = await createChildWorktree(ctx.cwd, entry.id, undefined, workflowSignal);
705
- }
706
- startWidgetItem(entry.id, entry.id, preparedLaunch.role, preparedLaunch.model.id, preparedLaunch.thinkingLevel, entry.delegation.name, ctx);
707
- setState("running", "");
708
- emitUpdate(emitToolUpdates);
709
- const task = worktree ? `${entry.delegation.task}${worktreeContextNote(worktree)}` : entry.delegation.task;
710
- const cwd = worktree?.cwd ?? ctx.cwd;
711
- return { launch: finalizeRoleLaunch(preparedLaunch), task, cwd };
712
- },
495
+ handle = await herdr.start({ ...prepared, args: [...transient.launch.args] },
496
+ `d-${randomUUID().replaceAll("-", "").slice(0, 24)}`, entry.delegation.name, entry.delegation.task, activeSignal, (tab) => {
497
+ const record = { taskId, entryId: entry.id, ...tab };
498
+ // Persist before agent start, including launches interrupted by a session switch.
499
+ pi.appendEntry(DIRECT_TAB_TYPE, record);
500
+ tabs.push(record);
501
+ tabByEntry.set(entry.id, record);
713
502
  });
714
- if (child.outcome === "failure") {
715
- status = "failed";
716
- text = capOutput(child.errorMessage || child.stderr.trim() || child.output || `Subagent exited with code ${child.exitCode}.`);
717
- } else {
718
- status = "succeeded";
719
- text = child.output;
720
- }
721
- } catch (error) {
722
- rejected = error;
723
- aborted = error instanceof EphemeralSubagentError && error.code === "aborted";
724
- rejectedUsage = error instanceof EphemeralSubagentError
725
- ? (error as EphemeralSubagentError & { usage?: Usage }).usage
726
- : undefined;
727
- const cause = error instanceof EphemeralSubagentError ? error.cause : error;
728
- if (cause instanceof WorktreeSetupError) {
729
- setupRecoveries.set(entry.id, cause.message);
730
- }
731
- text = capOutput(error instanceof Error ? error.message : String(error));
732
- }
733
- try {
734
- worktreePayload = worktree ? await finalizeChildWorktree(worktree) : undefined;
735
- } catch (error) {
736
- rejected = error;
737
- aborted = false;
738
- status = "rejected";
739
- text = capOutput(error instanceof Error ? error.message : String(error));
740
- worktreePayload = worktree ? {
741
- outcome: "recovery",
742
- path: worktree.path,
743
- branch: worktree.branch,
744
- note: capOutput(`Worktree finalization failed (${text}); commits/dirty UNKNOWN. Inspect ${worktree.path} (branch ${worktree.branch}) before assuming no work.`),
745
- } : undefined;
746
- }
747
- if (worktreePayload?.outcome === "recovery") {
748
- const note = capOutput(worktreePayload.note);
749
- worktreePayload = { ...worktreePayload, note };
750
- rejected = new Error(note, rejected === undefined ? undefined : { cause: rejected });
751
- status = "rejected";
752
- text = capOutput(`${note}\n${text}`);
753
- }
754
- if (rejected !== undefined) status = "rejected";
755
- setState(status, text);
756
- try {
757
- finishWidgetItem(entry.id, aborted ? "aborted" : status === "succeeded" ? "success" : "failure");
758
- } catch (error) {
759
- rejected = error;
760
- status = "rejected";
761
- setState("rejected", capOutput(error instanceof Error ? error.message : String(error)));
762
- finishWidgetItem(entry.id, "failure");
763
- }
764
- if (rejected !== undefined) throw rejected;
765
- return child!.outcome === "success"
766
- ? { ok: true, assistantOutput: text, result: child! }
767
- : { ok: false, result: child! };
768
- }, workflowSignal);
769
- } finally {
770
- if (workflow.mode === "chain" && (workflowSignal?.aborted
771
- || [...states.values()].some(({ status }) => status === "failed" || status === "rejected"))) {
772
- for (const [id, state] of states) {
773
- if (state.status === "pending") states.set(id, { ...state, status: "skipped" });
774
- }
775
- }
503
+ } catch (error) {
504
+ // A failed start can still be in flight. Keep its private prompt for recovery.
505
+ 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 });
776
506
  }
507
+ await transient.cleanup();
508
+ handles.push(handle);
509
+ return handle;
777
510
  };
778
-
779
- const recordInfrastructureFailure = (error: unknown) => {
780
- if ([...states.values()].some(({ status }) => status === "failed" || status === "rejected")) return;
781
- const target = [...states.values()].find(({ status }) => status === "pending" || status === "running")
782
- ?? [...states.values()].at(-1)!;
783
- states.set(target.id, {
784
- id: target.id,
785
- index: target.index,
786
- name: target.name,
787
- role: target.role,
788
- ...(target.model === undefined ? {} : { model: target.model }),
789
- ...(target.thinkingLevel === undefined ? {} : { thinkingLevel: target.thinkingLevel }),
790
- ...(target.worktreePayload === undefined ? {} : { worktreePayload: target.worktreePayload }),
791
- ...(target.usage === undefined ? {} : { usage: target.usage }),
792
- status: "rejected",
793
- failure: capOutput(error instanceof Error ? error.message : String(error)),
794
- });
795
- };
796
-
797
- throwIfAborted();
798
- if (workflow.background) {
799
- const taskId = `bg-${++backgroundSequence}-${Date.now().toString(36)}`;
800
- const controller = new AbortController();
801
- // Freeze the launching session now: a task that settles after a
802
- // reload must not deliver into whichever session is active then.
803
- const launchEpoch = sessionEpoch;
804
- const settled = (async () => {
805
- try {
806
- // Let the acknowledgement resolve before any route, Skill, worktree,
807
- // permit, or child work starts.
808
- await new Promise<void>((resolve) => setImmediate(resolve));
511
+ // The first tab is verified before returning a handle; subsequent independent
512
+ // entries launch asynchronously and retain chain/parallel dependency semantics.
513
+ let first: DirectHandle;
514
+ const abortLaunch = () => controller.abort(signal?.reason);
515
+ signal?.addEventListener("abort", abortLaunch, { once: true });
516
+ try {
517
+ first = await launch(workflow.mode === "chain" ? {
518
+ ...entries[0]!, delegation: { ...entries[0]!.delegation, task: entries[0]!.delegation.task.replaceAll("{previous}", "") },
519
+ } : entries[0]!, controller.signal);
520
+ if (launchEpoch !== sessionEpoch || controller.signal.aborted) {
521
+ await first.cancel();
522
+ throw new Error(`Launching session changed during direct start; inspect Herdr tab ${first.tabId}.`);
523
+ }
524
+ } catch (error) {
525
+ controller.abort();
526
+ finishWidgetItem(entries[0]!.id, "failure");
527
+ directTasks.delete(taskId);
528
+ resolveSettled();
529
+ throw boundedError(error);
530
+ } finally { signal?.removeEventListener("abort", abortLaunch); }
531
+ void (async () => {
532
+ try {
533
+ await new Promise<void>((resolve) => setImmediate(resolve));
534
+ let active = 0;
535
+ const queue: Array<() => void> = [];
536
+ const permit = async () => {
537
+ if (active >= policy.maxSubagents) await new Promise<void>((resolve) => queue.push(resolve));
538
+ active++;
539
+ return () => { active--; queue.shift()?.(); };
540
+ };
541
+ await runForegroundWorkflow(toolCallId, workflow, async (entry) => {
542
+ const release = await permit();
809
543
  try {
810
- await runWorkflow(controller.signal, false);
544
+ controller.signal.throwIfAborted();
545
+ const handle = entry.index === 0 ? first : await launch(entry, controller.signal);
546
+ const answer = await handle.answer(Math.floor(40 * 1024 / entries.length));
547
+ const base = states.get(entry.id)!;
548
+ states.set(entry.id, { id: base.id, index: base.index, name: base.name, role: base.role,
549
+ ...(base.model ? { model: base.model } : {}), ...(base.thinkingLevel ? { thinkingLevel: base.thinkingLevel } : {}),
550
+ status: "succeeded", assistantOutput: answer });
551
+ finishWidgetItem(entry.id, "success");
552
+ return answer;
811
553
  } catch (error) {
812
- if (!controller.signal.aborted) recordInfrastructureFailure(error);
813
- }
814
- reportBackground(launchEpoch, taskId, workflow.mode, [...states.values()], setupRecoveries);
815
- } finally {
816
- backgroundTasks.delete(taskId);
554
+ const base = states.get(entry.id)!;
555
+ states.set(entry.id, { id: base.id, index: base.index, name: base.name, role: base.role,
556
+ ...(base.model ? { model: base.model } : {}), ...(base.thinkingLevel ? { thinkingLevel: base.thinkingLevel } : {}),
557
+ status: "rejected", failure: `${tabByEntry.has(entry.id) ? `recover from Herdr tab ${tabByEntry.get(entry.id)!.tabId}, agent ${tabByEntry.get(entry.id)!.name}, session ${tabByEntry.get(entry.id)!.sessionFile}: ` : ""}${capOutput(error instanceof Error ? error.message : String(error))}` });
558
+ finishWidgetItem(entry.id, "failure");
559
+ throw error;
560
+ } finally { release(); }
561
+ }, controller.signal);
562
+ } catch (error) {
563
+ if (!controller.signal.aborted) {
564
+ const state = [...states.values()].find(({ status }) => status === "running" || status === "pending");
565
+ if (state) states.set(state.id, { id: state.id, index: state.index, name: state.name, role: state.role, status: "rejected", failure: capOutput(String(error)) });
817
566
  }
818
- })();
819
- backgroundTasks.set(taskId, { controller, settled });
820
- void settled;
821
- const title = workflow.mode === "single" ? "Background delegation"
822
- : workflow.mode === "parallel" ? "Background parallel delegation" : "Background delegation chain";
823
- const acknowledgement = capOutput([
824
- `${title} started${entries.length === 1 ? "" : ` · ${entries.length} tasks`}`,
825
- ...entries.map((entry, index) =>
826
- `○ [${index + 1}/${entries.length}] ${entry.delegation.name} · ${entry.delegation.role}`),
827
- "Results will arrive in one message.",
828
- ].join("\n"));
829
- return {
830
- content: [{ type: "text" as const, text: acknowledgement }],
831
- details: {
832
- taskId,
833
- background: true,
834
- mode: workflow.mode,
835
- entries: entries.map((entry) => ({ id: entry.id, index: entry.index, name: entry.delegation.name, role: entry.delegation.role })),
836
- },
837
- };
838
- }
839
-
840
- let outcomes: Awaited<ReturnType<typeof runWorkflow>>;
841
- try {
842
- outcomes = await runWorkflow(signal, true);
843
- } catch (error) {
844
- if (!signal?.aborted) throw error;
845
- const aborted = new WorkflowAbortedError(workflow.mode, [...states.values()], signal.reason);
846
- failedToolPatches.set(toolCallId, failedToolPatch(aborted));
847
- throw aborted;
848
- }
849
- if (outcomes.some(({ status }) => status === "failed" || status === "rejected")) {
850
- const failure = new WorkflowFailureError(workflow.mode, [...states.values()]);
851
- failedToolPatches.set(toolCallId, failedToolPatch(failure));
852
- throw failure;
853
- }
854
- const result = formatWorkflowResult(workflow.mode, [...states.values()]);
567
+ } finally {
568
+ for (const [id, state] of states) {
569
+ if (state.status === "pending" || state.status === "running") states.set(id, {
570
+ id: state.id, index: state.index, name: state.name, role: state.role,
571
+ status: "skipped",
572
+ });
573
+ }
574
+ if (!controller.signal.aborted) reportDirect(launchEpoch, taskId, workflow.mode, [...states.values()], tabs);
575
+ directTasks.delete(taskId);
576
+ resolveSettled();
577
+ }
578
+ })();
855
579
  return {
856
- content: [{ type: "text" as const, text: result.text }],
857
- details: result.details,
858
- ...(result.usage === undefined ? {} : { usage: result.usage }),
580
+ 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.`) }],
581
+ details: { taskId, mode: workflow.mode, tabId: first.tabId, sessionFile: first.sessionFile,
582
+ entries: entries.map(({ id, index, delegation }) => ({ id, index, name: delegation.name, role: delegation.role })) },
859
583
  };
860
584
  },
861
585
  });