@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.
@@ -1,21 +1,34 @@
1
- import { join } from "node:path";
1
+ import { realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
2
4
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
3
5
  import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
4
6
  import {
5
- createAgentSession,
6
- DefaultResourceLoader,
7
+ AgentSessionRuntime,
8
+ createAgentSessionFromServices,
9
+ createAgentSessionServices,
10
+ DefaultPackageManager,
7
11
  ModelRuntime,
8
12
  type ModelRegistry,
9
13
  type ResourceLoader,
10
14
  SessionManager,
11
15
  SettingsManager,
12
16
  type AgentSessionEvent,
13
- type CreateAgentSessionOptions,
17
+ type AgentSessionServices,
18
+ type CreateAgentSessionResult,
19
+ type DefaultResourceLoader,
14
20
  } from "@earendil-works/pi-coding-agent";
15
- import type { WorkerDefinition, WorkerOutcome, WorkerUsage } from "./domain.js";
21
+ import { Effect, Exit, Schema, Scope } from "effect";
22
+ import type {
23
+ WorkerDefinition,
24
+ WorkerMessageDirection,
25
+ WorkerOutcome,
26
+ WorkerUsage,
27
+ } from "./domain.js";
16
28
 
17
29
  const DIRECT_CHILD_BOUNDARY =
18
30
  "You are a direct child worker session. Do not spawn, delegate to, or orchestrate other workers or child sessions. Complete the assigned task yourself and return the result directly to the parent orchestrator.";
31
+ const ORCHESTRATE_PACKAGE_ROOT = canonicalPath(fileURLToPath(new URL("..", import.meta.url)));
19
32
 
20
33
  interface WorkerAgentSession {
21
34
  readonly sessionFile: string | undefined;
@@ -23,11 +36,19 @@ interface WorkerAgentSession {
23
36
  prompt(instructions: string): Promise<void>;
24
37
  abort(): Promise<void>;
25
38
  dispose(): void;
39
+ bindExtensions(bindings: { mode: "print" }): Promise<void>;
26
40
  subscribe(listener: (event: AgentSessionEvent) => void): () => void;
27
41
  }
28
42
 
29
- type ResourceLoaderOptions = ConstructorParameters<typeof DefaultResourceLoader>[0];
30
- type InMemorySettings = Parameters<typeof SettingsManager.inMemory>[0];
43
+ interface OwnedWorkerRuntime {
44
+ readonly session: WorkerAgentSession;
45
+ dispose(): Promise<void>;
46
+ }
47
+
48
+ type ResourceLoaderOptions = Omit<
49
+ ConstructorParameters<typeof DefaultResourceLoader>[0],
50
+ "cwd" | "agentDir" | "settingsManager"
51
+ >;
31
52
 
32
53
  export interface WorkerSessionFactoryOptions {
33
54
  cwd: string;
@@ -44,18 +65,42 @@ export interface WorkerSessionHandle {
44
65
  readonly sessionFile: string;
45
66
  prompt(instructions: string): Promise<WorkerOutcome>;
46
67
  abort(): Promise<void>;
47
- dispose(): void;
68
+ dispose(): Promise<void>;
48
69
  subscribeUsage(listener: (usage: WorkerUsage) => void): () => void;
49
70
  subscribeActivity(listener: (activity: string | undefined) => void): () => void;
71
+ subscribeMessageDirection(listener: (direction: WorkerMessageDirection) => void): () => void;
50
72
  }
51
73
 
52
74
  export interface WorkerSessionFactory {
53
75
  create(options: WorkerSessionFactoryOptions): Promise<WorkerSessionHandle>;
54
76
  }
55
77
 
56
- interface SessionManagerInput {
78
+ export class WorkerModelAcquisitionError extends Schema.TaggedErrorClass<WorkerModelAcquisitionError>()(
79
+ "WorkerSession.ModelAcquisitionError",
80
+ { message: Schema.String, cause: Schema.Defect() },
81
+ ) {}
82
+
83
+ export class WorkerResourceAcquisitionError extends Schema.TaggedErrorClass<WorkerResourceAcquisitionError>()(
84
+ "WorkerSession.ResourceAcquisitionError",
85
+ { message: Schema.String, cause: Schema.Defect() },
86
+ ) {}
87
+
88
+ export class WorkerAgentSessionAcquisitionError extends Schema.TaggedErrorClass<WorkerAgentSessionAcquisitionError>()(
89
+ "WorkerSession.AgentSessionAcquisitionError",
90
+ { message: Schema.String, cause: Schema.Defect() },
91
+ ) {}
92
+
93
+ interface SettingsManagerInput {
57
94
  cwd: string;
58
- parentSessionFile: string | undefined;
95
+ agentDir: string;
96
+ projectTrusted: boolean;
97
+ compaction: WorkerDefinition["compaction"];
98
+ }
99
+
100
+ interface ExtensionPathInput {
101
+ cwd: string;
102
+ agentDir: string;
103
+ settingsManager: SettingsManager;
59
104
  }
60
105
 
61
106
  interface ModelRuntimeInput {
@@ -63,38 +108,61 @@ interface ModelRuntimeInput {
63
108
  modelsPath: string;
64
109
  }
65
110
 
66
- interface AgentSessionInput {
111
+ interface ServicesInput {
67
112
  cwd: string;
68
113
  agentDir: string;
69
- model: Model<Api>;
114
+ settingsManager: SettingsManager;
70
115
  modelRuntime: ModelRuntime;
116
+ resourceLoaderOptions: ResourceLoaderOptions;
117
+ }
118
+
119
+ interface AgentSessionInput {
120
+ services: AgentSessionServices;
121
+ sessionManager: SessionManager;
122
+ model: Model<Api>;
71
123
  thinkingLevel: ThinkingLevel | undefined;
72
124
  tools: string[];
73
- resourceLoader: ResourceLoader;
74
- sessionManager: unknown;
75
- settingsManager: unknown;
125
+ }
126
+
127
+ interface RuntimeInput {
128
+ session: WorkerAgentSession;
129
+ services: AgentSessionServices;
76
130
  }
77
131
 
78
132
  export interface WorkerSessionDependencies {
79
- createResourceLoader(options: ResourceLoaderOptions): ResourceLoader;
80
- createSessionManager(input: SessionManagerInput): unknown;
81
- createSettingsManager(
82
- settings: InMemorySettings,
83
- options: { projectTrusted: boolean },
84
- ): unknown;
133
+ createSettingsManager(input: SettingsManagerInput): SettingsManager;
134
+ resolveExtensionPaths(input: ExtensionPathInput): Promise<readonly string[]>;
135
+ createSessionManager(input: { cwd: string; parentSessionFile: string | undefined }): SessionManager;
85
136
  createModelRuntime(input: ModelRuntimeInput): Promise<ModelRuntime>;
137
+ createServices(input: ServicesInput): Promise<AgentSessionServices>;
86
138
  createAgentSession(input: AgentSessionInput): Promise<{ session: WorkerAgentSession }>;
139
+ createRuntime(input: RuntimeInput): OwnedWorkerRuntime;
87
140
  }
88
141
 
89
142
  const defaultDependencies: WorkerSessionDependencies = {
90
- createResourceLoader: (options) => new DefaultResourceLoader(options),
143
+ createSettingsManager: ({ cwd, agentDir, projectTrusted, compaction }) => {
144
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
145
+ if (compaction !== undefined) settingsManager.applyOverrides({ compaction: { ...compaction } });
146
+ return settingsManager;
147
+ },
148
+ resolveExtensionPaths: async ({ cwd, agentDir, settingsManager }) => {
149
+ const resolved = await new DefaultPackageManager({ cwd, agentDir, settingsManager }).resolve();
150
+ return resolved.extensions.filter((entry) => entry.enabled).map((entry) => entry.path);
151
+ },
91
152
  createSessionManager: ({ cwd, parentSessionFile }) =>
92
153
  SessionManager.create(cwd, undefined, { parentSession: parentSessionFile }),
93
- createSettingsManager: (settings, options) =>
94
- SettingsManager.inMemory(settings, { projectTrusted: options.projectTrusted }),
95
154
  createModelRuntime: (input) => ModelRuntime.create(input),
155
+ createServices: (input) => createAgentSessionServices(input),
96
156
  createAgentSession: (input) =>
97
- createAgentSession(input as CreateAgentSessionOptions) as Promise<{ session: WorkerAgentSession }>,
157
+ createAgentSessionFromServices(input) as Promise<CreateAgentSessionResult & { session: WorkerAgentSession }>,
158
+ createRuntime: ({ session, services }) =>
159
+ new AgentSessionRuntime(
160
+ session as CreateAgentSessionResult["session"],
161
+ services,
162
+ async () => {
163
+ throw new Error("Worker sessions do not support runtime replacement");
164
+ },
165
+ ) as unknown as OwnedWorkerRuntime,
98
166
  };
99
167
 
100
168
  export function resolveWorkerModel(
@@ -119,57 +187,34 @@ export function resolveWorkerModel(
119
187
  return model;
120
188
  }
121
189
 
122
- interface DisposableResource {
123
- dispose(): void;
124
- }
125
-
126
- function isDisposableResource(resource: ResourceLoader): resource is ResourceLoader & DisposableResource {
127
- return "dispose" in resource && typeof resource.dispose === "function";
128
- }
129
-
130
- function disposeResourceLoader(resourceLoader: ResourceLoader): void {
131
- if (!isDisposableResource(resourceLoader)) return;
190
+ function canonicalPath(path: string): string {
132
191
  try {
133
- resourceLoader.dispose();
192
+ return realpathSync.native(path);
134
193
  } catch {
135
- // Cleanup is best-effort and must not hide the original failure.
194
+ return resolve(path);
136
195
  }
137
196
  }
138
197
 
139
- function disposeSession(session: WorkerAgentSession): void {
140
- try {
141
- session.dispose();
142
- } catch {
143
- // Other owned resources still need to be released.
144
- }
198
+ function isWithin(path: string, root: string): boolean {
199
+ const child = relative(root, path);
200
+ return child === "" || (!child.startsWith("..") && !isAbsolute(child));
145
201
  }
146
202
 
147
- function describePromptError(error: unknown): string {
148
- if (error instanceof Error) return error.message;
149
- if (typeof error === "string" && error !== "") return error;
150
- return "Worker prompt failed";
203
+ export function isOrchestrationExtensionPath(path: string): boolean {
204
+ return isWithin(canonicalPath(path), ORCHESTRATE_PACKAGE_ROOT);
151
205
  }
152
206
 
153
- interface MutableWorkerUsage {
154
- input: number;
155
- output: number;
156
- cacheRead: number;
157
- cacheWrite: number;
158
- cost: number;
159
- contextTokens: number;
160
- turns: number;
207
+ function resourceLoaderFinalizer(resourceLoader: ResourceLoader): Effect.Effect<void> {
208
+ return Effect.sync(() => {
209
+ if (!("dispose" in resourceLoader) || typeof resourceLoader.dispose !== "function") return;
210
+ resourceLoader.dispose();
211
+ }).pipe(Effect.ignoreCause);
161
212
  }
162
213
 
163
- function emptyUsage(): MutableWorkerUsage {
164
- return {
165
- input: 0,
166
- output: 0,
167
- cacheRead: 0,
168
- cacheWrite: 0,
169
- cost: 0,
170
- contextTokens: 0,
171
- turns: 0,
172
- };
214
+ function describeError(error: unknown, fallback: string): string {
215
+ if (error instanceof Error) return error.message;
216
+ if (typeof error === "string" && error !== "") return error;
217
+ return fallback;
173
218
  }
174
219
 
175
220
  function assistantText(message: AssistantMessage | undefined): string | undefined {
@@ -188,184 +233,157 @@ function lastAssistant(messages: AgentMessage[]): AssistantMessage | undefined {
188
233
  return undefined;
189
234
  }
190
235
 
236
+ interface MutableWorkerUsage {
237
+ input: number;
238
+ output: number;
239
+ cacheRead: number;
240
+ cacheWrite: number;
241
+ cost: number;
242
+ contextTokens: number;
243
+ turns: number;
244
+ }
245
+
246
+ function emptyUsage(): MutableWorkerUsage {
247
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
248
+ }
249
+
191
250
  class DefaultWorkerSessionHandle implements WorkerSessionHandle {
192
251
  readonly sessionFile: string;
193
-
194
252
  private readonly usage = emptyUsage();
195
253
  private readonly usageListeners = new Set<(usage: WorkerUsage) => void>();
196
- private readonly activityListeners = new Set<
197
- (activity: string | undefined) => void
198
- >();
199
- private readonly unsubscribeSession: () => void;
254
+ private readonly activityListeners = new Set<(activity: string | undefined) => void>();
255
+ private readonly messageDirectionListeners = new Set<(direction: WorkerMessageDirection) => void>();
256
+ private readonly activeToolCalls = new Map<string, string>();
200
257
  private disposed = false;
258
+ private disposePromise: Promise<void> | undefined;
201
259
  private activity: string | undefined;
202
- private readonly activeToolCalls = new Map<string, string>();
260
+ private messageDirection: WorkerMessageDirection | undefined;
203
261
  private prompting = false;
204
262
  private abortRequested = false;
205
263
  private promptAssistant: AssistantMessage | undefined;
206
264
 
207
265
  constructor(
208
- private readonly session: WorkerAgentSession,
209
- private readonly resourceLoader: ResourceLoader,
266
+ private readonly runtime: OwnedWorkerRuntime,
210
267
  private readonly reusable: boolean,
268
+ sessionFile: string,
269
+ private readonly scope: Scope.Closeable,
211
270
  ) {
212
- this.sessionFile = session.sessionFile!;
213
- this.unsubscribeSession = session.subscribe((event) => this.onSessionEvent(event));
271
+ this.sessionFile = sessionFile;
214
272
  }
215
273
 
216
- private onSessionEvent(event: AgentSessionEvent): void {
274
+ receiveSessionEvent(event: AgentSessionEvent): void {
275
+ if (event.type === "message_start") {
276
+ if (event.message.role === "assistant") this.setMessageDirection("from-model");
277
+ else if (event.message.role === "user" || event.message.role === "toolResult") this.setMessageDirection("to-model");
278
+ return;
279
+ }
217
280
  if (event.type === "tool_execution_start") {
218
281
  this.activeToolCalls.delete(event.toolCallId);
219
282
  this.activeToolCalls.set(event.toolCallId, event.toolName);
220
283
  this.setActivity(event.toolName);
221
284
  return;
222
285
  }
223
-
224
286
  if (event.type === "tool_execution_end") {
225
287
  if (this.activeToolCalls.get(event.toolCallId) !== event.toolName) return;
226
288
  this.activeToolCalls.delete(event.toolCallId);
227
- this.setActivity(this.mostRecentActiveTool());
289
+ this.setActivity([...this.activeToolCalls.values()].at(-1));
228
290
  return;
229
291
  }
230
-
231
292
  if (event.type !== "turn_end" || event.message.role !== "assistant") return;
232
-
233
- const message = event.message;
234
- this.promptAssistant = message;
235
- this.usage.input += message.usage.input ?? 0;
236
- this.usage.output += message.usage.output ?? 0;
237
- this.usage.cacheRead += message.usage.cacheRead ?? 0;
238
- this.usage.cacheWrite += message.usage.cacheWrite ?? 0;
239
- this.usage.cost += message.usage.cost?.total ?? 0;
240
- this.usage.contextTokens = message.usage.totalTokens ?? 0;
293
+ this.promptAssistant = event.message;
294
+ this.usage.input += event.message.usage.input ?? 0;
295
+ this.usage.output += event.message.usage.output ?? 0;
296
+ this.usage.cacheRead += event.message.usage.cacheRead ?? 0;
297
+ this.usage.cacheWrite += event.message.usage.cacheWrite ?? 0;
298
+ this.usage.cost += event.message.usage.cost?.total ?? 0;
299
+ this.usage.contextTokens = event.message.usage.totalTokens ?? 0;
241
300
  this.usage.turns += 1;
242
-
243
- for (const listener of [...this.usageListeners]) {
244
- try {
245
- listener({ ...this.usage });
246
- } catch {
247
- // One subscriber cannot prevent other subscribers from receiving usage.
248
- }
249
- }
250
- }
251
-
252
- private mostRecentActiveTool(): string | undefined {
253
- let latest: string | undefined;
254
- for (const toolName of this.activeToolCalls.values()) latest = toolName;
255
- return latest;
301
+ for (const listener of [...this.usageListeners]) safelyNotify(() => listener({ ...this.usage }));
256
302
  }
257
303
 
258
304
  private setActivity(activity: string | undefined): void {
259
305
  if (this.activity === activity) return;
260
306
  this.activity = activity;
261
- for (const listener of [...this.activityListeners]) {
262
- try {
263
- listener(activity);
264
- } catch {
265
- // One subscriber cannot prevent other subscribers from receiving activity.
266
- }
267
- }
307
+ for (const listener of [...this.activityListeners]) safelyNotify(() => listener(activity));
308
+ }
309
+
310
+ private setMessageDirection(direction: WorkerMessageDirection): void {
311
+ if (this.messageDirection === direction) return;
312
+ this.messageDirection = direction;
313
+ for (const listener of [...this.messageDirectionListeners]) safelyNotify(() => listener(direction));
268
314
  }
269
315
 
270
316
  async prompt(instructions: string): Promise<WorkerOutcome> {
271
317
  if (this.disposed) throw new Error("Worker session has been disposed");
272
318
  if (this.prompting) throw new Error("Worker session is already processing a prompt");
273
-
274
319
  this.prompting = true;
275
320
  this.abortRequested = false;
276
321
  this.promptAssistant = undefined;
277
- const previousAssistant = lastAssistant(this.session.messages);
278
-
322
+ this.setMessageDirection("to-model");
323
+ const previousAssistant = lastAssistant(this.runtime.session.messages);
324
+ let failureMessage: string | undefined;
279
325
  try {
280
- await this.session.prompt(instructions);
281
- const latestAssistant = lastAssistant(this.session.messages);
282
- const message = this.promptAssistant ?? (latestAssistant !== previousAssistant ? latestAssistant : undefined);
283
- return this.outcomeFrom(message);
326
+ await this.runtime.session.prompt(instructions);
284
327
  } catch (error) {
285
- const latestAssistant = lastAssistant(this.session.messages);
286
- const message = this.promptAssistant ?? (latestAssistant !== previousAssistant ? latestAssistant : undefined);
287
- return this.outcomeFrom(message, describePromptError(error));
328
+ failureMessage = describeError(error, "Worker prompt failed");
288
329
  } finally {
289
330
  this.prompting = false;
290
331
  }
291
- }
292
-
293
- private outcomeFrom(
294
- message: AssistantMessage | undefined,
295
- failureMessage?: string,
296
- ): WorkerOutcome {
332
+ const latestAssistant = lastAssistant(this.runtime.session.messages);
333
+ const message = this.promptAssistant ?? (latestAssistant !== previousAssistant ? latestAssistant : undefined);
297
334
  const text = assistantText(message);
298
335
  const assistantPayload = text === undefined ? {} : { assistantText: text };
299
-
300
336
  if (this.abortRequested || message?.stopReason === "aborted") {
301
337
  const abortMessage = message?.errorMessage ?? failureMessage;
302
- return {
303
- status: "aborted",
304
- ...(abortMessage === undefined ? {} : { message: abortMessage }),
305
- ...assistantPayload,
306
- };
338
+ return { status: "aborted", ...(abortMessage ? { message: abortMessage } : {}), ...assistantPayload };
307
339
  }
308
-
309
340
  if (message?.stopReason === "error") {
310
- return {
311
- status: "failed",
312
- message: message.errorMessage ?? failureMessage ?? "Worker assistant reported a failure",
313
- ...assistantPayload,
314
- };
315
- }
316
-
317
- if (failureMessage !== undefined) {
318
- return { status: "failed", message: failureMessage, ...assistantPayload };
341
+ return { status: "failed", message: message.errorMessage ?? failureMessage ?? "Worker assistant reported a failure", ...assistantPayload };
319
342
  }
320
-
321
- return {
322
- status: this.reusable ? "ready" : "completed",
323
- assistantText: text ?? "",
324
- };
343
+ if (failureMessage) return { status: "failed", message: failureMessage, ...assistantPayload };
344
+ return { status: this.reusable ? "ready" : "completed", assistantText: text ?? "" };
325
345
  }
326
346
 
327
347
  async abort(): Promise<void> {
328
348
  if (this.prompting) this.abortRequested = true;
329
- await this.session.abort();
349
+ await this.runtime.session.abort();
330
350
  }
331
351
 
332
- dispose(): void {
333
- if (this.disposed) return;
334
- this.disposed = true;
352
+ dispose(): Promise<void> {
353
+ if (this.disposePromise) return this.disposePromise;
335
354
 
336
- try {
337
- this.unsubscribeSession();
338
- } catch {
339
- // Continue releasing independently owned resources.
340
- }
355
+ this.disposed = true;
341
356
  this.activeToolCalls.clear();
342
357
  this.usageListeners.clear();
343
358
  this.activityListeners.clear();
344
- disposeSession(this.session);
345
- disposeResourceLoader(this.resourceLoader);
359
+ this.messageDirectionListeners.clear();
360
+ this.disposePromise = Effect.runPromise(disposeWorkerSession(this.scope));
361
+ return this.disposePromise;
346
362
  }
347
363
 
348
364
  subscribeUsage(listener: (usage: WorkerUsage) => void): () => void {
349
- this.usageListeners.add(listener);
350
- let subscribed = true;
351
- return () => {
352
- if (!subscribed) return;
353
- subscribed = false;
354
- this.usageListeners.delete(listener);
355
- };
365
+ return subscribe(this.usageListeners, listener);
356
366
  }
357
-
358
- subscribeActivity(
359
- listener: (activity: string | undefined) => void,
360
- ): () => void {
361
- this.activityListeners.add(listener);
362
- let subscribed = true;
363
- return () => {
364
- if (!subscribed) return;
365
- subscribed = false;
366
- this.activityListeners.delete(listener);
367
- };
367
+ subscribeActivity(listener: (activity: string | undefined) => void): () => void {
368
+ return subscribe(this.activityListeners, listener);
368
369
  }
370
+ subscribeMessageDirection(listener: (direction: WorkerMessageDirection) => void): () => void {
371
+ return subscribe(this.messageDirectionListeners, listener);
372
+ }
373
+ }
374
+
375
+ function safelyNotify(callback: () => void): void {
376
+ try { callback(); } catch { /* One cleanup/listener cannot block another. */ }
377
+ }
378
+
379
+ function subscribe<T>(listeners: Set<(value: T) => void>, listener: (value: T) => void): () => void {
380
+ listeners.add(listener);
381
+ let active = true;
382
+ return () => {
383
+ if (!active) return;
384
+ active = false;
385
+ listeners.delete(listener);
386
+ };
369
387
  }
370
388
 
371
389
  function selectedModelCoordinates(
@@ -374,158 +392,333 @@ function selectedModelCoordinates(
374
392
  ): { provider: string; modelId: string } {
375
393
  if (definition.model) return definition.model;
376
394
  if (parentModel) return { provider: parentModel.provider, modelId: parentModel.id };
377
- throw new Error(
378
- `Worker "${definition.name}" has no configured model and no parent model is available`,
379
- );
395
+ throw new Error(`Worker "${definition.name}" has no configured model and no parent model is available`);
396
+ }
397
+
398
+ function modelAcquisitionError(cause: unknown, fallback: string): WorkerModelAcquisitionError {
399
+ return new WorkerModelAcquisitionError({ message: describeError(cause, fallback), cause });
400
+ }
401
+
402
+ function resourceAcquisitionError(cause: unknown): WorkerResourceAcquisitionError {
403
+ return new WorkerResourceAcquisitionError({
404
+ message: describeError(cause, "Worker resource acquisition failed"),
405
+ cause,
406
+ });
407
+ }
408
+
409
+ function agentSessionAcquisitionError(cause: unknown): WorkerAgentSessionAcquisitionError {
410
+ return new WorkerAgentSessionAcquisitionError({
411
+ message: describeError(cause, "Worker agent session acquisition failed"),
412
+ cause,
413
+ });
380
414
  }
381
415
 
382
- async function refreshChildModelRuntime(
416
+ const refreshModelRuntime = Effect.fn("WorkerSession.refreshModelRuntime")(function* (
383
417
  modelRuntime: ModelRuntime,
384
418
  definition: WorkerDefinition,
385
- ): Promise<void> {
386
- const result = await modelRuntime.refresh({ allowNetwork: false });
419
+ ) {
420
+ const result = yield* Effect.tryPromise({
421
+ try: () => modelRuntime.refresh({ allowNetwork: false }),
422
+ catch: (cause) => modelAcquisitionError(cause, "Worker model refresh failed"),
423
+ });
387
424
  if (result.errors.size > 0) {
388
- const errors = [...result.errors]
389
- .map(([providerId, error]) => `${providerId}: ${error.message}`)
390
- .join("; ");
391
- throw new Error(
425
+ const errors = [...result.errors].map(([id, error]) => `${id}: ${error.message}`).join("; ");
426
+ const cause = new Error(
392
427
  `Worker "${definition.name}" failed to refresh child model providers: ${errors}`,
393
428
  );
429
+ return yield* Effect.fail(modelAcquisitionError(cause, "Worker model refresh failed"));
394
430
  }
395
431
  if (result.aborted) {
396
- throw new Error(`Worker "${definition.name}" child model refresh was aborted`);
432
+ const cause = new Error(`Worker "${definition.name}" child model refresh was aborted`);
433
+ return yield* Effect.fail(modelAcquisitionError(cause, "Worker model refresh failed"));
397
434
  }
398
- }
435
+ });
399
436
 
400
- async function prepareChildModelRuntime(
437
+ const prepareChildModelRuntime = Effect.fn("WorkerSession.prepareChildModelRuntime")(function* (
401
438
  options: WorkerSessionFactoryOptions,
402
439
  dependencies: WorkerSessionDependencies,
403
- ): Promise<{ model: Model<Api>; modelRuntime: ModelRuntime }> {
404
- const selected = selectedModelCoordinates(options.definition, options.parentModel);
405
- const modelRuntime = await dependencies.createModelRuntime({
406
- authPath: join(options.agentDir, "auth.json"),
407
- modelsPath: join(options.agentDir, "models.json"),
440
+ ) {
441
+ const selected = yield* Effect.try({
442
+ try: () => selectedModelCoordinates(options.definition, options.parentModel),
443
+ catch: (cause) => modelAcquisitionError(cause, "Worker model selection failed"),
444
+ });
445
+ const modelRuntime = yield* Effect.tryPromise({
446
+ try: () => dependencies.createModelRuntime({
447
+ authPath: join(options.agentDir, "auth.json"),
448
+ modelsPath: join(options.agentDir, "models.json"),
449
+ }),
450
+ catch: (cause) => modelAcquisitionError(cause, "Worker model runtime creation failed"),
408
451
  });
409
452
 
410
- for (const providerId of options.modelRegistry.getRegisteredProviderIds()) {
411
- const providerConfig = options.modelRegistry.getRegisteredProviderConfig(providerId);
412
- if (providerConfig) modelRuntime.registerProvider(providerId, { ...providerConfig });
413
- }
414
- await refreshChildModelRuntime(modelRuntime, options.definition);
415
-
416
- const initiallyResolvedModel = modelRuntime.getModel(selected.provider, selected.modelId);
417
- if (!initiallyResolvedModel) {
418
- throw new Error(
419
- `Worker "${options.definition.name}" configured model "${selected.provider}/${selected.modelId}" was not found`,
420
- );
421
- }
453
+ yield* Effect.try({
454
+ try: () => {
455
+ for (const providerId of options.modelRegistry.getRegisteredProviderIds()) {
456
+ const config = options.modelRegistry.getRegisteredProviderConfig(providerId);
457
+ if (config) modelRuntime.registerProvider(providerId, { ...config });
458
+ }
459
+ },
460
+ catch: (cause) => modelAcquisitionError(cause, "Worker model provider registration failed"),
461
+ });
462
+ yield* refreshModelRuntime(modelRuntime, options.definition);
422
463
 
423
- const resolvedAuth = await options.modelRegistry.getApiKeyAndHeaders(initiallyResolvedModel);
424
- const parentUsesOAuth = options.modelRegistry.isUsingOAuth(initiallyResolvedModel);
425
- if (resolvedAuth.ok) {
426
- if (resolvedAuth.headers) {
427
- modelRuntime.registerProvider(selected.provider, {
428
- headers: { ...resolvedAuth.headers },
464
+ const model = yield* Effect.try({
465
+ try: () => modelRuntime.getModel(selected.provider, selected.modelId),
466
+ catch: (cause) => modelAcquisitionError(cause, "Worker model resolution failed"),
467
+ });
468
+ if (model) {
469
+ const auth = yield* Effect.tryPromise({
470
+ try: () => options.modelRegistry.getApiKeyAndHeaders(model),
471
+ catch: (cause) => modelAcquisitionError(cause, "Worker model authentication failed"),
472
+ });
473
+ if (auth.ok) {
474
+ yield* Effect.tryPromise({
475
+ try: async () => {
476
+ if (auth.headers) {
477
+ modelRuntime.registerProvider(selected.provider, { headers: { ...auth.headers } });
478
+ }
479
+ if (auth.apiKey && !options.modelRegistry.isUsingOAuth(model)) {
480
+ await modelRuntime.setRuntimeApiKey(selected.provider, auth.apiKey);
481
+ }
482
+ },
483
+ catch: (cause) => modelAcquisitionError(cause, "Worker model runtime key setup failed"),
429
484
  });
430
485
  }
431
- // OAuth resolution returns an access token through the compatibility API, but
432
- // installing that token as a runtime API key masks the complete OAuth credential
433
- // that the child already reads from the shared auth.json file.
434
- if (resolvedAuth.apiKey && !parentUsesOAuth) {
435
- await modelRuntime.setRuntimeApiKey(selected.provider, resolvedAuth.apiKey);
436
- }
437
- // Pi 0.80.10 exposes resolved provider env here but has no public ModelRuntime
438
- // runtime-env setter. Provider registrations, resolved headers, and non-OAuth
439
- // API keys are copied.
440
486
  }
441
487
 
442
- await refreshChildModelRuntime(modelRuntime, options.definition);
443
- const model = modelRuntime.getModel(selected.provider, selected.modelId);
444
- if (!model) {
445
- throw new Error(
446
- `Worker "${options.definition.name}" configured model "${selected.provider}/${selected.modelId}" was not found`,
447
- );
448
- }
449
- return { model, modelRuntime };
488
+ return { selected, modelRuntime };
489
+ });
490
+
491
+ function skillLoaderOptions(definition: WorkerDefinition): Pick<ResourceLoaderOptions, "noSkills" | "skillsOverride"> {
492
+ if (definition.skills === undefined) return {};
493
+ const selectedSkills = new Set(definition.skills);
494
+ return {
495
+ noSkills: selectedSkills.size === 0,
496
+ skillsOverride: (base) => ({
497
+ skills: base.skills.filter((skill) => selectedSkills.has(skill.name)),
498
+ diagnostics: base.diagnostics,
499
+ }),
500
+ };
450
501
  }
451
502
 
452
- export function createWorkerSessionFactory(
453
- dependencies: WorkerSessionDependencies = defaultDependencies,
454
- ): WorkerSessionFactory {
503
+ function contextLoaderOptions(
504
+ agentDir: string,
505
+ projectTrusted: boolean,
506
+ ): Pick<ResourceLoaderOptions, "agentsFilesOverride"> {
507
+ if (projectTrusted) return {};
508
+ const globalRoot = canonicalPath(agentDir);
455
509
  return {
456
- async create(options) {
457
- const definition = options.definition;
458
- const { model, modelRuntime } = await prepareChildModelRuntime(options, dependencies);
459
- const selectedSkills = new Set(definition.skills);
460
- const settingsManager = dependencies.createSettingsManager(
461
- definition.compaction === undefined
462
- ? undefined
463
- : { compaction: { ...definition.compaction } },
464
- { projectTrusted: options.projectTrusted },
465
- );
466
- const resourceLoader = dependencies.createResourceLoader({
510
+ agentsFilesOverride: (base) => ({
511
+ agentsFiles: base.agentsFiles.filter((file) => isWithin(canonicalPath(file.path), globalRoot)),
512
+ }),
513
+ };
514
+ }
515
+
516
+ const disposeWorkerSession = Effect.fn("WorkerSession.dispose")(function* (
517
+ scope: Scope.Closeable,
518
+ ) {
519
+ yield* Scope.close(scope, Exit.void).pipe(Effect.ignoreCause);
520
+ });
521
+
522
+ const acquireWorkerServices = Effect.fn("WorkerSession.acquireServices")(function* (
523
+ options: WorkerSessionFactoryOptions,
524
+ dependencies: WorkerSessionDependencies,
525
+ modelRuntime: ModelRuntime,
526
+ ) {
527
+ const definition = options.definition;
528
+ const settingsManager = yield* Effect.try({
529
+ try: () => dependencies.createSettingsManager({
530
+ cwd: options.cwd,
531
+ agentDir: options.agentDir,
532
+ projectTrusted: options.projectTrusted,
533
+ compaction: definition.compaction,
534
+ }),
535
+ catch: resourceAcquisitionError,
536
+ });
537
+ const extensionPaths = yield* Effect.tryPromise({
538
+ try: () => dependencies.resolveExtensionPaths({
539
+ cwd: options.cwd,
540
+ agentDir: options.agentDir,
541
+ settingsManager,
542
+ }),
543
+ catch: resourceAcquisitionError,
544
+ }).pipe(Effect.map((paths) => paths.filter((path) => !isOrchestrationExtensionPath(path))));
545
+
546
+ return yield* Effect.acquireRelease(
547
+ Effect.tryPromise({
548
+ // Pi's supported helper constructs and reloads its loader internally. If reload rejects,
549
+ // it exposes no loader to dispose; callers cannot make that acquisition failure-atomic.
550
+ try: () => dependencies.createServices({
467
551
  cwd: options.cwd,
468
552
  agentDir: options.agentDir,
469
- settingsManager: settingsManager as SettingsManager,
470
- noExtensions: true,
471
- noSkills: selectedSkills.size === 0,
472
- noPromptTemplates: true,
473
- noThemes: true,
474
- noContextFiles: !options.projectTrusted,
475
- skillsOverride: (base) => ({
476
- skills: base.skills.filter((skill) => selectedSkills.has(skill.name)),
477
- diagnostics: base.diagnostics,
478
- }),
479
- appendSystemPrompt: [definition.systemPrompt, DIRECT_CHILD_BOUNDARY],
480
- });
553
+ settingsManager,
554
+ modelRuntime,
555
+ resourceLoaderOptions: {
556
+ noExtensions: true,
557
+ additionalExtensionPaths: extensionPaths,
558
+ noPromptTemplates: true,
559
+ noThemes: true,
560
+ ...skillLoaderOptions(definition),
561
+ ...contextLoaderOptions(options.agentDir, options.projectTrusted),
562
+ appendSystemPrompt: [definition.systemPrompt, DIRECT_CHILD_BOUNDARY],
563
+ },
564
+ }),
565
+ catch: resourceAcquisitionError,
566
+ }),
567
+ (services) => resourceLoaderFinalizer(services.resourceLoader),
568
+ );
569
+ });
481
570
 
482
- let session: WorkerAgentSession | undefined;
483
- try {
484
- await resourceLoader.reload();
571
+ interface AgentSessionOwnership {
572
+ readonly session: WorkerAgentSession;
573
+ runtime: OwnedWorkerRuntime | undefined;
574
+ }
485
575
 
486
- const loadedSkillNames = new Set(
487
- resourceLoader.getSkills().skills.map((skill) => skill.name),
488
- );
489
- const missingSkills = [...selectedSkills].filter(
490
- (skillName) => !loadedSkillNames.has(skillName),
576
+ function rawSessionFinalizer(session: WorkerAgentSession): Effect.Effect<void> {
577
+ return Effect.sync(() => session.dispose()).pipe(Effect.ignoreCause);
578
+ }
579
+
580
+ function agentSessionFinalizer(ownership: AgentSessionOwnership): Effect.Effect<void> {
581
+ const runtime = ownership.runtime;
582
+ if (!runtime) return rawSessionFinalizer(ownership.session);
583
+ return Effect.tryPromise(() => runtime.dispose()).pipe(
584
+ Effect.catch(() => rawSessionFinalizer(ownership.session)),
585
+ Effect.ignoreCause,
586
+ );
587
+ }
588
+
589
+ const acquireAgentSession = Effect.fn("WorkerSession.acquireAgentSession")(function* (
590
+ dependencies: WorkerSessionDependencies,
591
+ input: AgentSessionInput,
592
+ ) {
593
+ return yield* Effect.acquireRelease(
594
+ Effect.tryPromise({
595
+ try: () => dependencies.createAgentSession(input),
596
+ catch: agentSessionAcquisitionError,
597
+ }).pipe(Effect.map(({ session }) => {
598
+ const ownership: AgentSessionOwnership = { session, runtime: undefined };
599
+ return ownership;
600
+ })),
601
+ agentSessionFinalizer,
602
+ );
603
+ });
604
+
605
+ const acquireAgentSessionRuntime = Effect.fn("WorkerSession.acquireAgentSessionRuntime")(function* (
606
+ dependencies: WorkerSessionDependencies,
607
+ ownership: AgentSessionOwnership,
608
+ services: AgentSessionServices,
609
+ ) {
610
+ const runtime = yield* Effect.try({
611
+ try: () => dependencies.createRuntime({ session: ownership.session, services }),
612
+ catch: agentSessionAcquisitionError,
613
+ });
614
+ ownership.runtime = runtime;
615
+ return runtime;
616
+ });
617
+
618
+ const acquireSessionSubscription = Effect.fn("WorkerSession.acquireSubscription")(function* (
619
+ runtime: OwnedWorkerRuntime,
620
+ handle: DefaultWorkerSessionHandle,
621
+ ) {
622
+ return yield* Effect.acquireRelease(
623
+ Effect.try({
624
+ try: () => runtime.session.subscribe((event) => handle.receiveSessionEvent(event)),
625
+ catch: agentSessionAcquisitionError,
626
+ }),
627
+ (unsubscribe) => Effect.sync(unsubscribe).pipe(Effect.ignoreCause),
628
+ );
629
+ });
630
+
631
+ const createWorkerSession = Effect.fn("WorkerSession.create")(function* (
632
+ options: WorkerSessionFactoryOptions,
633
+ dependencies: WorkerSessionDependencies,
634
+ ) {
635
+ const scope = yield* Scope.make("sequential");
636
+ const acquisition = Effect.gen(function* () {
637
+ const definition = options.definition;
638
+ const { selected, modelRuntime } = yield* prepareChildModelRuntime(options, dependencies);
639
+ const services = yield* acquireWorkerServices(options, dependencies, modelRuntime);
640
+
641
+ yield* refreshModelRuntime(modelRuntime, definition);
642
+ const model = yield* Effect.try({
643
+ try: () => {
644
+ const resolvedModel = modelRuntime.getModel(selected.provider, selected.modelId);
645
+ if (!resolvedModel) {
646
+ throw new Error(
647
+ `Worker "${definition.name}" configured model "${selected.provider}/${selected.modelId}" was not found`,
648
+ );
649
+ }
650
+ return resolvedModel;
651
+ },
652
+ catch: (cause) => modelAcquisitionError(cause, "Worker model resolution failed"),
653
+ });
654
+ const sessionManager = yield* Effect.try({
655
+ try: () => dependencies.createSessionManager({
656
+ cwd: options.cwd,
657
+ parentSessionFile: options.parentSessionFile,
658
+ }),
659
+ catch: agentSessionAcquisitionError,
660
+ });
661
+ const ownership = yield* acquireAgentSession(dependencies, {
662
+ services,
663
+ sessionManager,
664
+ model,
665
+ thinkingLevel: definition.thinking,
666
+ tools: [...definition.tools],
667
+ });
668
+ const runtime = yield* acquireAgentSessionRuntime(dependencies, ownership, services);
669
+ yield* Effect.tryPromise({
670
+ try: () => runtime.session.bindExtensions({ mode: "print" }),
671
+ catch: agentSessionAcquisitionError,
672
+ });
673
+
674
+ yield* Effect.try({
675
+ try: () => {
676
+ if (definition.skills === undefined) return;
677
+ const loadedNames = new Set(
678
+ services.resourceLoader.getSkills().skills.map((skill) => skill.name),
491
679
  );
492
- if (missingSkills.length > 0) {
680
+ const missing = definition.skills.filter((name) => !loadedNames.has(name));
681
+ if (missing.length > 0) {
493
682
  throw new Error(
494
- `Worker "${definition.name}" selected skills were not loaded: ${missingSkills.join(", ")}`,
683
+ `Worker "${definition.name}" selected skills were not loaded: ${missing.join(", ")}`,
495
684
  );
496
685
  }
497
-
498
- const sessionManager = dependencies.createSessionManager({
499
- cwd: options.cwd,
500
- parentSessionFile: options.parentSessionFile,
501
- });
502
- ({ session } = await dependencies.createAgentSession({
503
- cwd: options.cwd,
504
- agentDir: options.agentDir,
505
- model,
506
- modelRuntime,
507
- thinkingLevel: definition.thinking,
508
- tools: [...definition.tools],
509
- resourceLoader,
510
- sessionManager,
511
- settingsManager,
512
- }));
513
- if (!session.sessionFile) {
686
+ },
687
+ catch: resourceAcquisitionError,
688
+ });
689
+ const sessionFile = yield* Effect.try({
690
+ try: () => {
691
+ if (!runtime.session.sessionFile) {
514
692
  throw new Error("Worker session was not created with durable storage");
515
693
  }
694
+ return runtime.session.sessionFile;
695
+ },
696
+ catch: agentSessionAcquisitionError,
697
+ });
698
+
699
+ const handle = new DefaultWorkerSessionHandle(
700
+ runtime,
701
+ definition.lifecycle === "reusable",
702
+ sessionFile,
703
+ scope,
704
+ );
705
+ yield* acquireSessionSubscription(runtime, handle);
706
+ return handle;
707
+ }).pipe(Scope.provide(scope));
708
+
709
+ return yield* acquisition.pipe(
710
+ Effect.catchCause((cause) =>
711
+ Effect.gen(function* () {
712
+ yield* Scope.close(scope, Exit.failCause(cause)).pipe(Effect.ignoreCause);
713
+ return yield* Effect.failCause(cause);
714
+ })
715
+ ),
716
+ );
717
+ });
516
718
 
517
- const handle = new DefaultWorkerSessionHandle(
518
- session,
519
- resourceLoader,
520
- definition.lifecycle === "reusable",
521
- );
522
- session = undefined;
523
- return handle;
524
- } catch (error) {
525
- if (session) disposeSession(session);
526
- disposeResourceLoader(resourceLoader);
527
- throw error;
528
- }
529
- },
530
- };
719
+ export function createWorkerSessionFactory(
720
+ overrides: Partial<WorkerSessionDependencies> = {},
721
+ ): WorkerSessionFactory {
722
+ const dependencies: WorkerSessionDependencies = { ...defaultDependencies, ...overrides };
723
+ return { create: (options) => Effect.runPromise(createWorkerSession(options, dependencies)) };
531
724
  }