pi-task-dispatcher 1.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/LICENSE +21 -0
- package/README.md +133 -0
- package/extensions/task-dispatcher.ts +581 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SorinGFS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# pi-task-dispatcher
|
|
2
|
+
|
|
3
|
+
Delegate bounded mechanical and engineering subtasks to configurable Pi worker models without replacing the model selected for the main Pi session.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
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:
|
|
8
|
+
|
|
9
|
+
| Tool | Use it when |
|
|
10
|
+
| --- | --- |
|
|
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. |
|
|
13
|
+
|
|
14
|
+
Both workers operate in the current workspace and can modify files when their configured tools permit it.
|
|
15
|
+
|
|
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.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Pi 1.0.0 or later
|
|
21
|
+
- 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:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pi install npm:pi-task-dispatcher@1.0.0
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For a local checkout, run this from its parent directory:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pi install ./pi-task-dispatcher
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
To register either source in the current project's `.pi/settings.json` instead, add `--local`:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pi install npm:pi-task-dispatcher@1.0.0 --local
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Project packages load only after that project is trusted. In an already running Pi session, run:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
/reload
|
|
48
|
+
```
|
|
49
|
+
|
|
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
|
+
```
|
|
74
|
+
|
|
75
|
+
## Configure workers
|
|
76
|
+
|
|
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.
|
|
78
|
+
|
|
79
|
+
This is a complete valid configuration using the defaults:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"version": 1,
|
|
84
|
+
"mechanic": {
|
|
85
|
+
"model": "openai-codex/gpt-5.6-luna",
|
|
86
|
+
"thinking": "max",
|
|
87
|
+
"tools": [
|
|
88
|
+
"read",
|
|
89
|
+
"bash",
|
|
90
|
+
"edit",
|
|
91
|
+
"write"
|
|
92
|
+
],
|
|
93
|
+
"timeoutSeconds": 300,
|
|
94
|
+
"maxTurns": 20
|
|
95
|
+
},
|
|
96
|
+
"engineer": {
|
|
97
|
+
"model": "openai-codex/gpt-5.6-terra",
|
|
98
|
+
"thinking": "max",
|
|
99
|
+
"tools": [
|
|
100
|
+
"read",
|
|
101
|
+
"bash",
|
|
102
|
+
"edit",
|
|
103
|
+
"write"
|
|
104
|
+
],
|
|
105
|
+
"timeoutSeconds": 900,
|
|
106
|
+
"maxTurns": 30
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| Field | Meaning |
|
|
112
|
+
| --- | --- |
|
|
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. |
|
|
120
|
+
|
|
121
|
+
## Isolation, limits, and safety
|
|
122
|
+
|
|
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.
|
|
124
|
+
|
|
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.
|
|
126
|
+
|
|
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.
|
|
128
|
+
|
|
129
|
+
## Short workflow
|
|
130
|
+
|
|
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.
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exposes two bounded delegation tools that run configurable worker models in isolated Pi processes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { type ExtensionAPI, type ExtensionToolContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
|
|
12
|
+
type Role = "mechanic" | "engineer";
|
|
13
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
14
|
+
type WorkerStatus =
|
|
15
|
+
| "running"
|
|
16
|
+
| "completed"
|
|
17
|
+
| "configuration_error"
|
|
18
|
+
| "model_error"
|
|
19
|
+
| "process_error"
|
|
20
|
+
| "aborted"
|
|
21
|
+
| "timeout"
|
|
22
|
+
| "turn_limit"
|
|
23
|
+
| "incomplete";
|
|
24
|
+
|
|
25
|
+
interface WorkerConfig {
|
|
26
|
+
model: string;
|
|
27
|
+
thinking: ThinkingLevel;
|
|
28
|
+
tools: string[];
|
|
29
|
+
timeoutSeconds: number;
|
|
30
|
+
maxTurns: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DispatcherConfig {
|
|
34
|
+
mechanic: WorkerConfig;
|
|
35
|
+
engineer: WorkerConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface UsageTotals {
|
|
39
|
+
input: number;
|
|
40
|
+
output: number;
|
|
41
|
+
cacheRead: number;
|
|
42
|
+
cacheWrite: number;
|
|
43
|
+
cacheWrite1h?: number;
|
|
44
|
+
reasoning?: number;
|
|
45
|
+
totalTokens: number;
|
|
46
|
+
cost: {
|
|
47
|
+
input: number;
|
|
48
|
+
output: number;
|
|
49
|
+
cacheRead: number;
|
|
50
|
+
cacheWrite: number;
|
|
51
|
+
total: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface WorkerDetails {
|
|
56
|
+
role: Role;
|
|
57
|
+
status: WorkerStatus;
|
|
58
|
+
model: string;
|
|
59
|
+
thinking: ThinkingLevel;
|
|
60
|
+
tools: string[];
|
|
61
|
+
turns: number;
|
|
62
|
+
durationMs: number;
|
|
63
|
+
exitCode: number | null;
|
|
64
|
+
stopReason?: string;
|
|
65
|
+
diagnostic?: string;
|
|
66
|
+
stderr?: string;
|
|
67
|
+
activity: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface WorkerResult {
|
|
71
|
+
output: string;
|
|
72
|
+
details: WorkerDetails;
|
|
73
|
+
usage?: UsageTotals;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ParsedEvent {
|
|
77
|
+
type?: string;
|
|
78
|
+
message?: {
|
|
79
|
+
role?: string;
|
|
80
|
+
content?: Array<{ type?: string; text?: string; name?: string; arguments?: unknown }>;
|
|
81
|
+
usage?: Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
|
|
82
|
+
stopReason?: string;
|
|
83
|
+
errorMessage?: string;
|
|
84
|
+
};
|
|
85
|
+
toolName?: string;
|
|
86
|
+
args?: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type EventUsage = NonNullable<NonNullable<ParsedEvent["message"]>["usage"]>;
|
|
90
|
+
|
|
91
|
+
const CONFIG_PATH = path.join(getAgentDir(), "task-dispatcher.json");
|
|
92
|
+
const WORKER_ENV = "PI_TASK_DISPATCHER_WORKER";
|
|
93
|
+
const RESULT_LIMIT = 24_000;
|
|
94
|
+
const STDERR_LIMIT = 8_000;
|
|
95
|
+
const DEFAULT_TOOLS = [
|
|
96
|
+
"read",
|
|
97
|
+
"bash",
|
|
98
|
+
"edit",
|
|
99
|
+
"write",
|
|
100
|
+
];
|
|
101
|
+
const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
102
|
+
|
|
103
|
+
const DEFAULT_CONFIG: DispatcherConfig = {
|
|
104
|
+
mechanic: {
|
|
105
|
+
model: "openai-codex/gpt-5.6-luna",
|
|
106
|
+
thinking: "max",
|
|
107
|
+
tools: DEFAULT_TOOLS,
|
|
108
|
+
timeoutSeconds: 300,
|
|
109
|
+
maxTurns: 20,
|
|
110
|
+
},
|
|
111
|
+
engineer: {
|
|
112
|
+
model: "openai-codex/gpt-5.6-terra",
|
|
113
|
+
thinking: "max",
|
|
114
|
+
tools: DEFAULT_TOOLS,
|
|
115
|
+
timeoutSeconds: 900,
|
|
116
|
+
maxTurns: 30,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const ROLE_PROMPTS: Record<Role, string> = {
|
|
121
|
+
mechanic: `You are the mechanical worker in a delegated Pi task.
|
|
122
|
+
Complete the supplied task directly with the available tools. The caller has retained ownership of the surrounding task.
|
|
123
|
+
|
|
124
|
+
Rules:
|
|
125
|
+
- Treat the delegated task as the complete scope. Do not invent missing requirements or broaden it.
|
|
126
|
+
- Inspect before modifying and preserve existing project conventions.
|
|
127
|
+
- Perform exact, repetitive, retrieval, transformation, command, and verification work autonomously.
|
|
128
|
+
- If a semantic, architectural, or user-intent decision is required, stop and report precisely what is blocked.
|
|
129
|
+
- Run requested checks. Never claim a check passed unless you observed it pass.
|
|
130
|
+
- Report partial changes after any failure.
|
|
131
|
+
- Finish with a concise report containing status, files changed, checks run, and anything unresolved.`,
|
|
132
|
+
engineer: `You are the engineering worker in a delegated Pi task.
|
|
133
|
+
Own the supplied subtask autonomously while the caller retains integration and final-response responsibility.
|
|
134
|
+
|
|
135
|
+
Rules:
|
|
136
|
+
- Treat the delegated objective, constraints, and acceptance conditions as authoritative.
|
|
137
|
+
- Inspect the relevant code and project instructions before changing it.
|
|
138
|
+
- You may investigate, research, reason, implement, refactor, test, and make local engineering decisions needed to complete the subtask.
|
|
139
|
+
- Keep work relevant to the delegated outcome. If completion requires materially changing its objective or constraints, stop and explain the needed decision.
|
|
140
|
+
- Run appropriate checks. Never claim a check passed unless you observed it pass.
|
|
141
|
+
- Report partial changes after any failure.
|
|
142
|
+
- Finish with a concise report containing the outcome, files changed, checks run, local decisions, and anything unresolved.`,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
|
146
|
+
mechanic:
|
|
147
|
+
"Delegate a bounded mechanical episode to the configured mechanic model. Use when the desired outcome and constraints can be stated precisely and the work is primarily file inspection, exact edits, repetitive transformations, commands, retrieval, or verification. Prefer this for multi-step work or bulky intermediate output; use a direct tool for one trivial operation. The worker operates in the current workspace and may modify files.",
|
|
148
|
+
engineer:
|
|
149
|
+
"Delegate any self-contained engineering subtask to the configured engineer model. Use broadly for investigation, implementation, debugging, refactoring, testing, review, research, or analysis that can be given a clear outcome and returned for integration. Include all relevant constraints and acceptance conditions. The worker operates in the current workspace and may modify files.",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const DelegateParams = Type.Object({
|
|
153
|
+
task: Type.String({
|
|
154
|
+
minLength: 1,
|
|
155
|
+
maxLength: 12_000,
|
|
156
|
+
description: "A self-contained task with its objective, scope, constraints, and completion checks.",
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** Identify plain objects before reading user-controlled JSON configuration fields. */
|
|
161
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
162
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Copy defaults so loaded configuration cannot mutate shared role settings. */
|
|
166
|
+
function copyWorkerConfig(config: WorkerConfig): WorkerConfig {
|
|
167
|
+
return { ...config, tools: [...config.tools] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Validate and merge one role's optional configuration over its package defaults. */
|
|
171
|
+
function parseWorkerConfig(role: Role, value: unknown): WorkerConfig {
|
|
172
|
+
const result = copyWorkerConfig(DEFAULT_CONFIG[role]);
|
|
173
|
+
if (value === undefined) return result;
|
|
174
|
+
if (!isRecord(value)) throw new Error(`"${role}" must be an object`);
|
|
175
|
+
|
|
176
|
+
// Validate the exact model identity used for deterministic dispatch.
|
|
177
|
+
if (value.model !== undefined) {
|
|
178
|
+
if (typeof value.model !== "string" || !value.model.includes("/")) {
|
|
179
|
+
throw new Error(`"${role}.model" must be a provider/model string`);
|
|
180
|
+
}
|
|
181
|
+
result.model = value.model;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Accept only thinking levels supported by Pi's public CLI contract.
|
|
185
|
+
if (value.thinking !== undefined) {
|
|
186
|
+
if (typeof value.thinking !== "string" || !THINKING_LEVELS.has(value.thinking as ThinkingLevel)) {
|
|
187
|
+
throw new Error(`"${role}.thinking" is not a valid Pi thinking level`);
|
|
188
|
+
}
|
|
189
|
+
result.thinking = value.thinking as ThinkingLevel;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Require an explicit non-empty allowlist when tools are overridden.
|
|
193
|
+
if (value.tools !== undefined) {
|
|
194
|
+
if (!Array.isArray(value.tools) || value.tools.length === 0 || value.tools.some((tool) => typeof tool !== "string" || !tool.trim())) {
|
|
195
|
+
throw new Error(`"${role}.tools" must be a non-empty array of tool names`);
|
|
196
|
+
}
|
|
197
|
+
result.tools = [...new Set(value.tools as string[])];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Bound worker lifetime settings to positive integers.
|
|
201
|
+
for (const field of ["timeoutSeconds", "maxTurns"] as const) {
|
|
202
|
+
const candidate = value[field];
|
|
203
|
+
if (candidate !== undefined) {
|
|
204
|
+
if (!Number.isInteger(candidate) || (candidate as number) <= 0) {
|
|
205
|
+
throw new Error(`"${role}.${field}" must be a positive integer`);
|
|
206
|
+
}
|
|
207
|
+
result[field] = candidate as number;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Load the optional personal configuration afresh for every delegation. */
|
|
215
|
+
function loadConfig(): DispatcherConfig {
|
|
216
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
217
|
+
return {
|
|
218
|
+
mechanic: copyWorkerConfig(DEFAULT_CONFIG.mechanic),
|
|
219
|
+
engineer: copyWorkerConfig(DEFAULT_CONFIG.engineer),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let parsed: unknown;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw new Error(`Cannot parse ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
228
|
+
}
|
|
229
|
+
if (!isRecord(parsed)) throw new Error(`${CONFIG_PATH} must contain a JSON object`);
|
|
230
|
+
if (parsed.version !== undefined && parsed.version !== 1) {
|
|
231
|
+
throw new Error(`${CONFIG_PATH} has unsupported version ${String(parsed.version)}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
mechanic: parseWorkerConfig("mechanic", parsed.mechanic),
|
|
236
|
+
engineer: parseWorkerConfig("engineer", parsed.engineer),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Resolve the currently running Pi entry point so workers use the same local installation. */
|
|
241
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
242
|
+
const currentScript = process.argv[1];
|
|
243
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
244
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
245
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const executable = path.basename(process.execPath).toLowerCase();
|
|
249
|
+
if (!/^(node|bun)(\.exe)?$/.test(executable)) {
|
|
250
|
+
return { command: process.execPath, args };
|
|
251
|
+
}
|
|
252
|
+
return { command: "pi", args };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Create a zeroed Pi usage object for aggregation into the parent tool result. */
|
|
256
|
+
function emptyUsage(): UsageTotals {
|
|
257
|
+
return {
|
|
258
|
+
input: 0,
|
|
259
|
+
output: 0,
|
|
260
|
+
cacheRead: 0,
|
|
261
|
+
cacheWrite: 0,
|
|
262
|
+
totalTokens: 0,
|
|
263
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Add one assistant response's reported usage to the worker total. */
|
|
268
|
+
function addUsage(total: UsageTotals, usage: EventUsage | undefined): void {
|
|
269
|
+
if (!usage) return;
|
|
270
|
+
total.input += usage.input ?? 0;
|
|
271
|
+
total.output += usage.output ?? 0;
|
|
272
|
+
total.cacheRead += usage.cacheRead ?? 0;
|
|
273
|
+
total.cacheWrite += usage.cacheWrite ?? 0;
|
|
274
|
+
total.totalTokens += usage.totalTokens ?? 0;
|
|
275
|
+
if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
|
|
276
|
+
if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
|
|
277
|
+
total.cost.input += usage.cost?.input ?? 0;
|
|
278
|
+
total.cost.output += usage.cost?.output ?? 0;
|
|
279
|
+
total.cost.cacheRead += usage.cost?.cacheRead ?? 0;
|
|
280
|
+
total.cost.cacheWrite += usage.cost?.cacheWrite ?? 0;
|
|
281
|
+
total.cost.total += usage.cost?.total ?? 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Keep model-facing worker output within a predictable context budget. */
|
|
285
|
+
function truncate(text: string, limit: number): string {
|
|
286
|
+
if (text.length <= limit) return text;
|
|
287
|
+
return `${text.slice(0, limit)}\n\n[Output truncated: ${text.length - limit} characters omitted.]`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Render a compact activity label from a worker tool call. */
|
|
291
|
+
function formatActivity(name: string, args: unknown): string {
|
|
292
|
+
if (name === "bash" && isRecord(args) && typeof args.command === "string") {
|
|
293
|
+
return `$ ${truncate(args.command, 120)}`;
|
|
294
|
+
}
|
|
295
|
+
if (isRecord(args) && typeof args.path === "string") return `${name} ${args.path}`;
|
|
296
|
+
return name;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Run one isolated Pi worker through its full tool-using agent loop. */
|
|
300
|
+
async function runWorker(
|
|
301
|
+
role: Role,
|
|
302
|
+
task: string,
|
|
303
|
+
config: WorkerConfig,
|
|
304
|
+
cwd: string,
|
|
305
|
+
signal: AbortSignal | undefined,
|
|
306
|
+
onUpdate: ((result: { content: Array<{ type: "text"; text: string }>; details: WorkerDetails }) => void) | undefined,
|
|
307
|
+
): Promise<WorkerResult> {
|
|
308
|
+
const startedAt = Date.now();
|
|
309
|
+
const usage = emptyUsage();
|
|
310
|
+
const activity: string[] = [];
|
|
311
|
+
let turns = 0;
|
|
312
|
+
let finalOutput = "";
|
|
313
|
+
let stopReason: string | undefined;
|
|
314
|
+
let modelError: string | undefined;
|
|
315
|
+
let stderr = "";
|
|
316
|
+
let forcedStatus: WorkerStatus | undefined;
|
|
317
|
+
let processError: string | undefined;
|
|
318
|
+
|
|
319
|
+
// Keep role instructions out of the command line and remove them after the worker exits.
|
|
320
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-task-dispatcher-"));
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const promptPath = path.join(tempDir, `${role}.md`);
|
|
324
|
+
await fs.promises.writeFile(promptPath, ROLE_PROMPTS[role], { encoding: "utf8", mode: 0o600 });
|
|
325
|
+
|
|
326
|
+
const args = [
|
|
327
|
+
"--mode",
|
|
328
|
+
"json",
|
|
329
|
+
"-p",
|
|
330
|
+
"--no-session",
|
|
331
|
+
"--model",
|
|
332
|
+
config.model,
|
|
333
|
+
"--thinking",
|
|
334
|
+
config.thinking,
|
|
335
|
+
"--tools",
|
|
336
|
+
config.tools.join(","),
|
|
337
|
+
"--append-system-prompt",
|
|
338
|
+
promptPath,
|
|
339
|
+
`Delegated ${role} task:\n${task}`,
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
const exitCode = await new Promise<number | null>((resolve) => {
|
|
343
|
+
const invocation = getPiInvocation(args);
|
|
344
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
345
|
+
cwd,
|
|
346
|
+
shell: false,
|
|
347
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
348
|
+
env: { ...process.env, [WORKER_ENV]: role },
|
|
349
|
+
});
|
|
350
|
+
let stdoutBuffer = "";
|
|
351
|
+
let unfinishedUsage: EventUsage | undefined;
|
|
352
|
+
const stdoutDecoder = new TextDecoder("utf-8");
|
|
353
|
+
const stderrDecoder = new TextDecoder("utf-8");
|
|
354
|
+
let settled = false;
|
|
355
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
356
|
+
|
|
357
|
+
// Stop the process once and escalate to a forceful kill if it does not exit.
|
|
358
|
+
const stopChild = (status: WorkerStatus) => {
|
|
359
|
+
if (forcedStatus) return;
|
|
360
|
+
forcedStatus = status;
|
|
361
|
+
child.kill("SIGTERM");
|
|
362
|
+
forceKillTimer = setTimeout(() => {
|
|
363
|
+
if (!settled) child.kill("SIGKILL");
|
|
364
|
+
}, 5_000);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// Retain in-flight usage while treating completed messages as authoritative.
|
|
368
|
+
const processLine = (line: string) => {
|
|
369
|
+
if (!line.trim()) return;
|
|
370
|
+
let event: ParsedEvent;
|
|
371
|
+
try {
|
|
372
|
+
event = JSON.parse(line) as ParsedEvent;
|
|
373
|
+
} catch {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (event.type === "message_update") {
|
|
377
|
+
if (event.message?.usage) unfinishedUsage = event.message.usage;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (event.type !== "message_end" || event.message?.role !== "assistant") return;
|
|
381
|
+
|
|
382
|
+
turns++;
|
|
383
|
+
stopReason = event.message.stopReason ?? stopReason;
|
|
384
|
+
modelError = event.message.errorMessage ?? modelError;
|
|
385
|
+
addUsage(usage, event.message.usage ?? unfinishedUsage);
|
|
386
|
+
unfinishedUsage = undefined;
|
|
387
|
+
|
|
388
|
+
// Preserve the latest final text and surface worker tool activity as progress.
|
|
389
|
+
for (const part of event.message.content ?? []) {
|
|
390
|
+
if (part.type === "text" && part.text) finalOutput = part.text;
|
|
391
|
+
if (part.type === "toolCall" && part.name) {
|
|
392
|
+
const label = formatActivity(part.name, part.arguments);
|
|
393
|
+
activity.push(label);
|
|
394
|
+
onUpdate?.({
|
|
395
|
+
content: [{ type: "text", text: `${role}: ${label}` }],
|
|
396
|
+
details: {
|
|
397
|
+
role,
|
|
398
|
+
status: "running",
|
|
399
|
+
model: config.model,
|
|
400
|
+
thinking: config.thinking,
|
|
401
|
+
tools: config.tools,
|
|
402
|
+
turns,
|
|
403
|
+
durationMs: Date.now() - startedAt,
|
|
404
|
+
exitCode: null,
|
|
405
|
+
activity: [...activity],
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (turns >= config.maxTurns && stopReason === "toolUse") stopChild("turn_limit");
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// Stream-decode UTF-8 before splitting complete JSONL records, retaining a trailing fragment.
|
|
415
|
+
child.stdout.on("data", (chunk) => {
|
|
416
|
+
stdoutBuffer += stdoutDecoder.decode(chunk, { stream: true });
|
|
417
|
+
const lines = stdoutBuffer.split("\n");
|
|
418
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
419
|
+
// Forward each complete event record to the worker-state reducer.
|
|
420
|
+
for (const line of lines) processLine(line);
|
|
421
|
+
});
|
|
422
|
+
child.stderr.on("data", (chunk) => {
|
|
423
|
+
stderr = truncate(stderr + stderrDecoder.decode(chunk, { stream: true }), STDERR_LIMIT);
|
|
424
|
+
});
|
|
425
|
+
child.on("error", (error) => {
|
|
426
|
+
processError = error.message;
|
|
427
|
+
});
|
|
428
|
+
// Flush decoder state and charge at most one unfinished streamed assistant response.
|
|
429
|
+
child.on("close", (code) => {
|
|
430
|
+
settled = true;
|
|
431
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
432
|
+
stdoutBuffer += stdoutDecoder.decode();
|
|
433
|
+
stderr = truncate(stderr + stderrDecoder.decode(), STDERR_LIMIT);
|
|
434
|
+
if (stdoutBuffer.trim()) processLine(stdoutBuffer);
|
|
435
|
+
if (unfinishedUsage) addUsage(usage, unfinishedUsage);
|
|
436
|
+
resolve(code);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const timeout = setTimeout(() => stopChild("timeout"), config.timeoutSeconds * 1_000);
|
|
440
|
+
child.once("close", () => clearTimeout(timeout));
|
|
441
|
+
|
|
442
|
+
// Propagate cancellation from the parent Pi turn to the worker process.
|
|
443
|
+
if (signal) {
|
|
444
|
+
const abort = () => stopChild("aborted");
|
|
445
|
+
if (signal.aborted) abort();
|
|
446
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
447
|
+
child.once("close", () => signal.removeEventListener("abort", abort));
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
let status: WorkerStatus = "completed";
|
|
452
|
+
if (forcedStatus) status = forcedStatus;
|
|
453
|
+
else if (processError || exitCode !== 0) status = "process_error";
|
|
454
|
+
else if (stopReason === "error") status = "model_error";
|
|
455
|
+
else if (stopReason === "aborted") status = "aborted";
|
|
456
|
+
else if (stopReason && stopReason !== "stop") status = "incomplete";
|
|
457
|
+
else if (!finalOutput.trim()) status = "incomplete";
|
|
458
|
+
|
|
459
|
+
// Put failure context ahead of output emitted before the worker reached a completed state.
|
|
460
|
+
const partialOutput = finalOutput.trim();
|
|
461
|
+
const stderrOutput = stderr.trim();
|
|
462
|
+
const statusDiagnostic =
|
|
463
|
+
status === "timeout"
|
|
464
|
+
? `Worker timed out after ${config.timeoutSeconds} seconds.`
|
|
465
|
+
: status === "turn_limit"
|
|
466
|
+
? `Worker reached the configured ${config.maxTurns}-turn limit.`
|
|
467
|
+
: status === "aborted"
|
|
468
|
+
? "Worker was aborted."
|
|
469
|
+
: status === "process_error"
|
|
470
|
+
? `Worker process exited with code ${exitCode ?? "unknown"}.`
|
|
471
|
+
: status === "model_error"
|
|
472
|
+
? "Worker model returned an error."
|
|
473
|
+
: `Worker did not complete${stopReason ? ` (stop reason: ${stopReason})` : ""}.`;
|
|
474
|
+
const diagnostic =
|
|
475
|
+
status === "completed" ? undefined : modelError || processError || stderrOutput || statusDiagnostic;
|
|
476
|
+
const output =
|
|
477
|
+
status !== "completed" && diagnostic
|
|
478
|
+
? `${diagnostic}${partialOutput ? `\n\n${partialOutput}` : ""}`
|
|
479
|
+
: partialOutput || stderrOutput || "The worker produced no final report.";
|
|
480
|
+
return {
|
|
481
|
+
output: truncate(output, RESULT_LIMIT),
|
|
482
|
+
details: {
|
|
483
|
+
role,
|
|
484
|
+
status,
|
|
485
|
+
model: config.model,
|
|
486
|
+
thinking: config.thinking,
|
|
487
|
+
tools: config.tools,
|
|
488
|
+
turns,
|
|
489
|
+
durationMs: Date.now() - startedAt,
|
|
490
|
+
exitCode,
|
|
491
|
+
stopReason,
|
|
492
|
+
diagnostic,
|
|
493
|
+
stderr: stderrOutput || undefined,
|
|
494
|
+
activity,
|
|
495
|
+
},
|
|
496
|
+
usage,
|
|
497
|
+
};
|
|
498
|
+
} finally {
|
|
499
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Confirm that a configured physical model exists and has credentials before spawning a worker. */
|
|
504
|
+
function validateModel(config: WorkerConfig, ctx: ExtensionToolContext): void {
|
|
505
|
+
const separator = config.model.indexOf("/");
|
|
506
|
+
const provider = config.model.slice(0, separator);
|
|
507
|
+
const modelId = config.model.slice(separator + 1);
|
|
508
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
509
|
+
if (!model) throw new Error(`Configured model ${config.model} was not found. Configuration: ${CONFIG_PATH}`);
|
|
510
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
511
|
+
throw new Error(`Configured model ${config.model} has no usable authentication. Configuration: ${CONFIG_PATH}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Register one role-specific public tool over the shared worker executor. */
|
|
516
|
+
function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
517
|
+
const toolName = role === "mechanic" ? "delegate_mechanical" : "delegate_engineering";
|
|
518
|
+
pi.registerTool({
|
|
519
|
+
name: toolName,
|
|
520
|
+
label: role === "mechanic" ? "Delegate Mechanical" : "Delegate Engineering",
|
|
521
|
+
description: ROLE_DESCRIPTIONS[role],
|
|
522
|
+
promptSnippet: `${toolName}: dispatch a self-contained ${role} subtask to an isolated configured worker model`,
|
|
523
|
+
promptGuidelines: [
|
|
524
|
+
role === "mechanic"
|
|
525
|
+
? "Use delegate_mechanical for bounded multi-step execution with precise desired results; give it a self-contained contract and integrate its report."
|
|
526
|
+
: "Use delegate_engineering broadly for self-contained investigation or production work; give it the outcome, constraints, and acceptance conditions, then integrate its report.",
|
|
527
|
+
"Delegation results are worker reports, not proof of correctness; inspect material changes and retain responsibility for the final answer.",
|
|
528
|
+
],
|
|
529
|
+
parameters: DelegateParams,
|
|
530
|
+
exposure: "model-only",
|
|
531
|
+
executionMode: "sequential",
|
|
532
|
+
annotations: {
|
|
533
|
+
readOnlyHint: false,
|
|
534
|
+
destructiveHint: true,
|
|
535
|
+
idempotentHint: false,
|
|
536
|
+
openWorldHint: true,
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
540
|
+
let config: WorkerConfig;
|
|
541
|
+
try {
|
|
542
|
+
config = loadConfig()[role];
|
|
543
|
+
validateModel(config, ctx);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
546
|
+
return {
|
|
547
|
+
content: [{ type: "text", text: `Cannot start ${role} worker: ${message}` }],
|
|
548
|
+
details: {
|
|
549
|
+
role,
|
|
550
|
+
status: "configuration_error" as const,
|
|
551
|
+
model: "",
|
|
552
|
+
thinking: "off" as const,
|
|
553
|
+
tools: [],
|
|
554
|
+
turns: 0,
|
|
555
|
+
durationMs: 0,
|
|
556
|
+
exitCode: null,
|
|
557
|
+
diagnostic: message,
|
|
558
|
+
activity: [],
|
|
559
|
+
},
|
|
560
|
+
isError: true,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const result = await runWorker(role, params.task, config, ctx.cwd, signal, onUpdate);
|
|
565
|
+
const heading = `${role} worker ${result.details.status} via ${result.details.model} (${result.details.thinking})`;
|
|
566
|
+
return {
|
|
567
|
+
content: [{ type: "text", text: `${heading}\n\n${result.output}` }],
|
|
568
|
+
details: result.details,
|
|
569
|
+
usage: result.usage,
|
|
570
|
+
isError: result.details.status !== "completed",
|
|
571
|
+
};
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Register only in the parent process; workers receive an explicit non-recursive tool allowlist. */
|
|
577
|
+
export default function taskDispatcher(pi: ExtensionAPI): void {
|
|
578
|
+
if (process.env[WORKER_ENV]) return;
|
|
579
|
+
registerDelegate(pi, "mechanic");
|
|
580
|
+
registerDelegate(pi, "engineer");
|
|
581
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-task-dispatcher",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Delegate bounded mechanical and engineering tasks to configurable Pi worker models.",
|
|
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
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
28
|
+
"typebox": "*"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./extensions/task-dispatcher.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"extensions",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|