@zachwill/pi-orchestrate 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,15 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
1
2
  import type {
2
3
  ExtensionAPI,
3
4
  ExtensionContext,
5
+ Theme,
4
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { Container, Markdown, Spacer, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
5
8
  import {
6
9
  formatSize,
7
10
  getAgentDir,
11
+ getMarkdownTheme,
12
+ keyHint,
8
13
  truncateHead,
9
14
  } from "@earendil-works/pi-coding-agent";
10
15
  import { Type } from "typebox";
@@ -110,15 +115,30 @@ export function registerOrchestrationTools(
110
115
  "Use orchestrate for one independent worker wave, with a complete brief for every task.",
111
116
  ],
112
117
  parameters: orchestrateSchema,
113
- async execute(toolCallId, params, signal, _onUpdate, ctx) {
118
+ renderCall(args, theme, { expanded }) {
119
+ return renderDispatchCall(theme, args.tasks, expanded);
120
+ },
121
+ renderResult(result, { isPartial, expanded }, theme, context) {
122
+ return renderOrchestrationResult(result, isPartial, expanded, theme, context.lastComponent);
123
+ },
124
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
114
125
  const mode = deps.getDispatchMode(toolCallId);
115
126
  const runtimeContext = await buildRuntimeContext(ctx, deps);
127
+ const settlements: unknown[] = [];
128
+ const onSettlement = mode === "inline" ? (settlement: unknown) => {
129
+ settlements.push(settlement);
130
+ onUpdate?.({
131
+ content: [{ type: "text", text: `${settlements.length} worker response(s) received.` }],
132
+ details: { mode: "inline", settlements: [...settlements] },
133
+ });
134
+ } : undefined;
116
135
  const wave = await orchestrateWithMode(
117
136
  deps.runtime,
118
137
  runtimeContext,
119
138
  params.tasks,
120
139
  mode,
121
140
  signal,
141
+ onSettlement,
122
142
  );
123
143
 
124
144
  if (mode === "async") {
@@ -163,6 +183,12 @@ export function registerOrchestrationTools(
163
183
  "Use orchestration_status only for diagnostics or recovery; never poll it for completion.",
164
184
  ],
165
185
  parameters: statusSchema,
186
+ renderCall(_args, theme) {
187
+ return new Text(theme.fg("toolTitle", theme.bold("orchestration_status")), 0, 0);
188
+ },
189
+ renderResult(result, { isPartial }, theme) {
190
+ return renderDiagnosticsResult(result, isPartial, theme);
191
+ },
166
192
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
167
193
  const ownerSessionId = requireNonblank(
168
194
  "owner session ID",
@@ -195,10 +221,24 @@ export function registerOrchestrationTools(
195
221
  "Use worker_send only for follow-up work on an owned ready reusable worker.",
196
222
  ],
197
223
  parameters: workerSendSchema,
198
- async execute(toolCallId, params, signal, _onUpdate, ctx) {
224
+ renderCall(args, theme, { expanded }) {
225
+ return renderWorkerMessageCall(theme, "worker_send", args.worker_id, args.instructions, expanded);
226
+ },
227
+ renderResult(result, { isPartial, expanded }, theme, context) {
228
+ return renderOrchestrationResult(result, isPartial, expanded, theme, context.lastComponent);
229
+ },
230
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
199
231
  const workerId = asWorkerId(params.worker_id);
200
232
  const mode = deps.getDispatchMode(toolCallId);
201
233
  const runtimeContext = await buildRuntimeContext(ctx, deps);
234
+ const settlements: unknown[] = [];
235
+ const onSettlement = mode === "inline" ? (settlement: unknown) => {
236
+ settlements.push(settlement);
237
+ onUpdate?.({
238
+ content: [{ type: "text", text: `${settlements.length} worker response(s) received.` }],
239
+ details: { mode: "inline", settlements: [...settlements] },
240
+ });
241
+ } : undefined;
202
242
  const wave = await sendWithMode(
203
243
  deps.runtime,
204
244
  runtimeContext,
@@ -206,6 +246,7 @@ export function registerOrchestrationTools(
206
246
  params.instructions,
207
247
  mode,
208
248
  signal,
249
+ onSettlement,
209
250
  );
210
251
 
211
252
  if (mode === "async") {
@@ -250,6 +291,17 @@ export function registerOrchestrationTools(
250
291
  "Use worker_abort only for active work; use worker_close for a ready reusable worker.",
251
292
  ],
252
293
  parameters: workerAbortSchema,
294
+ renderCall(args, theme) {
295
+ const target = "wave_id" in args
296
+ ? args.wave_id
297
+ : "worker_ids" in args
298
+ ? `${args.worker_ids.length} worker${args.worker_ids.length === 1 ? "" : "s"}`
299
+ : "all workers";
300
+ return renderCompactCall(theme, "worker_abort", target);
301
+ },
302
+ renderResult(result, { isPartial }, theme) {
303
+ return renderSimpleResult(result, isPartial ? "Requesting worker stop…" : "Worker stop requested", theme, "warning");
304
+ },
253
305
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
254
306
  const ownerSessionId = requireNonblank(
255
307
  "owner session ID",
@@ -279,6 +331,12 @@ export function registerOrchestrationTools(
279
331
  "Use worker_close when an owned ready reusable worker is finished.",
280
332
  ],
281
333
  parameters: workerCloseSchema,
334
+ renderCall(args, theme) {
335
+ return renderCompactCall(theme, "worker_close", args.worker_id);
336
+ },
337
+ renderResult(result, { isPartial }, theme) {
338
+ return renderSimpleResult(result, isPartial ? "Closing worker…" : "✓ Worker closed", theme);
339
+ },
282
340
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
283
341
  const ownerSessionId = requireNonblank(
284
342
  "owner session ID",
@@ -325,16 +383,10 @@ function orchestrateWithMode(
325
383
  tasks: readonly OrchestrateTaskInput[],
326
384
  mode: "async" | "inline",
327
385
  signal: AbortSignal | undefined,
386
+ onSettlement?: (settlement: unknown) => void,
328
387
  ): Promise<AcceptedWave | CompletedWave> {
329
388
  if (mode === "async") return runtime.orchestrate(context, tasks, "async");
330
-
331
- const orchestrateInline = runtime.orchestrate as unknown as (
332
- context: OrchestrationContext,
333
- tasks: readonly OrchestrateTaskInput[],
334
- mode: "inline",
335
- signal?: AbortSignal,
336
- ) => Promise<CompletedWave>;
337
- return orchestrateInline.call(runtime, context, tasks, "inline", signal);
389
+ return runtime.orchestrate(context, tasks, "inline", signal, onSettlement);
338
390
  }
339
391
 
340
392
  function sendWithMode(
@@ -344,17 +396,10 @@ function sendWithMode(
344
396
  instructions: string,
345
397
  mode: "async" | "inline",
346
398
  signal: AbortSignal | undefined,
399
+ onSettlement?: (settlement: unknown) => void,
347
400
  ): Promise<AcceptedWave | CompletedWave> {
348
401
  if (mode === "async") return runtime.send(context, workerId, instructions, "async");
349
-
350
- const sendInline = runtime.send as unknown as (
351
- context: OrchestrationContext,
352
- workerId: WorkerId,
353
- instructions: string,
354
- mode: "inline",
355
- signal?: AbortSignal,
356
- ) => Promise<CompletedWave>;
357
- return sendInline.call(runtime, context, workerId, instructions, "inline", signal);
402
+ return runtime.send(context, workerId, instructions, "inline", signal, onSettlement);
358
403
  }
359
404
 
360
405
  function requireNonblank(name: string, value: string): string {
@@ -557,3 +602,235 @@ function readableDetails(title: string, details: unknown): string {
557
602
 
558
603
  return `${truncation.content}\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}. Full structured details remain available.]`;
559
604
  }
605
+
606
+ function renderDispatchCall(
607
+ theme: Theme,
608
+ tasks: readonly { worker: string; title: string; instructions: string }[],
609
+ expanded: boolean,
610
+ ): Component {
611
+ const container = new Container();
612
+ const count = tasks.length;
613
+ container.addChild(new Text(
614
+ theme.fg("toolTitle", theme.bold("orchestrate ")) + theme.fg("muted", `${count} worker${count === 1 ? "" : "s"}`),
615
+ 0, 0,
616
+ ));
617
+ if (expanded) {
618
+ for (const task of tasks) {
619
+ container.addChild(new Spacer(1));
620
+ container.addChild(new Text(`${theme.fg("accent", "→")} ${theme.fg("muted", task.worker)} · ${theme.fg("text", theme.bold(task.title))}`, 0, 0));
621
+ container.addChild(new Text(safeTerminalText(task.instructions), 2, 0));
622
+ }
623
+ return new WidthBoundComponent(container);
624
+ }
625
+ container.addChild(new InstructionPreview(tasks, theme));
626
+ container.addChild(new Text(theme.fg("dim", keyHint("app.tools.expand", "to inspect full instructions")), 0, 0));
627
+ return new WidthBoundComponent(container);
628
+ }
629
+
630
+ class InstructionPreview implements Component {
631
+ constructor(
632
+ private readonly tasks: readonly { worker: string; title: string; instructions: string }[],
633
+ private readonly theme: Theme,
634
+ ) {}
635
+ render(width: number): string[] {
636
+ const bounded = Math.max(1, width);
637
+ return this.tasks.map((task) => {
638
+ const preview = firstInstructionLine(task.instructions) ?? "";
639
+ const row = `${this.theme.fg("accent", "→")} ${safeTerminalText(task.worker)} · ${safeTerminalText(task.title)}${preview ? ` — ${safeTerminalText(preview)}` : ""}`;
640
+ return truncateToWidth(row, bounded, "…");
641
+ });
642
+ }
643
+ invalidate(): void {}
644
+ }
645
+
646
+ function renderWorkerMessageCall(
647
+ theme: Theme,
648
+ tool: string,
649
+ workerId: string,
650
+ instructions: string,
651
+ expanded: boolean,
652
+ ): Component {
653
+ const container = new Container();
654
+ container.addChild(new Text(theme.fg("toolTitle", theme.bold(`${tool} `)) + theme.fg("muted", workerId), 0, 0));
655
+ if (expanded) container.addChild(new Text(safeTerminalText(instructions), 2, 0));
656
+ else {
657
+ container.addChild(new Text(`${theme.fg("accent", "→")} ${truncateInstruction(instructions, 240)}`, 0, 0));
658
+ container.addChild(new Text(theme.fg("dim", keyHint("app.tools.expand", "to inspect full message")), 0, 0));
659
+ }
660
+ return new WidthBoundComponent(container);
661
+ }
662
+
663
+ class WidthBoundComponent implements Component {
664
+ constructor(private readonly child: Component, private readonly maxLines?: number) {}
665
+ render(width: number): string[] {
666
+ const bounded = Math.max(1, Math.floor(width));
667
+ const lines = this.child.render(bounded);
668
+ return (this.maxLines === undefined ? lines : lines.slice(0, this.maxLines))
669
+ .map((line) => truncateToWidth(line, bounded, "…"));
670
+ }
671
+ invalidate(): void { this.child.invalidate(); }
672
+ dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
673
+ }
674
+
675
+ function safeTerminalText(value: string): string {
676
+ return value.replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(/[\x00-\x08\x0B-\x1F\x7F]/g, (character) => {
677
+ const code = character.charCodeAt(0);
678
+ return code === 0x7f ? "␡" : String.fromCodePoint(0x2400 + code);
679
+ });
680
+ }
681
+
682
+ function firstInstructionLine(instructions: string): string | undefined {
683
+ return instructions.split(/\r\n?|\n/).find((line) => line.trim().length > 0);
684
+ }
685
+
686
+ function truncateInstruction(instructions: string, limit: number): string {
687
+ const first = firstInstructionLine(instructions) ?? "";
688
+ return first.length > limit ? `${first.slice(0, limit - 1)}…` : first;
689
+ }
690
+
691
+ function renderCompactCall(theme: Theme, tool: string, target: string): Text {
692
+ return new Text(
693
+ theme.fg("toolTitle", theme.bold(`${tool} `)) + theme.fg("muted", target),
694
+ 0,
695
+ 0,
696
+ );
697
+ }
698
+
699
+ function renderOrchestrationResult(
700
+ result: AgentToolResult<unknown>,
701
+ isPartial: boolean,
702
+ expanded: boolean,
703
+ theme: Theme,
704
+ lastComponent: unknown,
705
+ ): Component {
706
+ const details = result.details;
707
+ if (isRecord(details) && typeof details.id === "string" && Array.isArray(details.workerIds) && details.workerIds.every((id) => typeof id === "string")) {
708
+ const count = details.workerIds.length;
709
+ return new WidthBoundComponent(new Text(theme.fg("success", `Sent to ${count} worker${count === 1 ? "" : "s"}`) + theme.fg("dim", " · responses arrive as they complete"), 0, 0));
710
+ }
711
+ const settlements = inlineSettlements(details);
712
+ if (settlements.length > 0) {
713
+ const component = lastComponent instanceof InlineResultComponent
714
+ ? lastComponent
715
+ : new InlineResultComponent(theme);
716
+ component.update(settlements, isPartial, expanded);
717
+ return component;
718
+ }
719
+ if (isRecord(details) && (Array.isArray(details.settlements) || Array.isArray(details.results) || "workerIds" in details)) {
720
+ return new WidthBoundComponent(new Text(theme.fg("warning", "Worker result details unavailable"), 0, 0));
721
+ }
722
+ if (isPartial) return new WidthBoundComponent(new Text(theme.fg("warning", "Sending work…"), 0, 0));
723
+ return new WidthBoundComponent(renderSimpleResult(result, firstResultLine(result) || "Work sent", theme, "warning"));
724
+ }
725
+
726
+ interface InlineSettlement {
727
+ worker: string;
728
+ title: string;
729
+ status: "completed" | "ready" | "failed" | "aborted";
730
+ response: string;
731
+ }
732
+
733
+ class InlineResultComponent implements Component {
734
+ private settlements: readonly InlineSettlement[] = [];
735
+ private partial = false;
736
+ private expanded = false;
737
+ private child: Component = new Container();
738
+ constructor(private readonly theme: Theme) {}
739
+ update(settlements: readonly InlineSettlement[], partial: boolean, expanded: boolean): void {
740
+ this.settlements = settlements;
741
+ this.partial = partial;
742
+ this.expanded = expanded;
743
+ this.rebuild();
744
+ }
745
+ render(width: number): string[] { return new WidthBoundComponent(this.child).render(width); }
746
+ invalidate(): void { this.rebuild(); }
747
+ dispose(): void { (this.child as Component & { dispose?: () => void }).dispose?.(); }
748
+ private rebuild(): void {
749
+ (this.child as Component & { dispose?: () => void }).dispose?.();
750
+ const container = new Container();
751
+ for (const settlement of this.settlements) {
752
+ const failed = settlement.status === "failed";
753
+ const aborted = settlement.status === "aborted";
754
+ const color = failed ? "error" : aborted ? "warning" : "success";
755
+ const icon = failed ? "✗" : aborted ? "■" : "✓";
756
+ container.addChild(new WidthBoundComponent(new Text(this.theme.fg(color, this.theme.bold(`${icon} ${settlement.worker} · ${settlement.title} · ${settlement.status}`)), 0, 0), 1));
757
+ if (settlement.response) {
758
+ const markdown = new Markdown(settlement.response, this.expanded ? 2 : 0, 0, getMarkdownTheme());
759
+ container.addChild(new WidthBoundComponent(markdown, this.expanded ? undefined : 2));
760
+ }
761
+ container.addChild(new Spacer(1));
762
+ }
763
+ if (this.partial) container.addChild(new Text(this.theme.fg("warning", "Waiting for remaining workers…"), 0, 0));
764
+ else if (!this.expanded) container.addChild(new Text(this.theme.fg("dim", keyHint("app.tools.expand", "to inspect full responses")), 0, 0));
765
+ this.child = container;
766
+ }
767
+ }
768
+
769
+ function inlineSettlements(details: unknown): InlineSettlement[] {
770
+ if (!isRecord(details)) return [];
771
+ const values = Array.isArray(details.settlements) ? details.settlements : Array.isArray(details.results) ? details.results : [];
772
+ const parsed: InlineSettlement[] = [];
773
+ for (const value of values) {
774
+ const settlement = readInlineSettlement(value);
775
+ if (settlement) parsed.push(settlement);
776
+ }
777
+ return parsed;
778
+ }
779
+
780
+ function readInlineSettlement(value: unknown): InlineSettlement | undefined {
781
+ if (!isRecord(value) || typeof value.worker !== "string" || typeof value.title !== "string" || !isRecord(value.outcome)) return undefined;
782
+ const outcome = value.outcome;
783
+ const statuses = ["completed", "ready", "failed", "aborted"] as const;
784
+ const status = statuses.find((item) => item === value.status);
785
+ const outcomeStatus = statuses.find((item) => item === outcome.status);
786
+ if (!status || outcomeStatus !== status) return undefined;
787
+ const message = outcome.message;
788
+ const camelAssistant = outcome.assistantText;
789
+ const snakeAssistant = outcome.assistant_text;
790
+ if (message !== undefined && typeof message !== "string") return undefined;
791
+ if (camelAssistant !== undefined && typeof camelAssistant !== "string") return undefined;
792
+ if (snakeAssistant !== undefined && typeof snakeAssistant !== "string") return undefined;
793
+ const assistantText = typeof camelAssistant === "string" ? camelAssistant : snakeAssistant;
794
+ if ((status === "completed" || status === "ready") && typeof assistantText !== "string") return undefined;
795
+ if (status === "failed" && typeof message !== "string") return undefined;
796
+ return {
797
+ worker: value.worker,
798
+ title: value.title,
799
+ status,
800
+ response: [message, assistantText].filter((item): item is string => typeof item === "string" && item.length > 0).join("\n\n"),
801
+ };
802
+ }
803
+
804
+ function renderDiagnosticsResult(result: AgentToolResult<unknown>, isPartial: boolean, theme: Theme): Text {
805
+ if (isPartial) return new Text(theme.fg("muted", "Reading orchestration diagnostics…"), 0, 0);
806
+ const details = result.details;
807
+ if (isRecord(details) && isRecord(details.snapshot) && Array.isArray(details.snapshot.workers)) {
808
+ const workers = details.snapshot.workers.filter(isRecord);
809
+ const active = workers.filter((worker) => ["starting", "running", "stopping"].includes(String(worker.status))).length;
810
+ const ready = workers.filter((worker) => worker.status === "ready").length;
811
+ const diagnostics = isRecord(details.catalog) && Array.isArray(details.catalog.diagnostics) ? details.catalog.diagnostics.length : 0;
812
+ const facts = [active ? `${active} active` : "No active workers", ready ? `${ready} available for follow-up` : undefined, diagnostics ? `${diagnostics} catalog diagnostic${diagnostics === 1 ? "" : "s"}` : undefined].filter(Boolean);
813
+ return new Text(theme.fg("muted", facts.join(" · ")), 0, 0);
814
+ }
815
+ return new Text(theme.fg("muted", firstResultLine(result) || "Diagnostics unavailable"), 0, 0);
816
+ }
817
+
818
+ function renderSimpleResult(
819
+ result: AgentToolResult<unknown>,
820
+ message: string,
821
+ theme: Theme,
822
+ normalColor: "success" | "warning" = "success",
823
+ ): Text {
824
+ const failed = "isError" in result && result.isError === true;
825
+ return new Text(theme.fg(failed ? "error" : normalColor, failed ? firstResultLine(result) || message : message), 0, 0);
826
+ }
827
+
828
+ function firstResultLine(result: AgentToolResult<unknown>): string | undefined {
829
+ const first = result.content[0];
830
+ if (first?.type !== "text") return undefined;
831
+ return first.text.split("\n").find((line) => line.trim())?.trim();
832
+ }
833
+
834
+ function isRecord(value: unknown): value is Record<string, unknown> {
835
+ return typeof value === "object" && value !== null;
836
+ }
@@ -421,17 +421,22 @@ async function prepareChildModelRuntime(
421
421
  }
422
422
 
423
423
  const resolvedAuth = await options.modelRegistry.getApiKeyAndHeaders(initiallyResolvedModel);
424
+ const parentUsesOAuth = options.modelRegistry.isUsingOAuth(initiallyResolvedModel);
424
425
  if (resolvedAuth.ok) {
425
426
  if (resolvedAuth.headers) {
426
427
  modelRuntime.registerProvider(selected.provider, {
427
428
  headers: { ...resolvedAuth.headers },
428
429
  });
429
430
  }
430
- if (resolvedAuth.apiKey) {
431
+ // OAuth resolution returns an access token through the compatibility API, but
432
+ // installing that token as a runtime API key masks the complete OAuth credential
433
+ // that the child already reads from the shared auth.json file.
434
+ if (resolvedAuth.apiKey && !parentUsesOAuth) {
431
435
  await modelRuntime.setRuntimeApiKey(selected.provider, resolvedAuth.apiKey);
432
436
  }
433
437
  // Pi 0.80.10 exposes resolved provider env here but has no public ModelRuntime
434
- // runtime-env setter. Provider registrations, resolved headers, and API keys are copied.
438
+ // runtime-env setter. Provider registrations, resolved headers, and non-OAuth
439
+ // API keys are copied.
435
440
  }
436
441
 
437
442
  await refreshChildModelRuntime(modelRuntime, options.definition);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zachwill/pi-orchestrate",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Concurrent worker orchestration for Pi",
6
6
  "files": ["extension/", "examples/", "README.md", "LICENSE"],