@zachwill/pi-orchestrate 0.9.0 → 0.9.2

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.
@@ -11,6 +11,12 @@ function sortedWorkers(catalog: WorkerCatalog) {
11
11
  });
12
12
  }
13
13
 
14
+ function escapeContractMarkers(value: string): string {
15
+ return value
16
+ .replaceAll(CONTRACT_START, "<!-- pi-orchestrate:contract:start -->")
17
+ .replaceAll(CONTRACT_END, "<!-- pi-orchestrate:contract:end -->");
18
+ }
19
+
14
20
  function formatCatalog(catalog: WorkerCatalog): string {
15
21
  const workers = sortedWorkers(catalog);
16
22
  if (workers.length === 0) return "- No trusted workers are available for this session.";
@@ -18,11 +24,74 @@ function formatCatalog(catalog: WorkerCatalog): string {
18
24
  return workers
19
25
  .map(
20
26
  (worker) =>
21
- `- \`${worker.name}\` [${worker.source.kind}] (${worker.lifecycle}): ${worker.description}`,
27
+ `- \`${escapeContractMarkers(worker.name)}\` [${worker.source.kind}] (${worker.lifecycle}): ${escapeContractMarkers(worker.description)}`,
22
28
  )
23
29
  .join("\n");
24
30
  }
25
31
 
32
+ interface ContractMarker {
33
+ readonly start: number;
34
+ readonly end: number;
35
+ readonly kind: "start" | "end";
36
+ }
37
+
38
+ function contractMarkers(prompt: string): ContractMarker[] {
39
+ const markers: ContractMarker[] = [];
40
+ for (const [value, kind] of [
41
+ [CONTRACT_START, "start"],
42
+ [CONTRACT_END, "end"],
43
+ ] as const) {
44
+ let offset = 0;
45
+ while (offset < prompt.length) {
46
+ const start = prompt.indexOf(value, offset);
47
+ if (start < 0) break;
48
+ markers.push({ start, end: start + value.length, kind });
49
+ offset = start + value.length;
50
+ }
51
+ }
52
+ return markers.sort((left, right) => left.start - right.start);
53
+ }
54
+
55
+ function removeContractMarkers(prompt: string): {
56
+ readonly prompt: string;
57
+ readonly insertionOffset?: number;
58
+ } {
59
+ const markers = contractMarkers(prompt);
60
+ if (markers.length === 0) return { prompt };
61
+
62
+ const removed: Array<{ start: number; end: number }> = [];
63
+ const stack: ContractMarker[] = [];
64
+ for (const marker of markers) {
65
+ if (marker.kind === "start") {
66
+ stack.push(marker);
67
+ continue;
68
+ }
69
+ const start = stack.pop();
70
+ if (start && stack.length === 0) removed.push({ start: start.start, end: marker.end });
71
+ }
72
+
73
+ for (const marker of markers) {
74
+ if (!removed.some((range) => marker.start >= range.start && marker.end <= range.end)) {
75
+ removed.push({ start: marker.start, end: marker.end });
76
+ }
77
+ }
78
+ removed.sort((left, right) => left.start - right.start);
79
+
80
+ const insertionPoint = markers[0]!.start;
81
+ let insertionOffset = 0;
82
+ let cursor = 0;
83
+ let cleaned = "";
84
+ for (const range of removed) {
85
+ if (range.start < cursor) continue;
86
+ const retained = prompt.slice(cursor, range.start);
87
+ cleaned += retained;
88
+ if (range.start <= insertionPoint) insertionOffset = cleaned.length;
89
+ cursor = range.end;
90
+ }
91
+ cleaned += prompt.slice(cursor);
92
+ return { prompt: cleaned, insertionOffset };
93
+ }
94
+
26
95
  function buildContract(catalog: WorkerCatalog): string {
27
96
  return `${CONTRACT_START}
28
97
  ## Pi Orchestrate Contract
@@ -60,12 +129,9 @@ export function appendOrchestratorContract(
60
129
  catalog: WorkerCatalog,
61
130
  ): string {
62
131
  const section = buildContract(catalog);
63
- const start = systemPrompt.indexOf(CONTRACT_START);
64
- if (start >= 0) {
65
- const end = systemPrompt.indexOf(CONTRACT_END, start);
66
- if (end >= 0) {
67
- return `${systemPrompt.slice(0, start)}${section}${systemPrompt.slice(end + CONTRACT_END.length)}`;
68
- }
132
+ const cleaned = removeContractMarkers(systemPrompt);
133
+ if (cleaned.insertionOffset !== undefined) {
134
+ return `${cleaned.prompt.slice(0, cleaned.insertionOffset)}${section}${cleaned.prompt.slice(cleaned.insertionOffset)}`;
69
135
  }
70
136
 
71
137
  const separator =
@@ -67,6 +67,8 @@ export class DeliveryCoordinator implements DeliveryService {
67
67
  private readonly pendingSettlements: WorkerSettlement[] = [];
68
68
  private readonly flushingOwners = new Set<string>();
69
69
  private readonly synthesisGroups = new Map<string, SynthesisGroupState>();
70
+ // Runtime settlement sequences are process-scoped and monotonic across owners,
71
+ // so one watermark is valid.
70
72
  private highestAcceptedSequence = 0;
71
73
 
72
74
  bind(binding: ParentBinding): void {
@@ -83,15 +85,14 @@ export class DeliveryCoordinator implements DeliveryService {
83
85
  }
84
86
 
85
87
  markAgentStarted(ownerSessionId: string, generation: ParentBindingGeneration): void {
86
- if (!this.matchesBinding(ownerSessionId, generation)) return;
87
88
  const parent = this.boundParents.get(ownerSessionId);
88
- if (parent) parent.agentRunning = true;
89
+ if (parent?.binding.generation !== generation) return;
90
+ parent.agentRunning = true;
89
91
  }
90
92
 
91
93
  markAgentSettled(ownerSessionId: string, generation: ParentBindingGeneration): void {
92
- if (!this.matchesBinding(ownerSessionId, generation)) return;
93
94
  const parent = this.boundParents.get(ownerSessionId);
94
- if (!parent) return;
95
+ if (parent?.binding.generation !== generation) return;
95
96
  parent.agentRunning = false;
96
97
  this.flush(ownerSessionId, generation);
97
98
  }
@@ -163,21 +164,23 @@ export class DeliveryCoordinator implements DeliveryService {
163
164
 
164
165
  this.flushingOwners.add(ownerSessionId);
165
166
  try {
167
+ // Deliver a stable owner-ordered prefix, stopping at the latest complete synthesis boundary.
166
168
  const queued = this.pendingSettlements.filter(
167
169
  (settlement) => settlement.ownerSessionId === ownerSessionId,
168
170
  );
169
171
  let latestFinalIndex = -1;
170
- for (let index = 0; index < queued.length; index += 1) {
171
- const settlement = queued[index];
172
- if (settlement && this.isFinalBoundary(settlement)) latestFinalIndex = index;
172
+ for (const [index, settlement] of queued.entries()) {
173
+ if (this.isFinalBoundary(settlement)) latestFinalIndex = index;
173
174
  }
174
175
  const flushThrough = latestFinalIndex >= 0 ? latestFinalIndex : queued.length - 1;
175
176
  let flushBytesRemaining = MAX_DELIVERY_MARKDOWN_BYTES;
176
177
 
177
- for (let index = 0; index <= flushThrough; index += 1) {
178
+ for (const [index, settlement] of queued.entries()) {
179
+ if (index > flushThrough) break;
180
+ // Synchronous delivery callbacks can change owner, generation, or idle
181
+ // state before the next send.
178
182
  if (!this.canDeliver(ownerSessionId, generation)) return;
179
- const settlement = queued[index];
180
- if (!settlement || !this.pendingSettlements.includes(settlement)) continue;
183
+ if (!this.pendingSettlements.includes(settlement)) continue;
181
184
 
182
185
  const messagesRemaining = flushThrough - index + 1;
183
186
  const fairFlushBytes = Math.floor(flushBytesRemaining / messagesRemaining);
@@ -186,6 +189,8 @@ export class DeliveryCoordinator implements DeliveryService {
186
189
  fairFlushBytes,
187
190
  flushBytesRemaining,
188
191
  ));
192
+ // Intermediate results add context; only the completed boundary
193
+ // transfers work to a parent turn.
189
194
  const triggerTurn = latestFinalIndex >= 0 && index === flushThrough;
190
195
  const message = this.renderWorkerMessage(settlement, byteLimit);
191
196
  const parent = this.boundParents.get(ownerSessionId);
@@ -194,6 +199,7 @@ export class DeliveryCoordinator implements DeliveryService {
194
199
  try {
195
200
  parent.binding.sendMessage(message, { triggerTurn });
196
201
  } catch {
202
+ // Keep this settlement and the remaining prefix queued for a later retry.
197
203
  return;
198
204
  }
199
205
 
package/extension/host.ts CHANGED
@@ -79,7 +79,7 @@ interface AttachmentAwareProcessHost extends ProcessHost {
79
79
  attachments?: Set<ProcessHostAttachment>;
80
80
  }
81
81
 
82
- type ProcessHostLifecycle = "active" | "destroying" | "destroyed";
82
+ type ProcessHostLifecycle = "destroying" | "destroyed";
83
83
 
84
84
  interface OwnedProcessHost extends AttachmentAwareProcessHost {
85
85
  readonly effectRuntime?: ManagedRuntime.ManagedRuntime<Orchestration | Delivery, never>;
@@ -254,11 +254,9 @@ export function createProcessHost(): ProcessHost {
254
254
  throw new Error("Cannot create a process host while the current host is being destroyed");
255
255
  }
256
256
  if (existing?.lifecycle === "destroyed") {
257
- delete global[PROCESS_HOST_KEY];
258
- } else if (existing) {
259
- existing.lifecycle = "active";
260
- return existing;
257
+ throw new Error("Cannot create a process host after the current host was destroyed");
261
258
  }
259
+ if (existing) return existing;
262
260
 
263
261
  const effectRuntime = ManagedRuntime.make(createProcessApplicationLayer());
264
262
  const runtime = createProcessHostRuntimeAdapter(effectRuntime);
@@ -268,7 +266,6 @@ export function createProcessHost(): ProcessHost {
268
266
  delivery,
269
267
  effectRuntime,
270
268
  attachments: new Set(),
271
- lifecycle: "active",
272
269
  };
273
270
  global[PROCESS_HOST_KEY] = host;
274
271
  return host;
@@ -304,7 +301,6 @@ export function destroyProcessHost(
304
301
  const ownedHost = host as OwnedProcessHost;
305
302
  if (ownedHost.destroyPromise) return ownedHost.destroyPromise;
306
303
  if ((ownedHost.attachments?.size ?? 0) > 0) return Promise.resolve();
307
- if (ownedHost.lifecycle === "destroyed") return Promise.resolve();
308
304
 
309
305
  ownedHost.lifecycle = "destroying";
310
306
  let resolveDestruction!: () => void;
@@ -57,7 +57,6 @@ export function createOrchestrationExtension(
57
57
  let host: ProcessHost | undefined;
58
58
  let hostAttachment: ProcessHostAttachment | undefined;
59
59
  let statusController: StatusController | undefined;
60
- let toolsRegistered = false;
61
60
  let activeBinding: OwnerBinding | undefined;
62
61
  let cachedCatalog: WorkerCatalog | undefined;
63
62
 
@@ -76,27 +75,16 @@ export function createOrchestrationExtension(
76
75
  registerOrchestrationPresentation(pi);
77
76
 
78
77
  pi.on("session_start", (_event, ctx) => {
79
- if (activeBinding && host && statusController) {
80
- host.delivery.unbind(
81
- activeBinding.ownerSessionId,
82
- activeBinding.generation,
83
- );
84
- statusController.unbind(activeBinding.ownerSessionId);
85
- }
86
-
87
78
  host ??= dependencies.getHost?.() ?? createProcessHost();
88
79
  statusController ??=
89
80
  dependencies.createStatusController?.(host.runtime) ??
90
81
  createStatusController(host.runtime);
91
- if (!toolsRegistered) {
92
- registerOrchestrationTools(pi, {
93
- runtime: host.runtime,
94
- getCatalog: catalogFor,
95
- getDispatchDecision: (toolCallId) =>
96
- dispatchDecisions.get(toolCallId) ?? { mode: "inline" },
97
- });
98
- toolsRegistered = true;
99
- }
82
+ registerOrchestrationTools(pi, {
83
+ runtime: host.runtime,
84
+ getCatalog: catalogFor,
85
+ getDispatchDecision: (toolCallId) =>
86
+ dispatchDecisions.get(toolCallId) ?? { mode: "inline" },
87
+ });
100
88
  hostAttachment ??= attachProcessHost(host);
101
89
 
102
90
  dispatchDecisions.clear();
@@ -132,6 +120,8 @@ export function createOrchestrationExtension(
132
120
  );
133
121
  const ownerSessionId = activeBinding?.ownerSessionId;
134
122
  if (!ownerSessionId) return;
123
+ // Sole dispatches and homogeneous orchestrate waves detach; mixed tools stay with the
124
+ // current parent turn, while a wave shares one boundary for one later synthesis turn.
135
125
  const isOrchestrateGroup =
136
126
  toolCalls.length > 1 &&
137
127
  toolCalls.every((toolCall) => toolCall.name === "orchestrate");
@@ -83,8 +83,6 @@ export class StatusController {
83
83
  private disposed = false;
84
84
  private unsubscribeState: (() => void) | undefined;
85
85
  private widget: WorkerStatusComponent | undefined;
86
- private widgetInstalled = false;
87
- private pendingSnapshot: RuntimeSnapshot | undefined;
88
86
 
89
87
  constructor(private readonly runtime: PresentationRuntime) {}
90
88
 
@@ -114,36 +112,35 @@ export class StatusController {
114
112
  ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, formatFooterStatus(snapshot));
115
113
  if (ctx.mode !== "tui") return;
116
114
  const active = activeWorkers(snapshot);
117
- this.pendingSnapshot = snapshot;
118
115
  if (active.length === 0) {
119
- if (this.widgetInstalled) ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
120
- this.widget = undefined;
121
- this.widgetInstalled = false;
116
+ if (this.widget !== undefined) {
117
+ ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
118
+ this.widget = undefined;
119
+ }
122
120
  return;
123
121
  }
124
- if (this.widget) {
122
+ if (this.widget !== undefined) {
125
123
  this.widget.update(snapshot);
126
124
  return;
127
125
  }
128
- if (this.widgetInstalled) return;
129
126
  ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, (tui, theme) => {
130
- this.widget = new WorkerStatusComponent(this.pendingSnapshot ?? snapshot, theme, tui);
131
- return this.widget;
127
+ const widget = new WorkerStatusComponent(snapshot, theme, tui);
128
+ this.widget = widget;
129
+ return widget;
132
130
  }, { placement: "aboveEditor" });
133
- this.widgetInstalled = true;
134
131
  }
135
132
 
136
133
  private clearBinding(): void {
137
134
  const unsubscribeState = this.unsubscribeState;
138
135
  this.unsubscribeState = undefined;
139
136
  unsubscribeState?.();
140
- this.widget = undefined;
141
- this.pendingSnapshot = undefined;
142
137
  const current = this.binding;
143
138
  if (!current) return;
144
139
  current.ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, undefined);
145
- if (current.ctx.mode === "tui" && this.widgetInstalled) current.ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
146
- this.widgetInstalled = false;
140
+ if (current.ctx.mode === "tui" && this.widget !== undefined) {
141
+ current.ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
142
+ this.widget = undefined;
143
+ }
147
144
  this.binding = undefined;
148
145
  }
149
146
  }
@@ -205,6 +205,7 @@ interface RuntimeWorker {
205
205
  readonly record: WorkerRecord;
206
206
  readonly context: OrchestrationContext;
207
207
  readonly definition: WorkerDefinition;
208
+ // Worker-local authority fence; stale asynchronous callbacks must revalidate it.
208
209
  readonly generation: number;
209
210
  readonly session?: WorkerSessionHandle;
210
211
  readonly observationRelease?: () => void;
@@ -271,9 +272,6 @@ class StatefulOrchestration implements OrchestrationService {
271
272
  effect: Effect.Effect<void, never>,
272
273
  ) => Fiber.Fiber<void, never>,
273
274
  private readonly cleanups: FiberSet.FiberSet<void, never>,
274
- private readonly runCleanup: (
275
- effect: Effect.Effect<void, never>,
276
- ) => Fiber.Fiber<void, never>,
277
275
  private readonly clock: Clock.Clock,
278
276
  private readonly idFactories: OrchestrateIdFactories,
279
277
  private readonly shutdownCompletion: Deferred.Deferred<void>,
@@ -429,34 +427,16 @@ class StatefulOrchestration implements OrchestrationService {
429
427
  };
430
428
 
431
429
  const admission = this.transact((draft) => {
432
- const open = openDecision(draft, "sendInteractive");
433
- if (open._tag === "rejected") return { value: open };
434
- const ownership = ownedWorkerDecision(
430
+ const ready = readyInteractiveDecision(
435
431
  draft,
436
- "sendInteractive",
437
432
  context.ownerSessionId,
438
433
  validatedWorkerId,
439
434
  );
440
- if (ownership._tag === "rejected") return { value: ownership };
441
-
442
- const worker = ownership.value;
443
- if (
444
- worker.record.lifecycle !== "interactive" ||
445
- worker.record.status !== "ready" ||
446
- !worker.session
447
- ) {
448
- return {
449
- value: rejected(
450
- "sendInteractive",
451
- "worker-state",
452
- "interactive_send requires an owned ready interactive worker",
453
- ),
454
- };
455
- }
435
+ if (ready._tag === "rejected") return { value: ready };
456
436
  if (draft.runs.has(runId)) throw new Error(`Duplicate run ID: ${runId}`);
457
437
 
438
+ const { worker, session } = ready.value;
458
439
  const generation = worker.generation + 1;
459
- const session = worker.session;
460
440
  const runningRecord: WorkerRecord = {
461
441
  ...transitionWorkerStatus(worker.record, "running"),
462
442
  runId,
@@ -822,6 +802,7 @@ class StatefulOrchestration implements OrchestrationService {
822
802
  ),
823
803
  );
824
804
  const exit = fiber.pollUnsafe();
805
+ // A closed FiberMap rejects admission with an interrupted sentinel.
825
806
  if (exit && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
826
807
  this.settleGenerationLaunchFailure(workerId, generation);
827
808
  }
@@ -1261,6 +1242,8 @@ class StatefulOrchestration implements OrchestrationService {
1261
1242
  });
1262
1243
  }
1263
1244
 
1245
+ // Commit stopping and one shared completion before post-commit physical abort
1246
+ // and generation interruption; every cancellation caller joins it.
1264
1247
  private markWorkersStopping(
1265
1248
  draft: RuntimeState,
1266
1249
  workers: readonly RuntimeWorker[],
@@ -1305,6 +1288,7 @@ class StatefulOrchestration implements OrchestrationService {
1305
1288
  ): void {
1306
1289
  const fiber = this.runCancellation(this.cancelWorker(workerId, completion));
1307
1290
  const exit = fiber.pollUnsafe();
1291
+ // A closed FiberSet rejects admission with an interrupted sentinel.
1308
1292
  if (!exit || Exit.isSuccess(exit) || !Cause.hasInterruptsOnly(exit.cause)) return;
1309
1293
 
1310
1294
  const worker = this.current().workers.get(workerId);
@@ -1461,7 +1445,7 @@ class StatefulOrchestration implements OrchestrationService {
1461
1445
  const session = worker.session;
1462
1446
  if (session) {
1463
1447
  actions.push(runAction(() => {
1464
- this.launchCleanupOrJoinAfterClosure(
1448
+ this.launchCleanup(
1465
1449
  Effect.suspend(() => session.dispose()).pipe(
1466
1450
  Effect.catchCause(() => Effect.void),
1467
1451
  Effect.uninterruptible,
@@ -1472,19 +1456,9 @@ class StatefulOrchestration implements OrchestrationService {
1472
1456
  return actions;
1473
1457
  }
1474
1458
 
1475
- private launchCleanupOrJoinAfterClosure(cleanup: Effect.Effect<void>): void {
1476
- if (this.cleanups.state._tag === "Closed") {
1477
- Effect.runFork(cleanup);
1478
- return;
1479
- }
1480
- const fiber = this.runCleanup(cleanup);
1481
- const exit = fiber.pollUnsafe();
1482
- if (exit && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
1483
- // FiberSet.runtime returns an already-interrupted sentinel when closure
1484
- // wins admission. If admission won and closure interrupted the real fiber,
1485
- // repeating the cached uninterruptible disposal only joins that cleanup.
1486
- Effect.runFork(cleanup);
1487
- }
1459
+ private launchCleanup(cleanup: Effect.Effect<void>): void {
1460
+ const fiber = Effect.runFork(cleanup);
1461
+ FiberSet.addUnsafe(this.cleanups, fiber);
1488
1462
  }
1489
1463
 
1490
1464
  private preflightOpen(
@@ -1500,30 +1474,12 @@ class StatefulOrchestration implements OrchestrationService {
1500
1474
  workerId: WorkerId,
1501
1475
  ): Decision<void> {
1502
1476
  return this.transact((draft) => {
1503
- const open = openDecision(draft, "sendInteractive");
1504
- if (open._tag === "rejected") return { value: open };
1505
- const ownership = ownedWorkerDecision(
1506
- draft,
1507
- "sendInteractive",
1508
- ownerSessionId,
1509
- workerId,
1510
- );
1511
- if (ownership._tag === "rejected") return { value: ownership };
1512
- const worker = ownership.value;
1513
- if (
1514
- worker.record.lifecycle !== "interactive" ||
1515
- worker.record.status !== "ready" ||
1516
- !worker.session
1517
- ) {
1518
- return {
1519
- value: rejected(
1520
- "sendInteractive",
1521
- "worker-state",
1522
- "interactive_send requires an owned ready interactive worker",
1523
- ),
1524
- };
1525
- }
1526
- return { value: accepted(undefined) };
1477
+ const ready = readyInteractiveDecision(draft, ownerSessionId, workerId);
1478
+ return {
1479
+ value: ready._tag === "accepted"
1480
+ ? accepted(undefined)
1481
+ : ready,
1482
+ };
1527
1483
  });
1528
1484
  }
1529
1485
 
@@ -1536,6 +1492,7 @@ class StatefulOrchestration implements OrchestrationService {
1536
1492
  ): A {
1537
1493
  const draft = makeDraft(this.state);
1538
1494
  const mutation = reducer(draft);
1495
+ // Commit before action factories capture the snapshot and callbacks can run.
1539
1496
  this.state = Object.freeze(draft);
1540
1497
  this.enqueueActions((mutation.actions ?? []).map((action) => action(
1541
1498
  this.state,
@@ -1546,6 +1503,7 @@ class StatefulOrchestration implements OrchestrationService {
1546
1503
  }
1547
1504
 
1548
1505
  private enqueueActions(actions: readonly CommittedAction[]): void {
1506
+ // Reentrant actions queue behind this drain; drain all before rethrowing the first failure.
1549
1507
  this.actionQueue.push(...actions);
1550
1508
  if (this.drainingActions) return;
1551
1509
 
@@ -1580,7 +1538,6 @@ export function orchestrationLayer(
1580
1538
  // Scope finalizers run in reverse acquisition order. Keep cleanup open while
1581
1539
  // generation and cancellation interruption settle workers and enqueue disposal.
1582
1540
  const cleanups = yield* FiberSet.make<void, never>();
1583
- const runCleanup = yield* FiberSet.runtime(cleanups)<never>();
1584
1541
  const cancellations = yield* FiberSet.make<void, never>();
1585
1542
  const runCancellation = yield* FiberSet.runtime(cancellations)<never>();
1586
1543
  const generations = yield* FiberMap.make<WorkerId, void, never>();
@@ -1594,7 +1551,6 @@ export function orchestrationLayer(
1594
1551
  cancellations,
1595
1552
  runCancellation,
1596
1553
  cleanups,
1597
- runCleanup,
1598
1554
  clock,
1599
1555
  options.idFactories ?? createRandomIdFactories(),
1600
1556
  shutdownCompletion,
@@ -1875,6 +1831,38 @@ function ownedWorkerDecision(
1875
1831
  : accepted(worker);
1876
1832
  }
1877
1833
 
1834
+ function readyInteractiveDecision(
1835
+ draft: RuntimeState,
1836
+ ownerSessionId: string,
1837
+ workerId: WorkerId,
1838
+ ): Decision<{
1839
+ readonly worker: RuntimeWorker;
1840
+ readonly session: WorkerSessionHandle;
1841
+ }> {
1842
+ const open = openDecision(draft, "sendInteractive");
1843
+ if (open._tag === "rejected") return open;
1844
+ const ownership = ownedWorkerDecision(
1845
+ draft,
1846
+ "sendInteractive",
1847
+ ownerSessionId,
1848
+ workerId,
1849
+ );
1850
+ if (ownership._tag === "rejected") return ownership;
1851
+ const worker = ownership.value;
1852
+ if (
1853
+ worker.record.lifecycle !== "interactive" ||
1854
+ worker.record.status !== "ready" ||
1855
+ !worker.session
1856
+ ) {
1857
+ return rejected(
1858
+ "sendInteractive",
1859
+ "worker-state",
1860
+ "interactive_send requires an owned ready interactive worker",
1861
+ );
1862
+ }
1863
+ return accepted({ worker, session: worker.session });
1864
+ }
1865
+
1878
1866
  function accepted<A>(value: A): Decision<A> {
1879
1867
  return { _tag: "accepted", value };
1880
1868
  }
@@ -162,7 +162,14 @@ export function registerOrchestrationTools(
162
162
  return renderDispatchCall(theme, args, expanded);
163
163
  },
164
164
  renderResult(result, { isPartial, expanded }, theme, context) {
165
- return renderOrchestrationResult(result, isPartial, expanded, theme, context.lastComponent);
165
+ return renderOrchestrationResult(
166
+ result,
167
+ isPartial,
168
+ expanded,
169
+ theme,
170
+ context.isError,
171
+ context.lastComponent,
172
+ );
166
173
  },
167
174
  async execute(toolCallId, params, signal, onUpdate, ctx) {
168
175
  const decision = deps.getDispatchDecision(toolCallId);
@@ -210,8 +217,8 @@ export function registerOrchestrationTools(
210
217
  renderCall(_args, theme) {
211
218
  return new Text(theme.fg("toolTitle", theme.bold("worker_status")), 0, 0);
212
219
  },
213
- renderResult(result, { isPartial }, theme) {
214
- return renderDiagnosticsResult(result, isPartial, theme);
220
+ renderResult(result, { isPartial }, theme, context) {
221
+ return renderDiagnosticsResult(result, isPartial, context.isError, theme);
215
222
  },
216
223
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
217
224
  const ownerSessionId = ctx.sessionManager.getSessionId();
@@ -236,10 +243,24 @@ export function registerOrchestrationTools(
236
243
  ],
237
244
  parameters: interactiveSendSchema,
238
245
  renderCall(args, theme, { expanded }) {
239
- return renderInteractiveMessageCall(theme, "interactive_send", args.worker_id, args.instructions, expanded);
246
+ const fields = recordFields(args);
247
+ return renderInteractiveMessageCall(
248
+ theme,
249
+ "interactive_send",
250
+ fields.worker_id,
251
+ fields.instructions,
252
+ expanded,
253
+ );
240
254
  },
241
255
  renderResult(result, { isPartial, expanded }, theme, context) {
242
- return renderOrchestrationResult(result, isPartial, expanded, theme, context.lastComponent);
256
+ return renderOrchestrationResult(
257
+ result,
258
+ isPartial,
259
+ expanded,
260
+ theme,
261
+ context.isError,
262
+ context.lastComponent,
263
+ );
243
264
  },
244
265
  async execute(toolCallId, params, signal, onUpdate, ctx) {
245
266
  const workerId = params.worker_id;
@@ -287,13 +308,23 @@ export function registerOrchestrationTools(
287
308
  ],
288
309
  parameters: workerAbortSchema,
289
310
  renderCall(args, theme) {
290
- const target = "worker_ids" in args
291
- ? `${args.worker_ids.length} worker${args.worker_ids.length === 1 ? "" : "s"}`
292
- : "all workers";
311
+ const fields = recordFields(args);
312
+ const workerIds = Array.isArray(fields.worker_ids)
313
+ ? fields.worker_ids
314
+ : undefined;
315
+ const target = workerIds
316
+ ? `${workerIds.length} worker${workerIds.length === 1 ? "" : "s"}`
317
+ : fields.all === true ? "all workers" : "";
293
318
  return renderCompactCall(theme, "worker_abort", target);
294
319
  },
295
- renderResult(result, { isPartial }, theme) {
296
- return renderSimpleResult(result, isPartial ? "Requesting worker stop…" : "Worker stop requested", theme, "warning");
320
+ renderResult(result, { isPartial }, theme, context) {
321
+ return renderSimpleResult(
322
+ result,
323
+ context.isError,
324
+ isPartial ? "Requesting worker stop…" : "Worker stop requested",
325
+ theme,
326
+ "warning",
327
+ );
297
328
  },
298
329
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
299
330
  const ownerSessionId = ctx.sessionManager.getSessionId();
@@ -318,10 +349,16 @@ export function registerOrchestrationTools(
318
349
  ],
319
350
  parameters: interactiveCloseSchema,
320
351
  renderCall(args, theme) {
321
- return renderCompactCall(theme, "interactive_close", args.worker_id);
352
+ const fields = recordFields(args);
353
+ return renderCompactCall(theme, "interactive_close", fields.worker_id);
322
354
  },
323
- renderResult(result, { isPartial }, theme) {
324
- return renderSimpleResult(result, isPartial ? "Closing worker…" : "✓ Worker closed", theme);
355
+ renderResult(result, { isPartial }, theme, context) {
356
+ return renderSimpleResult(
357
+ result,
358
+ context.isError,
359
+ isPartial ? "Closing worker…" : "✓ Worker closed",
360
+ theme,
361
+ );
325
362
  },
326
363
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
327
364
  const ownerSessionId = ctx.sessionManager.getSessionId();
@@ -531,31 +568,26 @@ function readableDetails(title: string, details: unknown): string {
531
568
  return `${truncation.content}\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}. Full structured details remain available.]`;
532
569
  }
533
570
 
534
- interface RenderableTask {
535
- readonly worker?: unknown;
536
- readonly title?: unknown;
537
- readonly instructions?: unknown;
538
- }
539
-
540
571
  function renderDispatchCall(
541
572
  theme: Theme,
542
- task: RenderableTask,
573
+ task: unknown,
543
574
  expanded: boolean,
544
575
  ): Component {
576
+ const fields = isRecord(task) ? task : {};
545
577
  const container = new Container();
546
578
  container.addChild(new Text(
547
- theme.fg("toolTitle", theme.bold("orchestrate ")) + theme.fg("muted", safeTerminalText(task.worker)),
579
+ theme.fg("toolTitle", theme.bold("orchestrate ")) + theme.fg("muted", safeTerminalText(fields.worker)),
548
580
  0, 0,
549
581
  ));
550
582
  container.addChild(new Text(
551
- `${theme.fg("accent", "→")} ${theme.fg("text", theme.bold(safeTerminalText(task.title)))}`,
583
+ `${theme.fg("accent", "→")} ${theme.fg("text", theme.bold(safeTerminalText(fields.title)))}`,
552
584
  0, 0,
553
585
  ));
554
586
  if (expanded) {
555
- container.addChild(new Text(safeTerminalText(task.instructions), 2, 0));
587
+ container.addChild(new Text(safeTerminalText(fields.instructions), 2, 0));
556
588
  return new WidthBoundComponent(container);
557
589
  }
558
- container.addChild(new InstructionPreview(task.instructions, theme));
590
+ container.addChild(new InstructionPreview(fields.instructions, theme));
559
591
  container.addChild(new Text(theme.fg("dim", keyHint("app.tools.expand", "to inspect full instructions")), 0, 0));
560
592
  return new WidthBoundComponent(container);
561
593
  }
@@ -642,8 +674,18 @@ function renderOrchestrationResult(
642
674
  isPartial: boolean,
643
675
  expanded: boolean,
644
676
  theme: Theme,
677
+ isError: boolean,
645
678
  lastComponent: unknown,
646
679
  ): Component {
680
+ if (isError) {
681
+ return new WidthBoundComponent(renderSimpleResult(
682
+ result,
683
+ true,
684
+ firstResultLine(result) || "Worker operation failed",
685
+ theme,
686
+ "warning",
687
+ ));
688
+ }
647
689
  const details = result.details;
648
690
  if (Result.isSuccess(decodeAcceptedRunRenderDetails(details))) {
649
691
  return new WidthBoundComponent(new Text(theme.fg("success", "Sent to worker") + theme.fg("dim", " · response arrives when complete"), 0, 0));
@@ -660,7 +702,13 @@ function renderOrchestrationResult(
660
702
  return new WidthBoundComponent(new Text(theme.fg("warning", "Worker result details unavailable"), 0, 0));
661
703
  }
662
704
  if (isPartial) return new WidthBoundComponent(new Text(theme.fg("warning", "Sending work…"), 0, 0));
663
- return new WidthBoundComponent(renderSimpleResult(result, firstResultLine(result) || "Work sent", theme, "warning"));
705
+ return new WidthBoundComponent(renderSimpleResult(
706
+ result,
707
+ false,
708
+ firstResultLine(result) || "Work sent",
709
+ theme,
710
+ "warning",
711
+ ));
664
712
  }
665
713
 
666
714
  interface RenderedInlineSettlement {
@@ -736,7 +784,20 @@ function readInlineResult(details: unknown): RenderedInlineSettlement | undefine
736
784
  };
737
785
  }
738
786
 
739
- function renderDiagnosticsResult(result: AgentToolResult<unknown>, isPartial: boolean, theme: Theme): Text {
787
+ function renderDiagnosticsResult(
788
+ result: AgentToolResult<unknown>,
789
+ isPartial: boolean,
790
+ isError: boolean,
791
+ theme: Theme,
792
+ ): Text {
793
+ if (isError) {
794
+ return renderSimpleResult(
795
+ result,
796
+ true,
797
+ firstResultLine(result) || "Worker diagnostics failed",
798
+ theme,
799
+ );
800
+ }
740
801
  if (isPartial) return new Text(theme.fg("muted", "Reading worker diagnostics…"), 0, 0);
741
802
  const details = result.details;
742
803
  if (isRecord(details) && isRecord(details.state) && Array.isArray(details.state.workers)) {
@@ -752,12 +813,13 @@ function renderDiagnosticsResult(result: AgentToolResult<unknown>, isPartial: bo
752
813
 
753
814
  function renderSimpleResult(
754
815
  result: AgentToolResult<unknown>,
816
+ isError: boolean,
755
817
  message: string,
756
818
  theme: Theme,
757
819
  normalColor: "success" | "warning" = "success",
758
820
  ): Text {
759
- const failed = "isError" in result && result.isError === true;
760
- return new Text(theme.fg(failed ? "error" : normalColor, failed ? firstResultLine(result) || message : message), 0, 0);
821
+ const text = isError ? firstResultLine(result) || message : message;
822
+ return new Text(theme.fg(isError ? "error" : normalColor, text), 0, 0);
761
823
  }
762
824
 
763
825
  function firstResultLine(result: AgentToolResult<unknown>): string | undefined {
@@ -766,6 +828,10 @@ function firstResultLine(result: AgentToolResult<unknown>): string | undefined {
766
828
  return first.text.split("\n").find((line) => line.trim())?.trim();
767
829
  }
768
830
 
831
+ function recordFields(value: unknown): Record<string, unknown> {
832
+ return isRecord(value) ? value : {};
833
+ }
834
+
769
835
  function isRecord(value: unknown): value is Record<string, unknown> {
770
836
  return typeof value === "object" && value !== null;
771
837
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zachwill/pi-orchestrate",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "description": "Concurrent worker orchestration for Pi",
6
6
  "files": ["extension/", "examples/", "README.md", "LICENSE"],