@henryqw/pi-subagent 18.0.1 → 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.
- package/CONTEXT.md +76 -24
- package/README.md +116 -142
- package/dist/composition.d.ts +11 -0
- package/dist/composition.js +107 -0
- package/dist/direct-herdr.d.ts +17 -0
- package/dist/direct-herdr.js +219 -0
- package/dist/ephemeral.d.ts +2 -2
- package/dist/ephemeral.js +7 -7
- package/dist/git-runtime.d.ts +110 -0
- package/dist/git-runtime.js +472 -0
- package/dist/herdr-runtime.d.ts +137 -0
- package/dist/herdr-runtime.js +1228 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +20 -16
- package/dist/integration-git.d.ts +59 -0
- package/dist/integration-git.js +484 -0
- package/dist/launch-runtime.d.ts +54 -0
- package/dist/launch-runtime.js +195 -0
- package/dist/process.d.ts +9 -0
- package/dist/process.js +11 -0
- package/dist/runner.d.ts +356 -0
- package/dist/runner.js +2710 -0
- package/dist/schema.d.ts +2234 -0
- package/dist/schema.js +1236 -0
- package/dist/store.d.ts +50 -0
- package/dist/store.js +288 -0
- package/dist/working-evidence.d.ts +25 -0
- package/dist/working-evidence.js +261 -0
- package/docs/adr/001-composable-ephemeral-execution.md +3 -3
- package/docs/delegation-routing.html +89 -0
- package/docs/delegation-routing.svg +78 -0
- package/docs/orchestration-architecture.html +238 -0
- package/docs/orchestration-architecture.svg +189 -0
- package/docs/orchestration.md +122 -331
- package/example.png +0 -0
- package/examples/roles/implementer.md +2 -3
- package/extensions/admission.ts +66 -0
- package/extensions/config.ts +51 -37
- package/extensions/isolated.ts +638 -0
- package/extensions/result-transport.ts +13 -103
- package/extensions/role-tools.ts +11 -11
- package/extensions/subagent-command.ts +167 -0
- package/extensions/subagent.ts +264 -540
- package/extensions/task-name.ts +1 -2
- package/extensions/workflow.ts +60 -96
- package/package.json +14 -6
- package/skills/pi-subagent/SKILL.md +35 -0
package/extensions/subagent.ts
CHANGED
|
@@ -1,101 +1,35 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 ? "
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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:
|
|
299
|
-
maxTurns:
|
|
300
|
-
maxTokens:
|
|
203
|
+
maxConcurrency: effectiveInitialPolicy.maxSubagents,
|
|
204
|
+
maxTurns: effectiveInitialPolicy.maxTurns,
|
|
205
|
+
maxTokens: effectiveInitialPolicy.maxTokens,
|
|
301
206
|
timeout: timeoutPolicy,
|
|
302
207
|
});
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 = [...
|
|
466
|
-
for (const { controller } of tasks)
|
|
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
|
-
|
|
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
|
|
390
|
+
const reportDirect = (
|
|
494
391
|
launchEpoch: number,
|
|
495
392
|
taskId: string,
|
|
496
393
|
mode: ParsedWorkflow["mode"],
|
|
497
394
|
entries: readonly WorkflowTransportEntry[],
|
|
498
|
-
|
|
395
|
+
tabs: readonly DirectTabRecord[],
|
|
499
396
|
): void => {
|
|
500
397
|
const stale = launchEpoch !== sessionEpoch;
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
})
|
|
505
|
-
|
|
506
|
-
const
|
|
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
|
-
|
|
542
|
-
...(stale ? { recovery: true } : {}),
|
|
407
|
+
tabs: [...tabs],
|
|
543
408
|
};
|
|
544
409
|
try {
|
|
545
|
-
//
|
|
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:
|
|
412
|
+
customType: DIRECT_RESULT_TYPE,
|
|
549
413
|
content,
|
|
550
414
|
display: true,
|
|
551
415
|
details,
|
|
552
|
-
}, { triggerTurn:
|
|
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 (
|
|
419
|
+
if (latestCtx?.hasUI) {
|
|
556
420
|
latestCtx.ui.notify(boundedError(new Error(
|
|
557
|
-
`
|
|
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
|
|
567
|
-
promptSnippet: "Delegate
|
|
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
|
-
"
|
|
570
|
-
"
|
|
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:
|
|
438
|
+
parameters: DelegateTaskSchema,
|
|
575
439
|
prepareArguments(args) {
|
|
576
440
|
try {
|
|
577
|
-
const
|
|
578
|
-
if (
|
|
579
|
-
|
|
580
|
-
return {
|
|
581
|
-
|
|
582
|
-
|
|
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,
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
604
|
-
const
|
|
605
|
-
|
|
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
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
...
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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:
|
|
857
|
-
details:
|
|
858
|
-
|
|
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
|
});
|