@zachwill/pi-orchestrate 0.1.1 → 0.2.1

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
@@ -80,6 +80,7 @@ Frontmatter supports these fields:
80
80
  - `tools` and `lifecycle` are required. Grant the smallest useful tool set.
81
81
  - `model` is optional. When omitted, the worker inherits the parent model active when dispatched.
82
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.
83
84
  - `lifecycle` must be exactly `one-shot` or `reusable`.
84
85
  - The Markdown body must be nonempty.
85
86
 
@@ -93,4 +94,6 @@ Use one-shot workers for bounded investigation, review, and implementation. Use
93
94
 
94
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.
95
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
+
96
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
  };
@@ -45,7 +45,7 @@ export interface WorkerDefinition {
45
45
  readonly systemPrompt: string;
46
46
  readonly lifecycle: WorkerLifecycle;
47
47
  readonly tools: readonly SupportedToolName[];
48
- readonly skills: readonly string[];
48
+ readonly skills?: readonly string[];
49
49
  readonly model?: WorkerModel;
50
50
  readonly thinking?: ThinkingLevel;
51
51
  readonly compaction?: WorkerCompaction;
@@ -160,6 +160,9 @@ export interface WorkerUsage {
160
160
  readonly turns: number;
161
161
  }
162
162
 
163
+ /** Direction of the most recent message across the worker/model boundary. */
164
+ export type WorkerMessageDirection = "to-model" | "from-model";
165
+
163
166
  export const EMPTY_WORKER_USAGE: WorkerUsage = Object.freeze({
164
167
  input: 0,
165
168
  output: 0,
@@ -231,6 +234,7 @@ export interface WorkerRecord {
231
234
  readonly startedAt: number;
232
235
  readonly settledAt?: number;
233
236
  readonly activity?: string;
237
+ readonly messageDirection?: WorkerMessageDirection;
234
238
  readonly outcome?: WorkerOutcome;
235
239
  readonly sessionFile?: string;
236
240
  }
package/extension/host.ts CHANGED
@@ -22,7 +22,7 @@ interface AttachmentAwareProcessHost extends ProcessHost {
22
22
  }
23
23
 
24
24
  interface OwnedProcessHost extends AttachmentAwareProcessHost {
25
- readonly unsubscribeSettlement: () => void;
25
+ readonly unsubscribeSettlement?: () => void;
26
26
  destroyPromise?: Promise<void>;
27
27
  }
28
28
 
@@ -99,7 +99,7 @@ export async function destroyProcessHost(host: ProcessHost): Promise<void> {
99
99
  await ownedHost.runtime.shutdown();
100
100
  } finally {
101
101
  ownedHost.delivery.clear();
102
- ownedHost.unsubscribeSettlement();
102
+ ownedHost.unsubscribeSettlement?.();
103
103
  const global = processGlobal();
104
104
  if (global[PROCESS_HOST_KEY] === ownedHost) {
105
105
  delete global[PROCESS_HOST_KEY];