pi-task-dispatcher 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,133 +1,124 @@
1
1
  # pi-task-dispatcher
2
2
 
3
- Delegate bounded mechanical and engineering subtasks to configurable Pi worker models without replacing the model selected for the main Pi session.
3
+ Delegate bounded tasks from a Pi session to dedicated mechanical and engineering worker models while the selected main model remains in control.
4
4
 
5
- ## How it works
5
+ ## Features
6
6
 
7
- Your selected Pi model remains the **orchestrator**: it decides whether to delegate, supplies a self-contained task, evaluates the worker report, reviews material changes, and owns the final answer. This package adds two model-only tools:
7
+ - **Two focused worker roles:** route precise execution work to a mechanic model and broader implementation work to an engineer model.
8
+ - **Least-privilege delegation:** every call supplies an exact tool allowlist selected from tools active for the main agent. Workers cannot invoke the delegation tools recursively.
9
+ - **Live progress:** streamed worker text, tool activity, and an elapsed-time heartbeat keep long-running tasks visible.
10
+ - **Recoverable interruption:** approaching time and turn limits request a worker-authored checkpoint. A hard timeout returns a synthetic fallback checkpoint.
11
+ - **Explicit continuation:** a checkpoint never starts another worker automatically. The main agent reviews current state and decides what happens next.
8
12
 
9
- | Tool | Use it when |
13
+ ## Worker tools
14
+
15
+ The package adds two model-only tools to Pi:
16
+
17
+ | Tool | Intended use |
10
18
  | --- | --- |
11
- | `delegate_mechanical` | The outcome and constraints are precise and the work is bounded, multi-step, and primarily inspection, exact edits, repetitive transformation, commands, retrieval, or verification. Use a direct tool for a single trivial operation. |
12
- | `delegate_engineering` | A self-contained engineering subtask needs investigation, implementation, debugging, refactoring, testing, review, research, or analysis. Include the outcome, constraints, and acceptance conditions. |
19
+ | `delegate_mechanical` | Bounded multi-step inspection, exact edits, repetitive transformations, commands, retrieval, or verification with precise acceptance conditions. |
20
+ | `delegate_engineering` | Self-contained investigation, implementation, debugging, refactoring, testing, review, research, or analysis. |
13
21
 
14
- Both workers operate in the current workspace and can modify files when their configured tools permit it.
22
+ The main model remains the **orchestrator**. It defines the delegated task, selects the smallest sufficient tool allowlist, evaluates the report, reviews material changes, performs required verification, and owns the final answer.
15
23
 
16
- After the orchestrator selects a delegation tool, its role is routed deterministically to that role's configured `provider/model`. That routing does **not** make a worker's output, changes, or correctness deterministic; treat every worker report as input to review and verification.
24
+ Workers operate in the current workspace and can modify files only when their selected tools permit it. Role routing to the configured `provider/model` is deterministic; worker output and correctness are not. Treat every worker report as input to review rather than proof of completion.
17
25
 
18
26
  ## Requirements
19
27
 
20
- - Pi 1.0.0 or later
28
+ - Pi 1.0.2 or later
21
29
  - Node.js 22.19.0 or later
22
- - A configured, authenticated Pi model for each worker role you invoke
23
-
24
- ## Install
25
-
26
- Install the published package for your Pi user configuration:
30
+ - A configured and authenticated Pi model for each worker role you use
27
31
 
28
- ```sh
29
- pi install npm:pi-task-dispatcher@1.0.0
30
- ```
32
+ ## Installation
31
33
 
32
- For a local checkout, run this from its parent directory:
34
+ Install the package for your Pi user configuration:
33
35
 
34
36
  ```sh
35
- pi install ./pi-task-dispatcher
37
+ pi install npm:pi-task-dispatcher
36
38
  ```
37
39
 
38
- To register either source in the current project's `.pi/settings.json` instead, add `--local`:
40
+ To install it only for the current project, add `--local`:
39
41
 
40
42
  ```sh
41
- pi install npm:pi-task-dispatcher@1.0.0 --local
43
+ pi install npm:pi-task-dispatcher --local
42
44
  ```
43
45
 
44
- Project packages load only after that project is trusted. In an already running Pi session, run:
46
+ Project packages load only after the project is trusted. In an active Pi session, load the extension with:
45
47
 
46
48
  ```text
47
49
  /reload
48
50
  ```
49
51
 
50
- `/reload` reloads extensions and other discovered resources. The worker configuration below is read again for every delegation.
51
-
52
- ## Default workers
53
-
54
- | Role | Model | Thinking | Timeout | Turn limit |
55
- | --- | --- | --- | ---: | ---: |
56
- | Mechanic | `openai-codex/gpt-5.6-luna` | `max` | 300 seconds | 20 |
57
- | Engineer | `openai-codex/gpt-5.6-terra` | `max` | 900 seconds | 30 |
58
-
59
- By default, both workers receive this explicit tool allowlist:
60
-
61
- ```text
62
- read,bash,edit,write
63
- ```
64
-
65
- `pi-task-dispatcher` provides only these four native Pi tools by default; it does not provide `web_search`, `fetch_content`, `get_search_content`, or `source_check`. If you have the separate `pi-web-access` package installed, you may explicitly add those tools to a role's `tools` array:
66
-
67
- ```json
68
- {
69
- "mechanic": {
70
- "tools": ["read", "bash", "edit", "write", "web_search", "fetch_content", "get_search_content", "source_check"]
71
- }
72
- }
73
- ```
52
+ ## Configuration
74
53
 
75
- ## Configure workers
54
+ The package works without a configuration file using these defaults:
76
55
 
77
- Optionally create `~/.pi/agent/task-dispatcher.json`. If Pi's agent directory is overridden, use `<agent-dir>/task-dispatcher.json` instead. Missing role objects and fields inherit the defaults.
56
+ | Role | Model | Thinking | Hard timeout | Checkpoint grace | Turn limit |
57
+ | --- | --- | --- | ---: | ---: | ---: |
58
+ | Mechanic | `openai-codex/gpt-5.6-luna` | `max` | 300 seconds | 30 seconds | 20 |
59
+ | Engineer | `openai-codex/gpt-5.6-terra` | `max` | 900 seconds | 45 seconds | 30 |
78
60
 
79
- This is a complete valid configuration using the defaults:
61
+ To override them, create `~/.pi/agent/task-dispatcher.json`. If Pi uses a custom agent directory, create `task-dispatcher.json` in that directory instead. Missing roles and fields retain their defaults.
80
62
 
81
63
  ```json
82
64
  {
83
- "version": 1,
84
65
  "mechanic": {
85
66
  "model": "openai-codex/gpt-5.6-luna",
86
67
  "thinking": "max",
87
- "tools": [
88
- "read",
89
- "bash",
90
- "edit",
91
- "write"
92
- ],
93
68
  "timeoutSeconds": 300,
69
+ "checkpointGraceSeconds": 30,
94
70
  "maxTurns": 20
95
71
  },
96
72
  "engineer": {
97
73
  "model": "openai-codex/gpt-5.6-terra",
98
74
  "thinking": "max",
99
- "tools": [
100
- "read",
101
- "bash",
102
- "edit",
103
- "write"
104
- ],
105
75
  "timeoutSeconds": 900,
76
+ "checkpointGraceSeconds": 45,
106
77
  "maxTurns": 30
107
78
  }
108
79
  }
109
80
  ```
110
81
 
111
- | Field | Meaning |
82
+ | Field | Description |
112
83
  | --- | --- |
113
- | `version` | Optional. When present, it must be `1`. |
114
- | `mechanic`, `engineer` | Optional per-role objects. Each supplied field overrides that role's default. |
115
- | `model` | A `provider/model` string. Pi verifies that the exact model exists in its registry and has usable authentication before starting the worker. |
116
- | `thinking` | One of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. |
117
- | `tools` | A non-empty array of tool names. It replaces, rather than extends, the default allowlist; duplicate names are removed. |
118
- | `timeoutSeconds` | Positive integer wall-clock limit for the worker process. |
119
- | `maxTurns` | Positive integer limit for a continuing tool-using worker loop. |
84
+ | `mechanic`, `engineer` | Optional role objects. Each supplied field overrides that role's default. |
85
+ | `model` | Exact `provider/model` identity. The dispatcher verifies model availability and authentication before starting a worker. |
86
+ | `thinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. |
87
+ | `timeoutSeconds` | Positive integer hard wall-clock limit for the worker process. |
88
+ | `checkpointGraceSeconds` | Positive integer period reserved before the hard timeout for a graceful checkpoint. It must be less than `timeoutSeconds`. |
89
+ | `maxTurns` | Positive integer work-turn limit. The dispatcher allows a final checkpoint response after the limit is reached. |
90
+
91
+ Configuration is read for every delegation, so configuration-only changes do not require `/reload`.
92
+
93
+ Tool access is deliberately absent from role configuration. Every delegation selects its own exact allowlist from the main agent's currently active tools; `[]` gives the worker no tools. A continuation must select its tools again and does not inherit the previous allowlist.
94
+
95
+ ## Usage
96
+
97
+ Give the main model a bounded objective and let it choose whether delegation is useful. For example:
98
+
99
+ > Use the engineering worker to diagnose the failing parser test. Do not change public APIs. Run the focused test and report the cause, changed files, and test result.
100
+
101
+ For tighter control, name the role and allowed tools:
102
+
103
+ > Delegate this to the mechanic worker with only `read` and `bash`: inspect the generated manifest, run its validation command, and report exact mismatches. Do not modify files.
104
+
105
+ A direct tool remains preferable for one trivial operation. Delegation is most useful when a self-contained episode would otherwise consume multiple agent turns or produce bulky intermediate output.
106
+
107
+ ## Limits and checkpoints
108
+
109
+ Each delegation runs in a separate Pi RPC child process with an in-memory, no-session conversation. The worker receives its role prompt, selected model, thinking level, and only the allowlist chosen for that call.
120
110
 
121
- ## Isolation, limits, and safety
111
+ The dispatcher propagates parent cancellation and reports live text and tool activity. Before the hard timeout, it steers the worker to stop at the next safe turn boundary and produce a checkpoint. Reaching the turn limit uses the same checkpoint flow. If the worker cannot settle before the hard deadline, the dispatcher terminates it and reports observed completed and interrupted activity in a fallback checkpoint.
122
112
 
123
- Each delegation starts a separate, no-session Pi child process in the current workspace. The worker receives its role prompt, selected model, thinking level, and only the configured `tools` allowlist. The dispatcher does not register either delegation tool inside worker processes, so workers cannot recursively delegate through this package.
113
+ Non-completed results include the stopping reason, available partial worker text, observed activity, and a warning when filesystem effects may remain. The main agent then chooses whether to:
124
114
 
125
- The parent cancellation signal is propagated to the child. Workers are stopped on timeout, and an ongoing tool-using loop is stopped at its configured turn limit. While a worker runs, its tool activity can be reported as progress. Its final tool result includes the role, model, thinking level, configured tool names, status, turns, duration, exit information, activity, and aggregated usage; non-completed runs are returned as errors with a diagnostic and any available partial report. Reported failure states include configuration, model, and process errors, cancellation, timeout, turn-limit, and incomplete outcomes.
115
+ - inspect and continue directly;
116
+ - delegate a bounded continuation with a newly selected allowlist;
117
+ - request user input; or
118
+ - stop and report a limitation.
126
119
 
127
- > **Security warning:** Worker process isolation is not a security sandbox. A worker can use every tool in its configured allowlist with the Pi host's permissions in the current environment. Use the smallest practical allowlist and delegate only work you are prepared to authorize.
120
+ Continuation is never automatic.
128
121
 
129
- ## Short workflow
122
+ ## Security
130
123
 
131
- 1. Select the normal Pi model that should orchestrate the task.
132
- 2. Give it a bounded request, for example: “Use `delegate_engineering` to diagnose the failing parser test. Do not change public APIs; run the focused test; return the cause, files changed, and test result.”
133
- 3. The orchestrator calls the selected worker tool, receives its report, inspects relevant changes and verification, then produces the final response.
124
+ Worker process separation is **not** a security sandbox. A worker uses its selected tools with the Pi host's permissions in the current environment. Tool availability does not establish task scope, authorization, or safety. Use the smallest practical allowlist and delegate only work you are prepared to authorize and review.
@@ -25,8 +25,8 @@ type WorkerStatus =
25
25
  interface WorkerConfig {
26
26
  model: string;
27
27
  thinking: ThinkingLevel;
28
- tools: string[];
29
28
  timeoutSeconds: number;
29
+ checkpointGraceSeconds: number;
30
30
  maxTurns: number;
31
31
  }
32
32
 
@@ -52,6 +52,21 @@ interface UsageTotals {
52
52
  };
53
53
  }
54
54
 
55
+ interface WorkerActivity {
56
+ toolCallId: string;
57
+ label: string;
58
+ status: "running" | "completed" | "failed";
59
+ }
60
+
61
+ interface WorkerCheckpoint {
62
+ reason: Exclude<WorkerStatus, "running" | "completed">;
63
+ task: string;
64
+ lastAssistantText?: string;
65
+ completedActivities: string[];
66
+ activeActivities: string[];
67
+ filesystemEffectsMayRemain: boolean;
68
+ }
69
+
55
70
  interface WorkerDetails {
56
71
  role: Role;
57
72
  status: WorkerStatus;
@@ -64,7 +79,8 @@ interface WorkerDetails {
64
79
  stopReason?: string;
65
80
  diagnostic?: string;
66
81
  stderr?: string;
67
- activity: string[];
82
+ activity: WorkerActivity[];
83
+ checkpoint?: WorkerCheckpoint;
68
84
  }
69
85
 
70
86
  interface WorkerResult {
@@ -74,45 +90,51 @@ interface WorkerResult {
74
90
  }
75
91
 
76
92
  interface ParsedEvent {
93
+ id?: string;
77
94
  type?: string;
95
+ success?: boolean;
96
+ error?: string;
78
97
  message?: {
79
98
  role?: string;
80
99
  content?: Array<{ type?: string; text?: string; name?: string; arguments?: unknown }>;
81
- usage?: Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
100
+ usage?: EventUsage;
82
101
  stopReason?: string;
83
102
  errorMessage?: string;
84
103
  };
104
+ usage?: EventUsage;
105
+ assistantMessageEvent?: {
106
+ type?: string;
107
+ contentIndex?: number;
108
+ delta?: string;
109
+ content?: string;
110
+ };
111
+ toolCallId?: string;
85
112
  toolName?: string;
86
113
  args?: unknown;
114
+ isError?: boolean;
87
115
  }
88
116
 
89
- type EventUsage = NonNullable<NonNullable<ParsedEvent["message"]>["usage"]>;
117
+ type EventUsage = Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
90
118
 
91
119
  const CONFIG_PATH = path.join(getAgentDir(), "task-dispatcher.json");
92
120
  const WORKER_ENV = "PI_TASK_DISPATCHER_WORKER";
93
121
  const RESULT_LIMIT = 24_000;
94
122
  const STDERR_LIMIT = 8_000;
95
- const DEFAULT_TOOLS = [
96
- "read",
97
- "bash",
98
- "edit",
99
- "write",
100
- ];
101
123
  const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
102
124
 
103
125
  const DEFAULT_CONFIG: DispatcherConfig = {
104
126
  mechanic: {
105
127
  model: "openai-codex/gpt-5.6-luna",
106
128
  thinking: "max",
107
- tools: DEFAULT_TOOLS,
108
129
  timeoutSeconds: 300,
130
+ checkpointGraceSeconds: 30,
109
131
  maxTurns: 20,
110
132
  },
111
133
  engineer: {
112
134
  model: "openai-codex/gpt-5.6-terra",
113
135
  thinking: "max",
114
- tools: DEFAULT_TOOLS,
115
136
  timeoutSeconds: 900,
137
+ checkpointGraceSeconds: 45,
116
138
  maxTurns: 30,
117
139
  },
118
140
  };
@@ -155,6 +177,10 @@ const DelegateParams = Type.Object({
155
177
  maxLength: 12_000,
156
178
  description: "A self-contained task with its objective, scope, constraints, and completion checks.",
157
179
  }),
180
+ tools: Type.Array(Type.String({ minLength: 1 }), {
181
+ maxItems: 32,
182
+ description: "The exact worker tool allowlist selected from the main agent's active tools. Use the smallest sufficient set; use an empty array for no tools.",
183
+ }),
158
184
  });
159
185
 
160
186
  /** Identify plain objects before reading user-controlled JSON configuration fields. */
@@ -164,7 +190,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
164
190
 
165
191
  /** Copy defaults so loaded configuration cannot mutate shared role settings. */
166
192
  function copyWorkerConfig(config: WorkerConfig): WorkerConfig {
167
- return { ...config, tools: [...config.tools] };
193
+ return { ...config };
168
194
  }
169
195
 
170
196
  /** Validate and merge one role's optional configuration over its package defaults. */
@@ -189,16 +215,8 @@ function parseWorkerConfig(role: Role, value: unknown): WorkerConfig {
189
215
  result.thinking = value.thinking as ThinkingLevel;
190
216
  }
191
217
 
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
218
  // Bound worker lifetime settings to positive integers.
201
- for (const field of ["timeoutSeconds", "maxTurns"] as const) {
219
+ for (const field of ["timeoutSeconds", "checkpointGraceSeconds", "maxTurns"] as const) {
202
220
  const candidate = value[field];
203
221
  if (candidate !== undefined) {
204
222
  if (!Number.isInteger(candidate) || (candidate as number) <= 0) {
@@ -207,6 +225,9 @@ function parseWorkerConfig(role: Role, value: unknown): WorkerConfig {
207
225
  result[field] = candidate as number;
208
226
  }
209
227
  }
228
+ if (result.checkpointGraceSeconds >= result.timeoutSeconds) {
229
+ throw new Error(`"${role}.checkpointGraceSeconds" must be less than "${role}.timeoutSeconds"`);
230
+ }
210
231
 
211
232
  return result;
212
233
  }
@@ -227,9 +248,6 @@ function loadConfig(): DispatcherConfig {
227
248
  throw new Error(`Cannot parse ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
228
249
  }
229
250
  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
- }
233
251
 
234
252
  return {
235
253
  mechanic: parseWorkerConfig("mechanic", parsed.mechanic),
@@ -296,24 +314,45 @@ function formatActivity(name: string, args: unknown): string {
296
314
  return name;
297
315
  }
298
316
 
317
+ /** Format a stopped worker's observed state for the orchestrator's continuation decision. */
318
+ function formatCheckpoint(checkpoint: WorkerCheckpoint): string {
319
+ const completed = checkpoint.completedActivities.length > 0 ? checkpoint.completedActivities.join("; ") : "none observed";
320
+ const active = checkpoint.activeActivities.length > 0 ? checkpoint.activeActivities.join("; ") : "none observed";
321
+ const text = checkpoint.lastAssistantText ? `\nLast worker text:\n${truncate(checkpoint.lastAssistantText, 4_000)}` : "";
322
+ return [
323
+ "Worker checkpoint:",
324
+ `- Stop reason: ${checkpoint.reason}`,
325
+ `- Delegated task: ${truncate(checkpoint.task, 2_000)}`,
326
+ `- Completed tool activity: ${completed}`,
327
+ `- Active or interrupted tool activity: ${active}`,
328
+ `- Filesystem effects may remain: ${checkpoint.filesystemEffectsMayRemain ? "yes" : "no"}`,
329
+ text,
330
+ "Continuation is not automatic. Inspect material state, then decide whether to continue directly, delegate a bounded continuation with a newly selected tool allowlist, request user input, or stop with a limitation.",
331
+ ].filter(Boolean).join("\n");
332
+ }
333
+
299
334
  /** Run one isolated Pi worker through its full tool-using agent loop. */
300
335
  async function runWorker(
301
336
  role: Role,
302
337
  task: string,
303
338
  config: WorkerConfig,
339
+ tools: string[],
304
340
  cwd: string,
305
341
  signal: AbortSignal | undefined,
306
342
  onUpdate: ((result: { content: Array<{ type: "text"; text: string }>; details: WorkerDetails }) => void) | undefined,
307
343
  ): Promise<WorkerResult> {
308
344
  const startedAt = Date.now();
309
345
  const usage = emptyUsage();
310
- const activity: string[] = [];
346
+ const activity: WorkerActivity[] = [];
347
+ const activeActivity = new Map<string, WorkerActivity>();
348
+ const streamingText = new Map<number, string>();
311
349
  let turns = 0;
312
350
  let finalOutput = "";
313
351
  let stopReason: string | undefined;
314
352
  let modelError: string | undefined;
315
353
  let stderr = "";
316
354
  let forcedStatus: WorkerStatus | undefined;
355
+ let checkpointRequestedReason: "timeout" | "turn_limit" | undefined;
317
356
  let processError: string | undefined;
318
357
 
319
358
  // Keep role instructions out of the command line and remove them after the worker exits.
@@ -325,18 +364,15 @@ async function runWorker(
325
364
 
326
365
  const args = [
327
366
  "--mode",
328
- "json",
329
- "-p",
367
+ "rpc",
330
368
  "--no-session",
331
369
  "--model",
332
370
  config.model,
333
371
  "--thinking",
334
372
  config.thinking,
335
- "--tools",
336
- config.tools.join(","),
373
+ ...(tools.length > 0 ? ["--tools", tools.join(",")] : ["--no-tools"]),
337
374
  "--append-system-prompt",
338
375
  promptPath,
339
- `Delegated ${role} task:\n${task}`,
340
376
  ];
341
377
 
342
378
  const exitCode = await new Promise<number | null>((resolve) => {
@@ -344,7 +380,7 @@ async function runWorker(
344
380
  const child = spawn(invocation.command, invocation.args, {
345
381
  cwd,
346
382
  shell: false,
347
- stdio: ["ignore", "pipe", "pipe"],
383
+ stdio: ["pipe", "pipe", "pipe"],
348
384
  env: { ...process.env, [WORKER_ENV]: role },
349
385
  });
350
386
  let stdoutBuffer = "";
@@ -364,7 +400,41 @@ async function runWorker(
364
400
  }, 5_000);
365
401
  };
366
402
 
367
- // Retain in-flight usage while treating completed messages as authoritative.
403
+ // Ask the worker to stop at the next safe turn boundary and author its handoff report.
404
+ const requestCheckpoint = (reason: "timeout" | "turn_limit") => {
405
+ if (checkpointRequestedReason || settled || child.stdin.writableEnded) return;
406
+ checkpointRequestedReason = reason;
407
+ const message =
408
+ reason === "timeout"
409
+ ? "The delegation time limit is approaching. Stop starting new work. At the next safe boundary, return a concise checkpoint with completed work, files changed, checks run, current state, interrupted work, and unresolved items. Do not continue implementation."
410
+ : "The delegation turn limit has been reached. After current tool calls finish, stop work and return a concise checkpoint with completed work, files changed, checks run, current state, interrupted work, and unresolved items. Do not continue implementation.";
411
+ child.stdin.write(`${JSON.stringify({ id: `checkpoint-${reason}`, type: "steer", message })}\n`);
412
+ };
413
+
414
+ let lastProgressAt = 0;
415
+ const currentStreamingText = () =>
416
+ [...streamingText.entries()].sort(([left], [right]) => left - right).map(([, text]) => text).join("\n").trim();
417
+ const emitProgress = (text: string, force = false) => {
418
+ const now = Date.now();
419
+ if (!force && now - lastProgressAt < 250) return;
420
+ lastProgressAt = now;
421
+ onUpdate?.({
422
+ content: [{ type: "text", text }],
423
+ details: {
424
+ role,
425
+ status: "running",
426
+ model: config.model,
427
+ thinking: config.thinking,
428
+ tools,
429
+ turns,
430
+ durationMs: now - startedAt,
431
+ exitCode: null,
432
+ activity: activity.map((item) => ({ ...item })),
433
+ },
434
+ });
435
+ };
436
+
437
+ // Reduce the documented JSON event stream into live progress and a recoverable checkpoint.
368
438
  const processLine = (line: string) => {
369
439
  if (!line.trim()) return;
370
440
  let event: ParsedEvent;
@@ -373,10 +443,65 @@ async function runWorker(
373
443
  } catch {
374
444
  return;
375
445
  }
446
+
447
+ // Fail promptly when RPC rejects the initial prompt instead of waiting for the wall-clock limit.
448
+ if (event.type === "response" && event.id === "delegated-task" && event.success === false) {
449
+ processError = event.error || "Pi rejected the delegated RPC prompt.";
450
+ stopChild("process_error");
451
+ return;
452
+ }
453
+
454
+ if (event.type === "agent_settled") {
455
+ if (checkpointRequestedReason && !forcedStatus) forcedStatus = checkpointRequestedReason;
456
+ if (!child.stdin.writableEnded) child.stdin.end();
457
+ return;
458
+ }
459
+
376
460
  if (event.type === "message_update") {
377
- if (event.message?.usage) unfinishedUsage = event.message.usage;
461
+ if (event.usage) unfinishedUsage = event.usage;
462
+ const update = event.assistantMessageEvent;
463
+ if (typeof update?.contentIndex === "number") {
464
+ if (update.type === "text_start") streamingText.set(update.contentIndex, "");
465
+ if (update.type === "text_delta" && update.delta) {
466
+ streamingText.set(update.contentIndex, (streamingText.get(update.contentIndex) ?? "") + update.delta);
467
+ emitProgress(`${role}: ${truncate(currentStreamingText(), 500) || "working..."}`);
468
+ }
469
+ if (update.type === "text_end" && update.content !== undefined) {
470
+ streamingText.set(update.contentIndex, update.content);
471
+ emitProgress(`${role}: ${truncate(currentStreamingText(), 500) || "working..."}`, true);
472
+ }
473
+ }
474
+ return;
475
+ }
476
+
477
+ if (event.type === "tool_execution_start" && event.toolCallId && event.toolName) {
478
+ const item: WorkerActivity = {
479
+ toolCallId: event.toolCallId,
480
+ label: formatActivity(event.toolName, event.args),
481
+ status: "running",
482
+ };
483
+ activity.push(item);
484
+ activeActivity.set(item.toolCallId, item);
485
+ emitProgress(`${role}: running ${item.label}`, true);
486
+ return;
487
+ }
488
+
489
+ if (event.type === "tool_execution_update" && event.toolCallId) {
490
+ const item = activeActivity.get(event.toolCallId);
491
+ if (item) emitProgress(`${role}: running ${item.label}`);
492
+ return;
493
+ }
494
+
495
+ if (event.type === "tool_execution_end" && event.toolCallId) {
496
+ const item = activeActivity.get(event.toolCallId);
497
+ if (item) {
498
+ item.status = event.isError ? "failed" : "completed";
499
+ activeActivity.delete(event.toolCallId);
500
+ emitProgress(`${role}: ${item.status} ${item.label}`, true);
501
+ }
378
502
  return;
379
503
  }
504
+
380
505
  if (event.type !== "message_end" || event.message?.role !== "assistant") return;
381
506
 
382
507
  turns++;
@@ -384,33 +509,23 @@ async function runWorker(
384
509
  modelError = event.message.errorMessage ?? modelError;
385
510
  addUsage(usage, event.message.usage ?? unfinishedUsage);
386
511
  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
- }
409
- }
410
-
411
- if (turns >= config.maxTurns && stopReason === "toolUse") stopChild("turn_limit");
512
+ const completedText = (event.message.content ?? [])
513
+ .filter((part) => part.type === "text" && part.text)
514
+ .map((part) => part.text as string)
515
+ .join("\n")
516
+ .trim();
517
+ if (completedText) finalOutput = completedText;
518
+ streamingText.clear();
519
+ if (turns >= config.maxTurns && stopReason === "toolUse") requestCheckpoint("turn_limit");
412
520
  };
413
521
 
522
+ // Keep long model and tool waits visibly alive even when the child emits no stream event.
523
+ const heartbeat = setInterval(() => {
524
+ const running = [...activeActivity.values()].map((item) => item.label).join("; ");
525
+ const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
526
+ emitProgress(`${role}: ${running ? `running ${running}` : "working"} (${elapsedSeconds}s)`, true);
527
+ }, 1_000);
528
+
414
529
  // Stream-decode UTF-8 before splitting complete JSONL records, retaining a trailing fragment.
415
530
  child.stdout.on("data", (chunk) => {
416
531
  stdoutBuffer += stdoutDecoder.decode(chunk, { stream: true });
@@ -425,9 +540,13 @@ async function runWorker(
425
540
  child.on("error", (error) => {
426
541
  processError = error.message;
427
542
  });
543
+ child.stdin.on("error", (error) => {
544
+ processError ??= error.message;
545
+ });
428
546
  // Flush decoder state and charge at most one unfinished streamed assistant response.
429
547
  child.on("close", (code) => {
430
548
  settled = true;
549
+ clearInterval(heartbeat);
431
550
  if (forceKillTimer) clearTimeout(forceKillTimer);
432
551
  stdoutBuffer += stdoutDecoder.decode();
433
552
  stderr = truncate(stderr + stderrDecoder.decode(), STDERR_LIMIT);
@@ -436,8 +555,18 @@ async function runWorker(
436
555
  resolve(code);
437
556
  });
438
557
 
558
+ // Start the RPC run after all stream handlers are ready.
559
+ child.stdin.write(
560
+ `${JSON.stringify({ id: "delegated-task", type: "prompt", message: `Delegated ${role} task:\n${task}` })}\n`,
561
+ );
562
+
563
+ const checkpointLeadMs = (config.timeoutSeconds - config.checkpointGraceSeconds) * 1_000;
564
+ const checkpointTimer = setTimeout(() => requestCheckpoint("timeout"), checkpointLeadMs);
439
565
  const timeout = setTimeout(() => stopChild("timeout"), config.timeoutSeconds * 1_000);
440
- child.once("close", () => clearTimeout(timeout));
566
+ child.once("close", () => {
567
+ clearTimeout(checkpointTimer);
568
+ clearTimeout(timeout);
569
+ });
441
570
 
442
571
  // Propagate cancellation from the parent Pi turn to the worker process.
443
572
  if (signal) {
@@ -456,14 +585,23 @@ async function runWorker(
456
585
  else if (stopReason && stopReason !== "stop") status = "incomplete";
457
586
  else if (!finalOutput.trim()) status = "incomplete";
458
587
 
459
- // Put failure context ahead of output emitted before the worker reached a completed state.
460
- const partialOutput = finalOutput.trim();
588
+ // Put failure context and a synthetic checkpoint ahead of any partial worker report.
589
+ const streamedOutput = [...streamingText.entries()]
590
+ .sort(([left], [right]) => left - right)
591
+ .map(([, text]) => text)
592
+ .join("\n")
593
+ .trim();
594
+ const partialOutput = finalOutput.trim() || streamedOutput;
461
595
  const stderrOutput = stderr.trim();
462
596
  const statusDiagnostic =
463
597
  status === "timeout"
464
- ? `Worker timed out after ${config.timeoutSeconds} seconds.`
598
+ ? checkpointRequestedReason === "timeout" && exitCode === 0
599
+ ? `Worker reached the time limit and returned a checkpoint during the ${config.checkpointGraceSeconds}-second grace period.`
600
+ : `Worker timed out after ${config.timeoutSeconds} seconds.`
465
601
  : status === "turn_limit"
466
- ? `Worker reached the configured ${config.maxTurns}-turn limit.`
602
+ ? checkpointRequestedReason === "turn_limit" && exitCode === 0
603
+ ? `Worker reached the configured ${config.maxTurns}-turn limit and returned a checkpoint.`
604
+ : `Worker reached the configured ${config.maxTurns}-turn limit.`
467
605
  : status === "aborted"
468
606
  ? "Worker was aborted."
469
607
  : status === "process_error"
@@ -473,10 +611,22 @@ async function runWorker(
473
611
  : `Worker did not complete${stopReason ? ` (stop reason: ${stopReason})` : ""}.`;
474
612
  const diagnostic =
475
613
  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.";
614
+ const checkpoint: WorkerCheckpoint | undefined =
615
+ status === "completed"
616
+ ? undefined
617
+ : {
618
+ reason: status,
619
+ task,
620
+ lastAssistantText: partialOutput || undefined,
621
+ completedActivities: activity
622
+ .filter((item) => item.status !== "running")
623
+ .map((item) => `${item.status}: ${item.label}`),
624
+ activeActivities: activity.filter((item) => item.status === "running").map((item) => item.label),
625
+ filesystemEffectsMayRemain: activity.length > 0,
626
+ };
627
+ const output = checkpoint
628
+ ? `${diagnostic ?? statusDiagnostic}\n\n${formatCheckpoint(checkpoint)}`
629
+ : partialOutput || stderrOutput || "The worker produced no final report.";
480
630
  return {
481
631
  output: truncate(output, RESULT_LIMIT),
482
632
  details: {
@@ -484,7 +634,7 @@ async function runWorker(
484
634
  status,
485
635
  model: config.model,
486
636
  thinking: config.thinking,
487
- tools: config.tools,
637
+ tools,
488
638
  turns,
489
639
  durationMs: Date.now() - startedAt,
490
640
  exitCode,
@@ -492,6 +642,7 @@ async function runWorker(
492
642
  diagnostic,
493
643
  stderr: stderrOutput || undefined,
494
644
  activity,
645
+ checkpoint,
495
646
  },
496
647
  usage,
497
648
  };
@@ -512,6 +663,20 @@ function validateModel(config: WorkerConfig, ctx: ExtensionToolContext): void {
512
663
  }
513
664
  }
514
665
 
666
+ /** Validate the exact per-delegation allowlist against tools active for the orchestrator. */
667
+ function validateDelegatedTools(requested: string[], pi: ExtensionAPI): string[] {
668
+ const tools = [...new Set(requested.map((tool) => tool.trim()).filter(Boolean))];
669
+ const forbidden = new Set(["delegate_mechanical", "delegate_engineering"]);
670
+ const recursive = tools.filter((tool) => forbidden.has(tool));
671
+ if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated recursively: ${recursive.join(", ")}`);
672
+ const active = new Set(pi.getActiveTools());
673
+ const unavailable = tools.filter((tool) => !active.has(tool));
674
+ if (unavailable.length > 0) {
675
+ throw new Error(`Requested worker tools are not active for the main agent: ${unavailable.join(", ")}`);
676
+ }
677
+ return tools;
678
+ }
679
+
515
680
  /** Register one role-specific public tool over the shared worker executor. */
516
681
  function registerDelegate(pi: ExtensionAPI, role: Role): void {
517
682
  const toolName = role === "mechanic" ? "delegate_mechanical" : "delegate_engineering";
@@ -524,7 +689,9 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
524
689
  role === "mechanic"
525
690
  ? "Use delegate_mechanical for bounded multi-step execution with precise desired results; give it a self-contained contract and integrate its report."
526
691
  : "Use delegate_engineering broadly for self-contained investigation or production work; give it the outcome, constraints, and acceptance conditions, then integrate its report.",
692
+ "Select the smallest exact worker tool allowlist from tools active for the main agent; tool availability does not establish scope or authorization.",
527
693
  "Delegation results are worker reports, not proof of correctness; inspect material changes and retain responsibility for the final answer.",
694
+ "When a worker returns a checkpoint, inspect current state and decide whether to continue directly, delegate a bounded continuation with a newly selected tool allowlist, request user input, or stop with a limitation. Continuation is never automatic.",
528
695
  ],
529
696
  parameters: DelegateParams,
530
697
  exposure: "model-only",
@@ -538,9 +705,11 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
538
705
 
539
706
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
540
707
  let config: WorkerConfig;
708
+ let tools: string[];
541
709
  try {
542
710
  config = loadConfig()[role];
543
711
  validateModel(config, ctx);
712
+ tools = validateDelegatedTools(params.tools, pi);
544
713
  } catch (error) {
545
714
  const message = error instanceof Error ? error.message : String(error);
546
715
  return {
@@ -550,7 +719,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
550
719
  status: "configuration_error" as const,
551
720
  model: "",
552
721
  thinking: "off" as const,
553
- tools: [],
722
+ tools: params.tools,
554
723
  turns: 0,
555
724
  durationMs: 0,
556
725
  exitCode: null,
@@ -561,7 +730,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
561
730
  };
562
731
  }
563
732
 
564
- const result = await runWorker(role, params.task, config, ctx.cwd, signal, onUpdate);
733
+ const result = await runWorker(role, params.task, config, tools, ctx.cwd, signal, onUpdate);
565
734
  const heading = `${role} worker ${result.details.status} via ${result.details.model} (${result.details.thinking})`;
566
735
  return {
567
736
  content: [{ type: "text", text: `${heading}\n\n${result.output}` }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-task-dispatcher",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Delegate bounded mechanical and engineering tasks to configurable Pi worker models.",
5
5
  "type": "module",
6
6
  "keywords": [