pi-task-dispatcher 2.0.0 → 3.0.1

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.
@@ -1,38 +1,66 @@
1
1
  /**
2
- * Exposes two bounded delegation tools that run configurable worker models in isolated Pi processes.
2
+ * Runs delegated Pi workers as supervised, resumable jobs that pause after each completed tool batch.
3
3
  */
4
4
 
5
- import { spawn } from "node:child_process";
5
+ import { spawn, type ChildProcess } from "node:child_process";
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { type ExtensionAPI, type ExtensionToolContext, getAgentDir } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ type ExtensionAPI,
11
+ type ExtensionToolContext,
12
+ getAgentDir,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { Text } from "@earendil-works/pi-tui";
10
15
  import { Type } from "typebox";
11
16
 
12
- type Role = "mechanic" | "engineer";
17
+ type Role = "mechanic" | "assistant" | "engineer" | "designer";
13
18
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
14
- type WorkerStatus =
19
+ type JobStatus =
20
+ | "starting"
15
21
  | "running"
22
+ | "paused"
23
+ | "compacting"
16
24
  | "completed"
17
- | "configuration_error"
18
- | "model_error"
19
- | "process_error"
20
- | "aborted"
21
- | "timeout"
22
- | "turn_limit"
23
- | "incomplete";
24
-
25
- interface WorkerConfig {
25
+ | "checkpointed"
26
+ | "taken_over"
27
+ | "cancelled"
28
+ | "hard_stopped"
29
+ | "failed";
30
+ interface WorkerRoleConfig {
31
+ model?: string;
32
+ thinking?: ThinkingLevel;
33
+ }
34
+
35
+ interface ResolvedWorkerRoleConfig {
26
36
  model: string;
27
37
  thinking: ThinkingLevel;
28
- timeoutSeconds: number;
29
- checkpointGraceSeconds: number;
30
- maxTurns: number;
31
38
  }
32
39
 
33
- interface DispatcherConfig {
34
- mechanic: WorkerConfig;
35
- engineer: WorkerConfig;
40
+ interface DispatcherPolicy {
41
+ absoluteMaxTurns: number;
42
+ absoluteMaxSeconds: number;
43
+ pausedLeaseSeconds: number;
44
+ noProgressSeconds: number;
45
+ }
46
+
47
+ type DispatcherConfig = Record<Role, WorkerRoleConfig> & {
48
+ policy: DispatcherPolicy;
49
+ };
50
+
51
+ interface Workload {
52
+ investigationUnits: number;
53
+ changeUnits: number;
54
+ verificationUnits: number;
55
+ expectedLongRunningSeconds: number;
56
+ }
57
+
58
+ interface CalculatedBudget {
59
+ workload: Workload;
60
+ turns: number;
61
+ seconds: number;
62
+ contextWindow: number;
63
+ maxOutputTokens: number;
36
64
  }
37
65
 
38
66
  interface UsageTotals {
@@ -52,51 +80,88 @@ interface UsageTotals {
52
80
  };
53
81
  }
54
82
 
55
- interface WorkerActivity {
83
+ interface WorkerMediaOutput {
84
+ mimeType: string;
85
+ bytes: number;
86
+ }
87
+
88
+ interface WorkerAction {
89
+ sequence: number;
56
90
  toolCallId: string;
91
+ toolName: string;
57
92
  label: string;
93
+ mutating: boolean;
58
94
  status: "running" | "completed" | "failed";
95
+ startedAt: number;
96
+ endedAt?: number;
97
+ completionSequence?: number;
98
+ parentToolCallId?: string;
99
+ mediaOutputs?: WorkerMediaOutput[];
59
100
  }
60
101
 
61
- interface WorkerCheckpoint {
62
- reason: Exclude<WorkerStatus, "running" | "completed">;
63
- task: string;
64
- lastAssistantText?: string;
65
- completedActivities: string[];
66
- activeActivities: string[];
67
- filesystemEffectsMayRemain: boolean;
102
+ interface ContextMetrics {
103
+ tokens?: number;
104
+ contextWindow: number;
105
+ percent?: number;
106
+ peakPercent: number;
107
+ compactionAttempts: number;
108
+ compactions: number;
109
+ compactionSkips: number;
110
+ compactionFailures: number;
68
111
  }
69
112
 
70
- interface WorkerDetails {
113
+ interface WorkerSnapshot {
114
+ jobId: string;
71
115
  role: Role;
72
- status: WorkerStatus;
116
+ status: JobStatus;
73
117
  model: string;
74
118
  thinking: ThinkingLevel;
75
- tools: string[];
119
+ sequence: number;
120
+ phase: string;
121
+ recommendation: string;
122
+ taskDisplay: string;
123
+ currentActivity?: string;
124
+ managerAction?: string;
76
125
  turns: number;
77
126
  durationMs: number;
78
- exitCode: number | null;
79
- stopReason?: string;
127
+ budget: CalculatedBudget;
128
+ context: ContextMetrics;
129
+ actionCounts: {
130
+ total: number;
131
+ completed: number;
132
+ failed: number;
133
+ mutations: number;
134
+ };
135
+ recentActions: WorkerAction[];
136
+ actionLedger: WorkerAction[];
137
+ workerText: string;
138
+ workerTextDisplay: string;
139
+ workerTextTruncated: boolean;
80
140
  diagnostic?: string;
81
- stderr?: string;
82
- activity: WorkerActivity[];
83
- checkpoint?: WorkerCheckpoint;
84
- }
85
-
86
- interface WorkerResult {
87
- output: string;
88
- details: WorkerDetails;
89
- usage?: UsageTotals;
141
+ tools: string[];
142
+ usage: UsageTotals;
90
143
  }
91
144
 
92
145
  interface ParsedEvent {
93
146
  id?: string;
94
147
  type?: string;
148
+ command?: string;
95
149
  success?: boolean;
150
+ disposition?: string;
96
151
  error?: string;
152
+ data?: unknown;
153
+ reason?: string;
154
+ aborted?: boolean;
155
+ errorMessage?: string;
156
+ willRetry?: boolean;
157
+ result?: {
158
+ content?: Array<{ type?: string; text?: string; data?: string; mimeType?: string }>;
159
+ usage?: EventUsage;
160
+ estimatedTokensAfter?: number;
161
+ };
97
162
  message?: {
98
163
  role?: string;
99
- content?: Array<{ type?: string; text?: string; name?: string; arguments?: unknown }>;
164
+ content?: Array<{ type?: string; text?: string }>;
100
165
  usage?: EventUsage;
101
166
  stopReason?: string;
102
167
  errorMessage?: string;
@@ -110,167 +175,116 @@ interface ParsedEvent {
110
175
  };
111
176
  toolCallId?: string;
112
177
  toolName?: string;
178
+ parentToolCallId?: string;
113
179
  args?: unknown;
114
180
  isError?: boolean;
115
181
  }
116
182
 
183
+ interface GateBoundaryMessage {
184
+ type: "task_dispatcher_boundary";
185
+ turnIndex: number;
186
+ toolResults: number;
187
+ }
188
+
117
189
  type EventUsage = Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
118
190
 
119
- const CONFIG_PATH = path.join(getAgentDir(), "task-dispatcher.json");
191
+ const TEST_MODE = process.env.PI_TASK_DISPATCHER_TEST_MODE === "1";
192
+ const CONFIG_PATH = TEST_MODE && process.env.PI_TASK_DISPATCHER_CONFIG_PATH
193
+ ? process.env.PI_TASK_DISPATCHER_CONFIG_PATH
194
+ : path.join(getAgentDir(), "task-dispatcher.json");
120
195
  const WORKER_ENV = "PI_TASK_DISPATCHER_WORKER";
121
- const RESULT_LIMIT = 24_000;
122
- const STDERR_LIMIT = 8_000;
196
+ const WIDGET_ID = "pi-task-dispatcher-worker";
197
+ const MODEL_TEXT_LIMIT = 5_000;
198
+ const STDERR_LIMIT = 16_000;
123
199
  const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
200
+ const ROLES: Role[] = ["mechanic", "assistant", "engineer", "designer"];
201
+ const MUTATING_TOOLS = new Set(["edit", "write", "bash", "powershell", "codemode"]);
202
+ const ISOLATED_WORKER_TOOLS = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls", "codemode"]);
124
203
 
125
204
  const DEFAULT_CONFIG: DispatcherConfig = {
126
- mechanic: {
127
- model: "openai-codex/gpt-5.6-luna",
128
- thinking: "max",
129
- timeoutSeconds: 300,
130
- checkpointGraceSeconds: 30,
131
- maxTurns: 20,
132
- },
133
- engineer: {
134
- model: "openai-codex/gpt-5.6-terra",
135
- thinking: "max",
136
- timeoutSeconds: 900,
137
- checkpointGraceSeconds: 45,
138
- maxTurns: 30,
205
+ mechanic: {},
206
+ assistant: {},
207
+ engineer: {},
208
+ designer: {},
209
+ policy: {
210
+ absoluteMaxTurns: 96,
211
+ absoluteMaxSeconds: 7_200,
212
+ pausedLeaseSeconds: 1_800,
213
+ noProgressSeconds: 600,
139
214
  },
140
215
  };
141
216
 
142
217
  const ROLE_PROMPTS: Record<Role, string> = {
143
- mechanic: `You are the mechanical worker in a delegated Pi task.
144
- Complete the supplied task directly with the available tools. The caller has retained ownership of the surrounding task.
145
-
146
- Rules:
147
- - Treat the delegated task as the complete scope. Do not invent missing requirements or broaden it.
148
- - Inspect before modifying and preserve existing project conventions.
149
- - Perform exact, repetitive, retrieval, transformation, command, and verification work autonomously.
150
- - If a semantic, architectural, or user-intent decision is required, stop and report precisely what is blocked.
151
- - Run requested checks. Never claim a check passed unless you observed it pass.
152
- - Report partial changes after any failure.
153
- - Finish with a concise report containing status, files changed, checks run, and anything unresolved.`,
154
- engineer: `You are the engineering worker in a delegated Pi task.
155
- Own the supplied subtask autonomously while the caller retains integration and final-response responsibility.
156
-
157
- Rules:
158
- - Treat the delegated objective, constraints, and acceptance conditions as authoritative.
159
- - Inspect the relevant code and project instructions before changing it.
160
- - You may investigate, research, reason, implement, refactor, test, and make local engineering decisions needed to complete the subtask.
161
- - Keep work relevant to the delegated outcome. If completion requires materially changing its objective or constraints, stop and explain the needed decision.
162
- - Run appropriate checks. Never claim a check passed unless you observed it pass.
163
- - Report partial changes after any failure.
164
- - Finish with a concise report containing the outcome, files changed, checks run, local decisions, and anything unresolved.`,
218
+ mechanic: `You are the mechanical worker in a supervised delegated Pi task.
219
+ Complete only the supplied bounded objective with the available tools. Work in cohesive tool batches. After each tool batch the dispatcher pauses you externally so the manager can inspect state. On continuation, use the existing tool results and continue without repeating completed work. Finish with a concise report containing status, files changed, checks, and unresolved items.`,
220
+ assistant: `You are the assistant worker in a supervised delegated Pi task.
221
+ Complete only the supplied bounded, low-complexity objective. Apply limited interpretation or synthesis, but do not assume manager responsibilities, expand scope, or make architectural decisions. Work in cohesive tool batches. After each tool batch the dispatcher pauses you externally so the manager can inspect state. On continuation, use existing results without repeating completed work. Finish with a concise report containing outcome, evidence, changes, checks, and unresolved items.`,
222
+ engineer: `You are the engineering worker in a supervised delegated Pi task.
223
+ Own only the supplied subtask while the caller retains integration responsibility. Investigate, implement, and verify in cohesive tool batches. After each tool batch the dispatcher pauses you externally so the manager can inspect state. On continuation, use the existing tool results and continue without repeating completed work. Finish with a concise report containing outcome, files changed, checks, decisions, and unresolved items.`,
224
+ designer: `You are the media designer worker in a supervised delegated Pi task.
225
+ Own only the supplied image, video, audio, or media-asset objective while the caller retains integration responsibility. When available, use read for visual inputs, codemode image models for image generation or editing, and explicitly available command-line programs such as ffmpeg for media processing. Verify required executables before depending on them. Treat codemode-generated images as temporary until you copy the selected result to the requested workspace destination and verify it. Preserve source assets unless the task explicitly requires replacement. Work in cohesive tool batches; the dispatcher pauses after each batch. Finish with a concise report containing outputs, source files affected, tools used, checks, and unresolved items.`,
165
226
  };
166
227
 
167
228
  const ROLE_DESCRIPTIONS: Record<Role, string> = {
168
- mechanic:
169
- "Delegate a bounded mechanical episode to the configured mechanic model. Use when the desired outcome and constraints can be stated precisely and the work is primarily file inspection, exact edits, repetitive transformations, commands, retrieval, or verification. Prefer this for multi-step work or bulky intermediate output; use a direct tool for one trivial operation. The worker operates in the current workspace and may modify files.",
170
- engineer:
171
- "Delegate any self-contained engineering subtask to the configured engineer model. Use broadly for investigation, implementation, debugging, refactoring, testing, review, research, or analysis that can be given a clear outcome and returned for integration. Include all relevant constraints and acceptance conditions. The worker operates in the current workspace and may modify files.",
229
+ mechanic: "Start a supervised mechanical delegation for prescribed execution with a known method and result.",
230
+ assistant: "Start a supervised assistant delegation for bounded, low-complexity interpretation, synthesis, or routine updates.",
231
+ engineer: "Start a supervised engineering delegation for uncertain, cross-component, or implementation-intensive work.",
232
+ designer: "Start a supervised media-design delegation for image generation or editing and command-line image, video, or audio processing.",
172
233
  };
173
234
 
174
- const DelegateParams = Type.Object({
175
- task: Type.String({
176
- minLength: 1,
177
- maxLength: 12_000,
178
- description: "A self-contained task with its objective, scope, constraints, and completion checks.",
179
- }),
180
- tools: Type.Array(Type.String({ minLength: 1 }), {
181
- maxItems: 32,
182
- description: "The exact worker tool allowlist selected from the main agent's active tools. Use the smallest sufficient set; use an empty array for no tools.",
183
- }),
184
- });
185
-
186
- /** Identify plain objects before reading user-controlled JSON configuration fields. */
187
- function isRecord(value: unknown): value is Record<string, unknown> {
188
- return typeof value === "object" && value !== null && !Array.isArray(value);
189
- }
190
-
191
- /** Copy defaults so loaded configuration cannot mutate shared role settings. */
192
- function copyWorkerConfig(config: WorkerConfig): WorkerConfig {
193
- return { ...config };
194
- }
195
-
196
- /** Validate and merge one role's optional configuration over its package defaults. */
197
- function parseWorkerConfig(role: Role, value: unknown): WorkerConfig {
198
- const result = copyWorkerConfig(DEFAULT_CONFIG[role]);
199
- if (value === undefined) return result;
200
- if (!isRecord(value)) throw new Error(`"${role}" must be an object`);
201
-
202
- // Validate the exact model identity used for deterministic dispatch.
203
- if (value.model !== undefined) {
204
- if (typeof value.model !== "string" || !value.model.includes("/")) {
205
- throw new Error(`"${role}.model" must be a provider/model string`);
206
- }
207
- result.model = value.model;
208
- }
209
-
210
- // Accept only thinking levels supported by Pi's public CLI contract.
211
- if (value.thinking !== undefined) {
212
- if (typeof value.thinking !== "string" || !THINKING_LEVELS.has(value.thinking as ThinkingLevel)) {
213
- throw new Error(`"${role}.thinking" is not a valid Pi thinking level`);
214
- }
215
- result.thinking = value.thinking as ThinkingLevel;
216
- }
217
-
218
- // Bound worker lifetime settings to positive integers.
219
- for (const field of ["timeoutSeconds", "checkpointGraceSeconds", "maxTurns"] as const) {
220
- const candidate = value[field];
221
- if (candidate !== undefined) {
222
- if (!Number.isInteger(candidate) || (candidate as number) <= 0) {
223
- throw new Error(`"${role}.${field}" must be a positive integer`);
224
- }
225
- result[field] = candidate as number;
226
- }
227
- }
228
- if (result.checkpointGraceSeconds >= result.timeoutSeconds) {
229
- throw new Error(`"${role}.checkpointGraceSeconds" must be less than "${role}.timeoutSeconds"`);
230
- }
235
+ const ROLE_TOOL_NAMES: Record<Role, string> = {
236
+ mechanic: "delegate_mechanical",
237
+ assistant: "delegate_assistant",
238
+ engineer: "delegate_engineering",
239
+ designer: "delegate_designer",
240
+ };
231
241
 
232
- return result;
233
- }
242
+ const ROLE_LABELS: Record<Role, string> = {
243
+ mechanic: "↗ mechanic",
244
+ assistant: "↗ assistant",
245
+ engineer: "↗ engineer",
246
+ designer: "↗ designer",
247
+ };
234
248
 
235
- /** Load the optional personal configuration afresh for every delegation. */
236
- function loadConfig(): DispatcherConfig {
237
- if (!fs.existsSync(CONFIG_PATH)) {
238
- return {
239
- mechanic: copyWorkerConfig(DEFAULT_CONFIG.mechanic),
240
- engineer: copyWorkerConfig(DEFAULT_CONFIG.engineer),
241
- };
242
- }
249
+ const WorkloadSchema = Type.Object({
250
+ investigationUnits: Type.Integer({ minimum: 0, maximum: 32 }),
251
+ changeUnits: Type.Integer({ minimum: 0, maximum: 32 }),
252
+ verificationUnits: Type.Integer({ minimum: 0, maximum: 16 }),
253
+ expectedLongRunningSeconds: Type.Integer({ minimum: 0, maximum: 3_600 }),
254
+ });
243
255
 
244
- let parsed: unknown;
245
- try {
246
- parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
247
- } catch (error) {
248
- throw new Error(`Cannot parse ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
249
- }
250
- if (!isRecord(parsed)) throw new Error(`${CONFIG_PATH} must contain a JSON object`);
256
+ const DelegateParams = Type.Object({
257
+ task: Type.String({ minLength: 1, maxLength: 12_000 }),
258
+ tools: Type.Array(Type.String({ minLength: 1 }), { maxItems: 32 }),
259
+ workload: WorkloadSchema,
260
+ });
251
261
 
252
- return {
253
- mechanic: parseWorkerConfig("mechanic", parsed.mechanic),
254
- engineer: parseWorkerConfig("engineer", parsed.engineer),
255
- };
256
- }
262
+ const ControlParams = Type.Object({
263
+ jobId: Type.String({ minLength: 1 }),
264
+ action: Type.Union([
265
+ Type.Literal("continue"),
266
+ Type.Literal("revise"),
267
+ Type.Literal("recalculate"),
268
+ Type.Literal("compact"),
269
+ Type.Literal("take_over"),
270
+ Type.Literal("checkpoint"),
271
+ Type.Literal("cancel"),
272
+ ]),
273
+ instructions: Type.Optional(Type.String({ minLength: 1, maxLength: 8_000 })),
274
+ workload: Type.Optional(WorkloadSchema),
275
+ actionBatches: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
276
+ });
257
277
 
258
- /** Resolve the currently running Pi entry point so workers use the same local installation. */
259
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
260
- const currentScript = process.argv[1];
261
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
262
- if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
263
- return { command: process.execPath, args: [currentScript, ...args] };
264
- }
278
+ const StatusParams = Type.Object({
279
+ jobId: Type.Optional(Type.String({ minLength: 1 })),
280
+ });
265
281
 
266
- const executable = path.basename(process.execPath).toLowerCase();
267
- if (!/^(node|bun)(\.exe)?$/.test(executable)) {
268
- return { command: process.execPath, args };
269
- }
270
- return { command: "pi", args };
282
+ /** Identify plain objects before reading configuration and RPC data. */
283
+ function isRecord(value: unknown): value is Record<string, unknown> {
284
+ return typeof value === "object" && value !== null && !Array.isArray(value);
271
285
  }
272
286
 
273
- /** Create a zeroed Pi usage object for aggregation into the parent tool result. */
287
+ /** Create a fresh usage accumulator for one worker job. */
274
288
  function emptyUsage(): UsageTotals {
275
289
  return {
276
290
  input: 0,
@@ -282,7 +296,7 @@ function emptyUsage(): UsageTotals {
282
296
  };
283
297
  }
284
298
 
285
- /** Add one assistant response's reported usage to the worker total. */
299
+ /** Add one independently reported usage record without changing its provenance. */
286
300
  function addUsage(total: UsageTotals, usage: EventUsage | undefined): void {
287
301
  if (!usage) return;
288
302
  total.input += usage.input ?? 0;
@@ -299,452 +313,1167 @@ function addUsage(total: UsageTotals, usage: EventUsage | undefined): void {
299
313
  total.cost.total += usage.cost?.total ?? 0;
300
314
  }
301
315
 
302
- /** Keep model-facing worker output within a predictable context budget. */
303
- function truncate(text: string, limit: number): string {
304
- if (text.length <= limit) return text;
305
- return `${text.slice(0, limit)}\n\n[Output truncated: ${text.length - limit} characters omitted.]`;
316
+ /** Return only usage not already attributed by an earlier parent tool result. */
317
+ function usageDifference(current: UsageTotals, previous: UsageTotals): UsageTotals {
318
+ const result = emptyUsage();
319
+ result.input = Math.max(0, current.input - previous.input);
320
+ result.output = Math.max(0, current.output - previous.output);
321
+ result.cacheRead = Math.max(0, current.cacheRead - previous.cacheRead);
322
+ result.cacheWrite = Math.max(0, current.cacheWrite - previous.cacheWrite);
323
+ result.totalTokens = Math.max(0, current.totalTokens - previous.totalTokens);
324
+ if (current.cacheWrite1h !== undefined) result.cacheWrite1h = Math.max(0, current.cacheWrite1h - (previous.cacheWrite1h ?? 0));
325
+ if (current.reasoning !== undefined) result.reasoning = Math.max(0, current.reasoning - (previous.reasoning ?? 0));
326
+ result.cost.input = Math.max(0, current.cost.input - previous.cost.input);
327
+ result.cost.output = Math.max(0, current.cost.output - previous.cost.output);
328
+ result.cost.cacheRead = Math.max(0, current.cost.cacheRead - previous.cost.cacheRead);
329
+ result.cost.cacheWrite = Math.max(0, current.cost.cacheWrite - previous.cost.cacheWrite);
330
+ result.cost.total = Math.max(0, current.cost.total - previous.cost.total);
331
+ return result;
332
+ }
333
+
334
+ /** Preserve both conclusions and setup when shortening model-facing worker text. */
335
+ function headTail(text: string, limit = MODEL_TEXT_LIMIT): { text: string; truncated: boolean } {
336
+ if (text.length <= limit) return { text, truncated: false };
337
+ const markerBudget = 100;
338
+ const available = Math.max(2, limit - markerBudget);
339
+ const headLength = Math.ceil(available * 0.4);
340
+ const tailLength = available - headLength;
341
+ const omitted = text.length - headLength - tailLength;
342
+ return {
343
+ text: `${text.slice(0, headLength)}\n\n[… ${omitted} characters omitted; complete text retained in tool details …]\n\n${text.slice(-tailLength)}`,
344
+ truncated: true,
345
+ };
346
+ }
347
+
348
+ /** Keep one-line task and activity clues compact without introducing multiline omission markers. */
349
+ function compactLine(value: unknown, limit = 180): string {
350
+ if (typeof value !== "string") return "Preparing delegation…";
351
+ const normalized = value.replaceAll(/\s+/g, " ").trim();
352
+ if (!normalized) return "Preparing delegation…";
353
+ return normalized.length <= limit ? normalized : `${normalized.slice(0, Math.max(1, limit - 1))}…`;
306
354
  }
307
355
 
308
- /** Render a compact activity label from a worker tool call. */
356
+ /** Keep the latest diagnostics because process failures are normally reported at the end. */
357
+ function appendTail(current: string, addition: string, limit: number): string {
358
+ const combined = current + addition;
359
+ return combined.length <= limit ? combined : `[earlier stderr omitted]\n${combined.slice(-limit)}`;
360
+ }
361
+
362
+ /** Validate workload semantics independently from the TypeBox transport schema. */
363
+ function validateWorkload(workload: Workload): void {
364
+ if (workload.investigationUnits + workload.changeUnits + workload.verificationUnits <= 0) {
365
+ throw new Error("workload must include at least one investigation, change, or verification unit");
366
+ }
367
+ }
368
+
369
+ /** Convert semantic work estimates and model limits into enforceable job ceilings. */
370
+ function calculateBudget(
371
+ role: Role,
372
+ workload: Workload,
373
+ contextWindow: number,
374
+ maxOutputTokens: number,
375
+ policy: DispatcherPolicy,
376
+ ): CalculatedBudget {
377
+ validateWorkload(workload);
378
+ const plannedTurns =
379
+ 2 +
380
+ Math.ceil(workload.investigationUnits * 1.25) +
381
+ Math.ceil(workload.changeUnits * 1.5) +
382
+ workload.verificationUnits;
383
+ const roleFactor = role === "engineer" || role === "designer" ? 1.25 : 1;
384
+ const contextScale = Math.max(0.75, Math.min(1.5, contextWindow / 400_000));
385
+ // Reserve two turns for synthesis or recovery after the planned tool-producing work.
386
+ const turns = Math.min(policy.absoluteMaxTurns, Math.max(6, Math.ceil(plannedTurns * roleFactor * contextScale) + 2));
387
+ const secondsPerTurn = role === "engineer" || role === "designer" ? 50 : 30;
388
+ const seconds = Math.min(
389
+ policy.absoluteMaxSeconds,
390
+ Math.max(120, 30 + turns * secondsPerTurn + workload.expectedLongRunningSeconds),
391
+ );
392
+ return { workload: { ...workload }, turns, seconds, contextWindow, maxOutputTokens };
393
+ }
394
+
395
+ /** Merge optional user configuration while keeping ordinary task budgets formula-owned. */
396
+ function loadConfig(): DispatcherConfig {
397
+ const result: DispatcherConfig = {
398
+ mechanic: { ...DEFAULT_CONFIG.mechanic },
399
+ assistant: { ...DEFAULT_CONFIG.assistant },
400
+ engineer: { ...DEFAULT_CONFIG.engineer },
401
+ designer: { ...DEFAULT_CONFIG.designer },
402
+ policy: { ...DEFAULT_CONFIG.policy },
403
+ };
404
+ if (!fs.existsSync(CONFIG_PATH)) return result;
405
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")) as unknown;
406
+ if (!isRecord(parsed)) throw new Error(`${CONFIG_PATH} must contain an object`);
407
+ for (const role of ROLES) {
408
+ const value = parsed[role];
409
+ if (value === undefined) continue;
410
+ if (!isRecord(value)) throw new Error(`"${role}" must be an object`);
411
+ if (value.model !== undefined) {
412
+ if (typeof value.model !== "string" || !value.model.includes("/")) throw new Error(`"${role}.model" must be provider/model`);
413
+ result[role].model = value.model;
414
+ }
415
+ if (value.thinking !== undefined) {
416
+ if (typeof value.thinking !== "string" || !THINKING_LEVELS.has(value.thinking as ThinkingLevel)) {
417
+ throw new Error(`"${role}.thinking" is invalid`);
418
+ }
419
+ result[role].thinking = value.thinking as ThinkingLevel;
420
+ }
421
+ }
422
+ if (parsed.policy !== undefined) {
423
+ if (!isRecord(parsed.policy)) throw new Error('"policy" must be an object');
424
+ for (const field of ["absoluteMaxTurns", "absoluteMaxSeconds", "pausedLeaseSeconds", "noProgressSeconds"] as const) {
425
+ const value = parsed.policy[field];
426
+ if (value === undefined) continue;
427
+ if (!Number.isInteger(value) || (value as number) <= 0) throw new Error(`"policy.${field}" must be a positive integer`);
428
+ result.policy[field] = value as number;
429
+ }
430
+ }
431
+ return result;
432
+ }
433
+
434
+ /** Resolve the direct current Pi CLI script required for Node IPC supervision. */
435
+ function getDirectPiInvocation(args: string[]): { command: string; args: string[] } {
436
+ if (TEST_MODE && process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE) {
437
+ return { command: process.execPath, args: [process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE, ...args] };
438
+ }
439
+ const currentScript = process.argv[1];
440
+ if (!currentScript || currentScript.startsWith("/$bunfs/root/") || !fs.existsSync(currentScript)) {
441
+ throw new Error("Supervised delegation requires a directly addressable current Pi CLI script.");
442
+ }
443
+ if (!/^(node)(\.exe)?$/i.test(path.basename(process.execPath))) {
444
+ throw new Error("Supervised delegation requires Pi to run under Node.js so the private IPC gate is enforceable.");
445
+ }
446
+ return { command: process.execPath, args: [currentScript, ...args] };
447
+ }
448
+
449
+ /** Render a bounded activity label without embedding bulky command content. */
309
450
  function formatActivity(name: string, args: unknown): string {
310
451
  if (name === "bash" && isRecord(args) && typeof args.command === "string") {
311
- return `$ ${truncate(args.command, 120)}`;
452
+ return `$ ${headTail(args.command, 140).text.replaceAll("\n", " ")}`;
312
453
  }
313
454
  if (isRecord(args) && typeof args.path === "string") return `${name} ${args.path}`;
314
455
  return name;
315
456
  }
316
457
 
317
- /** Format a stopped worker's observed state for the orchestrator's continuation decision. */
318
- function formatCheckpoint(checkpoint: WorkerCheckpoint): string {
319
- const completed = checkpoint.completedActivities.length > 0 ? checkpoint.completedActivities.join("; ") : "none observed";
320
- const active = checkpoint.activeActivities.length > 0 ? checkpoint.activeActivities.join("; ") : "none observed";
321
- const text = checkpoint.lastAssistantText ? `\nLast worker text:\n${truncate(checkpoint.lastAssistantText, 4_000)}` : "";
322
- return [
323
- "Worker checkpoint:",
324
- `- Stop reason: ${checkpoint.reason}`,
325
- `- Delegated task: ${truncate(checkpoint.task, 2_000)}`,
326
- `- Completed tool activity: ${completed}`,
327
- `- Active or interrupted tool activity: ${active}`,
328
- `- Filesystem effects may remain: ${checkpoint.filesystemEffectsMayRemain ? "yes" : "no"}`,
329
- text,
330
- "Continuation is not automatic. Inspect material state, then decide whether to continue directly, delegate a bounded continuation with a newly selected tool allowlist, request user input, or stop with a limitation.",
331
- ].filter(Boolean).join("\n");
458
+ /** Extract a text-only assistant report from one finalized RPC message. */
459
+ function assistantText(event: ParsedEvent): string {
460
+ return (event.message?.content ?? [])
461
+ .filter((part) => part.type === "text" && part.text)
462
+ .map((part) => part.text as string)
463
+ .join("\n")
464
+ .trim();
332
465
  }
333
466
 
334
- /** Run one isolated Pi worker through its full tool-using agent loop. */
335
- async function runWorker(
336
- role: Role,
337
- task: string,
338
- config: WorkerConfig,
339
- tools: string[],
340
- cwd: string,
341
- signal: AbortSignal | undefined,
342
- onUpdate: ((result: { content: Array<{ type: "text"; text: string }>; details: WorkerDetails }) => void) | undefined,
343
- ): Promise<WorkerResult> {
344
- const startedAt = Date.now();
345
- const usage = emptyUsage();
346
- const activity: WorkerActivity[] = [];
347
- const activeActivity = new Map<string, WorkerActivity>();
348
- const streamingText = new Map<number, string>();
349
- let turns = 0;
350
- let finalOutput = "";
351
- let stopReason: string | undefined;
352
- let modelError: string | undefined;
353
- let stderr = "";
354
- let forcedStatus: WorkerStatus | undefined;
355
- let checkpointRequestedReason: "timeout" | "turn_limit" | undefined;
356
- let processError: string | undefined;
357
-
358
- // Keep role instructions out of the command line and remove them after the worker exits.
359
- const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-task-dispatcher-"));
360
-
361
- try {
362
- const promptPath = path.join(tempDir, `${role}.md`);
363
- await fs.promises.writeFile(promptPath, ROLE_PROMPTS[role], { encoding: "utf8", mode: 0o600 });
467
+ /** Build the worker-only gate extension that stops the agent loop after persisted tool results. */
468
+ function gateSource(): string {
469
+ return `/** Pause this worker after each persisted tool batch so only the parent dispatcher can resume it. */
470
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
471
+ export default function (pi: ExtensionAPI): void {
472
+ pi.on("turn_end", async (event, ctx) => {
473
+ if (event.toolResults.length === 0) return;
474
+ if (typeof process.send !== "function") {
475
+ ctx.abort();
476
+ throw new Error("Task dispatcher IPC is unavailable.");
477
+ }
478
+ process.send({ type: "task_dispatcher_boundary", turnIndex: event.turnIndex, toolResults: event.toolResults.length });
479
+ ctx.abort();
480
+ });
481
+ }
482
+ `;
483
+ }
484
+
485
+ /** Return whether a job status can no longer execute or resume work. */
486
+ function isTerminal(status: JobStatus): boolean {
487
+ return ["completed", "checkpointed", "taken_over", "cancelled", "hard_stopped", "failed"].includes(status);
488
+ }
489
+
490
+ let nextJobIndex = 1;
491
+
492
+ /** Supervise one reusable in-memory Pi worker and expose only boundary-sized state. */
493
+ class WorkerJob {
494
+ readonly id: string;
495
+ readonly role: Role;
496
+ readonly task: string;
497
+ readonly tools: string[];
498
+ readonly model: string;
499
+ readonly thinking: ThinkingLevel;
500
+ readonly cwd: string;
501
+ readonly policy: DispatcherPolicy;
502
+ readonly budget: CalculatedBudget;
503
+ readonly startedAt = Date.now();
504
+ readonly usage = emptyUsage();
505
+ readonly actions: WorkerAction[] = [];
506
+ readonly context: ContextMetrics;
507
+
508
+ status: JobStatus = "starting";
509
+ sequence = 0;
510
+ turns = 0;
511
+ phase = "startup";
512
+ recommendation = "continue";
513
+ workerText = "";
514
+ diagnostic: string | undefined;
515
+
516
+ private child: ChildProcess | undefined;
517
+ private temporaryDirectory: string | undefined;
518
+ private stdoutBuffer = "";
519
+ private stderr = "";
520
+ private requestSequence = 0;
521
+ private actionSequence = 0;
522
+ private completionSequence = 0;
523
+ private activeActions = new Map<string, WorkerAction>();
524
+ private pendingRequests = new Map<string, { resolve: (event: ParsedEvent) => void; reject: (error: Error) => void }>();
525
+ private settleWaiter: { resolve: () => void; reject: (error: Error) => void } | undefined;
526
+ private boundaryWaiter: { resolve: () => void; reject: (error: Error) => void } | undefined;
527
+ private boundaryObserved = false;
528
+ private lastStopReason: string | undefined;
529
+ private lastProgressAt = Date.now();
530
+ private activeElapsedMs = 0;
531
+ private activeStartedAt: number | undefined;
532
+ private progressCallback: ((snapshot: WorkerSnapshot) => void) | undefined;
533
+ private pausedLease: ReturnType<typeof setTimeout> | undefined;
534
+ private closePromise: Promise<void> | undefined;
535
+
536
+ constructor(
537
+ role: Role,
538
+ task: string,
539
+ tools: string[],
540
+ config: ResolvedWorkerRoleConfig,
541
+ cwd: string,
542
+ policy: DispatcherPolicy,
543
+ budget: CalculatedBudget,
544
+ ) {
545
+ this.id = `${role}-${nextJobIndex++}`;
546
+ this.role = role;
547
+ this.task = task;
548
+ this.tools = tools;
549
+ this.model = config.model;
550
+ this.thinking = config.thinking;
551
+ this.cwd = cwd;
552
+ this.policy = policy;
553
+ this.budget = budget;
554
+ this.context = {
555
+ contextWindow: budget.contextWindow,
556
+ peakPercent: 0,
557
+ compactionAttempts: 0,
558
+ compactions: 0,
559
+ compactionSkips: 0,
560
+ compactionFailures: 0,
561
+ };
562
+ }
563
+
564
+ /** Start the isolated child, initialize compaction policy, and execute the first supervised stage. */
565
+ async start(signal: AbortSignal | undefined, onProgress: (snapshot: WorkerSnapshot) => void): Promise<WorkerSnapshot> {
566
+ this.temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-task-dispatcher-"));
567
+ const rolePromptPath = path.join(this.temporaryDirectory, `${this.role}.md`);
568
+ const gatePath = path.join(this.temporaryDirectory, "supervision-gate.ts");
569
+ await fs.promises.writeFile(rolePromptPath, ROLE_PROMPTS[this.role], { encoding: "utf8", mode: 0o600 });
570
+ await fs.promises.writeFile(gatePath, gateSource(), { encoding: "utf8", mode: 0o600 });
364
571
 
365
572
  const args = [
366
- "--mode",
367
- "rpc",
573
+ "--mode", "rpc",
368
574
  "--no-session",
369
- "--model",
370
- config.model,
371
- "--thinking",
372
- config.thinking,
373
- ...(tools.length > 0 ? ["--tools", tools.join(",")] : ["--no-tools"]),
374
- "--append-system-prompt",
375
- promptPath,
575
+ "--no-extensions",
576
+ "--model", this.model,
577
+ "--thinking", this.thinking,
578
+ ...(this.tools.length > 0 ? ["--tools", this.tools.join(",")] : ["--no-tools"]),
579
+ "--append-system-prompt", rolePromptPath,
580
+ ...(this.tools.includes("codemode") ? ["--extension", "builtin:codemode"] : []),
581
+ "--extension", gatePath,
376
582
  ];
583
+ const invocation = getDirectPiInvocation(args);
584
+ this.child = spawn(invocation.command, invocation.args, {
585
+ cwd: this.cwd,
586
+ shell: false,
587
+ stdio: ["pipe", "pipe", "pipe", "ipc"],
588
+ env: { ...process.env, [WORKER_ENV]: this.role },
589
+ });
590
+ this.attachProcessHandlers();
591
+ this.activeStartedAt = Date.now();
592
+ try {
593
+ await this.request("set_auto_compaction", { type: "set_auto_compaction", enabled: true }, this.operationTimeout(30_000));
594
+ } finally {
595
+ this.commitActiveElapsed();
596
+ }
597
+ return await this.runStages(`Delegated ${this.role} task:\n${this.task}`, 1, signal, onProgress);
598
+ }
377
599
 
378
- const exitCode = await new Promise<number | null>((resolve) => {
379
- const invocation = getPiInvocation(args);
380
- const child = spawn(invocation.command, invocation.args, {
381
- cwd,
382
- shell: false,
383
- stdio: ["pipe", "pipe", "pipe"],
384
- env: { ...process.env, [WORKER_ENV]: role },
385
- });
386
- let stdoutBuffer = "";
387
- let unfinishedUsage: EventUsage | undefined;
388
- const stdoutDecoder = new TextDecoder("utf-8");
389
- const stderrDecoder = new TextDecoder("utf-8");
390
- let settled = false;
391
- let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
392
-
393
- // Stop the process once and escalate to a forceful kill if it does not exit.
394
- const stopChild = (status: WorkerStatus) => {
395
- if (forcedStatus) return;
396
- forcedStatus = status;
397
- child.kill("SIGTERM");
398
- forceKillTimer = setTimeout(() => {
399
- if (!settled) child.kill("SIGKILL");
400
- }, 5_000);
401
- };
600
+ /** Continue or revise work for a bounded number of action batches. */
601
+ async continue(
602
+ instructions: string | undefined,
603
+ actionBatches: number,
604
+ signal: AbortSignal | undefined,
605
+ onProgress: (snapshot: WorkerSnapshot) => void,
606
+ ): Promise<WorkerSnapshot> {
607
+ if (this.status !== "paused") throw new Error(`Job ${this.id} is ${this.status}, not paused.`);
608
+ this.clearPausedLease();
609
+ const message = instructions
610
+ ? `The manager revised the delegated task with these controlling instructions:\n${instructions}\nContinue from completed work without repeating it.`
611
+ : "Continue the delegated task from the completed tool results. Do not repeat completed work.";
612
+ try {
613
+ return await this.runStages(message, actionBatches, signal, onProgress);
614
+ } catch (error) {
615
+ if (this.status === "paused") this.schedulePausedLease();
616
+ throw error;
617
+ }
618
+ }
402
619
 
403
- // Ask the worker to stop at the next safe turn boundary and author its handoff report.
404
- const requestCheckpoint = (reason: "timeout" | "turn_limit") => {
405
- if (checkpointRequestedReason || settled || child.stdin.writableEnded) return;
406
- checkpointRequestedReason = reason;
407
- const message =
408
- reason === "timeout"
409
- ? "The delegation time limit is approaching. Stop starting new work. At the next safe boundary, return a concise checkpoint with completed work, files changed, checks run, current state, interrupted work, and unresolved items. Do not continue implementation."
410
- : "The delegation turn limit has been reached. After current tool calls finish, stop work and return a concise checkpoint with completed work, files changed, checks run, current state, interrupted work, and unresolved items. Do not continue implementation.";
411
- child.stdin.write(`${JSON.stringify({ id: `checkpoint-${reason}`, type: "steer", message })}\n`);
412
- };
620
+ /** Recalculate formula-owned ceilings from a revised semantic workload. */
621
+ recalculate(workload: Workload): WorkerSnapshot {
622
+ if (this.status !== "paused") throw new Error(`Job ${this.id} is ${this.status}, not paused.`);
623
+ const remaining = calculateBudget(
624
+ this.role,
625
+ workload,
626
+ this.budget.contextWindow,
627
+ this.budget.maxOutputTokens,
628
+ this.policy,
629
+ );
630
+ const recalculated: CalculatedBudget = {
631
+ ...remaining,
632
+ turns: Math.min(this.policy.absoluteMaxTurns, this.turns + remaining.turns),
633
+ seconds: Math.min(this.policy.absoluteMaxSeconds, Math.ceil(this.currentActiveElapsedMs() / 1_000) + remaining.seconds),
634
+ };
635
+ Object.assign(this.budget, recalculated);
636
+ this.phase = "budget recalculated";
637
+ this.recommendation = "continue";
638
+ return this.snapshot();
639
+ }
413
640
 
414
- let lastProgressAt = 0;
415
- const currentStreamingText = () =>
416
- [...streamingText.entries()].sort(([left], [right]) => left - right).map(([, text]) => text).join("\n").trim();
417
- const emitProgress = (text: string, force = false) => {
418
- const now = Date.now();
419
- if (!force && now - lastProgressAt < 250) return;
420
- lastProgressAt = now;
421
- onUpdate?.({
422
- content: [{ type: "text", text }],
423
- details: {
424
- role,
425
- status: "running",
426
- model: config.model,
427
- thinking: config.thinking,
428
- tools,
429
- turns,
430
- durationMs: now - startedAt,
431
- exitCode: null,
432
- activity: activity.map((item) => ({ ...item })),
433
- },
434
- });
435
- };
641
+ /** Compact the idle worker context and refresh authoritative RPC statistics. */
642
+ async compact(): Promise<WorkerSnapshot> {
643
+ if (this.status !== "paused") throw new Error(`Job ${this.id} is ${this.status}, not paused.`);
644
+ this.clearPausedLease();
645
+ this.status = "compacting";
646
+ this.phase = "worker context compaction";
647
+ this.activeStartedAt = Date.now();
648
+ let response: ParsedEvent;
649
+ try {
650
+ response = await this.request("compact", { type: "compact" }, this.operationTimeout(300_000));
651
+ } finally {
652
+ this.commitActiveElapsed();
653
+ if (this.status === "compacting") this.status = "paused";
654
+ this.schedulePausedLease();
655
+ }
656
+ if (!response.success) {
657
+ this.phase = "compaction not performed";
658
+ this.diagnostic = response.error || "Worker compaction was unavailable.";
659
+ this.recommendation = "continue without compaction, checkpoint, or take over";
660
+ await this.refreshStats();
661
+ return this.snapshot();
662
+ }
663
+ this.phase = "compaction complete";
664
+ this.diagnostic = undefined;
665
+ this.recommendation = "continue";
666
+ await this.refreshStats();
667
+ return this.snapshot();
668
+ }
436
669
 
437
- // Reduce the documented JSON event stream into live progress and a recoverable checkpoint.
438
- const processLine = (line: string) => {
439
- if (!line.trim()) return;
440
- let event: ParsedEvent;
441
- try {
442
- event = JSON.parse(line) as ParsedEvent;
443
- } catch {
444
- return;
445
- }
670
+ /** End the job from parent-observed state without asking the worker to cooperate. */
671
+ async stop(status: Extract<JobStatus, "checkpointed" | "taken_over" | "cancelled" | "hard_stopped">, reason: string): Promise<WorkerSnapshot> {
672
+ this.clearPausedLease();
673
+ this.status = status;
674
+ this.phase = reason;
675
+ this.recommendation = status === "taken_over" ? "manager owns remaining work" : "closed";
676
+ const snapshot = this.snapshot();
677
+ await this.dispose();
678
+ return snapshot;
679
+ }
446
680
 
447
- // Fail promptly when RPC rejects the initial prompt instead of waiting for the wall-clock limit.
448
- if (event.type === "response" && event.id === "delegated-task" && event.success === false) {
449
- processError = event.error || "Pi rejected the delegated RPC prompt.";
450
- stopChild("process_error");
451
- return;
452
- }
681
+ /** Return a stable state projection without exposing process handles or raw RPC records. */
682
+ snapshot(): WorkerSnapshot {
683
+ const display = headTail(this.workerText);
684
+ const completed = this.actions.filter((action) => action.status === "completed").length;
685
+ const failed = this.actions.filter((action) => action.status === "failed").length;
686
+ const currentAction = [...this.actions].reverse().find((action) => action.status === "running");
687
+ // Present completed actions in actual completion order while retaining start order in the ledger.
688
+ const recentActions = this.actions
689
+ .filter((action) => action.status !== "running")
690
+ .sort((left, right) => (left.completionSequence ?? 0) - (right.completionSequence ?? 0))
691
+ .slice(-8)
692
+ .map((action) => ({ ...action, mediaOutputs: action.mediaOutputs?.map((output) => ({ ...output })) }));
693
+ return {
694
+ jobId: this.id,
695
+ role: this.role,
696
+ status: this.status,
697
+ model: this.model,
698
+ thinking: this.thinking,
699
+ sequence: this.sequence,
700
+ phase: this.phase,
701
+ recommendation: this.recommendation,
702
+ taskDisplay: compactLine(this.task),
703
+ currentActivity: currentAction?.label,
704
+ turns: this.turns,
705
+ durationMs: this.currentActiveElapsedMs(),
706
+ budget: { ...this.budget, workload: { ...this.budget.workload } },
707
+ context: { ...this.context },
708
+ actionCounts: {
709
+ total: this.actions.length,
710
+ completed,
711
+ failed,
712
+ mutations: this.actions.filter((action) => action.mutating).length,
713
+ },
714
+ recentActions,
715
+ actionLedger: this.actions.map((action) => ({ ...action, mediaOutputs: action.mediaOutputs?.map((output) => ({ ...output })) })),
716
+ workerText: this.workerText,
717
+ workerTextDisplay: display.text,
718
+ workerTextTruncated: display.truncated,
719
+ diagnostic: this.diagnostic,
720
+ tools: [...this.tools],
721
+ usage: structuredClone(this.usage),
722
+ };
723
+ }
453
724
 
454
- if (event.type === "agent_settled") {
455
- if (checkpointRequestedReason && !forcedStatus) forcedStatus = checkpointRequestedReason;
456
- if (!child.stdin.writableEnded) child.stdin.end();
457
- return;
725
+ /** Release process and temporary resources idempotently. */
726
+ async dispose(): Promise<void> {
727
+ if (this.closePromise) return await this.closePromise;
728
+ this.clearPausedLease();
729
+ this.closePromise = (async () => {
730
+ const child = this.child;
731
+ if (child && child.exitCode === null && child.signalCode === null) {
732
+ child.kill("SIGTERM");
733
+ await Promise.race([
734
+ new Promise<void>((resolve) => child.once("close", () => resolve())),
735
+ new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
736
+ ]);
737
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
738
+ }
739
+ await this.removeTemporaryDirectory();
740
+ })();
741
+ return await this.closePromise;
742
+ }
743
+
744
+ /** Attach one reducer to each child channel and preserve failures for synthetic handoff. */
745
+ private attachProcessHandlers(): void {
746
+ const child = this.child;
747
+ if (!child?.stdout || !child.stderr || !child.stdin) throw new Error("Worker process pipes are unavailable.");
748
+ const stdoutDecoder = new TextDecoder("utf-8");
749
+ const stderrDecoder = new TextDecoder("utf-8");
750
+ child.stdout.on("data", (chunk) => {
751
+ this.lastProgressAt = Date.now();
752
+ this.stdoutBuffer += stdoutDecoder.decode(chunk, { stream: true });
753
+ const lines = this.stdoutBuffer.split("\n");
754
+ this.stdoutBuffer = lines.pop() ?? "";
755
+ for (const line of lines) this.processLine(line);
756
+ });
757
+ child.stderr.on("data", (chunk) => {
758
+ this.lastProgressAt = Date.now();
759
+ this.stderr = appendTail(this.stderr, stderrDecoder.decode(chunk, { stream: true }), STDERR_LIMIT);
760
+ });
761
+ child.stdin.on("error", (error) => {
762
+ this.diagnostic = error.message;
763
+ this.failPending(error);
764
+ });
765
+ child.on("message", (message) => {
766
+ this.lastProgressAt = Date.now();
767
+ if (!isRecord(message) || message.type !== "task_dispatcher_boundary") return;
768
+ const boundary = message as unknown as GateBoundaryMessage;
769
+ this.boundaryObserved = true;
770
+ this.sequence++;
771
+ this.phase = `completed tool batch at worker turn ${boundary.turnIndex}`;
772
+ this.boundaryWaiter?.resolve();
773
+ });
774
+ child.on("error", (error) => {
775
+ this.diagnostic = error.message;
776
+ this.failPending(error);
777
+ });
778
+ child.on("close", (code) => {
779
+ this.stdoutBuffer += stdoutDecoder.decode();
780
+ this.stderr = appendTail(this.stderr, stderrDecoder.decode(), STDERR_LIMIT);
781
+ if (this.stdoutBuffer.trim()) this.processLine(this.stdoutBuffer);
782
+ const error = new Error(this.diagnostic || `Worker process exited with code ${code ?? "unknown"}.`);
783
+ this.failPending(error);
784
+ if (!isTerminal(this.status)) {
785
+ this.status = "failed";
786
+ this.diagnostic = error.message;
787
+ }
788
+ void this.removeTemporaryDirectory();
789
+ });
790
+ }
791
+
792
+ /** Reduce one complete JSONL record into requests, activity, usage, and settlement state. */
793
+ private processLine(line: string): void {
794
+ if (!line.trim()) return;
795
+ let event: ParsedEvent;
796
+ try {
797
+ event = JSON.parse(line) as ParsedEvent;
798
+ } catch {
799
+ return;
800
+ }
801
+ if (event.type === "response" && event.id) {
802
+ const pending = this.pendingRequests.get(event.id);
803
+ if (pending) {
804
+ this.pendingRequests.delete(event.id);
805
+ pending.resolve(event);
806
+ }
807
+ return;
808
+ }
809
+ if (event.type === "message_end" && event.message?.role === "assistant") {
810
+ const stopReason = event.message.stopReason;
811
+ const gateAbort =
812
+ (stopReason === "aborted" || stopReason === "error") &&
813
+ event.message.errorMessage?.toLowerCase().includes("operation was aborted");
814
+ if (!gateAbort) {
815
+ this.turns++;
816
+ this.lastStopReason = stopReason;
817
+ addUsage(this.usage, event.message.usage ?? event.usage);
818
+ const text = assistantText(event);
819
+ if (text) this.workerText = text;
820
+ if (event.message.errorMessage) this.diagnostic = event.message.errorMessage;
821
+ }
822
+ return;
823
+ }
824
+ if (event.type === "tool_execution_start" && event.toolCallId && event.toolName) {
825
+ const action: WorkerAction = {
826
+ sequence: ++this.actionSequence,
827
+ toolCallId: event.toolCallId,
828
+ toolName: event.toolName,
829
+ label: formatActivity(event.toolName, event.args),
830
+ mutating: MUTATING_TOOLS.has(event.toolName),
831
+ status: "running",
832
+ startedAt: Date.now(),
833
+ parentToolCallId: event.parentToolCallId,
834
+ };
835
+ this.actions.push(action);
836
+ this.activeActions.set(action.toolCallId, action);
837
+ this.emitProgress();
838
+ return;
839
+ }
840
+ if (event.type === "tool_execution_end" && event.toolCallId) {
841
+ const action = this.activeActions.get(event.toolCallId);
842
+ if (action) {
843
+ action.status = event.isError ? "failed" : "completed";
844
+ action.endedAt = Date.now();
845
+ action.completionSequence = ++this.completionSequence;
846
+ const mediaOutputs = (event.result?.content ?? []).flatMap((part) =>
847
+ part.type === "image" && typeof part.mimeType === "string" && typeof part.data === "string"
848
+ ? [{ mimeType: part.mimeType, bytes: Buffer.from(part.data, "base64").length }]
849
+ : []
850
+ );
851
+ if (mediaOutputs.length > 0) {
852
+ action.mediaOutputs = mediaOutputs;
853
+ action.label += ` · ${mediaOutputs.length} image${mediaOutputs.length === 1 ? "" : "s"}`;
458
854
  }
855
+ this.activeActions.delete(event.toolCallId);
856
+ }
857
+ // Execution-end events carry each call's own usage before Pi merges nested usage into persistence.
858
+ addUsage(this.usage, event.result?.usage);
859
+ this.emitProgress();
860
+ return;
861
+ }
862
+ if (event.type === "compaction_start") {
863
+ this.context.compactionAttempts++;
864
+ return;
865
+ }
866
+ if (event.type === "compaction_end") {
867
+ if (event.errorMessage?.toLowerCase().includes("nothing to compact")) this.context.compactionSkips++;
868
+ else if (event.aborted || event.errorMessage) this.context.compactionFailures++;
869
+ else this.context.compactions++;
870
+ addUsage(this.usage, event.result?.usage);
871
+ return;
872
+ }
873
+ if (event.type === "agent_settled") this.settleWaiter?.resolve();
874
+ }
459
875
 
460
- if (event.type === "message_update") {
461
- if (event.usage) unfinishedUsage = event.usage;
462
- const update = event.assistantMessageEvent;
463
- if (typeof update?.contentIndex === "number") {
464
- if (update.type === "text_start") streamingText.set(update.contentIndex, "");
465
- if (update.type === "text_delta" && update.delta) {
466
- streamingText.set(update.contentIndex, (streamingText.get(update.contentIndex) ?? "") + update.delta);
467
- emitProgress(`${role}: ${truncate(currentStreamingText(), 500) || "working..."}`);
468
- }
469
- if (update.type === "text_end" && update.content !== undefined) {
470
- streamingText.set(update.contentIndex, update.content);
471
- emitProgress(`${role}: ${truncate(currentStreamingText(), 500) || "working..."}`, true);
472
- }
876
+ /** Execute one or more supervised action batches while respecting calculated ceilings. */
877
+ private async runStages(
878
+ initialMessage: string,
879
+ actionBatches: number,
880
+ signal: AbortSignal | undefined,
881
+ onProgress: (snapshot: WorkerSnapshot) => void,
882
+ ): Promise<WorkerSnapshot> {
883
+ let message = initialMessage;
884
+ for (let batch = 0; batch < actionBatches; batch++) {
885
+ this.assertBudget();
886
+ const actionBaseline = this.actionSequence;
887
+ if (signal?.aborted) {
888
+ await this.stop("cancelled", "parent operation was already aborted");
889
+ throw new Error("Parent operation was already aborted.");
890
+ }
891
+ this.status = "running";
892
+ this.progressCallback = onProgress;
893
+ this.diagnostic = undefined;
894
+ this.activeStartedAt = Date.now();
895
+ this.phase = batch === 0 ? "worker turn" : "leased continuation";
896
+ this.recommendation = "wait for boundary";
897
+ onProgress(this.snapshot());
898
+ this.boundaryObserved = false;
899
+ this.lastStopReason = undefined;
900
+ this.settleWaiter = undefined;
901
+ const settled = new Promise<void>((resolve, reject) => {
902
+ this.settleWaiter = { resolve, reject };
903
+ });
904
+ const boundary = new Promise<void>((resolve, reject) => {
905
+ this.boundaryWaiter = { resolve, reject };
906
+ });
907
+ // Mark deferred rejections as observed even if cancellation beats prompt acceptance.
908
+ void settled.catch(() => undefined);
909
+ void boundary.catch(() => undefined);
910
+ const abort = () => {
911
+ const error = new Error("Parent operation aborted.");
912
+ this.settleWaiter?.reject(error);
913
+ void this.stop("cancelled", "parent operation aborted");
914
+ };
915
+ signal?.addEventListener("abort", abort, { once: true });
916
+ try {
917
+ const operation = (async () => {
918
+ const prompt = await this.request("prompt", { type: "prompt", message }, this.operationTimeout(30_000));
919
+ if (!prompt.success) throw new Error(prompt.error || "Worker rejected the prompt.");
920
+ if (!isRecord(prompt.data) || typeof prompt.data.disposition !== "string") {
921
+ throw new Error("Worker prompt response omitted its disposition.");
473
922
  }
474
- return;
923
+ if (prompt.data.disposition === "handled") throw new Error("Worker prompt was intercepted by an input handler; no agent run started.");
924
+ await settled;
925
+ })();
926
+ await this.waitForSettlement(operation);
927
+ if (!this.boundaryObserved && this.actionSequence > actionBaseline) {
928
+ await Promise.race([
929
+ boundary,
930
+ new Promise<void>((_, reject) => setTimeout(() => reject(new Error("Worker tool turn settled without an enforced IPC boundary.")), 2_000)),
931
+ ]);
475
932
  }
476
-
477
- if (event.type === "tool_execution_start" && event.toolCallId && event.toolName) {
478
- const item: WorkerActivity = {
479
- toolCallId: event.toolCallId,
480
- label: formatActivity(event.toolName, event.args),
481
- status: "running",
482
- };
483
- activity.push(item);
484
- activeActivity.set(item.toolCallId, item);
485
- emitProgress(`${role}: running ${item.label}`, true);
486
- return;
933
+ } finally {
934
+ signal?.removeEventListener("abort", abort);
935
+ this.settleWaiter = undefined;
936
+ this.boundaryWaiter = undefined;
937
+ this.progressCallback = undefined;
938
+ this.commitActiveElapsed();
939
+ }
940
+ await this.refreshStats();
941
+ // Classify tool-free settlement while preserving output-limit responses for recovery.
942
+ if (!this.boundaryObserved) {
943
+ if (this.lastStopReason === "length") {
944
+ this.status = "paused";
945
+ this.phase = "worker response reached its output limit";
946
+ this.recommendation = "continue to recover the truncated response, revise, checkpoint, cancel, or take over";
947
+ this.schedulePausedLease();
948
+ return this.snapshot();
487
949
  }
950
+ this.status = this.lastStopReason === "stop" ? "completed" : "failed";
951
+ this.phase = this.status === "completed" ? "worker completed" : `worker stopped with ${this.lastStopReason ?? "no final reason"}`;
952
+ this.recommendation = this.status === "completed" ? "review and integrate" : "inspect failure";
953
+ const snapshot = this.snapshot();
954
+ await this.dispose();
955
+ return snapshot;
956
+ }
957
+ if (batch + 1 < actionBatches) {
958
+ message = "Continue from the completed action batch without repeating completed work.";
959
+ continue;
960
+ }
961
+ this.status = "paused";
962
+ this.phase = "safe post-tool-batch boundary";
963
+ this.recommendation = this.contextRecommendation();
964
+ this.schedulePausedLease();
965
+ return this.snapshot();
966
+ }
967
+ throw new Error("Unreachable supervised stage state.");
968
+ }
488
969
 
489
- if (event.type === "tool_execution_update" && event.toolCallId) {
490
- const item = activeActivity.get(event.toolCallId);
491
- if (item) emitProgress(`${role}: running ${item.label}`);
970
+ /** Wait for settlement while enforcing job deadline and no-progress policy externally. */
971
+ private async waitForSettlement(settled: Promise<void>): Promise<void> {
972
+ await new Promise<void>((resolve, reject) => {
973
+ const monitor = setInterval(() => {
974
+ const now = Date.now();
975
+ this.emitProgress();
976
+ if (this.currentActiveElapsedMs() >= this.budget.seconds * 1_000) {
977
+ clearInterval(monitor);
978
+ void this.stop("hard_stopped", "calculated absolute job deadline reached");
979
+ reject(new Error("Calculated absolute job deadline reached."));
492
980
  return;
493
981
  }
494
-
495
- if (event.type === "tool_execution_end" && event.toolCallId) {
496
- const item = activeActivity.get(event.toolCallId);
497
- if (item) {
498
- item.status = event.isError ? "failed" : "completed";
499
- activeActivity.delete(event.toolCallId);
500
- emitProgress(`${role}: ${item.status} ${item.label}`, true);
501
- }
502
- return;
982
+ if (now - this.lastProgressAt >= this.policy.noProgressSeconds * 1_000) {
983
+ clearInterval(monitor);
984
+ void this.stop("hard_stopped", "worker made no observable progress within policy");
985
+ reject(new Error("Worker no-progress policy elapsed."));
503
986
  }
987
+ }, 1_000);
988
+ settled.then(
989
+ () => { clearInterval(monitor); resolve(); },
990
+ (error) => { clearInterval(monitor); reject(error); },
991
+ );
992
+ });
993
+ }
504
994
 
505
- if (event.type !== "message_end" || event.message?.role !== "assistant") return;
506
-
507
- turns++;
508
- stopReason = event.message.stopReason ?? stopReason;
509
- modelError = event.message.errorMessage ?? modelError;
510
- addUsage(usage, event.message.usage ?? unfinishedUsage);
511
- unfinishedUsage = undefined;
512
- const completedText = (event.message.content ?? [])
513
- .filter((part) => part.type === "text" && part.text)
514
- .map((part) => part.text as string)
515
- .join("\n")
516
- .trim();
517
- if (completedText) finalOutput = completedText;
518
- streamingText.clear();
519
- if (turns >= config.maxTurns && stopReason === "toolUse") requestCheckpoint("turn_limit");
520
- };
995
+ /** Reject work that has reached a calculated or administrative boundary. */
996
+ private assertBudget(): void {
997
+ if (this.turns >= this.budget.turns) throw new Error(`Calculated turn budget ${this.budget.turns} is exhausted.`);
998
+ if (this.currentActiveElapsedMs() >= this.budget.seconds * 1_000) throw new Error(`Calculated active-time budget ${this.budget.seconds}s is exhausted.`);
999
+ }
521
1000
 
522
- // Keep long model and tool waits visibly alive even when the child emits no stream event.
523
- const heartbeat = setInterval(() => {
524
- const running = [...activeActivity.values()].map((item) => item.label).join("; ");
525
- const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
526
- emitProgress(`${role}: ${running ? `running ${running}` : "working"} (${elapsedSeconds}s)`, true);
527
- }, 1_000);
1001
+ /** Ask the idle RPC child for authoritative current context statistics. */
1002
+ private async refreshStats(): Promise<void> {
1003
+ try {
1004
+ const response = await this.request("get_session_stats", { type: "get_session_stats" }, this.operationTimeout(30_000));
1005
+ if (!response.success || !isRecord(response.data)) return;
1006
+ const usage = response.data.contextUsage;
1007
+ if (!isRecord(usage)) return;
1008
+ if (typeof usage.tokens === "number") this.context.tokens = usage.tokens;
1009
+ if (typeof usage.contextWindow === "number") this.context.contextWindow = usage.contextWindow;
1010
+ if (typeof usage.percent === "number") {
1011
+ this.context.percent = usage.percent;
1012
+ this.context.peakPercent = Math.max(this.context.peakPercent, usage.percent);
1013
+ }
1014
+ } catch (error) {
1015
+ this.diagnostic = `Context statistics unavailable: ${error instanceof Error ? error.message : String(error)}`;
1016
+ if (isTerminal(this.status)) throw error;
1017
+ }
1018
+ }
528
1019
 
529
- // Stream-decode UTF-8 before splitting complete JSONL records, retaining a trailing fragment.
530
- child.stdout.on("data", (chunk) => {
531
- stdoutBuffer += stdoutDecoder.decode(chunk, { stream: true });
532
- const lines = stdoutBuffer.split("\n");
533
- stdoutBuffer = lines.pop() ?? "";
534
- // Forward each complete event record to the worker-state reducer.
535
- for (const line of lines) processLine(line);
536
- });
537
- child.stderr.on("data", (chunk) => {
538
- stderr = truncate(stderr + stderrDecoder.decode(chunk, { stream: true }), STDERR_LIMIT);
539
- });
540
- child.on("error", (error) => {
541
- processError = error.message;
542
- });
543
- child.stdin.on("error", (error) => {
544
- processError ??= error.message;
545
- });
546
- // Flush decoder state and charge at most one unfinished streamed assistant response.
547
- child.on("close", (code) => {
548
- settled = true;
549
- clearInterval(heartbeat);
550
- if (forceKillTimer) clearTimeout(forceKillTimer);
551
- stdoutBuffer += stdoutDecoder.decode();
552
- stderr = truncate(stderr + stderrDecoder.decode(), STDERR_LIMIT);
553
- if (stdoutBuffer.trim()) processLine(stdoutBuffer);
554
- if (unfinishedUsage) addUsage(usage, unfinishedUsage);
555
- resolve(code);
556
- });
1020
+ /** Recommend the next manager action from context pressure and remaining budget. */
1021
+ private contextRecommendation(): string {
1022
+ const remainingTurns = this.budget.turns - this.turns;
1023
+ const remainingSeconds = this.budget.seconds - Math.ceil(this.currentActiveElapsedMs() / 1_000);
1024
+ if (remainingTurns <= 0 || remainingSeconds <= 0) return "checkpoint or take over: calculated budget exhausted";
1025
+ if (remainingTurns <= 2 || remainingSeconds <= 120) return "finalize now, recalculate remaining workload, checkpoint, or take over";
1026
+ if ((this.context.percent ?? 0) >= 75) return "compact worker context before continuing";
1027
+ if (this.actions.some((action) => action.status === "failed")) return "inspect failure before continuing";
1028
+ return "continue, revise, compact, checkpoint, cancel, or take over";
1029
+ }
557
1030
 
558
- // Start the RPC run after all stream handlers are ready.
559
- child.stdin.write(
560
- `${JSON.stringify({ id: "delegated-task", type: "prompt", message: `Delegated ${role} task:\n${task}` })}\n`,
561
- );
1031
+ /** Correlate one RPC request; an uncertain timeout closes the worker rather than permitting a racing command. */
1032
+ private async request(label: string, payload: Record<string, unknown>, timeoutMs: number): Promise<ParsedEvent> {
1033
+ const child = this.child;
1034
+ if (!child?.stdin || child.stdin.writableEnded) throw new Error("Worker RPC input is closed.");
1035
+ const id = `dispatcher-${label}-${++this.requestSequence}`;
1036
+ const response = new Promise<ParsedEvent>((resolve, reject) => this.pendingRequests.set(id, { resolve, reject }));
1037
+ child.stdin.write(`${JSON.stringify({ id, ...payload })}\n`);
1038
+ let timer: ReturnType<typeof setTimeout> | undefined;
1039
+ try {
1040
+ return await Promise.race([
1041
+ response,
1042
+ new Promise<ParsedEvent>((_, reject) => {
1043
+ timer = setTimeout(() => {
1044
+ const error = new Error(`Worker RPC ${label} timed out; worker closed to prevent a command race.`);
1045
+ void this.stop("hard_stopped", error.message);
1046
+ reject(error);
1047
+ }, Math.max(1, timeoutMs));
1048
+ }),
1049
+ ]);
1050
+ } finally {
1051
+ if (timer) clearTimeout(timer);
1052
+ this.pendingRequests.delete(id);
1053
+ }
1054
+ }
562
1055
 
563
- const checkpointLeadMs = (config.timeoutSeconds - config.checkpointGraceSeconds) * 1_000;
564
- const checkpointTimer = setTimeout(() => requestCheckpoint("timeout"), checkpointLeadMs);
565
- const timeout = setTimeout(() => stopChild("timeout"), config.timeoutSeconds * 1_000);
566
- child.once("close", () => {
567
- clearTimeout(checkpointTimer);
568
- clearTimeout(timeout);
569
- });
1056
+ /** Reject all consumers when the worker process can no longer produce state. */
1057
+ private failPending(error: Error): void {
1058
+ for (const pending of this.pendingRequests.values()) pending.reject(error);
1059
+ this.pendingRequests.clear();
1060
+ this.settleWaiter?.reject(error);
1061
+ this.boundaryWaiter?.reject(error);
1062
+ }
570
1063
 
571
- // Propagate cancellation from the parent Pi turn to the worker process.
572
- if (signal) {
573
- const abort = () => stopChild("aborted");
574
- if (signal.aborted) abort();
575
- else signal.addEventListener("abort", abort, { once: true });
576
- child.once("close", () => signal.removeEventListener("abort", abort));
577
- }
578
- });
1064
+ /** Close abandoned paused jobs so extension reload and manager inaction cannot orphan workers. */
1065
+ private schedulePausedLease(): void {
1066
+ this.clearPausedLease();
1067
+ this.pausedLease = setTimeout(() => {
1068
+ if (this.status === "paused") void this.stop("checkpointed", "paused-job lease expired");
1069
+ }, this.policy.pausedLeaseSeconds * 1_000);
1070
+ }
579
1071
 
580
- let status: WorkerStatus = "completed";
581
- if (forcedStatus) status = forcedStatus;
582
- else if (processError || exitCode !== 0) status = "process_error";
583
- else if (stopReason === "error") status = "model_error";
584
- else if (stopReason === "aborted") status = "aborted";
585
- else if (stopReason && stopReason !== "stop") status = "incomplete";
586
- else if (!finalOutput.trim()) status = "incomplete";
587
-
588
- // Put failure context and a synthetic checkpoint ahead of any partial worker report.
589
- const streamedOutput = [...streamingText.entries()]
590
- .sort(([left], [right]) => left - right)
591
- .map(([, text]) => text)
592
- .join("\n")
593
- .trim();
594
- const partialOutput = finalOutput.trim() || streamedOutput;
595
- const stderrOutput = stderr.trim();
596
- const statusDiagnostic =
597
- status === "timeout"
598
- ? checkpointRequestedReason === "timeout" && exitCode === 0
599
- ? `Worker reached the time limit and returned a checkpoint during the ${config.checkpointGraceSeconds}-second grace period.`
600
- : `Worker timed out after ${config.timeoutSeconds} seconds.`
601
- : status === "turn_limit"
602
- ? checkpointRequestedReason === "turn_limit" && exitCode === 0
603
- ? `Worker reached the configured ${config.maxTurns}-turn limit and returned a checkpoint.`
604
- : `Worker reached the configured ${config.maxTurns}-turn limit.`
605
- : status === "aborted"
606
- ? "Worker was aborted."
607
- : status === "process_error"
608
- ? `Worker process exited with code ${exitCode ?? "unknown"}.`
609
- : status === "model_error"
610
- ? "Worker model returned an error."
611
- : `Worker did not complete${stopReason ? ` (stop reason: ${stopReason})` : ""}.`;
612
- const diagnostic =
613
- status === "completed" ? undefined : modelError || processError || stderrOutput || statusDiagnostic;
614
- const checkpoint: WorkerCheckpoint | undefined =
615
- status === "completed"
616
- ? undefined
617
- : {
618
- reason: status,
619
- task,
620
- lastAssistantText: partialOutput || undefined,
621
- completedActivities: activity
622
- .filter((item) => item.status !== "running")
623
- .map((item) => `${item.status}: ${item.label}`),
624
- activeActivities: activity.filter((item) => item.status === "running").map((item) => item.label),
625
- filesystemEffectsMayRemain: activity.length > 0,
626
- };
627
- const output = checkpoint
628
- ? `${diagnostic ?? statusDiagnostic}\n\n${formatCheckpoint(checkpoint)}`
629
- : partialOutput || stderrOutput || "The worker produced no final report.";
630
- return {
631
- output: truncate(output, RESULT_LIMIT),
632
- details: {
633
- role,
634
- status,
635
- model: config.model,
636
- thinking: config.thinking,
637
- tools,
638
- turns,
639
- durationMs: Date.now() - startedAt,
640
- exitCode,
641
- stopReason,
642
- diagnostic,
643
- stderr: stderrOutput || undefined,
644
- activity,
645
- checkpoint,
646
- },
647
- usage,
648
- };
649
- } finally {
650
- await fs.promises.rm(tempDir, { recursive: true, force: true });
1072
+ /** Cancel the current paused-job lease before a state transition. */
1073
+ private clearPausedLease(): void {
1074
+ if (this.pausedLease) clearTimeout(this.pausedLease);
1075
+ this.pausedLease = undefined;
1076
+ }
1077
+
1078
+ /** Remove runtime-only worker instructions after process termination without touching workspace artifacts. */
1079
+ private async removeTemporaryDirectory(): Promise<void> {
1080
+ const directory = this.temporaryDirectory;
1081
+ this.temporaryDirectory = undefined;
1082
+ if (directory) await fs.promises.rm(directory, { recursive: true, force: true });
1083
+ }
1084
+
1085
+ /** Refresh the partial tool block with elapsed time and the latest active command. */
1086
+ private emitProgress(): void {
1087
+ try {
1088
+ this.progressCallback?.(this.snapshot());
1089
+ } catch {
1090
+ // Rendering feedback must never alter worker control flow.
1091
+ }
1092
+ }
1093
+
1094
+ /** Bound control-channel waits by both the requested limit and remaining active-time policy. */
1095
+ private operationTimeout(requestedMs: number): number {
1096
+ return Math.max(1, Math.min(requestedMs, this.remainingMilliseconds(), this.policy.noProgressSeconds * 1_000));
1097
+ }
1098
+
1099
+ /** Calculate remaining active time for bounded RPC-side maintenance operations. */
1100
+ private remainingMilliseconds(): number {
1101
+ return Math.max(1, this.budget.seconds * 1_000 - this.currentActiveElapsedMs());
1102
+ }
1103
+
1104
+ /** Measure only delegated execution and compaction time; manager deliberation has its own paused lease. */
1105
+ private currentActiveElapsedMs(): number {
1106
+ return this.activeElapsedMs + (this.activeStartedAt === undefined ? 0 : Date.now() - this.activeStartedAt);
1107
+ }
1108
+
1109
+ /** Commit one active interval exactly once before entering an idle or paused state. */
1110
+ private commitActiveElapsed(): void {
1111
+ if (this.activeStartedAt === undefined) return;
1112
+ this.activeElapsedMs += Date.now() - this.activeStartedAt;
1113
+ this.activeStartedAt = undefined;
651
1114
  }
652
1115
  }
653
1116
 
654
- /** Confirm that a configured physical model exists and has credentials before spawning a worker. */
655
- function validateModel(config: WorkerConfig, ctx: ExtensionToolContext): void {
656
- const separator = config.model.indexOf("/");
657
- const provider = config.model.slice(0, separator);
658
- const modelId = config.model.slice(separator + 1);
659
- const model = ctx.modelRegistry.find(provider, modelId);
660
- if (!model) throw new Error(`Configured model ${config.model} was not found. Configuration: ${CONFIG_PATH}`);
661
- if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
662
- throw new Error(`Configured model ${config.model} has no usable authentication. Configuration: ${CONFIG_PATH}`);
1117
+ let activeJob: WorkerJob | undefined;
1118
+ let lastSnapshot: WorkerSnapshot | undefined;
1119
+ const latestRoleSnapshots = new Map<Role, WorkerSnapshot>();
1120
+ const reportedUsage = new Map<string, UsageTotals>();
1121
+
1122
+ /** Resolve role overrides against the active parent model without assuming provider availability. */
1123
+ function resolveWorkerConfig(config: WorkerRoleConfig, ctx: ExtensionToolContext): { role: ResolvedWorkerRoleConfig; contextWindow: number; maxTokens: number } {
1124
+ let model = ctx.model;
1125
+ if (config.model) {
1126
+ const separator = config.model.indexOf("/");
1127
+ const provider = config.model.slice(0, separator);
1128
+ const modelId = config.model.slice(separator + 1);
1129
+ model = ctx.modelRegistry.find(provider, modelId);
1130
+ if (!model) throw new Error(`Configured model ${config.model} was not found. Configuration: ${CONFIG_PATH}`);
663
1131
  }
1132
+ if (!model) throw new Error("No active model is available; select a model or configure a role override.");
1133
+ if (!ctx.modelRegistry.hasConfiguredAuth(model)) throw new Error(`Selected model ${model.provider}/${model.id} has no authentication.`);
1134
+ return {
1135
+ role: {
1136
+ model: `${model.provider}/${model.id}`,
1137
+ thinking: config.thinking ?? ctx.thinkingLevel ?? "medium",
1138
+ },
1139
+ contextWindow: model.contextWindow,
1140
+ maxTokens: model.maxTokens,
1141
+ };
664
1142
  }
665
1143
 
666
- /** Validate the exact per-delegation allowlist against tools active for the orchestrator. */
1144
+ /** Restrict delegations to active, non-recursive isolated-worker tools selected by the manager. */
667
1145
  function validateDelegatedTools(requested: string[], pi: ExtensionAPI): string[] {
668
1146
  const tools = [...new Set(requested.map((tool) => tool.trim()).filter(Boolean))];
669
- const forbidden = new Set(["delegate_mechanical", "delegate_engineering"]);
1147
+ const forbidden = new Set([...Object.values(ROLE_TOOL_NAMES), "delegate_control", "delegate_status"]);
670
1148
  const recursive = tools.filter((tool) => forbidden.has(tool));
671
- if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated recursively: ${recursive.join(", ")}`);
1149
+ if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated: ${recursive.join(", ")}`);
672
1150
  const active = new Set(pi.getActiveTools());
673
1151
  const unavailable = tools.filter((tool) => !active.has(tool));
674
- if (unavailable.length > 0) {
675
- throw new Error(`Requested worker tools are not active for the main agent: ${unavailable.join(", ")}`);
1152
+ if (unavailable.length > 0) throw new Error(`Delegated tools are not active for the manager: ${unavailable.join(", ")}`);
1153
+ const unsupportedTools = tools.filter((tool) => !ISOLATED_WORKER_TOOLS.has(tool));
1154
+ if (unsupportedTools.length > 0) {
1155
+ throw new Error(`Isolated workers do not support these tools: ${unsupportedTools.join(", ")}`);
676
1156
  }
677
1157
  return tools;
678
1158
  }
679
1159
 
680
- /** Register one role-specific public tool over the shared worker executor. */
1160
+ /** Format context pressure against the worker model's own physical context window. */
1161
+ function formatContext(snapshot: WorkerSnapshot): string {
1162
+ const window = snapshot.context.contextWindow;
1163
+ const windowLabel = window >= 1_000_000
1164
+ ? `${Number((window / 1_000_000).toFixed(1))}m`
1165
+ : `${Math.round(window / 1_000)}k`;
1166
+ return `${snapshot.context.percent?.toFixed(1) ?? "?"}%/${windowLabel} context`;
1167
+ }
1168
+
1169
+ /** Format active execution duration without counting time paused for manager decisions. */
1170
+ function formatElapsed(durationMs: number): string {
1171
+ const seconds = Math.ceil(durationMs / 1_000);
1172
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
1173
+ }
1174
+
1175
+ /** Format one bounded state report for both model consumption and non-TUI modes. */
1176
+ function formatSnapshot(snapshot: WorkerSnapshot): string {
1177
+ const context = snapshot.context.percent === undefined
1178
+ ? `${snapshot.context.contextWindow.toLocaleString()} window; usage unavailable`
1179
+ : `${snapshot.context.tokens?.toLocaleString() ?? "?"}/${snapshot.context.contextWindow.toLocaleString()} (${snapshot.context.percent.toFixed(1)}%)`;
1180
+ const recent = snapshot.recentActions.length > 0
1181
+ ? snapshot.recentActions.map((action) => `${action.status}: ${action.label}`).join("; ")
1182
+ : "none";
1183
+ const worker = snapshot.workerTextDisplay ? `\nWorker text:\n${snapshot.workerTextDisplay}` : "";
1184
+ return [
1185
+ `State: ${snapshot.status}`,
1186
+ `- Job: ${snapshot.jobId}`,
1187
+ `- Task: ${snapshot.taskDisplay}`,
1188
+ `- Role/model: ${snapshot.role} via ${snapshot.model} (${snapshot.thinking})`,
1189
+ `- Boundary: ${snapshot.sequence}; phase: ${snapshot.phase}`,
1190
+ snapshot.currentActivity ? `- Now: ${snapshot.currentActivity}` : "",
1191
+ `- Actions: ${snapshot.actionCounts.completed} completed, ${snapshot.actionCounts.failed} failed, ${snapshot.actionCounts.mutations} potentially mutating`,
1192
+ `- Recent: ${recent}`,
1193
+ `- Budget: ${snapshot.turns}/${snapshot.budget.turns} turns; ${Math.ceil(snapshot.durationMs / 1_000)}/${snapshot.budget.seconds}s`,
1194
+ `- Context: ${formatContext(snapshot)} (${context}); compactions: ${snapshot.context.compactions} completed, ${snapshot.context.compactionSkips} skipped, ${snapshot.context.compactionFailures} failed (${snapshot.context.compactionAttempts} attempts)`,
1195
+ `- Recommendation: ${snapshot.recommendation}`,
1196
+ snapshot.diagnostic ? `- Diagnostic: ${snapshot.diagnostic}` : "",
1197
+ worker,
1198
+ ].filter(Boolean).join("\n");
1199
+ }
1200
+
1201
+ /** Keep one unindented footer line with the most recent state observed for every invoked role. */
1202
+ function updateWorkerStatus(ctx: ExtensionToolContext, snapshot: WorkerSnapshot): void {
1203
+ latestRoleSnapshots.delete(snapshot.role);
1204
+ latestRoleSnapshots.set(snapshot.role, snapshot);
1205
+ if (!ctx.hasUI) return;
1206
+ // Clear the pre-3.1 editor widget if this extension was reloaded in place.
1207
+ ctx.ui.setWidget(WIDGET_ID, undefined);
1208
+ const status = [...latestRoleSnapshots.values()]
1209
+ .reverse()
1210
+ .map((roleSnapshot) => {
1211
+ const model = roleSnapshot.model.slice(roleSnapshot.model.indexOf("/") + 1);
1212
+ const context = formatContext(roleSnapshot).replace(/ context$/, "");
1213
+ return `${roleSnapshot.jobId} · ${context} • ${model} • ${roleSnapshot.thinking}`;
1214
+ })
1215
+ .join(" | ");
1216
+ ctx.ui.setStatus(WIDGET_ID, ctx.ui.theme.fg("dim", status));
1217
+ }
1218
+
1219
+ /** Obtain mutable state shared by call/result renderers, including during partial argument streaming. */
1220
+ function rendererState(context: any): Record<string, unknown> {
1221
+ if (!isRecord(context.state)) context.state = {};
1222
+ return context.state;
1223
+ }
1224
+
1225
+ /** Format partial delegate arguments without assuming every streamed field is present yet. */
1226
+ function renderArgumentSummary(value: unknown): { task: string; workload: string } {
1227
+ if (!isRecord(value)) return { task: "Preparing delegation…", workload: "workload pending" };
1228
+ const tools = Array.isArray(value.tools) ? value.tools.length : 0;
1229
+ const workload = isRecord(value.workload) ? value.workload : {};
1230
+ const investigation = typeof workload.investigationUnits === "number" ? workload.investigationUnits : "?";
1231
+ const change = typeof workload.changeUnits === "number" ? workload.changeUnits : "?";
1232
+ const verification = typeof workload.verificationUnits === "number" ? workload.verificationUnits : "?";
1233
+ return { task: compactLine(value.task), workload: `${tools} tools · ${investigation}/${change}/${verification} I/C/V units` };
1234
+ }
1235
+
1236
+ /** Create a wrapping component whose text can reflect shared renderer state after execution updates. */
1237
+ function dynamicText(build: () => string): { render(width: number): string[]; invalidate(): void } {
1238
+ const text = new Text("", 0, 0);
1239
+ let currentText: string | undefined;
1240
+ return {
1241
+ render(width) {
1242
+ let nextText: string;
1243
+ try {
1244
+ nextText = build();
1245
+ } catch {
1246
+ nextText = "Preparing delegation…";
1247
+ }
1248
+ // Text.setText() invalidates the TUI, so call it only when observable content changed.
1249
+ if (nextText !== currentText) {
1250
+ currentText = nextText;
1251
+ text.setText(nextText);
1252
+ }
1253
+ return text.render(width);
1254
+ },
1255
+ invalidate() {
1256
+ text.invalidate();
1257
+ },
1258
+ };
1259
+ }
1260
+
1261
+ /** Render only nonredundant result details; the shared call header owns identity, task, and status. */
1262
+ function renderWorkerResult(
1263
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
1264
+ expanded: boolean,
1265
+ isPartial: boolean,
1266
+ theme: any,
1267
+ context: any,
1268
+ standalone = false,
1269
+ ): Text {
1270
+ const snapshot = result.details as WorkerSnapshot | undefined;
1271
+ if (!snapshot) return new Text(theme.fg(isPartial ? "warning" : "muted", result.content[0]?.text ?? "Delegation state unavailable"), 0, 0);
1272
+ rendererState(context).snapshot = snapshot;
1273
+ const color = snapshot.status === "paused" ? "warning" : snapshot.status === "completed" ? "success" : isTerminal(snapshot.status) && snapshot.status !== "completed" ? "error" : "accent";
1274
+ const elapsedLabel = isPartial ? "Elapsed" : "Took";
1275
+ const latestActivity = snapshot.currentActivity ?? snapshot.recentActions.at(-1)?.label;
1276
+ const activityLabel = snapshot.currentActivity ? "Now" : "Last";
1277
+ const lines = [
1278
+ standalone ? theme.fg(color, theme.bold(`${snapshot.jobId}: ${snapshot.status}`)) : "",
1279
+ standalone ? theme.fg("muted", `Task: ${snapshot.taskDisplay}`) : "",
1280
+ latestActivity ? theme.fg(snapshot.currentActivity ? "warning" : "muted", `${activityLabel}: ${latestActivity}`) : "",
1281
+ theme.fg("dim", `${snapshot.phase} · boundary ${snapshot.sequence} · turn ${snapshot.turns}/${snapshot.budget.turns}`),
1282
+ theme.fg("muted", `${snapshot.actionCounts.completed} actions complete · ${snapshot.actionCounts.failed} failed · ${formatContext(snapshot)}`),
1283
+ theme.fg("accent", snapshot.recommendation),
1284
+ expanded && snapshot.workerTextDisplay ? `\n${snapshot.workerTextDisplay}` : "",
1285
+ theme.fg("dim", `${elapsedLabel}: ${formatElapsed(snapshot.durationMs)}`),
1286
+ ].filter(Boolean);
1287
+ return new Text(lines.join("\n"), 0, 0);
1288
+ }
1289
+
1290
+ /** Package a snapshot as bounded model content plus complete machine-readable details. */
1291
+ function snapshotResult(snapshot: WorkerSnapshot, isError = false): { content: Array<{ type: "text"; text: string }>; details: WorkerSnapshot; usage: UsageTotals; isError: boolean } {
1292
+ lastSnapshot = snapshot;
1293
+ for (const jobId of reportedUsage.keys()) {
1294
+ if (jobId !== snapshot.jobId) reportedUsage.delete(jobId);
1295
+ }
1296
+ const previous = reportedUsage.get(snapshot.jobId) ?? emptyUsage();
1297
+ const usage = usageDifference(snapshot.usage, previous);
1298
+ reportedUsage.set(snapshot.jobId, structuredClone(snapshot.usage));
1299
+ return {
1300
+ content: [{ type: "text", text: formatSnapshot(snapshot) }],
1301
+ details: snapshot,
1302
+ usage,
1303
+ isError,
1304
+ };
1305
+ }
1306
+
1307
+ /** Register one role-specific supervised worker starter. */
681
1308
  function registerDelegate(pi: ExtensionAPI, role: Role): void {
682
- const toolName = role === "mechanic" ? "delegate_mechanical" : "delegate_engineering";
1309
+ const name = ROLE_TOOL_NAMES[role];
683
1310
  pi.registerTool({
684
- name: toolName,
685
- label: role === "mechanic" ? "Delegate Mechanical" : "Delegate Engineering",
1311
+ name,
1312
+ label: ROLE_LABELS[role],
686
1313
  description: ROLE_DESCRIPTIONS[role],
687
- promptSnippet: `${toolName}: dispatch a self-contained ${role} subtask to an isolated configured worker model`,
1314
+ promptSnippet: `${name}: start a supervised ${role} delegation that pauses after each completed action batch`,
688
1315
  promptGuidelines: [
689
- role === "mechanic"
690
- ? "Use delegate_mechanical for bounded multi-step execution with precise desired results; give it a self-contained contract and integrate its report."
691
- : "Use delegate_engineering broadly for self-contained investigation or production work; give it the outcome, constraints, and acceptance conditions, then integrate its report.",
692
- "Select the smallest exact worker tool allowlist from tools active for the main agent; tool availability does not establish scope or authorization.",
693
- "Delegation results are worker reports, not proof of correctness; inspect material changes and retain responsibility for the final answer.",
694
- "When a worker returns a checkpoint, inspect current state and decide whether to continue directly, delegate a bounded continuation with a newly selected tool allowlist, request user input, or stop with a limitation. Continuation is never automatic.",
1316
+ "Supply semantic workload units rather than raw turn or timeout values.",
1317
+ "Inspect every paused state and choose continue, revise, recalculate, compact, checkpoint, cancel, or take over.",
1318
+ "Worker reports remain evidence to review, not proof of correctness.",
695
1319
  ],
696
1320
  parameters: DelegateParams,
697
1321
  exposure: "model-only",
698
1322
  executionMode: "sequential",
699
- annotations: {
700
- readOnlyHint: false,
701
- destructiveHint: true,
702
- idempotentHint: false,
703
- openWorldHint: true,
1323
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1324
+ renderCall(args, theme, context) {
1325
+ const expectedId = `${role}-${nextJobIndex}`;
1326
+ return dynamicText(() => {
1327
+ const snapshot = rendererState(context).snapshot as WorkerSnapshot | undefined;
1328
+ const current = renderArgumentSummary(context.args ?? args);
1329
+ const id = snapshot?.jobId ?? expectedId;
1330
+ const status = snapshot?.status ?? "starting";
1331
+ return [
1332
+ theme.fg("toolTitle", theme.bold(`${id}: ${status}`)),
1333
+ theme.fg("muted", `Task: ${current.task}`),
1334
+ theme.fg("dim", current.workload),
1335
+ ].join("\n");
1336
+ });
1337
+ },
1338
+ renderResult(result, { expanded, isPartial }, theme, context) {
1339
+ return renderWorkerResult(result as any, expanded, isPartial, theme, context);
1340
+ },
1341
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
1342
+ try {
1343
+ if (activeJob && !isTerminal(activeJob.status)) throw new Error(`${activeJob.id} is still ${activeJob.status}.`);
1344
+ if (activeJob) {
1345
+ await activeJob.dispose();
1346
+ activeJob = undefined;
1347
+ }
1348
+ const config = loadConfig();
1349
+ const tools = validateDelegatedTools(params.tools, pi);
1350
+ const resolved = resolveWorkerConfig(config[role], ctx);
1351
+ const budget = calculateBudget(role, params.workload, resolved.contextWindow, resolved.maxTokens, config.policy);
1352
+ const job = new WorkerJob(role, params.task, tools, resolved.role, ctx.cwd, config.policy, budget);
1353
+ activeJob = job;
1354
+ const progress = (snapshot: WorkerSnapshot) => {
1355
+ updateWorkerStatus(ctx, snapshot);
1356
+ onUpdate?.({ content: [{ type: "text", text: formatSnapshot(snapshot) }], details: snapshot });
1357
+ };
1358
+ const snapshot = await job.start(signal, progress);
1359
+ updateWorkerStatus(ctx, snapshot);
1360
+ return snapshotResult(snapshot, snapshot.status === "failed" || snapshot.status === "hard_stopped");
1361
+ } catch (error) {
1362
+ const message = error instanceof Error ? error.message : String(error);
1363
+ if (activeJob && !isTerminal(activeJob.status)) await activeJob.stop("hard_stopped", message);
1364
+ const snapshot = activeJob?.snapshot();
1365
+ if (!snapshot) return { content: [{ type: "text", text: `Cannot start ${role}: ${message}` }], details: undefined, isError: true };
1366
+ updateWorkerStatus(ctx, snapshot);
1367
+ const result = snapshotResult(snapshot, true);
1368
+ result.content[0].text = `Cannot start ${role}: ${message}\n\n${result.content[0].text}`;
1369
+ return result;
1370
+ }
704
1371
  },
1372
+ });
1373
+ }
705
1374
 
1375
+ /** Register the explicit manager decisions that resume or close a paused delegation. */
1376
+ function registerControl(pi: ExtensionAPI): void {
1377
+ pi.registerTool({
1378
+ name: "delegate_control",
1379
+ label: "Manager",
1380
+ description: "Control one paused supervised worker: continue, revise, recalculate, compact, take over, checkpoint, cancel.",
1381
+ promptSnippet: "delegate_control: make an explicit manager decision for a paused delegation",
1382
+ promptGuidelines: ["Use only after inspecting the latest worker boundary state.", "Takeover, cancellation, and ceilings are enforced by the dispatcher rather than worker compliance."],
1383
+ parameters: ControlParams,
1384
+ exposure: "model-only",
1385
+ executionMode: "sequential",
1386
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1387
+ renderCall(args, theme, context) {
1388
+ return dynamicText(() => {
1389
+ const snapshot = rendererState(context).snapshot as WorkerSnapshot | undefined;
1390
+ const current = isRecord(context.args) ? context.args : isRecord(args) ? args : {};
1391
+ const jobId = typeof current.jobId === "string" ? current.jobId : "delegation";
1392
+ const action = typeof current.action === "string" ? current.action : "decision";
1393
+ const outcome = snapshot?.status ?? action;
1394
+ const lines = [theme.fg("toolTitle", theme.bold(`manager: ${jobId} → ${outcome}`))];
1395
+ if (typeof current.actionBatches === "number") lines.push(theme.fg("muted", `lease: ${current.actionBatches} action batches`));
1396
+ return lines.join("\n");
1397
+ });
1398
+ },
1399
+ renderResult(result, { expanded, isPartial }, theme, context) {
1400
+ return renderWorkerResult(result as any, expanded, isPartial, theme, context);
1401
+ },
706
1402
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
707
- let config: WorkerConfig;
708
- let tools: string[];
709
1403
  try {
710
- config = loadConfig()[role];
711
- validateModel(config, ctx);
712
- tools = validateDelegatedTools(params.tools, pi);
1404
+ const job = activeJob;
1405
+ if (!job || job.id !== params.jobId) throw new Error(`No live delegation matches ${params.jobId}.`);
1406
+ let snapshot: WorkerSnapshot;
1407
+ if (params.action === "recalculate") {
1408
+ if (!params.workload) throw new Error("recalculate requires workload");
1409
+ snapshot = job.recalculate(params.workload);
1410
+ } else if (params.action === "compact") {
1411
+ snapshot = await job.compact();
1412
+ } else if (params.action === "take_over") {
1413
+ snapshot = await job.stop("taken_over", "manager requested takeover");
1414
+ } else if (params.action === "checkpoint") {
1415
+ snapshot = await job.stop("checkpointed", "manager requested synthetic checkpoint");
1416
+ } else if (params.action === "cancel") {
1417
+ snapshot = await job.stop("cancelled", "manager cancelled delegated work");
1418
+ } else {
1419
+ if (params.action === "revise" && !params.instructions) throw new Error("revise requires instructions");
1420
+ const progress = (state: WorkerSnapshot) => {
1421
+ state.managerAction = params.action;
1422
+ updateWorkerStatus(ctx, state);
1423
+ onUpdate?.({ content: [{ type: "text", text: formatSnapshot(state) }], details: state });
1424
+ };
1425
+ snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, signal, progress);
1426
+ }
1427
+ snapshot.managerAction = params.action;
1428
+ updateWorkerStatus(ctx, snapshot);
1429
+ return snapshotResult(snapshot, snapshot.status === "failed" || snapshot.status === "hard_stopped");
713
1430
  } catch (error) {
714
1431
  const message = error instanceof Error ? error.message : String(error);
715
- return {
716
- content: [{ type: "text", text: `Cannot start ${role} worker: ${message}` }],
717
- details: {
718
- role,
719
- status: "configuration_error" as const,
720
- model: "",
721
- thinking: "off" as const,
722
- tools: params.tools,
723
- turns: 0,
724
- durationMs: 0,
725
- exitCode: null,
726
- diagnostic: message,
727
- activity: [],
728
- },
729
- isError: true,
730
- };
1432
+ const snapshot = activeJob?.snapshot();
1433
+ if (!snapshot) return { content: [{ type: "text", text: `Manager action failed: ${message}` }], details: undefined, isError: true };
1434
+ snapshot.managerAction = params.action;
1435
+ updateWorkerStatus(ctx, snapshot);
1436
+ const result = snapshotResult(snapshot, true);
1437
+ result.content[0].text = `Manager action failed: ${message}\n\n${result.content[0].text}`;
1438
+ return result;
731
1439
  }
1440
+ },
1441
+ });
1442
+ }
732
1443
 
733
- const result = await runWorker(role, params.task, config, tools, ctx.cwd, signal, onUpdate);
734
- const heading = `${role} worker ${result.details.status} via ${result.details.model} (${result.details.thinking})`;
735
- return {
736
- content: [{ type: "text", text: `${heading}\n\n${result.output}` }],
737
- details: result.details,
738
- usage: result.usage,
739
- isError: result.details.status !== "completed",
740
- };
1444
+ /** Register state recovery when the manager lost a delegation identifier after context compaction. */
1445
+ function registerStatus(pi: ExtensionAPI): void {
1446
+ pi.registerTool({
1447
+ name: "delegate_status",
1448
+ label: "Delegate Status",
1449
+ description: "Return the current or most recent supervised worker state without changing it.",
1450
+ promptSnippet: "delegate_status: recover the current supervised delegation state",
1451
+ parameters: StatusParams,
1452
+ exposure: "model-only",
1453
+ executionMode: "sequential",
1454
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1455
+ renderResult(result, { expanded, isPartial }, theme, context) {
1456
+ return renderWorkerResult(result as any, expanded, isPartial, theme, context, true);
1457
+ },
1458
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1459
+ const snapshot = activeJob?.snapshot() ?? lastSnapshot;
1460
+ if (!snapshot || (params.jobId && snapshot.jobId !== params.jobId)) {
1461
+ return { content: [{ type: "text", text: "No matching supervised worker state is available." }], details: undefined, isError: true };
1462
+ }
1463
+ updateWorkerStatus(ctx, snapshot);
1464
+ return snapshotResult(snapshot);
741
1465
  },
742
1466
  });
743
1467
  }
744
1468
 
745
- /** Register only in the parent process; workers receive an explicit non-recursive tool allowlist. */
1469
+ /** Register only in the parent process and clean every live child when the session shuts down. */
746
1470
  export default function taskDispatcher(pi: ExtensionAPI): void {
747
1471
  if (process.env[WORKER_ENV]) return;
748
- registerDelegate(pi, "mechanic");
749
- registerDelegate(pi, "engineer");
1472
+ for (const role of ROLES) registerDelegate(pi, role);
1473
+ registerControl(pi);
1474
+ registerStatus(pi);
1475
+ pi.on("session_shutdown", async () => {
1476
+ if (activeJob) await activeJob.dispose();
1477
+ activeJob = undefined;
1478
+ });
750
1479
  }