@zachwill/pi-orchestrate 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,25 @@
1
- import type { CompletedWave } from "./runtime.js";
1
+ import type { WorkerSettlement } from "./runtime.js";
2
2
 
3
3
  export const MAX_DELIVERY_MARKDOWN_BYTES = 50 * 1024;
4
+ export const MAX_WORKER_DELIVERY_MARKDOWN_BYTES = 16 * 1024;
4
5
  export const DELIVERY_TRUNCATION_MARKER =
5
- "\n\n[Worker results truncated at 50KB. Full structured results remain available.]";
6
+ "\n\n[Worker result truncated for parent context. Full output remains in structured details.]";
6
7
  export const DELIVERY_PARENT_INSTRUCTIONS =
7
8
  "Parent: Synthesize all results, resolve conflicts, review changes and evidence, run integration checks, and continue the user's task. Do not merely forward worker reports.";
8
9
 
9
10
  export type ParentBindingGeneration = string | number | symbol;
10
11
 
11
- export interface WaveDeliveryDetails {
12
- readonly id: CompletedWave["id"];
13
- readonly ownerSessionId: CompletedWave["ownerSessionId"];
14
- readonly mode: CompletedWave["mode"];
15
- readonly results: CompletedWave["results"];
16
- }
12
+ /** Complete, immutable worker output for presentation and history consumers. */
13
+ export type WorkerDeliveryDetails = WorkerSettlement;
17
14
 
18
- export interface WaveDeliveryMessage {
19
- readonly customType: "pi-orchestrate-wave";
15
+ export interface WorkerDeliveryMessage {
16
+ readonly customType: "pi-orchestrate-worker-result";
20
17
  readonly content: string;
21
18
  readonly display: true;
22
- readonly details: WaveDeliveryDetails;
19
+ readonly details: WorkerDeliveryDetails;
23
20
  }
24
21
 
25
- export interface WaveDeliveryOptions {
22
+ export interface WorkerDeliveryOptions {
26
23
  readonly triggerTurn: boolean;
27
24
  }
28
25
 
@@ -30,7 +27,7 @@ export interface ParentBinding {
30
27
  readonly ownerSessionId: string;
31
28
  readonly generation: ParentBindingGeneration;
32
29
  isIdle(): boolean;
33
- sendMessage(message: WaveDeliveryMessage, options: WaveDeliveryOptions): void;
30
+ sendMessage(message: WorkerDeliveryMessage, options: WorkerDeliveryOptions): void;
34
31
  }
35
32
 
36
33
  interface BoundParent {
@@ -40,7 +37,9 @@ interface BoundParent {
40
37
 
41
38
  export class DeliveryCoordinator {
42
39
  private readonly boundParents = new Map<string, BoundParent>();
43
- private readonly pendingWaves: CompletedWave[] = [];
40
+ private readonly pendingSettlements: WorkerSettlement[] = [];
41
+ private readonly flushingOwners = new Set<string>();
42
+ private highestAcceptedSequence = 0;
44
43
 
45
44
  bind(binding: ParentBinding): void {
46
45
  this.boundParents.set(binding.ownerSessionId, {
@@ -57,33 +56,42 @@ export class DeliveryCoordinator {
57
56
 
58
57
  markAgentStarted(ownerSessionId: string, generation: ParentBindingGeneration): void {
59
58
  if (!this.matchesBinding(ownerSessionId, generation)) return;
60
- this.boundParents.get(ownerSessionId)!.agentRunning = true;
59
+ const parent = this.boundParents.get(ownerSessionId);
60
+ if (parent) parent.agentRunning = true;
61
61
  }
62
62
 
63
63
  markAgentSettled(ownerSessionId: string, generation: ParentBindingGeneration): void {
64
64
  if (!this.matchesBinding(ownerSessionId, generation)) return;
65
- this.boundParents.get(ownerSessionId)!.agentRunning = false;
65
+ const parent = this.boundParents.get(ownerSessionId);
66
+ if (!parent) return;
67
+ parent.agentRunning = false;
66
68
  this.flush(ownerSessionId, generation);
67
69
  }
68
70
 
69
- accept(completedWave: CompletedWave): boolean {
70
- if (completedWave.mode === "inline") return false;
71
-
72
- this.pendingWaves.push(completedWave);
73
- const generation = this.boundParents.get(completedWave.ownerSessionId)?.binding.generation;
74
- if (generation !== undefined) {
75
- this.flush(completedWave.ownerSessionId, generation);
71
+ accept(settlement: WorkerSettlement): boolean {
72
+ if (settlement.mode === "inline" || settlement.sequence <= this.highestAcceptedSequence) {
73
+ return false;
76
74
  }
75
+
76
+ this.highestAcceptedSequence = settlement.sequence;
77
+ this.pendingSettlements.push(settlement);
78
+
79
+ const parent = this.boundParents.get(settlement.ownerSessionId);
80
+ if (parent) this.flush(settlement.ownerSessionId, parent.binding.generation);
77
81
  return true;
78
82
  }
79
83
 
80
84
  pendingCount(ownerSessionId: string): number {
81
- return this.pendingWaves.filter((wave) => wave.ownerSessionId === ownerSessionId).length;
85
+ return this.pendingSettlements.filter(
86
+ (settlement) => settlement.ownerSessionId === ownerSessionId,
87
+ ).length;
82
88
  }
83
89
 
84
90
  clear(): void {
85
91
  this.boundParents.clear();
86
- this.pendingWaves.length = 0;
92
+ this.pendingSettlements.length = 0;
93
+ this.flushingOwners.clear();
94
+ this.highestAcceptedSequence = 0;
87
95
  }
88
96
 
89
97
  close(): void {
@@ -94,14 +102,10 @@ export class DeliveryCoordinator {
94
102
  ownerSessionId: string,
95
103
  generation: ParentBindingGeneration,
96
104
  ): boolean {
97
- const binding = this.boundParents.get(ownerSessionId)?.binding;
98
- return binding?.generation === generation;
105
+ return this.boundParents.get(ownerSessionId)?.binding.generation === generation;
99
106
  }
100
107
 
101
- private canDeliver(
102
- ownerSessionId: string,
103
- generation: ParentBindingGeneration,
104
- ): boolean {
108
+ private canDeliver(ownerSessionId: string, generation: ParentBindingGeneration): boolean {
105
109
  const parent = this.boundParents.get(ownerSessionId);
106
110
  return (
107
111
  parent !== undefined &&
@@ -111,73 +115,117 @@ export class DeliveryCoordinator {
111
115
  );
112
116
  }
113
117
 
114
- private flush(
115
- ownerSessionId: string,
116
- generation: ParentBindingGeneration,
117
- ): void {
118
- if (!this.canDeliver(ownerSessionId, generation)) return;
119
-
120
- const waves = this.pendingWaves.filter((wave) => wave.ownerSessionId === ownerSessionId);
121
- for (let index = 0; index < waves.length; index += 1) {
122
- if (!this.canDeliver(ownerSessionId, generation)) return;
123
-
124
- const wave = waves[index];
125
- if (!wave) return;
126
-
127
- try {
128
- // Pi 0.80.10 starts an idle turn only through triggerTurn. Omitting deliverAs keeps
129
- // streaming races as steering; nextTurn would queue without starting a turn.
130
- this.boundParents.get(ownerSessionId)!.binding.sendMessage(renderWaveMessage(wave), {
131
- triggerTurn: index === waves.length - 1,
132
- });
133
- } catch {
134
- return;
135
- }
118
+ private flush(ownerSessionId: string, generation: ParentBindingGeneration): void {
119
+ if (this.flushingOwners.has(ownerSessionId) || !this.canDeliver(ownerSessionId, generation)) {
120
+ return;
121
+ }
136
122
 
137
- const pendingIndex = this.pendingWaves.indexOf(wave);
138
- if (pendingIndex >= 0) this.pendingWaves.splice(pendingIndex, 1);
123
+ this.flushingOwners.add(ownerSessionId);
124
+ try {
125
+ const queued = this.pendingSettlements.filter(
126
+ (settlement) => settlement.ownerSessionId === ownerSessionId,
127
+ );
128
+ let latestFinalIndex = -1;
129
+ for (let index = 0; index < queued.length; index += 1) {
130
+ if (queued[index]?.waveComplete) latestFinalIndex = index;
131
+ }
132
+ const flushThrough = latestFinalIndex >= 0 ? latestFinalIndex : queued.length - 1;
133
+ let flushBytesRemaining = MAX_DELIVERY_MARKDOWN_BYTES;
134
+
135
+ for (let index = 0; index <= flushThrough; index += 1) {
136
+ if (!this.canDeliver(ownerSessionId, generation)) return;
137
+ const settlement = queued[index];
138
+ if (!settlement || !this.pendingSettlements.includes(settlement)) continue;
139
+
140
+ const messagesRemaining = flushThrough - index + 1;
141
+ const fairFlushBytes = Math.floor(flushBytesRemaining / messagesRemaining);
142
+ const fairWaveBytes = Math.floor(
143
+ MAX_DELIVERY_MARKDOWN_BYTES / Math.max(1, settlement.waveSize),
144
+ );
145
+ const byteLimit = Math.max(0, Math.min(
146
+ MAX_WORKER_DELIVERY_MARKDOWN_BYTES,
147
+ fairFlushBytes,
148
+ fairWaveBytes,
149
+ flushBytesRemaining,
150
+ ));
151
+ const triggerTurn = latestFinalIndex >= 0 && index === flushThrough;
152
+ const message = this.renderWorkerMessage(settlement, byteLimit);
153
+ const parent = this.boundParents.get(ownerSessionId);
154
+ if (!parent || parent.binding.generation !== generation) return;
155
+
156
+ try {
157
+ parent.binding.sendMessage(message, { triggerTurn });
158
+ } catch {
159
+ return;
160
+ }
161
+
162
+ const deliveredBytes = Buffer.byteLength(message.content, "utf8");
163
+ flushBytesRemaining = Math.max(0, flushBytesRemaining - deliveredBytes);
164
+ const pendingIndex = this.pendingSettlements.indexOf(settlement);
165
+ if (pendingIndex >= 0) this.pendingSettlements.splice(pendingIndex, 1);
166
+
167
+ if (triggerTurn) {
168
+ parent.agentRunning = true;
169
+ return;
170
+ }
171
+ }
172
+ } finally {
173
+ this.flushingOwners.delete(ownerSessionId);
139
174
  }
140
175
  }
141
- }
142
176
 
143
- function renderWaveMessage(wave: CompletedWave): WaveDeliveryMessage {
144
- const resultWord = wave.results.length === 1 ? "result" : "results";
145
- const sections = wave.results.map((result) => {
146
- const outcome = renderOutcome(result.outcome);
147
- const heading = `### ${result.worker} — ${result.title}`;
148
- const metadata = `Worker \`${result.workerId}\` · status \`${result.status}\``;
149
- return outcome.length > 0 ? `${heading}\n${metadata}\n\n${outcome}` : `${heading}\n${metadata}`;
150
- });
151
- const summary = `## Worker results — wave \`${wave.id}\`\n\n${wave.results.length} ${resultWord}`;
152
- const results = sections.length > 0 ? `${summary}\n\n${sections.join("\n\n")}` : summary;
153
- const instructions = `\n\n---\n\n${DELIVERY_PARENT_INSTRUCTIONS}`;
154
-
155
- return {
156
- customType: "pi-orchestrate-wave",
157
- content: capMarkdown(results, instructions),
158
- display: true,
159
- details: {
160
- id: wave.id,
161
- ownerSessionId: wave.ownerSessionId,
162
- mode: wave.mode,
163
- results: wave.results,
164
- },
165
- };
177
+ private renderWorkerMessage(
178
+ settlement: WorkerSettlement,
179
+ byteLimit: number,
180
+ ): WorkerDeliveryMessage {
181
+ const heading = `## Worker result — ${settlement.worker}`;
182
+ const metadata = [
183
+ `Worker \`${settlement.workerId}\``,
184
+ `wave \`${settlement.waveId}\``,
185
+ `status \`${settlement.status}\``,
186
+ ].join(" · ");
187
+ const body = renderOutcome(settlement.outcome);
188
+ const content = body.length > 0
189
+ ? `${heading}\n\n### ${settlement.title}\n${metadata}\n\n${body}`
190
+ : `${heading}\n\n### ${settlement.title}\n${metadata}`;
191
+ const appendix = settlement.waveComplete
192
+ ? `\n\n---\n\n${DELIVERY_PARENT_INSTRUCTIONS}`
193
+ : "";
194
+
195
+ return Object.freeze({
196
+ customType: "pi-orchestrate-worker-result",
197
+ content: capMarkdown(content, appendix, byteLimit),
198
+ display: true,
199
+ details: settlement,
200
+ });
201
+ }
166
202
  }
167
203
 
168
- function capMarkdown(content: string, appendix: string): string {
204
+ function capMarkdown(content: string, appendix: string, byteLimit: number): string {
169
205
  const complete = `${content}${appendix}`;
170
- const completeBytes = Buffer.from(complete, "utf8");
171
- if (completeBytes.byteLength <= MAX_DELIVERY_MARKDOWN_BYTES) return complete;
172
-
173
- const contentBytes = Buffer.from(content, "utf8");
174
- const reservedBytes = Buffer.byteLength(`${DELIVERY_TRUNCATION_MARKER}${appendix}`, "utf8");
175
- let prefixEnd = MAX_DELIVERY_MARKDOWN_BYTES - reservedBytes;
176
- while (prefixEnd > 0 && (contentBytes[prefixEnd]! & 0xc0) === 0x80) prefixEnd -= 1;
177
- return `${contentBytes.subarray(0, prefixEnd).toString("utf8")}${DELIVERY_TRUNCATION_MARKER}${appendix}`;
206
+ if (Buffer.byteLength(complete, "utf8") <= byteLimit) return complete;
207
+
208
+ const suffix = `${DELIVERY_TRUNCATION_MARKER}${appendix}`;
209
+ const suffixBytes = Buffer.byteLength(suffix, "utf8");
210
+ if (suffixBytes >= byteLimit) return truncateUtf8(suffix, byteLimit);
211
+ const prefix = truncateUtf8(content, byteLimit - suffixBytes);
212
+ return `${prefix}${suffix}`;
213
+ }
214
+
215
+ function truncateUtf8(content: string, byteLimit: number): string {
216
+ if (byteLimit <= 0) return "";
217
+ const bytes = Buffer.from(content, "utf8");
218
+ if (bytes.byteLength <= byteLimit) return content;
219
+ let end = byteLimit;
220
+ while (end > 0) {
221
+ const byte = bytes[end];
222
+ if (byte === undefined || (byte & 0xc0) !== 0x80) break;
223
+ end -= 1;
224
+ }
225
+ return bytes.subarray(0, end).toString("utf8");
178
226
  }
179
227
 
180
- function renderOutcome(outcome: CompletedWave["results"][number]["outcome"]): string {
228
+ function renderOutcome(outcome: WorkerSettlement["outcome"]): string {
181
229
  switch (outcome.status) {
182
230
  case "completed":
183
231
  case "ready":
@@ -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,
@@ -228,7 +231,10 @@ export interface WorkerRecord {
228
231
  readonly lifecycle: WorkerLifecycle;
229
232
  readonly status: WorkerStatus;
230
233
  readonly usage: WorkerUsage;
234
+ readonly startedAt: number;
235
+ readonly settledAt?: number;
231
236
  readonly activity?: string;
237
+ readonly messageDirection?: WorkerMessageDirection;
232
238
  readonly outcome?: WorkerOutcome;
233
239
  readonly sessionFile?: string;
234
240
  }
package/extension/host.ts CHANGED
@@ -5,7 +5,8 @@ import {
5
5
  } from "./runtime.js";
6
6
  import { createWorkerSessionFactory } from "./worker-session.js";
7
7
 
8
- const PROCESS_HOST_KEY = Symbol.for("@zachwill/pi-orchestrate/process-host/v1");
8
+ const PROCESS_HOST_KEY = Symbol.for("@zachwill/pi-orchestrate/process-host/v2");
9
+ const LEGACY_PROCESS_HOST_KEY = Symbol.for("@zachwill/pi-orchestrate/process-host/v1");
9
10
 
10
11
  export interface ProcessHost {
11
12
  readonly runtime: OrchestratorRuntime;
@@ -21,12 +22,18 @@ interface AttachmentAwareProcessHost extends ProcessHost {
21
22
  }
22
23
 
23
24
  interface OwnedProcessHost extends AttachmentAwareProcessHost {
24
- readonly unsubscribeCompletion: () => void;
25
+ readonly unsubscribeSettlement?: () => void;
25
26
  destroyPromise?: Promise<void>;
26
27
  }
27
28
 
29
+ interface LegacyProcessHost extends ProcessHost {
30
+ unsubscribeCompletion?: () => void;
31
+ unsubscribeSettlement?: () => void;
32
+ }
33
+
28
34
  type ProcessGlobal = typeof globalThis & {
29
35
  [PROCESS_HOST_KEY]?: OwnedProcessHost;
36
+ [LEGACY_PROCESS_HOST_KEY]?: LegacyProcessHost;
30
37
  };
31
38
 
32
39
  function processGlobal(): ProcessGlobal {
@@ -42,6 +49,8 @@ export function createProcessHost(): ProcessHost {
42
49
  const existing = global[PROCESS_HOST_KEY];
43
50
  if (existing) return existing;
44
51
 
52
+ retireLegacyProcessHost(global);
53
+
45
54
  const runtime = createOrchestratorRuntime({
46
55
  workerSessionFactory: createWorkerSessionFactory(),
47
56
  });
@@ -50,8 +59,8 @@ export function createProcessHost(): ProcessHost {
50
59
  runtime,
51
60
  delivery,
52
61
  attachments: new Set(),
53
- unsubscribeCompletion: runtime.subscribeCompletion((wave) => {
54
- delivery.accept(wave);
62
+ unsubscribeSettlement: runtime.subscribeSettlement((settlement) => {
63
+ delivery.accept(settlement);
55
64
  }),
56
65
  };
57
66
  global[PROCESS_HOST_KEY] = host;
@@ -90,7 +99,7 @@ export async function destroyProcessHost(host: ProcessHost): Promise<void> {
90
99
  await ownedHost.runtime.shutdown();
91
100
  } finally {
92
101
  ownedHost.delivery.clear();
93
- ownedHost.unsubscribeCompletion();
102
+ ownedHost.unsubscribeSettlement?.();
94
103
  const global = processGlobal();
95
104
  if (global[PROCESS_HOST_KEY] === ownedHost) {
96
105
  delete global[PROCESS_HOST_KEY];
@@ -105,3 +114,30 @@ export async function quitProcessHost(): Promise<void> {
105
114
  if (!host) return;
106
115
  await destroyProcessHost(host);
107
116
  }
117
+
118
+ function retireLegacyProcessHost(global: ProcessGlobal): void {
119
+ const legacy = global[LEGACY_PROCESS_HOST_KEY];
120
+ if (!legacy) return;
121
+
122
+ delete global[LEGACY_PROCESS_HOST_KEY];
123
+ try {
124
+ legacy.unsubscribeCompletion?.();
125
+ } catch {
126
+ // A stale subscription cannot prevent installation of the current host.
127
+ }
128
+ try {
129
+ legacy.unsubscribeSettlement?.();
130
+ } catch {
131
+ // A stale subscription cannot prevent installation of the current host.
132
+ }
133
+ try {
134
+ legacy.delivery.close();
135
+ } catch {
136
+ // Legacy delivery cleanup is best-effort during reload migration.
137
+ }
138
+ try {
139
+ void legacy.runtime.shutdown().catch(() => undefined);
140
+ } catch {
141
+ // Legacy runtime shutdown is best-effort during reload migration.
142
+ }
143
+ }