@zachwill/pi-orchestrate 0.1.0 → 0.2.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,6 +1,6 @@
1
1
  # Pi Orchestrate
2
2
 
3
- [`@zachwill/pi-orchestrate`](https://www.npmjs.com/package/@zachwill/pi-orchestrate) adds concurrent worker orchestration to [Pi](https://pi.dev). It helps a parent agent **delegate → dispatch → deliver**: delegate bounded work, dispatch independent tasks as one wave, then deliver one aggregate result to the parent.
3
+ [`@zachwill/pi-orchestrate`](https://www.npmjs.com/package/@zachwill/pi-orchestrate) adds concurrent worker orchestration to [Pi](https://pi.dev). It helps a parent agent delegate bounded work, message independent workers concurrently, and synthesize each response as it arrives.
4
4
 
5
5
  ## Install
6
6
 
@@ -22,11 +22,13 @@ Pi Orchestrate adds exactly five tools:
22
22
 
23
23
  An `orchestrate` wave first performs atomic input, catalog, and model preflight. No worker starts if any task fails that preflight. After acceptance, worker resources start independently: a resource startup failure becomes that worker's `failed` result and does not roll back or stop its peers.
24
24
 
25
- All tasks in a wave execute concurrently, including a full 12-task wave. Pi Orchestrate applies no hidden concurrency throttle. The aggregate result preserves task order.
25
+ All tasks in a wave execute concurrently, including a full 12-task wave. Pi Orchestrate applies no hidden concurrency throttle. Exact unchanged worker instructions remain visible in each tool call: collapsed calls preview every message, and expanded calls show every full brief. Titles are labels, never replacements for instructions.
26
26
 
27
27
  For asynchronous behavior, `orchestrate` or `worker_send` must be the sole tool call in its assistant message. Mixing either with any sibling tool call makes its wave inline and blocking. Inline work receives the parent turn's cancellation signal. Accepted async work does not retain that signal and continues independently.
28
28
 
29
- A successful async call yields the parent turn until aggregate results arrive. Do not poll `orchestration_status` for completion. If the owning session becomes inactive, its workers continue and completed results remain queued. Those results become available only when that exact owning session resumes; they are never delivered to another session.
29
+ A successful async call yields the parent turn. Responses enter the transcript individually as workers settle, and the final response starts the parent's synthesis turn. The bottom widget is ephemeral and shows active work only; completed, failed, aborted, and reusable ready workers disappear immediately. Inline responses accumulate in the live tool output while the call blocks.
30
+
31
+ `orchestration_status` is for diagnostics and recovery, never a normal completion mechanism. Do not poll it for completion. If the owning session becomes inactive, its workers continue and completed results remain queued. Those results become available only when that exact owning session resumes; they are never delivered to another session.
30
32
 
31
33
  Worker IDs identify live worker sessions. A reusable worker keeps the same worker ID across `worker_send` follow-ups, with each follow-up result belonging to a new wave. One-shot workers finish as `completed`. Reusable workers deliver as `ready` and wait for `worker_send` or `worker_close`. Use `worker_abort` only for active work, not to close a ready worker.
32
34
 
@@ -78,6 +80,7 @@ Frontmatter supports these fields:
78
80
  - `tools` and `lifecycle` are required. Grant the smallest useful tool set.
79
81
  - `model` is optional. When omitted, the worker inherits the parent model active when dispatched.
80
82
  - `thinking`, `skills`, and `compaction` are optional.
83
+ - Omitted `skills` uses Pi's normal discovered skills. A nonempty `skills` list is an exact name allowlist, and `skills: []` disables skills.
81
84
  - `lifecycle` must be exactly `one-shot` or `reusable`.
82
85
  - The Markdown body must be nonempty.
83
86
 
@@ -91,4 +94,6 @@ Use one-shot workers for bounded investigation, review, and implementation. Use
91
94
 
92
95
  Worker sessions are isolated from the parent's conversational context, but they run in-process and are not sandboxes. They share the parent process's filesystem and environment permissions. Treat worker prompts, optional skills, models, and tool grants as trusted code.
93
96
 
97
+ Workers use regular persisted Pi global settings, authentication, packages, extensions, skills, and context. Trusted projects also contribute their project settings and resources; untrusted projects do not. Extensions are active in print mode for the complete worker lifecycle, including resource discovery and provider request hooks. Pi Orchestrate excludes its own package before child extension factories execute, so workers remain direct children while other configured extensions—including provider integrations such as `@benvargas/pi-claude-code-use`—load normally. Worker definitions still provide the exact bounded tool allowlist.
98
+
94
99
  Pi Orchestrate performs no automatic filesystem writes. A worker writes only when its instructions and granted tools cause it to do so. Parallel workers must have non-overlapping write scopes, and the parent must inspect and verify their changes.
@@ -6,41 +6,79 @@ import {
6
6
  import { lstatSync, readdirSync, readFileSync } from "node:fs";
7
7
  import { basename, extname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { Result, Schema, SchemaGetter, type SchemaIssue } from "effect";
9
10
  import type {
10
11
  CatalogDiagnostic,
11
- SupportedToolName,
12
12
  WorkerCatalog,
13
13
  WorkerDefinition,
14
- WorkerLifecycle,
15
14
  WorkerSourceKind,
16
15
  } from "./domain.js";
17
- import { isSupportedToolName } from "./domain.js";
16
+ import { isSupportedToolName, SUPPORTED_TOOL_NAMES } from "./domain.js";
18
17
 
19
18
  const MAX_WORKER_BYTES = 64 * 1024;
20
- const KNOWN_FIELDS = new Set([
21
- "name",
22
- "description",
23
- "model",
24
- "thinking",
25
- "tools",
26
- "skills",
27
- "compaction",
28
- "lifecycle",
29
- ]);
30
- const THINKING_LEVELS: ReadonlySet<string> = new Set([
31
- "off",
32
- "minimal",
33
- "low",
34
- "medium",
35
- "high",
36
- "xhigh",
37
- "max",
38
- ]);
39
-
40
- function isThinkingLevel(value: string): value is NonNullable<WorkerDefinition["thinking"]> {
41
- return THINKING_LEVELS.has(value);
19
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
20
+
21
+ const RequiredText = Schema.Trim.check(Schema.isNonEmpty());
22
+ const StringListInput = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
23
+
24
+ function commaList<Item extends Schema.Constraint & { readonly Encoded: string }>(
25
+ item: Item,
26
+ allowEmpty = false,
27
+ ) {
28
+ const items = allowEmpty
29
+ ? Schema.Array(item)
30
+ : Schema.Array(item).check(Schema.isMinLength(1));
31
+ return StringListInput.pipe(
32
+ Schema.decodeTo(items, {
33
+ decode: SchemaGetter.transform((value) =>
34
+ typeof value === "string" ? value.split(",").map((entry) => entry.trim()) : value,
35
+ ),
36
+ encode: SchemaGetter.transform((value) => value),
37
+ }),
38
+ );
42
39
  }
43
40
 
41
+ const ModelCoordinate = Schema.Trim.check(Schema.isPattern(/^[^/\s]+\/\S+$/)).pipe(
42
+ Schema.decodeTo(
43
+ Schema.Struct({ provider: Schema.NonEmptyString, modelId: Schema.NonEmptyString }),
44
+ {
45
+ decode: SchemaGetter.transform((coordinate) => {
46
+ const separator = coordinate.indexOf("/");
47
+ return {
48
+ provider: coordinate.slice(0, separator),
49
+ modelId: coordinate.slice(separator + 1),
50
+ };
51
+ }),
52
+ encode: SchemaGetter.transform(({ provider, modelId }) => `${provider}/${modelId}`),
53
+ },
54
+ ),
55
+ );
56
+
57
+ const NonNegativeInteger = Schema.Number.check(
58
+ Schema.isInt(),
59
+ Schema.isGreaterThanOrEqualTo(0),
60
+ );
61
+ const Compaction = Schema.Struct({
62
+ enabled: Schema.optionalKey(Schema.Boolean),
63
+ reserveTokens: Schema.optionalKey(NonNegativeInteger),
64
+ keepRecentTokens: Schema.optionalKey(NonNegativeInteger),
65
+ });
66
+ const ThinkingLevel = Schema.Trim.pipe(Schema.decodeTo(Schema.Literals(THINKING_LEVELS)));
67
+ const WorkerFrontmatter = Schema.Struct({
68
+ name: RequiredText,
69
+ description: RequiredText,
70
+ model: Schema.optionalKey(ModelCoordinate),
71
+ thinking: Schema.optionalKey(ThinkingLevel),
72
+ tools: commaList(Schema.Literals(SUPPORTED_TOOL_NAMES)),
73
+ skills: Schema.optionalKey(commaList(Schema.NonEmptyString, true)),
74
+ compaction: Schema.optionalKey(Compaction),
75
+ lifecycle: Schema.Literals(["one-shot", "reusable"]),
76
+ });
77
+ const decodeWorkerFrontmatter = Schema.decodeUnknownResult(WorkerFrontmatter, {
78
+ errors: "all",
79
+ onExcessProperty: "error",
80
+ });
81
+
44
82
  export interface CatalogFileStat {
45
83
  readonly size: number;
46
84
  isFile(): boolean;
@@ -65,18 +103,6 @@ interface CatalogSource {
65
103
  readonly directory: string;
66
104
  }
67
105
 
68
- interface WorkerFrontmatter {
69
- readonly name?: unknown;
70
- readonly description?: unknown;
71
- readonly model?: unknown;
72
- readonly thinking?: unknown;
73
- readonly tools?: unknown;
74
- readonly skills?: unknown;
75
- readonly compaction?: unknown;
76
- readonly lifecycle?: unknown;
77
- readonly [field: string]: unknown;
78
- }
79
-
80
106
  const productionFileSystem: CatalogFileSystem = {
81
107
  readDirectory: (directory) => readdirSync(directory),
82
108
  inspect: (path) => lstatSync(path),
@@ -101,98 +127,128 @@ function isMissingPath(error: unknown): boolean {
101
127
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
102
128
  }
103
129
 
104
- function requiredString(value: unknown, field: string): string {
105
- if (typeof value !== "string" || value.trim() === "") {
106
- throw new Error(`frontmatter field '${field}' must be a non-empty string`);
107
- }
108
- return value.trim();
109
- }
110
-
111
- function optionalString(value: unknown, field: string): string | undefined {
112
- if (value === undefined) return undefined;
113
- return requiredString(value, field);
130
+ interface UnexpectedPath {
131
+ readonly path: readonly PropertyKey[];
114
132
  }
115
133
 
116
- function stringList(value: unknown, field: string, required: boolean): string[] | undefined {
117
- if (value === undefined) {
118
- if (required) throw new Error(`frontmatter field '${field}' is required`);
119
- return undefined;
120
- }
121
-
122
- const values = typeof value === "string" ? value.split(",").map((item) => item.trim()) : value;
123
- if (!Array.isArray(values) || values.length === 0) {
124
- throw new Error(`frontmatter field '${field}' must be a non-empty comma string or string array`);
125
- }
126
-
127
- const strings: string[] = [];
128
- for (const item of values) {
129
- if (typeof item !== "string" || item === "") {
130
- throw new Error(`frontmatter field '${field}' must be a non-empty comma string or string array`);
131
- }
132
- strings.push(item);
134
+ function collectUnexpectedPaths(
135
+ issue: SchemaIssue.Issue,
136
+ parentPath: readonly PropertyKey[] = [],
137
+ ): UnexpectedPath[] {
138
+ switch (issue._tag) {
139
+ case "Pointer":
140
+ return collectUnexpectedPaths(issue.issue, [...parentPath, ...issue.path]);
141
+ case "Composite":
142
+ case "AnyOf":
143
+ return issue.issues.flatMap((child) => collectUnexpectedPaths(child, parentPath));
144
+ case "Encoding":
145
+ case "Filter":
146
+ return collectUnexpectedPaths(issue.issue, parentPath);
147
+ case "UnexpectedKey":
148
+ return [{ path: parentPath }];
149
+ default:
150
+ return [];
133
151
  }
134
- return strings;
135
152
  }
136
153
 
137
- function parseTools(value: unknown): SupportedToolName[] {
138
- const tools: SupportedToolName[] = [];
139
- for (const tool of stringList(value, "tools", true) ?? []) {
140
- if (!isSupportedToolName(tool)) throw new Error(`unsupported tool '${tool}'`);
141
- tools.push(tool);
142
- }
143
- return tools;
154
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
155
+ return typeof value === "object" && value !== null && !Array.isArray(value);
144
156
  }
145
157
 
146
- function parseOptionalBoolean(value: unknown, field: string): boolean | undefined {
147
- if (value === undefined) return undefined;
148
- if (typeof value !== "boolean") {
149
- throw new Error(`frontmatter field '${field}' must be a boolean`);
150
- }
151
- return value;
158
+ function fieldValue(frontmatter: unknown, field: string): unknown {
159
+ if (!isUnknownRecord(frontmatter)) return undefined;
160
+ return field in frontmatter ? frontmatter[field] : undefined;
152
161
  }
153
162
 
154
- function parseLifecycle(value: unknown): WorkerLifecycle {
155
- if (value !== "one-shot" && value !== "reusable") {
156
- throw new Error("frontmatter field 'lifecycle' must be 'one-shot' or 'reusable'");
157
- }
158
- return value;
163
+ function listItems(value: unknown): readonly unknown[] {
164
+ if (typeof value === "string") return value.split(",").map((item) => item.trim());
165
+ return Array.isArray(value) ? value : [];
159
166
  }
160
167
 
161
- function parseCompaction(value: unknown): WorkerDefinition["compaction"] {
162
- if (value === undefined) return undefined;
163
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
164
- throw new Error("frontmatter field 'compaction' must be a mapping");
168
+ function schemaDiagnostic(error: Schema.SchemaError, frontmatter: unknown): string {
169
+ const unexpected = collectUnexpectedPaths(error.issue);
170
+ const frontmatterFields = unexpected
171
+ .filter(({ path }) => path.length === 1 && typeof path[0] === "string")
172
+ .map(({ path }) => String(path[0]))
173
+ .sort(compareText);
174
+ if (frontmatterFields.length > 0) {
175
+ return `unknown frontmatter field${frontmatterFields.length === 1 ? "" : "s"}: ${frontmatterFields.join(", ")}`;
165
176
  }
166
177
 
167
- const fields = Object.keys(value).sort(compareText);
168
- const unknownFields = fields.filter(
169
- (field) => field !== "enabled" && field !== "reserveTokens" && field !== "keepRecentTokens",
170
- );
171
- if (unknownFields.length > 0) {
172
- throw new Error(
173
- `unknown compaction field${unknownFields.length === 1 ? "" : "s"}: ${unknownFields.join(", ")}`,
174
- );
178
+ const compactionFields = unexpected
179
+ .filter(
180
+ ({ path }) => path.length === 2 && path[0] === "compaction" && typeof path[1] === "string",
181
+ )
182
+ .map(({ path }) => String(path[1]))
183
+ .sort(compareText);
184
+
185
+ if (typeof frontmatter !== "object" || frontmatter === null || Array.isArray(frontmatter)) {
186
+ return "frontmatter must be a mapping";
175
187
  }
176
188
 
177
- const enabledValue = "enabled" in value ? value.enabled : undefined;
178
- const reserveTokens = "reserveTokens" in value ? value.reserveTokens : undefined;
179
- const keepRecentTokens = "keepRecentTokens" in value ? value.keepRecentTokens : undefined;
180
- const enabled = parseOptionalBoolean(enabledValue, "compaction.enabled");
189
+ const orderedFields = [
190
+ "name",
191
+ "description",
192
+ "model",
193
+ "thinking",
194
+ "tools",
195
+ "skills",
196
+ "compaction",
197
+ "lifecycle",
198
+ ];
199
+ const message = error.message;
200
+ const field = orderedFields.find((candidate) => message.includes(`["${candidate}"]`));
201
+ const value = field === undefined ? undefined : fieldValue(frontmatter, field);
181
202
 
182
- if (
183
- reserveTokens !== undefined &&
184
- (typeof reserveTokens !== "number" || !Number.isSafeInteger(reserveTokens) || reserveTokens < 0)
185
- ) {
186
- throw new Error("frontmatter field 'compaction.reserveTokens' must be a non-negative integer");
203
+ if (field === "name" || field === "description") {
204
+ return `frontmatter field '${field}' must be a non-empty string`;
187
205
  }
188
- if (
189
- keepRecentTokens !== undefined &&
190
- (typeof keepRecentTokens !== "number" || !Number.isSafeInteger(keepRecentTokens) || keepRecentTokens < 0)
191
- ) {
192
- throw new Error("frontmatter field 'compaction.keepRecentTokens' must be a non-negative integer");
206
+ if (field === "model") {
207
+ if (typeof value !== "string" || value.trim() === "") {
208
+ return "frontmatter field 'model' must be a non-empty string";
209
+ }
210
+ return "frontmatter field 'model' must use provider/model format";
193
211
  }
194
-
195
- return { enabled, reserveTokens, keepRecentTokens };
212
+ if (field === "thinking") {
213
+ if (typeof value !== "string" || value.trim() === "") {
214
+ return "frontmatter field 'thinking' must be a non-empty string";
215
+ }
216
+ return `unsupported thinking level '${value.trim()}'`;
217
+ }
218
+ if (field === "tools") {
219
+ const items = listItems(value);
220
+ const validList = items.length > 0 && items.every(
221
+ (item) => typeof item === "string" && item !== "",
222
+ );
223
+ const unsupported = validList ? items.find(
224
+ (item) => typeof item === "string" && !isSupportedToolName(item),
225
+ ) : undefined;
226
+ if (typeof unsupported === "string") return `unsupported tool '${unsupported}'`;
227
+ if (value === undefined) return "frontmatter field 'tools' is required";
228
+ return "frontmatter field 'tools' must be a non-empty comma string or string array";
229
+ }
230
+ if (field === "skills") {
231
+ return "frontmatter field 'skills' must be a comma string or string array";
232
+ }
233
+ if (field === "compaction") {
234
+ if (compactionFields.length > 0) {
235
+ return `unknown compaction field${compactionFields.length === 1 ? "" : "s"}: ${compactionFields.join(", ")}`;
236
+ }
237
+ if (message.includes('["enabled"]')) {
238
+ return "frontmatter field 'compaction.enabled' must be a boolean";
239
+ }
240
+ if (message.includes('["reserveTokens"]')) {
241
+ return "frontmatter field 'compaction.reserveTokens' must be a non-negative integer";
242
+ }
243
+ if (message.includes('["keepRecentTokens"]')) {
244
+ return "frontmatter field 'compaction.keepRecentTokens' must be a non-negative integer";
245
+ }
246
+ return "frontmatter field 'compaction' must be a mapping";
247
+ }
248
+ if (field === "lifecycle") {
249
+ return "frontmatter field 'lifecycle' must be 'one-shot' or 'reusable'";
250
+ }
251
+ return "invalid worker definition";
196
252
  }
197
253
 
198
254
  function parseWorker(
@@ -200,65 +256,28 @@ function parseWorker(
200
256
  source: WorkerSourceKind,
201
257
  content: string,
202
258
  ): WorkerDefinition {
203
- let parsed: ReturnType<typeof parseFrontmatter<WorkerFrontmatter>>;
259
+ let parsed: ReturnType<typeof parseFrontmatter>;
204
260
  try {
205
- parsed = parseFrontmatter<WorkerFrontmatter>(content);
261
+ parsed = parseFrontmatter(content);
206
262
  } catch {
207
263
  throw new Error("frontmatter is not valid YAML");
208
264
  }
209
265
 
210
266
  const { frontmatter, body } = parsed;
211
- if (typeof frontmatter !== "object" || frontmatter === null || Array.isArray(frontmatter)) {
212
- throw new Error("frontmatter must be a mapping");
213
- }
214
-
215
- const unknownFields = Object.keys(frontmatter)
216
- .filter((field) => !KNOWN_FIELDS.has(field))
217
- .sort(compareText);
218
- if (unknownFields.length > 0) {
219
- throw new Error(
220
- `unknown frontmatter field${unknownFields.length === 1 ? "" : "s"}: ${unknownFields.join(", ")}`,
221
- );
267
+ const decoded = decodeWorkerFrontmatter(frontmatter);
268
+ if (Result.isFailure(decoded)) {
269
+ throw new Error(schemaDiagnostic(decoded.failure, frontmatter));
222
270
  }
223
271
 
224
- const name = requiredString(frontmatter.name, "name");
272
+ const worker = decoded.success;
225
273
  const expectedName = basename(filePath, extname(filePath));
226
- if (name !== expectedName) {
227
- throw new Error(`frontmatter name '${name}' must match basename '${expectedName}'`);
274
+ if (worker.name !== expectedName) {
275
+ throw new Error(`frontmatter name '${worker.name}' must match basename '${expectedName}'`);
228
276
  }
229
-
230
- const description = requiredString(frontmatter.description, "description");
231
- const model = optionalString(frontmatter.model, "model");
232
- if (model !== undefined && !/^[^/\s]+\/\S+$/.test(model)) {
233
- throw new Error("frontmatter field 'model' must use provider/model format");
234
- }
235
-
236
- const thinking = optionalString(frontmatter.thinking, "thinking");
237
- if (thinking !== undefined && !isThinkingLevel(thinking)) {
238
- throw new Error(`unsupported thinking level '${thinking}'`);
239
- }
240
-
241
- const tools = parseTools(frontmatter.tools);
242
- const skills = stringList(frontmatter.skills, "skills", false);
243
- const compaction = parseCompaction(frontmatter.compaction);
244
- const lifecycle = parseLifecycle(frontmatter.lifecycle);
245
277
  if (body.trim() === "") throw new Error("worker prompt body must not be empty");
246
278
 
247
279
  return {
248
- name,
249
- description,
250
- model:
251
- model === undefined
252
- ? undefined
253
- : {
254
- provider: model.slice(0, model.indexOf("/")),
255
- modelId: model.slice(model.indexOf("/") + 1),
256
- },
257
- thinking,
258
- tools,
259
- skills: skills ?? [],
260
- compaction,
261
- lifecycle,
280
+ ...worker,
262
281
  systemPrompt: body,
263
282
  source: { kind: source, filePath },
264
283
  };
@@ -34,7 +34,9 @@ You are the parent orchestrator and own the task end to end.
34
34
  - Give every worker a full brief: objective; paths/scope; forbidden actions; context; constraints; observable success; checks; expected output.
35
35
  - Input, catalog, and model preflight is atomic before any worker starts. Resource startup failures become per-worker failed results and do not roll back peers.
36
36
  - For asynchronous behavior, \`orchestrate\` or \`worker_send\` must be the sole tool call in its assistant message. Mixing either with any sibling tool call makes its wave inline and blocking.
37
- - After an accepted async wave, yield the parent turn until aggregate results arrive. Do not duplicate delegated work or poll \`orchestration_status\` for completion.
37
+ - Exact worker instructions remain visible in the tool call and can be expanded; titles are labels, not substitutes for complete messages.
38
+ - After an accepted async wave, yield the parent turn. Worker responses arrive individually as each worker settles, and the final response starts parent synthesis. Do not duplicate delegated work, poll \`orchestration_status\`, or use it as a normal completion mechanism.
39
+ - The active-work widget shows only workers currently starting, running, or stopping. Inline worker responses appear progressively in live tool output.
38
40
  - The parent synthesizes worker results, reviews their evidence and changes, resolves conflicts, integrates the final result, and runs the relevant verification before declaring completion.
39
41
  - Prefer one-shot workers. Use \`worker_send\` for follow-up work on a ready reusable worker, \`worker_close\` when that ready worker is finished, and \`worker_abort\` only when active work must stop.
40
42
  - The public tools are \`orchestrate\`, \`orchestration_status\`, \`worker_send\`, \`worker_abort\`, and \`worker_close\`.