pi-task-dispatcher 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,29 +1,29 @@
1
1
  # pi-task-dispatcher
2
2
 
3
- Supervised mechanical and engineering delegations for Pi.
3
+ Supervised mechanical, assistant, engineering, and media-design delegations for Pi.
4
4
 
5
5
  Each delegation runs in an isolated, in-memory Pi RPC session. After every cohesive tool batch, a worker-side gate stops execution at a safe boundary and returns a bounded state report. The manager can then inspect the result and explicitly continue, revise, compact, checkpoint, cancel, or take over.
6
6
 
7
7
  ## Features
8
8
 
9
- - **Two worker roles:** mechanical execution and broader engineering work.
9
+ - **Four worker roles:** prescribed mechanical execution, fallback assistant work, broader engineering, and media design.
10
10
  - **Enforced supervision:** a private parent-child IPC gate calls `ctx.abort()` after persisted tool results; pausing does not depend on the worker following a prompt.
11
11
  - **Explicit control:** continue, revise, recalculate, compact, checkpoint, cancel, or take over.
12
12
  - **Resumable context:** continuation uses the same in-memory worker session and completed tool results.
13
- - **Least privilege:** every start selects an exact Pi built-in worker-tool allowlist; delegation tools and unrelated discovered extensions are unavailable to the worker.
14
- - **Model agnostic:** both roles inherit Pi's active model and thinking level unless an explicit role override is configured.
13
+ - **Least privilege:** every start selects an exact isolated-worker tool allowlist; delegation tools and unrelated discovered extensions are unavailable to the worker. Pi's built-in `codemode` extension loads only when explicitly selected.
14
+ - **Model agnostic:** every role inherits Pi's active model and thinking level unless an explicit role override is configured.
15
15
  - **Formula-owned budgets:** the manager supplies semantic workload units, while the dispatcher calculates turn and time ceilings from workload, role, and delegated-model limits.
16
16
  - **Context insight:** boundary reports include worker context usage, compactions, turns, elapsed time, action counts, and recommendations.
17
17
  - **Recoverable reports:** the complete action ledger and worker text remain in structured tool details; bounded model-facing text retains recent actions plus both the beginning and ending of long reports.
18
- - **Deterministic UI:** tool renderers display task, current activity, elapsed time, context pressure, and terminal success/failure colors. A compact paused-state line appears in Pi's footer.
18
+ - **Deterministic UI:** tool renderers display task, current activity, elapsed time, context pressure, and terminal success/failure colors. Pi's footer retains the latest state of every role invoked in the session.
19
19
 
20
20
  ## Requirements
21
21
 
22
- - Pi 1.0.3 or later
22
+ - Pi 1.0.4 or later
23
23
  - Node.js 22.19.0 or later
24
24
  - One active, authenticated Pi model; optional role-specific models must also be available and authenticated
25
25
 
26
- The supervision gate requires the parent Pi process and child Pi CLI to run under Node.js with an inherited IPC channel. The dispatcher fails closed when that boundary cannot be established; it does not fall back to prompt-only supervision. Worker processes disable ordinary extension discovery and load only the generated supervision gate.
26
+ The supervision gate requires the parent Pi process and child Pi CLI to run under Node.js with an inherited IPC channel. The dispatcher fails closed when that boundary cannot be established; it does not fall back to prompt-only supervision. Worker processes disable ordinary extension discovery and load the generated supervision gate plus Pi's built-in `codemode` extension only when that tool was explicitly selected.
27
27
 
28
28
  ## Installation
29
29
 
@@ -37,16 +37,31 @@ pi install npm:pi-task-dispatcher
37
37
 
38
38
  | Tool | Responsibility |
39
39
  | --- | --- |
40
- | `delegate_mechanical` | Start a supervised mechanic delegation. |
41
- | `delegate_engineering` | Start a supervised engineer delegation. |
40
+ | `delegate_mechanical` | Execute prescribed work whose method and expected result are already known. |
41
+ | `delegate_assistant` | Perform the user-requested work when no other worker role applies; the orchestrator does not perform that work directly. |
42
+ | `delegate_engineering` | Investigate or implement uncertain, cross-component, or technically intensive work. |
43
+ | `delegate_designer` | Create or edit image assets and process image, video, or audio files with available tools. |
42
44
  | `delegate_control` | Apply one explicit manager decision to a paused delegation. |
43
45
  | `delegate_status` | Recover the current or most recent state without changing it. |
44
46
 
45
47
  Only one live delegation is currently permitted. A terminal delegation may be inspected until a new one replaces it.
46
48
 
49
+ ## Choosing a role
50
+
51
+ | Role | Selection rule |
52
+ | --- | --- |
53
+ | Mechanic | The method and expected result are known; execute exact steps. |
54
+ | Assistant | When no other worker role applies, use `delegate_assistant` for the user-requested work instead of having the orchestrator perform it directly. |
55
+ | Engineer | The cause, design, or implementation path is uncertain or spans components. |
56
+ | Designer | The requested result is an image, video, audio, or related media asset or transformation. |
57
+
58
+ The orchestrator retains responsibility for delegation decisions, worker supervision, integration of delegated results, and the final response.
59
+
60
+ The designer can inspect images with `read`, use image models through `codemode`, and run explicitly selected command-line media programs through `bash` or `powershell`. Generated codemode images are runtime-temporary until the worker deliberately copies a selected output to the requested workspace destination and verifies it. Video and audio generation depends on explicitly available command-line programs or services; editing workflows can use programs such as `ffmpeg` when installed.
61
+
47
62
  ## Starting a delegation
48
63
 
49
- Both role tools require:
64
+ Every role tool requires:
50
65
 
51
66
  - `task`: a self-contained objective, scope, constraints, and acceptance checks;
52
67
  - `tools`: the smallest exact allowlist selected from Pi's built-in tools and active for the main agent;
@@ -74,7 +89,7 @@ A workload unit is intentionally semantic:
74
89
 
75
90
  At least one investigation, change, or verification unit is required.
76
91
 
77
- Isolated workers accept `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `ls` when the selected tools are also active in the main Pi session. Extension tools are not supported because the child disables extension discovery.
92
+ Isolated workers accept `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`, and `codemode` when the selected tools are also active in the main Pi session. `codemode` is loaded as an explicit Pi built-in while ordinary extension discovery remains disabled. Its scripts can call only the worker's callable tools and non-LLM model catalog; unrelated manager extensions and delegation tools remain unavailable.
78
93
 
79
94
  The current formula derives planned work turns from those units, applies a role factor and a bounded scale based on the selected delegated model's own context window, reserves two additional turns for synthesis or recovery, then derives active execution time from calculated turns plus expected long-running time. Administrative ceilings always win. Formula inputs and results are returned in every state report so they can be reviewed and recalculated. Time spent paused for manager deliberation does not consume the active execution budget.
80
95
 
@@ -91,6 +106,8 @@ A worker run proceeds as follows:
91
106
 
92
107
  An action batch can contain parallel tool calls from one worker response. Already-running tools are allowed to finish. The boundary prevents another productive model turn from starting without a manager decision.
93
108
 
109
+ A tool-free response that reaches the model's output limit is retained as a recoverable paused state. Continuing resumes the same in-memory session so the worker can finish its truncated report; the dispatcher does not misreport that response as completed.
110
+
94
111
  ## Controlling a paused delegation
95
112
 
96
113
  `delegate_control` accepts these actions:
@@ -125,7 +142,7 @@ A paused result contains:
125
142
  - the delegated task and live command or tool activity while execution is running;
126
143
  - boundary sequence and current phase;
127
144
  - completed, failed, and potentially mutating action counts;
128
- - recent bounded activity labels;
145
+ - recent bounded activity labels in completion order and image-output MIME/size metadata without retained base64 payloads;
129
146
  - active elapsed time and turns against calculated budgets;
130
147
  - context tokens, context window, percentage, and compaction counts;
131
148
  - a recommended next decision;
@@ -133,7 +150,7 @@ A paused result contains:
133
150
 
134
151
  The TUI renders the same structured state as a compact block. Expanding the tool result reveals bounded worker text. The complete action ledger and worker text remain in result details for state reconstruction rather than being discarded by display truncation.
135
152
 
136
- When paused, one unindented footer status appears below Pi's primary report. It shows the identity, active elapsed time, last activity, and context as percentage/window—for example, `10.3%/32k context`. The percentage and window both come from that delegation's selected model and live worker-session statistics, not from the main model.
153
+ After the first worker state, one unindented, dim footer status remains below Pi's primary report and updates across worker lifecycle states. It retains the newest state observed for every invoked role, with the most recently updated role first and each entry ordered as identity, context, model, and thinking—for example, `engineer-2 · 10.3%/32k • model-name • medium | mechanic-1 · 8.1%/128k • smaller-model • low`. Each percentage and window comes from that delegation's own fresh worker session; a continuation accumulates context in the same job, while a new delegation starts a new context. Current or latest action details appear in the delegation or manager block rather than the footer.
137
154
 
138
155
  ## Worker compaction
139
156
 
@@ -155,6 +172,22 @@ The optional configuration file is `task-dispatcher.json` in Pi's agent director
155
172
 
156
173
  ```json
157
174
  {
175
+ "mechanic": {
176
+ "model": "provider/mechanic-model",
177
+ "thinking": "high"
178
+ },
179
+ "assistant": {
180
+ "model": "provider/assistant-model",
181
+ "thinking": "low"
182
+ },
183
+ "engineer": {
184
+ "model": "provider/engineer-model",
185
+ "thinking": "high"
186
+ },
187
+ "designer": {
188
+ "model": "provider/vision-model",
189
+ "thinking": "medium"
190
+ },
158
191
  "policy": {
159
192
  "absoluteMaxTurns": 96,
160
193
  "absoluteMaxSeconds": 7200,
@@ -164,7 +197,7 @@ The optional configuration file is `task-dispatcher.json` in Pi's agent director
164
197
  }
165
198
  ```
166
199
 
167
- Configuration is read for every new delegation. By default, both roles inherit the manager's active model and thinking level, so installation assumes no particular provider or model access. A role may optionally override `model` with `"provider/model"`, `thinking`, or both. Policy values are administrative failsafes, not ordinary task budgets.
200
+ Configuration is read for every new delegation. By default, all roles inherit the manager's active model and thinking level, so installation assumes no particular provider or model access. Each role may optionally override `model` with `"provider/model"`, `thinking`, or both. Policy values are administrative failsafes, not ordinary task budgets.
168
201
 
169
202
  Version 3 intentionally ignores the version 2 fields `maxTurns`, `timeoutSeconds`, and `checkpointGraceSeconds`. Remove them from local configuration after migration.
170
203
 
@@ -175,6 +208,7 @@ Version 3 intentionally ignores the version 2 fields `maxTurns`, `timeoutSeconds
175
208
  - Session shutdown or extension reload disposes the child and runtime-controlled temporary files.
176
209
  - Worker process failures reject pending RPC operations and produce a failed state.
177
210
  - Worker role instructions and the supervision gate exist only in a runtime-controlled temporary directory.
211
+ - The selected built-in `codemode` extension may create runtime-temporary image outputs; the designer must persist requested outputs explicitly before completion.
178
212
  - The worker uses `--no-session`; no worker session file is persisted.
179
213
 
180
214
  Process separation is not a security sandbox. Worker tools execute with the Pi host's operating-system permissions. Tool availability does not establish task scope or authorization.
@@ -186,7 +220,7 @@ npm run check
186
220
  npm run test:gate
187
221
  ```
188
222
 
189
- `check` runs static release invariants and deterministic fake-RPC lifecycle and renderer scenarios. The fake child verifies pause/resume, usage deltas, full action-ledger retention, intercepted prompts, missing-boundary failure, fail-closed timeouts, pre-aborted operations, partial argument rendering, stable repeated rendering, and manager-state transitions without a model request.
223
+ `check` runs static release invariants and deterministic fake-RPC lifecycle and renderer scenarios. The fake child verifies all role registrations, pause/resume, output-limit recovery, usage deltas, nested codemode usage accounting, media metadata without base64 retention, persistent per-role footer state, full action-ledger retention, intercepted prompts, missing-boundary failure, fail-closed timeouts, pre-aborted operations, partial argument rendering, stable repeated rendering, and manager-state transitions without a model request.
190
224
 
191
- `test:gate` performs a small live model integration check with the active parent model exported by Pi through `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`, falling back to Pi's configured default when those values are unavailable; it contains no provider or model assumption. Optional `PI_TASK_DISPATCHER_GATE_MODEL` and `PI_TASK_DISPATCHER_GATE_THINKING` overrides are available for explicit test matrices. The probe verifies that the private gate pauses after a read batch and that the same in-memory child can subsequently complete. It requires configured credentials and therefore is not a hermetic unit test.
225
+ `test:gate` performs a small live model integration check with the active parent model exported by Pi through `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`, falling back to Pi's configured default when those values are unavailable; it contains no provider or model assumption. Optional `PI_TASK_DISPATCHER_GATE_MODEL` and `PI_TASK_DISPATCHER_GATE_THINKING` overrides are available for explicit test matrices. The probe verifies selective built-in `codemode` loading, a private pause after its completed tool batch, and subsequent completion in the same in-memory child. It requires configured credentials and therefore is not a hermetic unit test.
192
226
 
@@ -14,7 +14,7 @@ import {
14
14
  import { Text } from "@earendil-works/pi-tui";
15
15
  import { Type } from "typebox";
16
16
 
17
- type Role = "mechanic" | "engineer";
17
+ type Role = "mechanic" | "assistant" | "engineer" | "designer";
18
18
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
19
19
  type JobStatus =
20
20
  | "starting"
@@ -44,11 +44,9 @@ interface DispatcherPolicy {
44
44
  noProgressSeconds: number;
45
45
  }
46
46
 
47
- interface DispatcherConfig {
48
- mechanic: WorkerRoleConfig;
49
- engineer: WorkerRoleConfig;
47
+ type DispatcherConfig = Record<Role, WorkerRoleConfig> & {
50
48
  policy: DispatcherPolicy;
51
- }
49
+ };
52
50
 
53
51
  interface Workload {
54
52
  investigationUnits: number;
@@ -82,6 +80,11 @@ interface UsageTotals {
82
80
  };
83
81
  }
84
82
 
83
+ interface WorkerMediaOutput {
84
+ mimeType: string;
85
+ bytes: number;
86
+ }
87
+
85
88
  interface WorkerAction {
86
89
  sequence: number;
87
90
  toolCallId: string;
@@ -91,6 +94,9 @@ interface WorkerAction {
91
94
  status: "running" | "completed" | "failed";
92
95
  startedAt: number;
93
96
  endedAt?: number;
97
+ completionSequence?: number;
98
+ parentToolCallId?: string;
99
+ mediaOutputs?: WorkerMediaOutput[];
94
100
  }
95
101
 
96
102
  interface ContextMetrics {
@@ -149,6 +155,7 @@ interface ParsedEvent {
149
155
  errorMessage?: string;
150
156
  willRetry?: boolean;
151
157
  result?: {
158
+ content?: Array<{ type?: string; text?: string; data?: string; mimeType?: string }>;
152
159
  usage?: EventUsage;
153
160
  estimatedTokensAfter?: number;
154
161
  };
@@ -168,6 +175,7 @@ interface ParsedEvent {
168
175
  };
169
176
  toolCallId?: string;
170
177
  toolName?: string;
178
+ parentToolCallId?: string;
171
179
  args?: unknown;
172
180
  isError?: boolean;
173
181
  }
@@ -189,12 +197,15 @@ const WIDGET_ID = "pi-task-dispatcher-worker";
189
197
  const MODEL_TEXT_LIMIT = 5_000;
190
198
  const STDERR_LIMIT = 16_000;
191
199
  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"]);
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"]);
194
203
 
195
204
  const DEFAULT_CONFIG: DispatcherConfig = {
196
205
  mechanic: {},
206
+ assistant: {},
197
207
  engineer: {},
208
+ designer: {},
198
209
  policy: {
199
210
  absoluteMaxTurns: 96,
200
211
  absoluteMaxSeconds: 7_200,
@@ -206,13 +217,33 @@ const DEFAULT_CONFIG: DispatcherConfig = {
206
217
  const ROLE_PROMPTS: Record<Role, string> = {
207
218
  mechanic: `You are the mechanical worker in a supervised delegated Pi task.
208
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.`,
209
222
  engineer: `You are the engineering worker in a supervised delegated Pi task.
210
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.`,
211
226
  };
212
227
 
213
228
  const ROLE_DESCRIPTIONS: Record<Role, string> = {
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.",
229
+ mechanic: "Start a supervised mechanical delegation for prescribed execution with a known method and result.",
230
+ assistant: "Start a supervised assistant delegation for a subtask you would otherwise perform directly, when no other worker role applies.",
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.",
233
+ };
234
+
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
+ };
241
+
242
+ const ROLE_LABELS: Record<Role, string> = {
243
+ mechanic: "↗ mechanic",
244
+ assistant: "↗ assistant",
245
+ engineer: "↗ engineer",
246
+ designer: "↗ designer",
216
247
  };
217
248
 
218
249
  const WorkloadSchema = Type.Object({
@@ -349,11 +380,11 @@ function calculateBudget(
349
380
  Math.ceil(workload.investigationUnits * 1.25) +
350
381
  Math.ceil(workload.changeUnits * 1.5) +
351
382
  workload.verificationUnits;
352
- const roleFactor = role === "engineer" ? 1.25 : 1;
383
+ const roleFactor = role === "engineer" || role === "designer" ? 1.25 : 1;
353
384
  const contextScale = Math.max(0.75, Math.min(1.5, contextWindow / 400_000));
354
385
  // Reserve two turns for synthesis or recovery after the planned tool-producing work.
355
386
  const turns = Math.min(policy.absoluteMaxTurns, Math.max(6, Math.ceil(plannedTurns * roleFactor * contextScale) + 2));
356
- const secondsPerTurn = role === "engineer" ? 50 : 30;
387
+ const secondsPerTurn = role === "engineer" || role === "designer" ? 50 : 30;
357
388
  const seconds = Math.min(
358
389
  policy.absoluteMaxSeconds,
359
390
  Math.max(120, 30 + turns * secondsPerTurn + workload.expectedLongRunningSeconds),
@@ -365,13 +396,15 @@ function calculateBudget(
365
396
  function loadConfig(): DispatcherConfig {
366
397
  const result: DispatcherConfig = {
367
398
  mechanic: { ...DEFAULT_CONFIG.mechanic },
399
+ assistant: { ...DEFAULT_CONFIG.assistant },
368
400
  engineer: { ...DEFAULT_CONFIG.engineer },
401
+ designer: { ...DEFAULT_CONFIG.designer },
369
402
  policy: { ...DEFAULT_CONFIG.policy },
370
403
  };
371
404
  if (!fs.existsSync(CONFIG_PATH)) return result;
372
405
  const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")) as unknown;
373
406
  if (!isRecord(parsed)) throw new Error(`${CONFIG_PATH} must contain an object`);
374
- for (const role of ["mechanic", "engineer"] as const) {
407
+ for (const role of ROLES) {
375
408
  const value = parsed[role];
376
409
  if (value === undefined) continue;
377
410
  if (!isRecord(value)) throw new Error(`"${role}" must be an object`);
@@ -486,6 +519,7 @@ class WorkerJob {
486
519
  private stderr = "";
487
520
  private requestSequence = 0;
488
521
  private actionSequence = 0;
522
+ private completionSequence = 0;
489
523
  private activeActions = new Map<string, WorkerAction>();
490
524
  private pendingRequests = new Map<string, { resolve: (event: ParsedEvent) => void; reject: (error: Error) => void }>();
491
525
  private settleWaiter: { resolve: () => void; reject: (error: Error) => void } | undefined;
@@ -543,6 +577,7 @@ class WorkerJob {
543
577
  "--thinking", this.thinking,
544
578
  ...(this.tools.length > 0 ? ["--tools", this.tools.join(",")] : ["--no-tools"]),
545
579
  "--append-system-prompt", rolePromptPath,
580
+ ...(this.tools.includes("codemode") ? ["--extension", "builtin:codemode"] : []),
546
581
  "--extension", gatePath,
547
582
  ];
548
583
  const invocation = getDirectPiInvocation(args);
@@ -649,6 +684,12 @@ class WorkerJob {
649
684
  const completed = this.actions.filter((action) => action.status === "completed").length;
650
685
  const failed = this.actions.filter((action) => action.status === "failed").length;
651
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 })) }));
652
693
  return {
653
694
  jobId: this.id,
654
695
  role: this.role,
@@ -670,8 +711,8 @@ class WorkerJob {
670
711
  failed,
671
712
  mutations: this.actions.filter((action) => action.mutating).length,
672
713
  },
673
- recentActions: this.actions.slice(-8).map((action) => ({ ...action })),
674
- actionLedger: this.actions.map((action) => ({ ...action })),
714
+ recentActions,
715
+ actionLedger: this.actions.map((action) => ({ ...action, mediaOutputs: action.mediaOutputs?.map((output) => ({ ...output })) })),
675
716
  workerText: this.workerText,
676
717
  workerTextDisplay: display.text,
677
718
  workerTextTruncated: display.truncated,
@@ -789,6 +830,7 @@ class WorkerJob {
789
830
  mutating: MUTATING_TOOLS.has(event.toolName),
790
831
  status: "running",
791
832
  startedAt: Date.now(),
833
+ parentToolCallId: event.parentToolCallId,
792
834
  };
793
835
  this.actions.push(action);
794
836
  this.activeActions.set(action.toolCallId, action);
@@ -800,8 +842,19 @@ class WorkerJob {
800
842
  if (action) {
801
843
  action.status = event.isError ? "failed" : "completed";
802
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"}`;
854
+ }
803
855
  this.activeActions.delete(event.toolCallId);
804
856
  }
857
+ // Execution-end events carry each call's own usage before Pi merges nested usage into persistence.
805
858
  addUsage(this.usage, event.result?.usage);
806
859
  this.emitProgress();
807
860
  return;
@@ -843,6 +896,7 @@ class WorkerJob {
843
896
  this.recommendation = "wait for boundary";
844
897
  onProgress(this.snapshot());
845
898
  this.boundaryObserved = false;
899
+ this.lastStopReason = undefined;
846
900
  this.settleWaiter = undefined;
847
901
  const settled = new Promise<void>((resolve, reject) => {
848
902
  this.settleWaiter = { resolve, reject };
@@ -863,7 +917,10 @@ class WorkerJob {
863
917
  const operation = (async () => {
864
918
  const prompt = await this.request("prompt", { type: "prompt", message }, this.operationTimeout(30_000));
865
919
  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.");
920
+ if (!isRecord(prompt.data) || typeof prompt.data.disposition !== "string") {
921
+ throw new Error("Worker prompt response omitted its disposition.");
922
+ }
923
+ if (prompt.data.disposition === "handled") throw new Error("Worker prompt was intercepted by an input handler; no agent run started.");
867
924
  await settled;
868
925
  })();
869
926
  await this.waitForSettlement(operation);
@@ -881,9 +938,17 @@ class WorkerJob {
881
938
  this.commitActiveElapsed();
882
939
  }
883
940
  await this.refreshStats();
941
+ // Classify tool-free settlement while preserving output-limit responses for recovery.
884
942
  if (!this.boundaryObserved) {
885
- this.status = this.lastStopReason === "error" ? "failed" : "completed";
886
- this.phase = this.status === "completed" ? "worker completed" : "worker model error";
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();
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"}`;
887
952
  this.recommendation = this.status === "completed" ? "review and integrate" : "inspect failure";
888
953
  const snapshot = this.snapshot();
889
954
  await this.dispose();
@@ -1051,6 +1116,7 @@ class WorkerJob {
1051
1116
 
1052
1117
  let activeJob: WorkerJob | undefined;
1053
1118
  let lastSnapshot: WorkerSnapshot | undefined;
1119
+ const latestRoleSnapshots = new Map<Role, WorkerSnapshot>();
1054
1120
  const reportedUsage = new Map<string, UsageTotals>();
1055
1121
 
1056
1122
  /** Resolve role overrides against the active parent model without assuming provider availability. */
@@ -1075,18 +1141,18 @@ function resolveWorkerConfig(config: WorkerRoleConfig, ctx: ExtensionToolContext
1075
1141
  };
1076
1142
  }
1077
1143
 
1078
- /** Restrict delegations to active, non-recursive built-in tools selected by the manager. */
1144
+ /** Restrict delegations to active, non-recursive isolated-worker tools selected by the manager. */
1079
1145
  function validateDelegatedTools(requested: string[], pi: ExtensionAPI): string[] {
1080
1146
  const tools = [...new Set(requested.map((tool) => tool.trim()).filter(Boolean))];
1081
- const forbidden = new Set(["delegate_mechanical", "delegate_engineering", "delegate_control", "delegate_status"]);
1147
+ const forbidden = new Set([...Object.values(ROLE_TOOL_NAMES), "delegate_control", "delegate_status"]);
1082
1148
  const recursive = tools.filter((tool) => forbidden.has(tool));
1083
1149
  if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated: ${recursive.join(", ")}`);
1084
1150
  const active = new Set(pi.getActiveTools());
1085
1151
  const unavailable = tools.filter((tool) => !active.has(tool));
1086
1152
  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(", ")}`);
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(", ")}`);
1090
1156
  }
1091
1157
  return tools;
1092
1158
  }
@@ -1132,18 +1198,22 @@ function formatSnapshot(snapshot: WorkerSnapshot): string {
1132
1198
  ].filter(Boolean).join("\n");
1133
1199
  }
1134
1200
 
1135
- /** Keep one unindented paused-state line in Pi's footer, below its primary report. */
1201
+ /** Keep one unindented footer line with the most recent state observed for every invoked role. */
1136
1202
  function updateWorkerStatus(ctx: ExtensionToolContext, snapshot: WorkerSnapshot): void {
1203
+ latestRoleSnapshots.delete(snapshot.role);
1204
+ latestRoleSnapshots.set(snapshot.role, snapshot);
1137
1205
  if (!ctx.hasUI) return;
1138
1206
  // Clear the pre-3.1 editor widget if this extension was reloaded in place.
1139
1207
  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}`);
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));
1147
1217
  }
1148
1218
 
1149
1219
  /** Obtain mutable state shared by call/result renderers, including during partial argument streaming. */
@@ -1202,10 +1272,12 @@ function renderWorkerResult(
1202
1272
  rendererState(context).snapshot = snapshot;
1203
1273
  const color = snapshot.status === "paused" ? "warning" : snapshot.status === "completed" ? "success" : isTerminal(snapshot.status) && snapshot.status !== "completed" ? "error" : "accent";
1204
1274
  const elapsedLabel = isPartial ? "Elapsed" : "Took";
1275
+ const latestActivity = snapshot.currentActivity ?? snapshot.recentActions.at(-1)?.label;
1276
+ const activityLabel = snapshot.currentActivity ? "Now" : "Last";
1205
1277
  const lines = [
1206
1278
  standalone ? theme.fg(color, theme.bold(`${snapshot.jobId}: ${snapshot.status}`)) : "",
1207
1279
  standalone ? theme.fg("muted", `Task: ${snapshot.taskDisplay}`) : "",
1208
- snapshot.currentActivity ? theme.fg("warning", `Now: ${snapshot.currentActivity}`) : "",
1280
+ latestActivity ? theme.fg(snapshot.currentActivity ? "warning" : "muted", `${activityLabel}: ${latestActivity}`) : "",
1209
1281
  theme.fg("dim", `${snapshot.phase} · boundary ${snapshot.sequence} · turn ${snapshot.turns}/${snapshot.budget.turns}`),
1210
1282
  theme.fg("muted", `${snapshot.actionCounts.completed} actions complete · ${snapshot.actionCounts.failed} failed · ${formatContext(snapshot)}`),
1211
1283
  theme.fg("accent", snapshot.recommendation),
@@ -1234,13 +1306,16 @@ function snapshotResult(snapshot: WorkerSnapshot, isError = false): { content: A
1234
1306
 
1235
1307
  /** Register one role-specific supervised worker starter. */
1236
1308
  function registerDelegate(pi: ExtensionAPI, role: Role): void {
1237
- const name = role === "mechanic" ? "delegate_mechanical" : "delegate_engineering";
1309
+ const name = ROLE_TOOL_NAMES[role];
1238
1310
  pi.registerTool({
1239
1311
  name,
1240
- label: role === "mechanic" ? "↗ mechanic" : "↗ engineer",
1312
+ label: ROLE_LABELS[role],
1241
1313
  description: ROLE_DESCRIPTIONS[role],
1242
1314
  promptSnippet: `${name}: start a supervised ${role} delegation that pauses after each completed action batch`,
1243
1315
  promptGuidelines: [
1316
+ ...(role === "assistant"
1317
+ ? ["When no other worker role applies, use delegate_assistant for the user-requested work instead of performing it directly. Retain delegation decisions, worker supervision, result integration, and the final response."]
1318
+ : []),
1244
1319
  "Supply semantic workload units rather than raw turn or timeout values.",
1245
1320
  "Inspect every paused state and choose continue, revise, recalculate, compact, checkpoint, cancel, or take over.",
1246
1321
  "Worker reports remain evidence to review, not proof of correctness.",
@@ -1280,6 +1355,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
1280
1355
  const job = new WorkerJob(role, params.task, tools, resolved.role, ctx.cwd, config.policy, budget);
1281
1356
  activeJob = job;
1282
1357
  const progress = (snapshot: WorkerSnapshot) => {
1358
+ updateWorkerStatus(ctx, snapshot);
1283
1359
  onUpdate?.({ content: [{ type: "text", text: formatSnapshot(snapshot) }], details: snapshot });
1284
1360
  };
1285
1361
  const snapshot = await job.start(signal, progress);
@@ -1346,6 +1422,7 @@ function registerControl(pi: ExtensionAPI): void {
1346
1422
  if (params.action === "revise" && !params.instructions) throw new Error("revise requires instructions");
1347
1423
  const progress = (state: WorkerSnapshot) => {
1348
1424
  state.managerAction = params.action;
1425
+ updateWorkerStatus(ctx, state);
1349
1426
  onUpdate?.({ content: [{ type: "text", text: formatSnapshot(state) }], details: state });
1350
1427
  };
1351
1428
  snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, signal, progress);
@@ -1395,8 +1472,7 @@ function registerStatus(pi: ExtensionAPI): void {
1395
1472
  /** Register only in the parent process and clean every live child when the session shuts down. */
1396
1473
  export default function taskDispatcher(pi: ExtensionAPI): void {
1397
1474
  if (process.env[WORKER_ENV]) return;
1398
- registerDelegate(pi, "mechanic");
1399
- registerDelegate(pi, "engineer");
1475
+ for (const role of ROLES) registerDelegate(pi, role);
1400
1476
  registerControl(pi);
1401
1477
  registerStatus(pi);
1402
1478
  pi.on("session_shutdown", async () => {
package/package.json CHANGED
@@ -1,50 +1,50 @@
1
- {
2
- "name": "pi-task-dispatcher",
3
- "version": "3.0.0",
4
- "description": "Run configurable Pi worker models as supervised, observable, resumable jobs.",
5
- "type": "module",
6
- "keywords": [
7
- "pi-package",
8
- "pi-extension",
9
- "delegation",
10
- "subagent",
11
- "model-routing"
12
- ],
13
- "author": "SorinGFS",
14
- "license": "MIT",
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/soringfs/pi-task-dispatcher.git"
18
- },
19
- "homepage": "https://github.com/soringfs/pi-task-dispatcher#readme",
20
- "bugs": {
21
- "url": "https://github.com/soringfs/pi-task-dispatcher/issues"
22
- },
23
- "engines": {
24
- "node": ">=22.19.0"
25
- },
26
- "scripts": {
27
- "test:gate": "node tests/gate-probe.mjs",
28
- "test:fake-rpc": "node tests/fake-rpc-check.mjs",
29
- "check": "node tests/static-check.mjs && node tests/fake-rpc-check.mjs"
30
- },
31
- "peerDependencies": {
32
- "@earendil-works/pi-coding-agent": "*",
33
- "@earendil-works/pi-tui": "*",
34
- "typebox": "*"
35
- },
36
- "pi": {
37
- "extensions": [
38
- "./extensions/task-dispatcher.ts"
39
- ]
40
- },
41
- "files": [
42
- "extensions",
43
- "tests",
44
- "README.md",
45
- "LICENSE"
46
- ],
47
- "publishConfig": {
48
- "access": "public"
49
- }
50
- }
1
+ {
2
+ "name": "pi-task-dispatcher",
3
+ "version": "3.0.2",
4
+ "description": "Run configurable Pi worker models as supervised, observable, resumable jobs.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "delegation",
10
+ "subagent",
11
+ "model-routing"
12
+ ],
13
+ "author": "SorinGFS",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/soringfs/pi-task-dispatcher.git"
18
+ },
19
+ "homepage": "https://github.com/soringfs/pi-task-dispatcher#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/soringfs/pi-task-dispatcher/issues"
22
+ },
23
+ "engines": {
24
+ "node": ">=22.19.0"
25
+ },
26
+ "scripts": {
27
+ "test:gate": "node tests/gate-probe.mjs",
28
+ "test:fake-rpc": "node tests/fake-rpc-check.mjs",
29
+ "check": "node tests/static-check.mjs && node tests/fake-rpc-check.mjs"
30
+ },
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-coding-agent": "*",
33
+ "@earendil-works/pi-tui": "*",
34
+ "typebox": "*"
35
+ },
36
+ "pi": {
37
+ "extensions": [
38
+ "./extensions/task-dispatcher.ts"
39
+ ]
40
+ },
41
+ "files": [
42
+ "extensions",
43
+ "tests",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
@@ -42,18 +42,23 @@ try {
42
42
  const handlers = new Map();
43
43
  const pi = {
44
44
  registerTool(tool) { tools.set(tool.name, tool); },
45
- getActiveTools() { return ["read", "bash", "edit", "write"]; },
45
+ getActiveTools() { return ["read", "bash", "edit", "write", "codemode"]; },
46
46
  on(event, handler) { handlers.set(event, handler); },
47
47
  };
48
48
  register(pi);
49
49
 
50
50
  const activeModel = { provider: "fake-provider", id: "active-model", contextWindow: 272_000, maxTokens: 128_000 };
51
+ const statusUpdates = [];
51
52
  const context = {
52
53
  cwd: projectRoot,
53
- hasUI: false,
54
+ hasUI: true,
54
55
  model: activeModel,
55
56
  thinkingLevel: "medium",
56
- ui: { setWidget() {}, setStatus() {} },
57
+ ui: {
58
+ theme: { fg(_color, text) { return text; } },
59
+ setWidget() {},
60
+ setStatus(key, text) { statusUpdates.push({ key, text }); },
61
+ },
57
62
  modelRegistry: {
58
63
  find(provider, id) { return { provider, id, contextWindow: 272_000, maxTokens: 128_000 }; },
59
64
  hasConfiguredAuth() { return true; },
@@ -61,8 +66,10 @@ try {
61
66
  };
62
67
  const workload = { investigationUnits: 1, changeUnits: 0, verificationUnits: 1, expectedLongRunningSeconds: 0 };
63
68
  const start = tools.get("delegate_engineering");
69
+ const designerStart = tools.get("delegate_designer");
64
70
  const control = tools.get("delegate_control");
65
- assert(start && control, "Expected supervised tools to register.");
71
+ assert(start && designerStart && control, "Expected supervised tools to register.");
72
+ assert(tools.has("delegate_mechanical") && tools.has("delegate_assistant"), "Expected every worker role to register.");
66
73
  const theme = { fg(_color, text) { return text; }, bold(text) { return text; } };
67
74
  let rendererInvalidations = 0;
68
75
  const renderContext = { args: {}, state: {}, invalidate() { rendererInvalidations++; }, cwd: projectRoot };
@@ -83,7 +90,9 @@ try {
83
90
  assert.equal(result.usage.output, 3);
84
91
  assert.equal(result.usage.totalTokens, 16);
85
92
  const partialRender = start.renderResult(result, { expanded: false, isPartial: true }, theme, renderContext).render(100).join("\n");
93
+ assert.match(partialRender, /Last: read package\.json/);
86
94
  assert.match(partialRender, /Elapsed:/);
95
+ assert.equal(statusUpdates.at(-1)?.text, "engineer-1 · 11.0%/1k • active-model • medium");
87
96
  const resultComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext);
88
97
  const firstRender = resultComponent.render(100).join("\n");
89
98
  assert.match(firstRender, /Took:/);
@@ -109,10 +118,66 @@ try {
109
118
  assert.equal(result.details.workerText, "deterministic worker complete");
110
119
  assert.equal(result.usage.input, 5, "Continuation must return only newly observed usage.");
111
120
  assert.equal(result.usage.totalTokens, 8);
121
+ assert.equal(statusUpdates.at(-1)?.text, "engineer-1 · 12.0%/1k • active-model • medium");
122
+ assert(!statusUpdates.some((update) => update.text === undefined), "Worker status must remain visible after it first appears.");
112
123
  control.renderResult(result, { expanded: false, isPartial: false }, theme, managerRenderContext).render(100);
113
124
  assert.match(managerCall.render(100).join("\n"), /manager: engineer-1 → completed/);
114
125
  assert.doesNotMatch(managerCall.render(100).join("\n"), /continue\/completed/);
115
126
 
127
+ // The designer must load only the selected built-in codemode extension and account nested usage once.
128
+ await writeFile(configPath, JSON.stringify({ designer: { model: "media-provider/media-model", thinking: "high" } }), "utf8");
129
+ process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "codemode";
130
+ result = await designerStart.execute("start-designer", { task: "fake image job", tools: ["read", "codemode"], workload }, undefined, undefined, context);
131
+ assert.equal(result.isError, false);
132
+ assert.equal(result.details.status, "paused");
133
+ assert.equal(result.details.role, "designer");
134
+ assert.equal(result.details.model, "media-provider/media-model");
135
+ assert.equal(result.details.thinking, "high");
136
+ assert.equal(result.details.actionLedger.length, 2);
137
+ assert.equal(result.details.actionCounts.mutations, 1);
138
+ assert.equal(result.details.recentActions.at(-1).toolName, "codemode");
139
+ assert.deepEqual(result.details.recentActions.at(-1).mediaOutputs, [{ mimeType: "image/png", bytes: 4 }]);
140
+ assert.match(result.details.recentActions.at(-1).label, /1 image/);
141
+ assert.doesNotMatch(JSON.stringify(result.details), /ZmFrZQ==/, "Snapshots must not retain base64 media payloads.");
142
+ assert.equal(result.usage.input, 20, "Each nested and parent execution must contribute its own pre-merge usage exactly once.");
143
+ assert.equal(result.usage.output, 6);
144
+ assert.equal(result.usage.totalTokens, 26);
145
+ assert.equal(
146
+ statusUpdates.at(-1)?.text,
147
+ "designer-2 · 11.0%/1k • media-model • high | engineer-1 · 12.0%/1k • active-model • medium",
148
+ "Starting another role must retain the previous role's footer status.",
149
+ );
150
+ result = await control.execute(
151
+ "continue-designer",
152
+ { jobId: result.details.jobId, action: "continue", actionBatches: 1 },
153
+ undefined,
154
+ undefined,
155
+ context,
156
+ );
157
+ assert.equal(result.details.status, "completed");
158
+ assert.match(statusUpdates.at(-1)?.text ?? "", /designer-2.*\|.*engineer-1/);
159
+ await writeFile(configPath, "{}\n", "utf8");
160
+
161
+ // A tool-free output-limit stop must preserve the session so the manager can recover the report.
162
+ process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "length";
163
+ result = await start.execute("start-length", { task: "fake truncated job", tools: ["read"], workload }, undefined, undefined, context);
164
+ assert.equal(result.isError, false);
165
+ assert.equal(result.details.status, "paused");
166
+ assert.equal(result.details.turns, 1);
167
+ assert.equal(result.details.workerText, "truncated worker report");
168
+ assert.match(result.details.phase, /output limit/i);
169
+ assert.match(result.details.recommendation, /continue to recover/i);
170
+ result = await control.execute(
171
+ "continue-length",
172
+ { jobId: result.details.jobId, action: "continue", actionBatches: 1 },
173
+ undefined,
174
+ undefined,
175
+ context,
176
+ );
177
+ assert.equal(result.isError, false);
178
+ assert.equal(result.details.status, "completed");
179
+ assert.equal(result.details.workerText, "deterministic worker complete");
180
+
116
181
  // A handled prompt starts no agent run and must fail promptly rather than waiting for settlement.
117
182
  process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "handled";
118
183
  const handledStartedAt = Date.now();
@@ -50,18 +50,60 @@ readline.createInterface({ input: process.stdin }).on("line", async (line) => {
50
50
  if (markerPath) await appendFile(markerPath, `${promptCount}\n`, "utf8");
51
51
  if (scenario === "timeout") return;
52
52
  if (scenario === "handled") {
53
- send({ type: "response", id: command.id, command: command.type, success: true, disposition: "handled" });
53
+ send({ type: "response", id: command.id, command: command.type, success: true, data: { disposition: "handled" } });
54
54
  return;
55
55
  }
56
56
 
57
- send({ type: "response", id: command.id, command: command.type, success: true, disposition: "started" });
57
+ if (scenario === "codemode" && promptCount === 1) {
58
+ const toolsIndex = process.argv.indexOf("--tools");
59
+ const selectedTools = toolsIndex >= 0 ? process.argv[toolsIndex + 1]?.split(",") ?? [] : [];
60
+ const extensionValues = process.argv.flatMap((argument, index) => argument === "--extension" ? [process.argv[index + 1]] : []);
61
+ if (!selectedTools.includes("codemode") || !extensionValues.includes("builtin:codemode")) {
62
+ send({ type: "response", id: command.id, command: command.type, success: false, error: "codemode was not isolated and loaded" });
63
+ return;
64
+ }
65
+ }
66
+
67
+ send({ type: "response", id: command.id, command: command.type, success: true, data: { disposition: "started" } });
68
+ if (scenario === "length" && promptCount === 1) {
69
+ send({
70
+ type: "message_end",
71
+ message: {
72
+ role: "assistant",
73
+ stopReason: "length",
74
+ content: [{ type: "text", text: "truncated worker report" }],
75
+ usage: usage(7, 4),
76
+ },
77
+ });
78
+ send({ type: "agent_settled" });
79
+ return;
80
+ }
58
81
  if (promptCount === 1) {
59
82
  send({
60
83
  type: "message_end",
61
84
  message: { role: "assistant", stopReason: "toolUse", content: [], usage: usage(10, 2) },
62
85
  });
63
- send({ type: "tool_execution_start", toolCallId: "fake-read-1", toolName: "read", args: { path: "package.json" } });
64
- send({ type: "tool_execution_end", toolCallId: "fake-read-1", toolName: "read", isError: false, result: { usage: usage(3, 1) } });
86
+ if (scenario === "codemode") {
87
+ send({ type: "tool_execution_start", toolCallId: "fake-codemode-1", toolName: "codemode", args: "return image" });
88
+ send({ type: "tool_execution_start", toolCallId: "fake-read-nested", parentToolCallId: "fake-codemode-1", toolName: "read", args: { path: "reference.png" } });
89
+ send({ type: "tool_execution_end", toolCallId: "fake-read-nested", parentToolCallId: "fake-codemode-1", toolName: "read", isError: false, result: { usage: usage(3, 1) } });
90
+ send({
91
+ type: "tool_execution_end",
92
+ toolCallId: "fake-codemode-1",
93
+ toolName: "codemode",
94
+ isError: false,
95
+ result: {
96
+ content: [
97
+ { type: "text", text: "generated image retained in runtime temporary storage" },
98
+ { type: "image", data: "ZmFrZQ==", mimeType: "image/png" },
99
+ ],
100
+ usage: usage(7, 3),
101
+ },
102
+ });
103
+ } else {
104
+ send({ type: "tool_execution_start", toolCallId: "fake-read-1", toolName: "read", args: { path: "package.json" } });
105
+ send({ type: "tool_execution_end", toolCallId: "fake-read-1", toolName: "read", isError: false, result: { usage: usage(3, 1) } });
106
+ }
65
107
  if (scenario === "missing-boundary") {
66
108
  send({
67
109
  type: "message_end",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Verify that a worker-side turn boundary can abort Pi after a completed tool batch while keeping the RPC child reusable.
2
+ * Verify supervised codemode loading and a reusable worker-side boundary after one completed tool batch.
3
3
  */
4
4
 
5
5
  import { spawn } from "node:child_process";
@@ -55,7 +55,9 @@ export default function (pi: ExtensionAPI): void {
55
55
  "--no-session",
56
56
  "--no-extensions",
57
57
  "--tools",
58
- "read",
58
+ "codemode",
59
+ "--extension",
60
+ "builtin:codemode",
59
61
  "--extension",
60
62
  gatePath,
61
63
  ];
@@ -92,11 +94,11 @@ export default function (pi: ExtensionAPI): void {
92
94
  });
93
95
  child.on("message", (message) => boundaries.push(message));
94
96
 
95
- // Run one tool batch and require the gate to settle the run before a productive follow-up turn.
97
+ // Run one codemode batch and require the gate to settle before a productive follow-up turn.
96
98
  child.stdin.write(`${JSON.stringify({
97
99
  id: "probe-1",
98
100
  type: "prompt",
99
- message: "Read package.json exactly once. Do not use any other tool. After reading it, report only its package name.",
101
+ message: "Call codemode exactly once with the raw JavaScript source: text('pi-task-dispatcher-codemode-ready')\nDo not use any other tool and do not add other script statements.",
100
102
  })}\n`);
101
103
  await Promise.race([
102
104
  settled,
@@ -109,6 +111,9 @@ export default function (pi: ExtensionAPI): void {
109
111
  if (!firstAssistantMessages.some((event) => event.message?.stopReason === "toolUse")) {
110
112
  throw new Error("The first run did not execute the expected tool-use turn.");
111
113
  }
114
+ if (!events.some((event) => event.type === "tool_execution_end" && event.toolName === "codemode" && !event.isError)) {
115
+ throw new Error("The isolated worker did not complete the selected built-in codemode tool.");
116
+ }
112
117
 
113
118
  // Continue in the same in-memory RPC session and require a normal final answer without another tool batch.
114
119
  events.length = 0;
@@ -118,7 +123,7 @@ export default function (pi: ExtensionAPI): void {
118
123
  child.stdin.write(`${JSON.stringify({
119
124
  id: "probe-2",
120
125
  type: "prompt",
121
- message: "Continue from the completed read. Do not call tools. Report only the package name.",
126
+ message: "Continue from the completed codemode result. Do not call tools. Report only the exact marker produced by the script.",
122
127
  })}\n`);
123
128
  await Promise.race([
124
129
  settled,
@@ -132,7 +137,7 @@ export default function (pi: ExtensionAPI): void {
132
137
  .map((part) => part.text)
133
138
  .join("\n")
134
139
  .trim();
135
- if (!finalText.includes("pi-task-dispatcher")) throw new Error(`Unexpected continuation output: ${finalText}`);
140
+ if (!finalText.includes("pi-task-dispatcher-codemode-ready")) throw new Error(`Unexpected continuation output: ${finalText}`);
136
141
 
137
142
  console.log(JSON.stringify({ firstBoundaryCount, firstAssistantMessages: firstAssistantMessages.length, finalText }, null, 2));
138
143
  if (stderr.trim()) console.error(stderr.trim());
@@ -21,8 +21,13 @@ if (!source.includes("let model = ctx.model")) throw new Error("Role defaults no
21
21
 
22
22
  // Require the supervised protocol and its externally enforced gate in every v3 candidate.
23
23
  for (const required of [
24
+ "delegate_mechanical",
25
+ "delegate_assistant",
26
+ "delegate_engineering",
27
+ "delegate_designer",
24
28
  "delegate_control",
25
29
  "delegate_status",
30
+ "builtin:codemode",
26
31
  "task_dispatcher_boundary",
27
32
  "without an enforced IPC boundary",
28
33
  "--no-extensions",