pi-task-dispatcher 1.0.0 → 3.0.0

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