@zachwill/pi-orchestrate 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,13 +6,21 @@
6
6
  - Workers run independently and return their results to the parent.
7
7
  - Only the parent can delegate; workers cannot create more workers.
8
8
 
9
+ Requires Pi 0.85.0 or newer.
10
+
11
+ ```bash
12
+ pi install npm:@zachwill/pi-orchestrate
13
+ ```
14
+
9
15
  ## The model
10
16
 
11
17
  Each `orchestrate` dispatch creates a fresh worker session with its own transcript. The worker receives a complete brief from the parent but not the parent's conversation.
12
18
 
13
- A worker definition is reusable configuration: it selects the worker's prompt, tools, lifecycle, and optional model settings. It is not a running or retained session.
19
+ A worker definition is reusable configuration for the worker's prompt, tools, lifecycle, and optional model settings. The parent remains responsible for the user's complete requested outcome across its own and worker work.
20
+
21
+ Workers run in the background. `orchestrate` and `interactive_send` return acceptance so the parent can continue useful independent work, including tool calls dispatched alongside the workers. Worker dispatches in the same response form one result group; dispatches in later responses form separate groups.
14
22
 
15
- Independent workers dispatched together run concurrently. Their results return only to the parent session that started them, and the parent synthesizes the group after every worker finishes. A rejected or failed worker does not cancel its peers. Orchestration dispatched alongside unrelated tool calls runs inline instead of in the background.
23
+ Results return only to the parent session that started the workers. Results that settle while the parent is busy are queued. After the parent run ends normally, individual results can enter its context as they arrive; a dispatch group resumes the parent after every admitted member settles. The parent can do useful independent work before ending its run, or end promptly when progress needs worker evidence. A rejected, failed, or aborted worker does not cancel its peers; stopping other active workers requires an explicit `worker_abort` request.
16
24
 
17
25
  Workers have one of two lifecycles:
18
26
 
@@ -23,6 +31,8 @@ A **worker ID** identifies a worker session. A **run ID** identifies one generat
23
31
 
24
32
  Interactive workers remain available across session switches and extension reloads within the same Pi process. Closing one releases its retained session; process shutdown releases any that remain.
25
33
 
34
+ Workers keep running when the parent conversation is compacted, and their results return automatically. Restarting Pi does not restore running workers.
35
+
26
36
  ## Agent interface
27
37
 
28
38
  Pi Orchestrate gives the parent five model-facing tools:
@@ -35,11 +45,11 @@ Pi Orchestrate gives the parent five model-facing tools:
35
45
  | `worker_abort` | Stop active workers owned by the parent |
36
46
  | `worker_status` | Inspect the trusted catalog and diagnose the parent's worker state |
37
47
 
38
- The extension supplies the parent with the exact dispatch and lifecycle rules for these tools. The README describes their behavior rather than duplicating those model instructions.
48
+ The extension supplies the parent with instructions for using these tools.
39
49
 
40
50
  ## Worker definitions
41
51
 
42
- The package includes four fallback definitions in [`examples/workers/`](examples/workers/): `scout` for small factual probes, `investigator` for read-only cross-file research, `worker` for bounded implementation, and `web` for public web research. The first three inherit the parent's active model. The `web` worker uses the model declared in its definition and requires an installed, authenticated Codex CLI.
52
+ The package includes four fallback definitions in [`examples/workers/`](examples/workers/): `scout` for small factual probes, `investigator` for read-only cross-file research, `worker` for bounded implementation, and `web` for public web research. The fallbacks use `openai-codex/gpt-6-sol`, except `scout`, which uses `openai-codex/gpt-6-luna`. The `web` worker also requires an installed, authenticated Codex CLI.
43
53
 
44
54
  Definitions are loaded by name in this precedence order:
45
55
 
@@ -77,8 +87,6 @@ The frontmatter is strict:
77
87
 
78
88
  The Markdown body is the worker's nonempty system prompt. `tools` and `skills` accept either YAML arrays or comma-separated strings. Supported Pi tools are `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls`. Unknown fields and malformed definitions are rejected and appear in catalog diagnostics.
79
89
 
80
- Each `orchestrate` call starts a new worker session from the selected definition. Only `interactive_send` continues an existing session.
81
-
82
90
  ## Trust boundary
83
91
 
84
92
  Workers run in the parent process and are not security sandboxes. They share its filesystem and environment permissions.
@@ -87,6 +95,4 @@ Workers can use global Pi settings, authentication, packages, extensions, skills
87
95
 
88
96
  A definition's `tools` field controls Pi's tool allowlist, not operating-system authority. A worker with `bash` can start external processes, including other agent CLIs. A read-only prompt also does not prevent writes when the worker has a write-capable tool.
89
97
 
90
- Concurrent workers share the same working tree, so overlapping write scopes can collide. The parent owns the outcome, works directly, and delegates independent parts when doing so improves speed or quality. The parent integrates worker results and checks the evidence behind consequential claims or changes, adding independent review when a specific risk warrants it.
91
-
92
- Pi Orchestrate excludes itself from child sessions and keeps workers as direct Pi children.
98
+ Concurrent workers share the same working tree, so overlapping write scopes can collide.
@@ -1,15 +1,14 @@
1
1
  ---
2
2
  name: investigator
3
- description: Investigates cross-file questions and synthesizes grounded evidence.
4
- thinking: medium
3
+ description: Investigates cross-file questions through read-only inspection and evidence-based synthesis.
4
+ model: openai-codex/gpt-6-sol
5
+ thinking: high
5
6
  tools: read, grep, find, ls, bash
6
7
  lifecycle: one-shot
7
8
  ---
8
9
 
9
- Investigate the assigned cross-file question through read-only inspection, comparison, and evidence synthesis.
10
+ Investigate the assigned question, tracing relevant relationships and comparing evidence across files. Stay within scope and stop when the evidence supports an answer; do not keep exploring for completeness.
10
11
 
11
- Do not modify files or run builds, tests, or commands that mutate state. Use bash only for read-only commands.
12
+ Do not modify files or run builds, tests, or other state-changing commands. Use bash only for read-only inspection.
12
13
 
13
- Ground each finding in file paths, line ranges, or symbols. Distinguish confirmed behavior from inference, connect evidence across files, and explain the resulting system shape or conclusion. Provide grounded recommendations when the assignment requests them.
14
-
15
- Return concise **Findings** and **Synthesis** sections. Add **Gaps** only for material unresolved questions and **Start Here** only when useful.
14
+ Lead with the answer. Support material findings and requested recommendations with exact paths, line ranges, or symbols. Explain how the evidence supports the conclusion, distinguish facts from inference, and identify unresolved gaps that could change the answer.
@@ -1,15 +1,14 @@
1
1
  ---
2
2
  name: scout
3
- description: Answers one small factual repository question with read-only evidence.
4
- thinking: medium
3
+ description: Answers a small factual repository question through shallow, read-only inspection.
4
+ model: openai-codex/gpt-6-luna
5
+ thinking: low
5
6
  tools: read, grep, find, ls, bash
6
7
  lifecycle: one-shot
7
8
  ---
8
9
 
9
- Answer one small factual probe through fast, shallow, read-only repository inspection.
10
+ Answer the assigned factual question using direct repository evidence. Keep the inspection shallow and stop as soon as the question is answerable. If it requires deeper investigation, return what you found and explain what remains rather than expanding the task.
10
11
 
11
- Do not modify files or run builds, tests, or commands that mutate state. Use bash only for read-only commands.
12
+ Do not modify files or run builds, tests, or other state-changing commands. Use bash only for read-only inspection.
12
13
 
13
- Accept one path, symbol, command output, short inventory, direct comparison, or existence check. If the assignment requires broader investigation, synthesis, architecture judgment, planning, or implementation, stop concisely and recommend the investigator.
14
-
15
- Return a short **Answer** and **Evidence** grounded in paths, line ranges, symbols, or command output. Add **Gaps** only when material.
14
+ Lead with a short answer and cite the paths, line ranges, symbols, or command output needed to support it. Distinguish confirmed facts from inference and identify missing evidence that affects the answer.
@@ -1,26 +1,20 @@
1
1
  ---
2
2
  name: web
3
3
  description: Researches the public web with one or more Codex searches and returns a source-grounded synthesis.
4
- model: openai-codex/gpt-5.6-sol
4
+ model: openai-codex/gpt-6-sol
5
5
  thinking: medium
6
6
  tools: bash
7
7
  skills: []
8
8
  lifecycle: one-shot
9
9
  ---
10
10
 
11
- You are a web research worker. Understand the assigned research objective, choose an efficient search strategy, and return a useful source-grounded synthesis in the assignment's language.
11
+ Answer the assigned question using public web sources.
12
12
 
13
13
  Use the installed, authenticated `codex` CLI as your web-search backend. This external search process is explicitly part of your task; do not invoke Pi or other Pi workers. Do not modify project files or install anything. Use fresh temporary directories and clean them up.
14
14
 
15
15
  ## Strategy
16
16
 
17
- Use your judgment:
18
-
19
- - For a narrow lookup, run one focused Codex search.
20
- - For independent entities, claims, or source families, run separate focused searches in parallel by issuing sibling bash calls in the same turn.
21
- - For dependent questions, search serially so later work can use earlier evidence.
22
- - Use a follow-up search only for a material gap, conflict, or verification need.
23
- - Stop when the objective is adequately answered. Do not multiply searches for cosmetic coverage.
17
+ Use one focused search for a narrow lookup. Search independent angles in parallel and dependent questions serially. Follow up only to resolve a material gap, conflict, or verification need. Stop when the question is adequately answered.
24
18
 
25
19
  Tell each Codex process to use at most four actual web searches unless the assignment justifies a different bound. Use cached search for stable documentation or background and live search for current or time-sensitive questions.
26
20
 
@@ -37,7 +31,7 @@ stderr_log="$work_dir/stderr.log"
37
31
 
38
32
  codex exec - \
39
33
  --ignore-user-config \
40
- --model gpt-5.6-sol \
34
+ --model gpt-6-sol \
41
35
  -c 'model_reasoning_effort="medium"' \
42
36
  -c 'web_search="cached"' \
43
37
  --ephemeral \
@@ -74,11 +68,6 @@ Never use `--dangerously-bypass-approvals-and-sandbox`. Retry only when diagnost
74
68
 
75
69
  ## Response
76
70
 
77
- Return a concise synthesis that directly serves the assignment. Include:
78
-
79
- - the answer or strongest supported conclusion;
80
- - material findings and conflicts;
81
- - source titles with exact URLs and relevance;
82
- - unresolved gaps or cautions when they matter.
71
+ Lead with the strongest supported answer. Cite exact source URLs alongside material factual claims. Identify conflicts, uncertainty, and freshness limits that affect the conclusion.
83
72
 
84
73
  Do not dump search transcripts or raw temporary paths. If research fails, say what failed and return any useful partial evidence.
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  name: worker
3
- description: Implements a bounded change within explicit scope and acceptance criteria.
3
+ description: Implements bounded code changes, fixes, and refactors.
4
+ model: openai-codex/gpt-6-sol
4
5
  thinking: medium
5
6
  tools: read, bash, edit, write, grep, find, ls
6
7
  lifecycle: one-shot
7
8
  ---
8
9
 
9
- Implement the assigned change within its stated scope.
10
+ Complete the assigned change within its scope. Follow project instructions, inspect nearby code, and preserve changes you do not own. Report relevant out-of-scope findings rather than fixing them.
10
11
 
11
- - Follow loaded project conventions. Inspect nearby code and reuse existing helpers and patterns before writing new code.
12
- - Change only what the assignment requires. Do not fix, refactor, or investigate unrelated work.
13
- - Do not commit, push, or perform destructive actions unless assigned.
14
- - Stop and report a blocker rather than guessing when a required decision is unclear.
15
- - Remove unused imports, dead code, debug output, and other leftovers from your changes.
16
- - Run only the narrowest relevant verification permitted by the assignment and project conventions. Report pre-existing failures separately; fix only failures caused by your changes.
12
+ Resolve uncertainty through inspection where possible. Make reasonable, reversible decisions within the assignment; report a blocker when progress requires missing authority or a material decision the assignment does not resolve.
17
13
 
18
- Return concise sections for **Completed**, **Files Changed**, and **Verification**. Add **Blockers** only when blocked and **Observations** only for directly relevant out-of-scope findings.
14
+ Run checks that establish whether the change works, following the assignment and project requirements. Fix failures introduced by your work and remove leftovers from your changes. Report pre-existing failures separately.
15
+
16
+ Lead with the result. Include changed paths, verification performed, and anything unresolved. Use the structure the handoff needs rather than a fixed template.
17
+
18
+ Do not commit, push, deploy, or take destructive action unless explicitly authorized.
@@ -9,6 +9,10 @@ import {
9
9
  } from "./catalog/discovery.ts";
10
10
  import type { WorkerCatalog } from "./catalog/definition.ts";
11
11
  import { applyOrchestratorContract } from "./parent/contract.ts";
12
+ import {
13
+ projectLiveWorkerContext,
14
+ replaceLiveWorkerContext,
15
+ } from "./parent/worker-context.ts";
12
16
  import {
13
17
  attachProcessHost,
14
18
  createProcessHost,
@@ -80,7 +84,7 @@ export function createOrchestrationExtension(
80
84
  orchestration: host.orchestration,
81
85
  getCatalog: catalogFor,
82
86
  getDispatchDecision: (toolCallId) =>
83
- dispatchDecisions.get(toolCallId) ?? { mode: "inline" },
87
+ dispatchDecisions.get(toolCallId) ?? {},
84
88
  });
85
89
  hostAttachment ??= attachProcessHost(host);
86
90
 
@@ -110,6 +114,52 @@ export function createOrchestrationExtension(
110
114
  };
111
115
  });
112
116
 
117
+ pi.on("context", async (event, ctx) => {
118
+ const messages = replaceLiveWorkerContext(event.messages, undefined);
119
+ const binding = activeBinding;
120
+ const boundHost = host;
121
+ const attachment = hostAttachment;
122
+
123
+ if (!binding || !boundHost || !attachment) return { messages };
124
+
125
+ try {
126
+ if (
127
+ attachment.host !== boundHost ||
128
+ ctx.sessionManager.getSessionId() !== binding.ownerSessionId
129
+ ) {
130
+ return { messages };
131
+ }
132
+
133
+ const snapshot = await boundHost.orchestration.snapshot(
134
+ binding.ownerSessionId,
135
+ );
136
+
137
+ // Session replacement and reload can race the awaited snapshot. Only the
138
+ // exact attachment generation that requested it may add parent context.
139
+ if (
140
+ activeBinding !== binding ||
141
+ host !== boundHost ||
142
+ hostAttachment !== attachment ||
143
+ attachment.host !== boundHost ||
144
+ ctx.sessionManager.getSessionId() !== binding.ownerSessionId
145
+ ) {
146
+ return { messages };
147
+ }
148
+
149
+ const context = projectLiveWorkerContext(snapshot, {
150
+ ownerSessionId: binding.ownerSessionId,
151
+ pendingResultCount: boundHost.delivery.pendingCount(
152
+ binding.ownerSessionId,
153
+ ),
154
+ });
155
+ return { messages: replaceLiveWorkerContext(messages, context) };
156
+ } catch {
157
+ // A context projection is advisory. On snapshot or stale-context failure,
158
+ // omit it rather than retaining an older authority-bearing projection.
159
+ return { messages };
160
+ }
161
+ });
162
+
113
163
  pi.on("message_end", (event) => {
114
164
  if (event.message.role !== "assistant") return;
115
165
  const toolCalls = event.message.content.filter(
@@ -376,14 +376,13 @@ class OrchestrationEngine implements OrchestrationService {
376
376
  const runId = this.idFactories.runId();
377
377
  const completion = yield* Deferred.make<CompletedRun>();
378
378
  const now = this.clock.currentTimeMillisUnsafe();
379
- const runRecord: RunRecord = {
380
- id: runId,
381
- ownerSessionId: context.ownerSessionId,
382
- workerId: validatedWorkerId,
379
+ const runRecord = makeRunRecord(
380
+ runId,
381
+ validatedWorkerId,
382
+ context,
383
383
  mode,
384
- state: "running",
385
- createdAt: now,
386
- };
384
+ now,
385
+ );
387
386
 
388
387
  const admission = this.transact((draft) => {
389
388
  const ready = readyInteractiveDecision(
@@ -96,43 +96,35 @@ function buildContract(catalog: WorkerCatalog): string {
96
96
  return `${CONTRACT_START}
97
97
  ## Pi Orchestrate Contract
98
98
 
99
- You are responsible for delivering the user’s requested outcome. Work directly and use workers where parallel ownership or specialized judgment materially helps. Own the difficult decisions, shared problems, and final answer.
99
+ You own the user’s outcome across parent and worker work. Exercise judgment: use workers when they help, continue useful work yourself, and deliver one coherent answer.
100
100
 
101
- ### Scope
101
+ ### Outcome and scope
102
102
 
103
- - Scope comes from the user’s request and applicable instructions. Preserve explicitly broad tasks, but do not broaden narrow tasks because execution reveals related work.
104
- - Understand the requested outcome and take the next concrete step. Keep planning proportional to dependencies and risk; do not require a written scope statement, roster, or approval checkpoint unless it resolves a real ambiguity.
105
- - Necessary investigation and implementation details belong to the task. Separate optional improvements from the requested work; ask before making consequential changes beyond it.
106
- - Admit newly discovered work only when the current change would otherwise be incorrect, unsafe, nonfunctional, or unverifiable. If that work exceeds the boundary, narrow, revert, or ask the user rather than silently expanding.
107
- - Do not introduce cross-feature policy, infrastructure, deployment, or compatibility work unless the request or an unavoidable requirement of the current change calls for it.
108
- - Completed worker effort does not justify retaining an overgrown change set.
103
+ - Preserve the requested scope, completeness, and form. Do not silently substitute an easier deliverable, narrow a broad request to a sample, or treat checks against a selected subset as evidence that the full requirement is satisfied.
104
+ - Do the investigation and implementation needed for the outcome, but do not invent adjacent deliverables, policies, or cleanup. Ask when a material ambiguity or consequential out-of-scope change requires the user’s authority.
105
+ - Assign write ownership so concurrent scopes are disjoint. Give one owner any shared file or integration point, and preserve changes you do not own.
109
106
 
110
107
  ### Delegation
111
108
 
112
- - Delegate substantive, independent parts of the problem when doing so improves speed or quality. Prefer end-to-end assignments: each worker investigates what its question requires, does the work, and checks its result. The parent should solve useful parts of the problem directly rather than defaulting to a coordination-only role.
113
- - Respect the user’s requested workers and counts. Otherwise choose the smallest team that usefully advances the outcome. Do not add roles merely because preparation, implementation, and review can be separated.
114
- - Each \`orchestrate\` call creates a fresh worker session. The same worker definition and identical brief may be used for independent judgments; do not vary briefs merely to make them appear different. Interactive follow-up continues one worker ID with its existing context.
115
- - Give every worker a self-contained brief with its objective, context, owned paths, forbidden changes, success criteria, expected output, and stop condition. Instruct workers to report adjacent findings without fixing them. Workers do not receive the parent conversation.
109
+ - Delegate separable work when the expected improvement in quality or latency is worth the coordination cost. Respect worker choices and counts requested by the user; otherwise choose from the work rather than applying a minimum, maximum, or mandatory role pattern.
110
+ - Give each worker enough context to own one outcome and scope boundary, including relevant constraints, owned paths, and consequential evidence checks. Workers do not receive the parent conversation. Have them report out-of-scope findings instead of fixing them.
111
+ - **Important:** When independent worker assignments are ready, dispatch them using one \`multi_tool_use.parallel\` call containing every \`functions.orchestrate\` call. Do not dispatch one worker first and batch the rest afterward.
112
+ - Each \`orchestrate\` call creates a fresh worker session. Interactive follow-up uses \`interactive_send\` with the existing worker ID and context; close a ready interactive worker with \`interactive_close\` when it is no longer needed.
116
113
 
117
- ### Parallel dispatch
114
+ ### Dispatch and dependencies
118
115
 
119
- - Form the complete wave before emitting any tool call. “Complete” means every worker admitted for the current change and turn, not every potentially useful concern.
120
- - For one worker, make one fully briefed \`orchestrate\` call. For N workers where N > 1, make exactly one \`multi_tool_use.parallel\` call containing exactly N \`functions.orchestrate\` entries and no other tools.
121
- - If \`multi_tool_use.parallel\` is unavailable, emit all N \`orchestrate\` calls as native siblings in one assistant response.
122
- - Never split a multi-worker wave across assistant responses; an admitted sole asynchronous \`orchestrate\` call ends the parent turn.
123
- - The expanded tool-call group must contain only the intended \`orchestrate\` calls. Mixing another tool into the group makes orchestration inline and blocking.
116
+ - \`orchestrate\` and \`interactive_send\` start background work and return acceptance without ending the parent run, including alongside other tools. Worker dispatches in the same response are grouped for result delivery; dispatches in later responses form separate groups.
117
+ - After dispatch, continue useful independent work. When that work is exhausted or progress needs worker evidence, end the run normally so automatic result delivery can resume you. A brief truthful pending-status response is acceptable; do not claim final completion before necessary results are considered.
118
+ - Do not poll \`worker_status\`, sleep, duplicate active assignments, or invent work while results are pending. Do not force parent work when none is useful, and do not stop before doing independent work that materially advances the outcome.
119
+ - Results that settle while the parent is busy are queued and delivered when the parent run ends. Do not redispatch queued work.
120
+ - Compaction does not stop workers. Use the fresh live-worker snapshot for active assignments and ready interactive sessions rather than stale conversation summaries. Do not redispatch work because its dispatch was compacted away. Use \`worker_status\` once only for diagnostics or recovery when the snapshot overflows or state appears inconsistent.
124
121
 
125
122
  ### Completion
126
123
 
127
- - After dispatching, wait for automatic result delivery instead of polling \`worker_status\`. Do not call \`sleep\`, poll with another tool, inspect progress indirectly, or issue no-op calls.
128
- - While waiting, perform only already-admitted independent work from the current change; otherwise end the turn.
129
- - Classify findings before acting: fix or remove defects introduced by the current change, complete unfinished requirements inside its boundary, and record adjacent or pre-existing concerns without admitting them.
130
- - Dispatch another wave only for admitted work inside the current change. Independence, local correctness, reviewer concern, or consistency alone does not justify more work.
131
- - Inspect worker results and check the evidence behind consequential claims or changes. Add independent review when a specific risk warrants it, not as an automatic phase. Reuse credible verification already performed; investigate gaps and contradictions.
132
- - Inspect the combined result and worker evidence, resolve disagreements, and accept, reduce, or discard the change. Do not personally repeat delegated review or verification without a concrete reason.
133
- - Verification decides whether to accept the change; it is not a general source of new work. Fix failures caused by the change, but narrow, revert, report, or ask when verification demands unrelated work.
134
- - Stop when the acceptance criteria pass. Report delivered work separately from findings deliberately left outside scope.
135
- - Prefer one-shot workers. Use interactive workers only when retained context is useful and follow the lifecycle requirements in the tool descriptions.
124
+ - Treat worker reports as input, not the answer. Resolve material conflicts and assess the combined result against the original request; a worker’s local success does not redefine completion.
125
+ - Check consequential claims, changes, and failure modes with evidence suited to the task. Add review or integration tests only when a concrete risk warrants them, and do not repeat credible worker checks without a reason.
126
+ - Do not give the final answer until every worker result necessary to the outcome has been delivered and considered. Report completed work and any unresolved or out-of-scope finding directly.
127
+ - Use \`worker_abort\` only to stop active owned work. Ending the parent run, compaction, and closing a ready interactive session do not cancel other workers.
136
128
 
137
129
  ### Trusted worker catalog
138
130
 
@@ -7,9 +7,10 @@ export const MAX_WORKER_DELIVERY_MARKDOWN_BYTES = 16 * 1024;
7
7
  export const DELIVERY_TRUNCATION_MARKER =
8
8
  "\n\n[Worker result truncated for parent context. Full output remains in structured details.]";
9
9
  export const DELIVERY_PARENT_INSTRUCTIONS =
10
- "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.";
10
+ "Parent: Use these results as input to the user's task. Resolve material conflicts, check consequential evidence, and complete the requested outcome; do not merely forward worker reports or reopen work that is already satisfied.";
11
11
 
12
12
  export type ParentBindingGeneration = string | number | symbol;
13
+ export type ScheduleIdleRecheck = (recheck: () => void) => () => void;
13
14
 
14
15
  export interface WorkerDeliveryMessage {
15
16
  readonly customType: "pi-orchestrate-worker-result";
@@ -34,6 +35,11 @@ interface BoundParent {
34
35
  agentRunning: boolean;
35
36
  }
36
37
 
38
+ interface ScheduledIdleRecheck {
39
+ readonly generation: ParentBindingGeneration;
40
+ cancel(): void;
41
+ }
42
+
37
43
  interface SynthesisGroupState {
38
44
  expected: number;
39
45
  readonly acceptedEventIds: string[];
@@ -63,21 +69,30 @@ export class DeliveryCoordinator implements DeliveryService {
63
69
  private readonly boundParents = new Map<string, BoundParent>();
64
70
  private readonly pendingSettlements: WorkerSettlement[] = [];
65
71
  private readonly flushingOwners = new Set<string>();
72
+ private readonly idleRechecks = new Map<string, ScheduledIdleRecheck>();
66
73
  private readonly synthesisGroups = new Map<string, SynthesisGroupState>();
67
74
  // Orchestration settlement sequences are process-scoped and monotonic across
68
75
  // owners, so one watermark is valid.
69
76
  private highestAcceptedSequence = 0;
70
77
 
78
+ constructor(
79
+ private readonly scheduleIdleRecheck: ScheduleIdleRecheck = scheduleDeliveryIdleRecheck,
80
+ ) {}
81
+
71
82
  bind(binding: ParentBinding): void {
83
+ this.cancelIdleRecheck(binding.ownerSessionId);
72
84
  this.boundParents.set(binding.ownerSessionId, {
73
85
  binding,
74
- agentRunning: !binding.isIdle(),
86
+ // Non-idle also covers manual compaction. Agent lifecycle events, rather
87
+ // than the broader idle flag, own this state.
88
+ agentRunning: false,
75
89
  });
76
90
  this.flush(binding.ownerSessionId, binding.generation);
77
91
  }
78
92
 
79
93
  unbind(ownerSessionId: string, generation: ParentBindingGeneration): void {
80
94
  if (!this.matchesBinding(ownerSessionId, generation)) return;
95
+ this.cancelIdleRecheck(ownerSessionId, generation);
81
96
  this.boundParents.delete(ownerSessionId);
82
97
  }
83
98
 
@@ -85,6 +100,7 @@ export class DeliveryCoordinator implements DeliveryService {
85
100
  const parent = this.boundParents.get(ownerSessionId);
86
101
  if (parent?.binding.generation !== generation) return;
87
102
  parent.agentRunning = true;
103
+ this.cancelIdleRecheck(ownerSessionId, generation);
88
104
  }
89
105
 
90
106
  markAgentSettled(ownerSessionId: string, generation: ParentBindingGeneration): void {
@@ -130,6 +146,8 @@ export class DeliveryCoordinator implements DeliveryService {
130
146
  }
131
147
 
132
148
  clear(): void {
149
+ for (const recheck of this.idleRechecks.values()) recheck.cancel();
150
+ this.idleRechecks.clear();
133
151
  this.boundParents.clear();
134
152
  this.pendingSettlements.length = 0;
135
153
  this.flushingOwners.clear();
@@ -146,18 +164,28 @@ export class DeliveryCoordinator implements DeliveryService {
146
164
 
147
165
  private canDeliver(ownerSessionId: string, generation: ParentBindingGeneration): boolean {
148
166
  const parent = this.boundParents.get(ownerSessionId);
149
- return (
150
- parent !== undefined &&
151
- parent.binding.generation === generation &&
152
- !parent.agentRunning &&
153
- parent.binding.isIdle()
154
- );
167
+ if (
168
+ parent === undefined ||
169
+ parent.binding.generation !== generation ||
170
+ parent.agentRunning
171
+ ) {
172
+ return false;
173
+ }
174
+ if (!parent.binding.isIdle()) {
175
+ this.ensureIdleRecheck(ownerSessionId, generation);
176
+ return false;
177
+ }
178
+ this.cancelIdleRecheck(ownerSessionId, generation);
179
+ return true;
155
180
  }
156
181
 
157
182
  private flush(ownerSessionId: string, generation: ParentBindingGeneration): void {
158
- if (this.flushingOwners.has(ownerSessionId) || !this.canDeliver(ownerSessionId, generation)) {
183
+ if (this.flushingOwners.has(ownerSessionId)) return;
184
+ if (this.pendingCount(ownerSessionId) === 0) {
185
+ this.cancelIdleRecheck(ownerSessionId, generation);
159
186
  return;
160
187
  }
188
+ if (!this.canDeliver(ownerSessionId, generation)) return;
161
189
 
162
190
  this.flushingOwners.add(ownerSessionId);
163
191
  try {
@@ -216,6 +244,36 @@ export class DeliveryCoordinator implements DeliveryService {
216
244
  }
217
245
  }
218
246
 
247
+ private ensureIdleRecheck(
248
+ ownerSessionId: string,
249
+ generation: ParentBindingGeneration,
250
+ ): void {
251
+ const current = this.idleRechecks.get(ownerSessionId);
252
+ if (current?.generation === generation) return;
253
+ current?.cancel();
254
+
255
+ const scheduled: ScheduledIdleRecheck = {
256
+ generation,
257
+ cancel: () => {},
258
+ };
259
+ this.idleRechecks.set(ownerSessionId, scheduled);
260
+ scheduled.cancel = this.scheduleIdleRecheck(() => {
261
+ if (this.idleRechecks.get(ownerSessionId) !== scheduled) return;
262
+ this.idleRechecks.delete(ownerSessionId);
263
+ this.flush(ownerSessionId, generation);
264
+ });
265
+ }
266
+
267
+ private cancelIdleRecheck(
268
+ ownerSessionId: string,
269
+ generation?: ParentBindingGeneration,
270
+ ): void {
271
+ const scheduled = this.idleRechecks.get(ownerSessionId);
272
+ if (!scheduled || (generation !== undefined && scheduled.generation !== generation)) return;
273
+ this.idleRechecks.delete(ownerSessionId);
274
+ scheduled.cancel();
275
+ }
276
+
219
277
  private renderWorkerMessage(
220
278
  settlement: WorkerSettlement,
221
279
  byteLimit: number,
@@ -302,6 +360,12 @@ export const deliveryLayer: Layer.Layer<Delivery, never, Orchestration> = Layer.
302
360
  }),
303
361
  );
304
362
 
363
+ function scheduleDeliveryIdleRecheck(recheck: () => void): () => void {
364
+ const timeout = setTimeout(recheck, 100);
365
+ timeout.unref();
366
+ return () => clearTimeout(timeout);
367
+ }
368
+
305
369
  function synthesisGroupKey(ownerSessionId: string, synthesisGroupId: string): string {
306
370
  return `${ownerSessionId}\u0000${synthesisGroupId}`;
307
371
  }
@@ -6,7 +6,6 @@ export interface ParentToolCall {
6
6
  }
7
7
 
8
8
  export interface DispatchDecision {
9
- readonly mode: "async" | "inline";
10
9
  readonly synthesisGroup?: SynthesisGroup;
11
10
  }
12
11
 
@@ -20,30 +19,21 @@ const DISPATCH_TOOL_NAMES: ReadonlySet<string> = new Set([
20
19
  "interactive_send",
21
20
  ]);
22
21
 
23
- // Sole dispatches and homogeneous orchestrate waves detach so the parent turn can
24
- // end while work continues. Mixed tools stay inline because their shared parent
25
- // turn still has sibling work; one wave boundary defers one synthesis turn until
26
- // every admitted member has settled.
22
+ // Every public dispatch detaches so sibling parent tools can finish independently.
23
+ // Dispatches from one assistant response share a synthesis boundary; ordinary
24
+ // sibling tools are neither group members nor part of its expected size.
27
25
  export function classifyParentDispatches(
28
26
  toolCalls: readonly ParentToolCall[],
29
27
  ): readonly ClassifiedParentDispatch[] {
30
- const isOrchestrateGroup =
31
- toolCalls.length > 1 &&
32
- toolCalls.every((toolCall) => toolCall.name === "orchestrate");
33
- const synthesisGroup = isOrchestrateGroup
34
- ? { id: `orchestrate:${toolCalls[0]?.id ?? "group"}`, size: toolCalls.length }
28
+ const dispatches = toolCalls.filter((toolCall) =>
29
+ DISPATCH_TOOL_NAMES.has(toolCall.name)
30
+ );
31
+ const synthesisGroup = dispatches.length > 1
32
+ ? { id: `dispatch:${dispatches[0]!.id}`, size: dispatches.length }
35
33
  : undefined;
36
34
 
37
- return toolCalls.flatMap((toolCall): ClassifiedParentDispatch[] => {
38
- if (!DISPATCH_TOOL_NAMES.has(toolCall.name)) return [];
39
- return [{
40
- toolCallId: toolCall.id,
41
- decision: {
42
- mode: isOrchestrateGroup || toolCalls.length === 1 ? "async" : "inline",
43
- ...(toolCall.name === "orchestrate" && synthesisGroup
44
- ? { synthesisGroup }
45
- : {}),
46
- },
47
- }];
48
- });
35
+ return dispatches.map((toolCall) => ({
36
+ toolCallId: toolCall.id,
37
+ decision: synthesisGroup ? { synthesisGroup } : {},
38
+ }));
49
39
  }
@@ -0,0 +1,196 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type {} from "@earendil-works/pi-coding-agent";
3
+ import type { WorkerRecord } from "../orchestration/model.ts";
4
+ import type { OwnerSnapshot } from "../orchestration/service.ts";
5
+
6
+ export const LIVE_WORKER_CONTEXT_TYPE = "pi-orchestrate-live-worker-context";
7
+ export const MAX_LIVE_WORKER_CONTEXT_BYTES = 12 * 1024;
8
+ export const MAX_LIVE_WORKER_ITEM_BYTES = 1024;
9
+ export const MAX_LIVE_WORKER_ASSIGNMENT_BYTES = 480;
10
+
11
+ const ACTIVE_STATUSES = new Set<WorkerRecord["status"]>([
12
+ "starting",
13
+ "running",
14
+ "stopping",
15
+ ]);
16
+ const ITEM_TRUNCATION_MARKER =
17
+ "\n [entry truncated; use worker_status once for diagnostics/recovery]";
18
+ const ASSIGNMENT_TRUNCATION_MARKER = " … [assignment excerpt truncated]";
19
+
20
+ type CustomMessage = Extract<AgentMessage, { readonly role: "custom" }>;
21
+
22
+ export interface LiveWorkerContextOptions {
23
+ readonly ownerSessionId: string;
24
+ readonly pendingResultCount: number;
25
+ readonly timestamp?: number;
26
+ }
27
+
28
+ /** Project only current actionable process state; terminal history and outcomes stay out. */
29
+ export function projectLiveWorkerContext(
30
+ snapshot: OwnerSnapshot,
31
+ options: LiveWorkerContextOptions,
32
+ ): CustomMessage | undefined {
33
+ const workers = snapshot.workers.filter((worker) =>
34
+ worker.ownerSessionId === options.ownerSessionId && isActionable(worker)
35
+ );
36
+ const pendingResultCount = normalizeCount(options.pendingResultCount);
37
+ if (workers.length === 0 && pendingResultCount === 0) return undefined;
38
+
39
+ const activeCount = workers.filter((worker) => ACTIVE_STATUSES.has(worker.status)).length;
40
+ const readyCount = workers.length - activeCount;
41
+ const items = workers.map(renderWorkerItem);
42
+ const content = renderBoundedContext(
43
+ items,
44
+ activeCount,
45
+ readyCount,
46
+ pendingResultCount,
47
+ );
48
+
49
+ return {
50
+ role: "custom",
51
+ customType: LIVE_WORKER_CONTEXT_TYPE,
52
+ content,
53
+ display: false,
54
+ timestamp: options.timestamp ?? Date.now(),
55
+ };
56
+ }
57
+
58
+ /** Replace this extension's transient projection while preserving every unrelated message. */
59
+ export function replaceLiveWorkerContext(
60
+ messages: readonly AgentMessage[],
61
+ context: CustomMessage | undefined,
62
+ ): AgentMessage[] {
63
+ const retained = messages.filter((message) =>
64
+ message.role !== "custom" || message.customType !== LIVE_WORKER_CONTEXT_TYPE
65
+ );
66
+ return context ? [...retained, context] : retained;
67
+ }
68
+
69
+ function isActionable(worker: WorkerRecord): boolean {
70
+ return ACTIVE_STATUSES.has(worker.status) ||
71
+ (worker.status === "ready" && worker.lifecycle === "interactive");
72
+ }
73
+
74
+ function renderWorkerItem(worker: WorkerRecord): string {
75
+ const metadata = [
76
+ `worker_id=${quote(worker.id)}`,
77
+ `run_id=${quote(worker.runId)}`,
78
+ `definition=${quote(normalizeText(worker.worker))}`,
79
+ `title=${quote(normalizeText(worker.title))}`,
80
+ `lifecycle=${worker.lifecycle}`,
81
+ `status=${worker.status}`,
82
+ ].join(" | ");
83
+ const assignment = capUtf8(
84
+ normalizeText(worker.instructions),
85
+ MAX_LIVE_WORKER_ASSIGNMENT_BYTES,
86
+ ASSIGNMENT_TRUNCATION_MARKER,
87
+ );
88
+ const item = `- ${metadata}\n assignment=${quote(assignment)}`;
89
+ return capUtf8(item, MAX_LIVE_WORKER_ITEM_BYTES, ITEM_TRUNCATION_MARKER);
90
+ }
91
+
92
+ function renderBoundedContext(
93
+ items: readonly string[],
94
+ activeCount: number,
95
+ readyCount: number,
96
+ pendingResultCount: number,
97
+ ): string {
98
+ for (let shown = items.length; shown >= 0; shown -= 1) {
99
+ const content = renderContext(
100
+ items.slice(0, shown),
101
+ items.length,
102
+ activeCount,
103
+ readyCount,
104
+ pendingResultCount,
105
+ );
106
+ if (utf8Bytes(content) <= MAX_LIVE_WORKER_CONTEXT_BYTES) return content;
107
+ }
108
+
109
+ // Fixed guidance is intentionally far below the global limit. Keep a defensive
110
+ // cap so future copy changes cannot violate the public bound.
111
+ return capUtf8(
112
+ renderContext([], items.length, activeCount, readyCount, pendingResultCount),
113
+ MAX_LIVE_WORKER_CONTEXT_BYTES,
114
+ "\n[Live worker context truncated; use worker_status once for recovery.]",
115
+ );
116
+ }
117
+
118
+ function renderContext(
119
+ shownItems: readonly string[],
120
+ totalWorkers: number,
121
+ activeCount: number,
122
+ readyCount: number,
123
+ pendingResultCount: number,
124
+ ): string {
125
+ const sections = [
126
+ "## Pi Orchestrate live worker context",
127
+ "Authoritative transient process snapshot for this parent provider call; it is not conversation history.",
128
+ `Relevant owned workers: ${totalWorkers} (${activeCount} active, ${readyCount} ready interactive).`,
129
+ ];
130
+
131
+ if (shownItems.length > 0) {
132
+ sections.push(shownItems.join("\n"));
133
+ }
134
+ if (shownItems.length < totalWorkers) {
135
+ const omitted = totalWorkers - shownItems.length;
136
+ sections.push(
137
+ `Snapshot overflow: showing ${shownItems.length} of ${totalWorkers}; ${omitted} worker${omitted === 1 ? "" : "s"} omitted by the ${MAX_LIVE_WORKER_CONTEXT_BYTES}-byte limit. Use worker_status once for diagnostics/recovery to inspect omitted worker IDs and current state; do not poll.`,
138
+ );
139
+ }
140
+ if (pendingResultCount > 0) {
141
+ sections.push(
142
+ `Pending delivery: ${pendingResultCount} settled worker result${pendingResultCount === 1 ? "" : "s"} await automatic delivery. Do not redispatch that work.`,
143
+ );
144
+ }
145
+
146
+ const guidance: string[] = [];
147
+ if (activeCount > 0) {
148
+ guidance.push(
149
+ "Do not duplicate active assignments. Continue useful independent work when it materially advances the outcome.",
150
+ );
151
+ }
152
+ if (readyCount > 0) {
153
+ guidance.push(
154
+ "Ready interactive sessions are retained: use interactive_send with the worker_id for follow-up, or interactive_close when finished.",
155
+ );
156
+ }
157
+ guidance.push(
158
+ "Settled results are delivered automatically after the parent run ends. If progress depends on pending evidence, end the run truthfully rather than polling. Use worker_status only for diagnostics or recovery when this snapshot reports overflow or state appears inconsistent.",
159
+ );
160
+ sections.push(`Guidance:\n- ${guidance.join("\n- ")}`);
161
+
162
+ return sections.join("\n\n");
163
+ }
164
+
165
+ function normalizeText(value: string): string {
166
+ return value.replace(/\s+/gu, " ").trim();
167
+ }
168
+
169
+ function quote(value: string): string {
170
+ return JSON.stringify(value);
171
+ }
172
+
173
+ function normalizeCount(value: number): number {
174
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
175
+ }
176
+
177
+ function utf8Bytes(value: string): number {
178
+ return Buffer.byteLength(value, "utf8");
179
+ }
180
+
181
+ function capUtf8(value: string, byteLimit: number, marker: string): string {
182
+ if (utf8Bytes(value) <= byteLimit) return value;
183
+ const markerBytes = utf8Bytes(marker);
184
+ if (markerBytes >= byteLimit) return truncateUtf8(marker, byteLimit);
185
+ return `${truncateUtf8(value, byteLimit - markerBytes)}${marker}`;
186
+ }
187
+
188
+ function truncateUtf8(value: string, byteLimit: number): string {
189
+ if (byteLimit <= 0) return "";
190
+ const bytes = Buffer.from(value, "utf8");
191
+ if (bytes.byteLength <= byteLimit) return value;
192
+
193
+ let end = byteLimit;
194
+ while (end > 0 && (bytes[end] ?? 0) >> 6 === 0b10) end -= 1;
195
+ return bytes.subarray(0, end).toString("utf8");
196
+ }
@@ -1,4 +1,3 @@
1
- import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
1
  import type {
3
2
  ExtensionAPI,
4
3
  ExtensionContext,
@@ -28,10 +27,7 @@ import type {
28
27
  } from "../orchestration/admission.ts";
29
28
  import type {
30
29
  AcceptedRun,
31
- CompletedRun,
32
30
  OwnerSnapshot,
33
- SettlementListener,
34
- WorkerRunResult,
35
31
  } from "../orchestration/service.ts";
36
32
  import type { DispatchDecision } from "../parent/dispatch-policy.ts";
37
33
  import type { OrchestrationClient } from "../parent/process-host.ts";
@@ -42,11 +38,6 @@ import {
42
38
  workerAbortToolRenderer,
43
39
  workerStatusToolRenderer,
44
40
  } from "./tool-renderer.ts";
45
- import {
46
- encodeInlineWorkerToolDetails,
47
- type InlineWorkerSettlementDetails,
48
- type WorkerSettlement,
49
- } from "../orchestration/settlement.ts";
50
41
 
51
42
  const STRICT_OBJECT = { additionalProperties: false } as const;
52
43
  const shortTextSchema = Type.String({
@@ -114,45 +105,30 @@ export function registerOrchestrationTools(
114
105
  name: "orchestrate",
115
106
  label: "Orchestrate",
116
107
  description:
117
- "Dispatch fully briefed worker scopes. Pi executes native sibling tools concurrently; Pi Orchestrate treats a successfully admitted sole orchestrate call or pure sibling group as async. Mixing orchestrate with another tool makes it inline and blocking.",
118
- promptSnippet: "Dispatch fully briefed parallel worker scopes",
108
+ "Dispatch one fully briefed worker scope in the background. All orchestrate and interactive_send calls in the same assistant response form one result group; ordinary sibling tools execute concurrently and are not group members.",
109
+ promptSnippet: "Dispatch one fully briefed worker scope in the background",
119
110
  promptGuidelines: [
120
- "Spin up as many workers as needed to cover every useful parallel scope and distinct validation perspective. Treat user-named workers or counts as a floor unless explicitly capped, and reuse the same worker role across multiple calls when useful.",
121
- "For an intended async wave of N workers, the next assistant response must contain exactly N separate, fully briefed orchestrate calls; one call is valid only when N=1. To run it asynchronously, include no other tool calls; harmless response text does not affect runtime classification.",
122
- "When a parallel tool dispatcher is available, use it once with exactly N orchestrate entries and no other tools; for example, put N functions.orchestrate entries in multi_tool_use.parallel. Otherwise emit N native sibling orchestrate calls in one assistant response.",
123
- "Form all N calls before emitting or finalizing the response. Never emit one call and wait for its result before forming the rest of the wave: a successfully admitted sole async orchestrate call returns terminate=true and ends the turn.",
111
+ "orchestrate starts background work and returns acceptance without ending the parent run. Dispatch siblings in one assistant response share a result group; ordinary sibling tools do not join that group.",
124
112
  ],
125
113
  executionMode: "parallel",
126
114
  parameters: taskSchema,
127
115
  ...orchestrateToolRenderer,
128
- async execute(toolCallId, params, signal, onUpdate, ctx) {
116
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
129
117
  const decision = deps.getDispatchDecision(toolCallId);
130
- const mode = decision.mode;
131
- const orchestrationContext = buildOrchestrationContext(ctx, deps, decision.synthesisGroup);
132
- if (mode === "async") {
133
- const acceptedRun = await deps.orchestration.orchestrate(
134
- orchestrationContext,
135
- params,
136
- "async",
137
- signal,
138
- );
139
- const readable = acceptedRunSummary(acceptedRun);
140
- return {
141
- ...readableToolResult(`Accepted async run ${readable.run_id}.`, readable),
142
- terminate: true,
143
- };
144
- }
145
-
146
- const completedRun = await deps.orchestration.orchestrate(
118
+ const orchestrationContext = buildOrchestrationContext(
119
+ ctx,
120
+ deps,
121
+ decision.synthesisGroup,
122
+ );
123
+ const acceptedRun = await deps.orchestration.orchestrate(
147
124
  orchestrationContext,
148
125
  params,
149
- "inline",
126
+ "async",
150
127
  signal,
151
- createInlineSettlementListener(onUpdate),
152
128
  );
153
- const readable = completedRunSummary(completedRun);
129
+ const readable = acceptedRunSummary(acceptedRun);
154
130
  return readableToolResult(
155
- `Completed inline run ${readable.run_id}.`,
131
+ `Accepted async run ${readable.run_id}.`,
156
132
  readable,
157
133
  );
158
134
  },
@@ -185,43 +161,31 @@ export function registerOrchestrationTools(
185
161
  name: "interactive_send",
186
162
  label: "Interactive Send",
187
163
  description:
188
- "Send follow-up instructions only to an owned lifecycle interactive worker whose status is ready. Never use for one-shot or completed workers; one-shot sessions terminate automatically. A sole tool call runs asynchronously; sibling tool calls make it inline and blocking.",
164
+ "Send background follow-up instructions only to an owned lifecycle interactive worker whose status is ready. All interactive_send and orchestrate calls in the same assistant response form one result group; ordinary sibling tools execute concurrently and are not group members. Never use for one-shot or completed workers.",
189
165
  promptSnippet: "Use only for an owned lifecycle interactive worker with status ready; never one-shot/completed because one-shot sessions terminate automatically",
190
166
  promptGuidelines: [
191
- "Use interactive_send only for an owned lifecycle interactive worker whose status is ready; never use it for one-shot or completed workers because one-shot sessions terminate automatically.",
167
+ "interactive_send starts background follow-up work and returns acceptance without ending the parent run. Dispatch siblings in one assistant response share a result group; ordinary sibling tools do not join that group.",
192
168
  ],
193
169
  parameters: interactiveSendSchema,
194
170
  ...interactiveSendToolRenderer,
195
- async execute(toolCallId, params, signal, onUpdate, ctx) {
171
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
196
172
  const workerId = params.worker_id;
197
- const mode = deps.getDispatchDecision(toolCallId).mode;
198
- const orchestrationContext = buildOrchestrationContext(ctx, deps);
199
- if (mode === "async") {
200
- const acceptedRun = await deps.orchestration.sendInteractive(
201
- orchestrationContext,
202
- workerId,
203
- params.instructions,
204
- "async",
205
- signal,
206
- );
207
- const readable = acceptedRunSummary(acceptedRun);
208
- return {
209
- ...readableToolResult(`Accepted async run ${readable.run_id}.`, readable),
210
- terminate: true,
211
- };
212
- }
213
-
214
- const completedRun = await deps.orchestration.sendInteractive(
173
+ const decision = deps.getDispatchDecision(toolCallId);
174
+ const orchestrationContext = buildOrchestrationContext(
175
+ ctx,
176
+ deps,
177
+ decision.synthesisGroup,
178
+ );
179
+ const acceptedRun = await deps.orchestration.sendInteractive(
215
180
  orchestrationContext,
216
181
  workerId,
217
182
  params.instructions,
218
- "inline",
183
+ "async",
219
184
  signal,
220
- createInlineSettlementListener(onUpdate),
221
185
  );
222
- const readable = completedRunSummary(completedRun);
186
+ const readable = acceptedRunSummary(acceptedRun);
223
187
  return readableToolResult(
224
- `Completed inline run ${readable.run_id}.`,
188
+ `Accepted async run ${readable.run_id}.`,
225
189
  readable,
226
190
  );
227
191
  },
@@ -289,20 +253,6 @@ function buildOrchestrationContext(
289
253
  };
290
254
  }
291
255
 
292
- function createInlineSettlementListener(
293
- onUpdate: ((result: AgentToolResult<unknown>) => void) | undefined,
294
- ): SettlementListener {
295
- return (settlement) => {
296
- onUpdate?.({
297
- content: [{ type: "text", text: "Worker response received." }],
298
- details: encodeInlineWorkerToolDetails({
299
- mode: "inline",
300
- result: inlineResultValue(settlement),
301
- }),
302
- });
303
- };
304
- }
305
-
306
256
  function normalizeAbortTarget(params: {
307
257
  worker_ids?: string[];
308
258
  all?: boolean;
@@ -321,33 +271,6 @@ function acceptedRunSummary(run: AcceptedRun) {
321
271
  };
322
272
  }
323
273
 
324
- function completedRunSummary(run: CompletedRun) {
325
- return encodeInlineWorkerToolDetails({
326
- mode: "inline",
327
- runId: run.id,
328
- ownerSessionId: run.ownerSessionId,
329
- result: inlineResultValue(run.result),
330
- });
331
- }
332
-
333
- function inlineResultValue(
334
- result: WorkerRunResult | WorkerSettlement,
335
- ): InlineWorkerSettlementDetails {
336
- return {
337
- workerId: result.workerId,
338
- worker: result.worker,
339
- title: result.title,
340
- status: result.status,
341
- outcome: result.outcome,
342
- usage: result.usage,
343
- startedAt: result.startedAt,
344
- settledAt: result.settledAt,
345
- ...(result.sessionFile === undefined
346
- ? {}
347
- : { sessionFile: result.sessionFile }),
348
- };
349
- }
350
-
351
274
  function statusSummary(catalog: WorkerCatalog, snapshot: OwnerSnapshot) {
352
275
  return {
353
276
  catalog: {
@@ -674,7 +674,14 @@ const prepareChildModelRuntime = Effect.fn("WorkerSession.prepareChildModelRunti
674
674
  yield* Effect.tryPromise({
675
675
  try: async () => {
676
676
  if (auth.headers) {
677
- modelRuntime.registerProvider(selected.provider, { headers: { ...auth.headers } });
677
+ // Provider config accepts resolved values, not request-only null suppressions;
678
+ // the child runtime derives those again from the provider's auth implementation.
679
+ const headers = Object.fromEntries(
680
+ Object.entries(auth.headers).filter(
681
+ (entry): entry is [string, string] => entry[1] !== null,
682
+ ),
683
+ );
684
+ modelRuntime.registerProvider(selected.provider, { headers });
678
685
  }
679
686
  if (auth.apiKey && !options.modelRegistry.isUsingOAuth(model)) {
680
687
  await modelRuntime.setRuntimeApiKey(selected.provider, auth.apiKey);
@@ -869,8 +876,8 @@ export const createWorkerSession = Effect.fn("WorkerSession.create")(function* (
869
876
  const services = yield* acquireWorkerServices(options, dependencies, modelRuntime);
870
877
 
871
878
  // createAgentSessionServices registers extension providers and refreshes, but
872
- // discards that result in Pi 0.80.10. Keep this worker-owned probe so provider
873
- // errors and aborts remain typed acquisition failures; remove it when Pi surfaces them.
879
+ // does not surface the refresh result. Keep this worker-owned probe so provider
880
+ // errors and aborts remain typed acquisition failures.
874
881
  yield* refreshModelRuntime(modelRuntime, definition);
875
882
  const model = yield* Effect.try({
876
883
  try: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zachwill/pi-orchestrate",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "Concurrent worker orchestration for Pi",
6
6
  "exports": {},
@@ -29,17 +29,18 @@
29
29
  "effect": "4.0.0-rc.111"
30
30
  },
31
31
  "peerDependencies": {
32
- "@earendil-works/pi-agent-core": "^0.80.10",
33
- "@earendil-works/pi-ai": "^0.80.10",
34
- "@earendil-works/pi-coding-agent": "^0.80.10",
35
- "@earendil-works/pi-tui": "^0.80.10",
32
+ "@earendil-works/pi-agent-core": "^0.85.0",
33
+ "@earendil-works/pi-ai": "^0.85.0",
34
+ "@earendil-works/pi-coding-agent": "^0.85.0",
35
+ "@earendil-works/pi-tui": "^0.85.0",
36
36
  "typebox": "*"
37
37
  },
38
38
  "devDependencies": {
39
- "@earendil-works/pi-agent-core": "0.80.10",
40
- "@earendil-works/pi-ai": "0.80.10",
41
- "@earendil-works/pi-coding-agent": "0.80.10",
42
- "@earendil-works/pi-tui": "0.80.10",
39
+ "@earendil-works/pi-agent-core": "0.85.0",
40
+ "@earendil-works/pi-ai": "0.85.0",
41
+ "@earendil-works/pi-coding-agent": "0.85.0",
42
+ "@earendil-works/pi-server": "0.85.0",
43
+ "@earendil-works/pi-tui": "0.85.0",
43
44
  "@types/bun": "^1.3.14",
44
45
  "@types/node": "^22.19.17",
45
46
  "typebox": "^1.1.37",