@g3un/pi-orchestra 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.
package/README.md CHANGED
@@ -15,6 +15,9 @@ pi -e npm:@g3un/pi-orchestra
15
15
  ```
16
16
 
17
17
  Pi-Orchestra registers four tools: `bus`, `subagent`, `workgroup`, and `workflow`.
18
+ Active workflows are also shown in a TUI progress widget with the current stage
19
+ and agent completion counts. Use `/orchestra-workflows` to reopen the widget if
20
+ needed.
18
21
 
19
22
  ## Core concepts
20
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@g3un/pi-orchestra",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Subagent orchestration tools for Pi.",
5
5
  "keywords": [
6
6
  "orchestration",
@@ -3,16 +3,21 @@ import type { Bus, BusMessage } from "../core/bus.ts";
3
3
  import type { AgentStore } from "../core/store.ts";
4
4
  import type { WorkflowRun } from "../core/workflow.ts";
5
5
 
6
+ interface StoreSubscription<T> {
7
+ listener(value: T): void;
8
+ filter?: (value: T) => boolean;
9
+ }
10
+
6
11
  export class InMemoryAgentStore implements AgentStore {
7
12
  private readonly runs = new Map<string, AgentRun>();
8
13
  private readonly buses = new Map<string, Bus>();
9
14
  private readonly workflows = new Map<string, WorkflowRun>();
10
- private readonly runListeners = new Map<string, Set<(run: AgentRun) => void>>();
11
- private readonly workflowListeners = new Map<string, Set<(workflow: WorkflowRun) => void>>();
15
+ private readonly runSubscriptions = new Set<StoreSubscription<AgentRun>>();
16
+ private readonly workflowSubscriptions = new Set<StoreSubscription<WorkflowRun>>();
12
17
 
13
18
  saveRun(run: AgentRun): void {
14
19
  this.runs.set(run.id, run);
15
- for (const listener of this.runListeners.get(run.id) ?? []) listener(run);
20
+ notifySubscribers(this.runSubscriptions, run);
16
21
  }
17
22
 
18
23
  getRun(id: string): AgentRun | undefined {
@@ -23,15 +28,10 @@ export class InMemoryAgentStore implements AgentStore {
23
28
  return [...this.runs.values()];
24
29
  }
25
30
 
26
- subscribeRun(id: string, listener: (run: AgentRun) => void): () => void {
27
- const listeners = this.runListeners.get(id) ?? new Set<(run: AgentRun) => void>();
28
- listeners.add(listener);
29
- this.runListeners.set(id, listeners);
30
-
31
- return () => {
32
- listeners.delete(listener);
33
- if (listeners.size === 0) this.runListeners.delete(id);
34
- };
31
+ subscribeRuns(listener: (run: AgentRun) => void, filter?: (run: AgentRun) => boolean): () => void {
32
+ const subscription = { listener, filter };
33
+ this.runSubscriptions.add(subscription);
34
+ return () => this.runSubscriptions.delete(subscription);
35
35
  }
36
36
 
37
37
  saveBus(bus: Bus): void {
@@ -61,7 +61,7 @@ export class InMemoryAgentStore implements AgentStore {
61
61
 
62
62
  saveWorkflow(workflow: WorkflowRun): void {
63
63
  this.workflows.set(workflow.id, workflow);
64
- for (const listener of this.workflowListeners.get(workflow.id) ?? []) listener(workflow);
64
+ notifySubscribers(this.workflowSubscriptions, workflow);
65
65
  }
66
66
 
67
67
  getWorkflow(id: string): WorkflowRun | undefined {
@@ -72,14 +72,18 @@ export class InMemoryAgentStore implements AgentStore {
72
72
  return [...this.workflows.values()];
73
73
  }
74
74
 
75
- subscribeWorkflow(id: string, listener: (workflow: WorkflowRun) => void): () => void {
76
- const listeners = this.workflowListeners.get(id) ?? new Set<(workflow: WorkflowRun) => void>();
77
- listeners.add(listener);
78
- this.workflowListeners.set(id, listeners);
75
+ subscribeWorkflows(
76
+ listener: (workflow: WorkflowRun) => void,
77
+ filter?: (workflow: WorkflowRun) => boolean,
78
+ ): () => void {
79
+ const subscription = { listener, filter };
80
+ this.workflowSubscriptions.add(subscription);
81
+ return () => this.workflowSubscriptions.delete(subscription);
82
+ }
83
+ }
79
84
 
80
- return () => {
81
- listeners.delete(listener);
82
- if (listeners.size === 0) this.workflowListeners.delete(id);
83
- };
85
+ function notifySubscribers<T>(subscriptions: Set<StoreSubscription<T>>, value: T): void {
86
+ for (const subscription of subscriptions) {
87
+ if (!subscription.filter || subscription.filter(value)) subscription.listener(value);
84
88
  }
85
89
  }
@@ -183,11 +183,14 @@ export class Orchestra implements OrchestraApi {
183
183
  for (const run of initialRuns) {
184
184
  if (isTerminalAgentState(run.state)) continue;
185
185
  unsubscribeAll.push(
186
- this.store.subscribeRun(run.id, (updatedRun) => {
187
- if (settled) return;
188
- latestRuns.set(updatedRun.id, updatedRun);
189
- resolveIfDone();
190
- }),
186
+ this.store.subscribeRuns(
187
+ (updatedRun) => {
188
+ if (settled) return;
189
+ latestRuns.set(updatedRun.id, updatedRun);
190
+ resolveIfDone();
191
+ },
192
+ (updatedRun) => updatedRun.id === run.id,
193
+ ),
191
194
  );
192
195
  }
193
196
 
@@ -238,12 +241,15 @@ export class Orchestra implements OrchestraApi {
238
241
  for (const run of initialRuns) {
239
242
  if (isTerminalAgentState(run.state)) continue;
240
243
  unsubscribeAll.push(
241
- this.store.subscribeRun(run.id, (updatedRun) => {
242
- if (settled) return;
243
- latestRuns.set(updatedRun.id, updatedRun);
244
- if (!excludedRunIds.has(updatedRun.id) && isTerminalAgentState(updatedRun.state))
245
- resolveWithRun(updatedRun);
246
- }),
244
+ this.store.subscribeRuns(
245
+ (updatedRun) => {
246
+ if (settled) return;
247
+ latestRuns.set(updatedRun.id, updatedRun);
248
+ if (!excludedRunIds.has(updatedRun.id) && isTerminalAgentState(updatedRun.state))
249
+ resolveWithRun(updatedRun);
250
+ },
251
+ (updatedRun) => updatedRun.id === run.id,
252
+ ),
247
253
  );
248
254
  }
249
255
  });
package/src/core/store.ts CHANGED
@@ -6,7 +6,7 @@ export interface AgentStore {
6
6
  saveRun(run: AgentRun): void;
7
7
  getRun(id: string): AgentRun | undefined;
8
8
  listRuns(): AgentRun[];
9
- subscribeRun(id: string, listener: (run: AgentRun) => void): () => void;
9
+ subscribeRuns(listener: (run: AgentRun) => void, filter?: (run: AgentRun) => boolean): () => void;
10
10
 
11
11
  saveBus(bus: Bus): void;
12
12
  getBus(id: string): Bus | undefined;
@@ -17,5 +17,8 @@ export interface AgentStore {
17
17
  saveWorkflow(workflow: WorkflowRun): void;
18
18
  getWorkflow(id: string): WorkflowRun | undefined;
19
19
  listWorkflows(): WorkflowRun[];
20
- subscribeWorkflow(id: string, listener: (workflow: WorkflowRun) => void): () => void;
20
+ subscribeWorkflows(
21
+ listener: (workflow: WorkflowRun) => void,
22
+ filter?: (workflow: WorkflowRun) => boolean,
23
+ ): () => void;
21
24
  }
@@ -6,12 +6,14 @@ import { createBusTool, defineBusPiTool, type BusTool } from "../tools/bus.ts";
6
6
  import { createSubagentTool, defineSubagentPiTool, type SubagentTool } from "../tools/subagent.ts";
7
7
  import { createWorkflowTool, defineWorkflowPiTool, type WorkflowTool } from "../tools/workflow.ts";
8
8
  import { createWorkgroupTool, defineWorkgroupPiTool, type WorkgroupTool } from "../tools/workgroup.ts";
9
+ import { WorkflowMonitorController } from "./workflow-monitor.ts";
9
10
 
10
11
  interface ToolBundle {
11
12
  busTool: BusTool;
12
13
  subagentTool: SubagentTool;
13
14
  workgroupTool: WorkgroupTool;
14
15
  workflowTool: WorkflowTool;
16
+ workflowMonitor: WorkflowMonitorController;
15
17
  }
16
18
 
17
19
  export default function piOrchestraExtension(pi: ExtensionAPI): void {
@@ -21,7 +23,25 @@ export default function piOrchestraExtension(pi: ExtensionAPI): void {
21
23
  pi.registerTool(defineBusPiTool((ctx) => getToolBundle(ctx).busTool));
22
24
  pi.registerTool(defineSubagentPiTool((ctx) => getToolBundle(ctx).subagentTool));
23
25
  pi.registerTool(defineWorkgroupPiTool((ctx) => getToolBundle(ctx).workgroupTool));
24
- pi.registerTool(defineWorkflowPiTool((ctx) => getToolBundle(ctx).workflowTool));
26
+ pi.registerTool(
27
+ defineWorkflowPiTool((ctx) => getToolBundle(ctx).workflowTool, {
28
+ onWorkflowInput: (ctx) => getToolBundle(ctx).workflowMonitor.show(ctx),
29
+ onWorkflowOutput: (ctx) => getToolBundle(ctx).workflowMonitor.show(ctx),
30
+ }),
31
+ );
32
+
33
+ pi.registerCommand("orchestra-workflows", {
34
+ description: "Show the active pi-orchestra workflow progress widget.",
35
+ handler: async (_args, ctx) => {
36
+ const monitor = getToolBundle(ctx).workflowMonitor;
37
+ if (monitor.show(ctx)) return;
38
+ ctx.ui.notify("No active pi-orchestra workflows.", "info");
39
+ },
40
+ });
41
+
42
+ pi.on("session_shutdown", (_event, ctx) => {
43
+ bundles.get(ctx.cwd)?.workflowMonitor.dispose();
44
+ });
25
45
  }
26
46
 
27
47
  function getBundle(bundles: Map<string, ToolBundle>, ctx: ExtensionContext): ToolBundle {
@@ -40,6 +60,7 @@ function getBundle(bundles: Map<string, ToolBundle>, ctx: ExtensionContext): Too
40
60
  subagentTool: createSubagentTool({ orchestra }),
41
61
  workgroupTool: createWorkgroupTool({ orchestra }),
42
62
  workflowTool: createWorkflowTool({ orchestra, store }),
63
+ workflowMonitor: new WorkflowMonitorController(store),
43
64
  };
44
65
  bundles.set(ctx.cwd, bundle);
45
66
  return bundle;
@@ -0,0 +1,143 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentRun } from "../core/subagent.ts";
3
+ import type { AgentStore } from "../core/store.ts";
4
+ import type { WorkflowRun, WorkflowStageRun } from "../core/workflow.ts";
5
+ import { formatNamedEntityLabel, isTerminalAgentState } from "../utils.ts";
6
+
7
+ const WIDGET_KEY = "pi-orchestra.workflow-monitor";
8
+ const MAX_MONITORED_WORKFLOWS = 2;
9
+ const MAX_WIDGET_LINES = 10;
10
+
11
+ export class WorkflowMonitorController {
12
+ private unsubscribe?: () => void;
13
+ private ctx?: ExtensionContext;
14
+
15
+ constructor(private readonly store: AgentStore) {}
16
+
17
+ hasActiveWorkflows(): boolean {
18
+ return listActiveWorkflows(this.store).length > 0;
19
+ }
20
+
21
+ show(ctx: ExtensionContext): boolean {
22
+ if (!ctx.hasUI) return false;
23
+
24
+ this.ctx = ctx;
25
+ if (!this.unsubscribe) {
26
+ const unsubscribeRuns = this.store.subscribeRuns(() => this.render());
27
+ const unsubscribeWorkflows = this.store.subscribeWorkflows(() => this.render());
28
+ this.unsubscribe = () => {
29
+ unsubscribeRuns();
30
+ unsubscribeWorkflows();
31
+ };
32
+ }
33
+
34
+ return this.render();
35
+ }
36
+
37
+ dispose(): void {
38
+ this.unsubscribe?.();
39
+ this.unsubscribe = undefined;
40
+
41
+ if (this.ctx?.hasUI) {
42
+ this.ctx.ui.setWidget(WIDGET_KEY, undefined);
43
+ }
44
+ this.ctx = undefined;
45
+ }
46
+
47
+ private render(): boolean {
48
+ const ctx = this.ctx;
49
+ if (!ctx?.hasUI) return false;
50
+
51
+ const lines = buildWorkflowMonitorLines(this.store);
52
+ if (lines.length === 0) {
53
+ this.dispose();
54
+ return false;
55
+ }
56
+
57
+ ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "belowEditor" });
58
+ return true;
59
+ }
60
+ }
61
+
62
+ export function buildWorkflowMonitorLines(store: AgentStore): string[] {
63
+ const workflows = listActiveWorkflows(store);
64
+ if (workflows.length === 0) return [];
65
+
66
+ const lines: string[] = [];
67
+ for (const workflow of workflows.slice(0, MAX_MONITORED_WORKFLOWS)) {
68
+ appendWorkflowLines(lines, store, workflow);
69
+ if (lines.length >= MAX_WIDGET_LINES) break;
70
+ }
71
+
72
+ const hiddenWorkflowCount = workflows.length - MAX_MONITORED_WORKFLOWS;
73
+ if (hiddenWorkflowCount > 0 && lines.length < MAX_WIDGET_LINES) {
74
+ lines.push(`... +${hiddenWorkflowCount} more active ${pluralize("workflow", hiddenWorkflowCount)}`);
75
+ }
76
+
77
+ return lines.slice(0, MAX_WIDGET_LINES);
78
+ }
79
+
80
+ function appendWorkflowLines(lines: string[], store: AgentStore, workflow: WorkflowRun): void {
81
+ if (lines.length >= MAX_WIDGET_LINES) return;
82
+
83
+ const stage = getCurrentStage(workflow);
84
+ const stageLabel = stage ? formatStageLabel(store, workflow, stage) : "none · agents 0/0";
85
+ lines.push(`${formatNamedEntityLabel(workflow)} | ${stageLabel}`);
86
+ }
87
+
88
+ function listActiveWorkflows(store: AgentStore): WorkflowRun[] {
89
+ return store.listWorkflows().filter((workflow) => !isTerminalAgentState(workflow.state));
90
+ }
91
+
92
+ function getCurrentStage(workflow: WorkflowRun): WorkflowStageRun | undefined {
93
+ return (
94
+ workflow.stages[workflow.currentStageIndex] ?? workflow.stages.find((stage) => !isTerminalAgentState(stage.state))
95
+ );
96
+ }
97
+
98
+ function formatStageLabel(store: AgentStore, workflow: WorkflowRun, stage: WorkflowStageRun): string {
99
+ const stageIndex = workflow.stages.indexOf(stage);
100
+ const stagePosition = stageIndex >= 0 ? `${stageIndex + 1}/${workflow.stages.length}` : `?/${workflow.stages.length}`;
101
+ return `${stage.name} · step ${stagePosition} · agents ${formatStageProgress(store, stage)}`;
102
+ }
103
+
104
+ function formatStageProgress(store: AgentStore, stage: WorkflowStageRun): string {
105
+ const progress = calculateStageProgress(store, stage);
106
+ return `${progress.completed}/${progress.total}`;
107
+ }
108
+
109
+ function calculateStageProgress(store: AgentStore, stage: WorkflowStageRun): { completed: number; total: number } {
110
+ const runs = collectStageRuns(store, stage);
111
+ const completed = runs.filter((run) => isTerminalAgentState(run.state)).length;
112
+ const workerRunCount = runs.filter((run) => run.id !== stage.leaderRunId).length;
113
+ const workerTotal = Math.max(stage.members.length, stage.workerRunIds.length, workerRunCount);
114
+ const leaderTotal = stage.phase === "leader" || stage.leaderRunId !== undefined ? 1 : 0;
115
+ const total = Math.max(workerTotal + leaderTotal, runs.length);
116
+ return { completed: Math.min(completed, total), total };
117
+ }
118
+
119
+ function collectStageRuns(store: AgentStore, stage: WorkflowStageRun): AgentRun[] {
120
+ const runsById = new Map<string, AgentRun>();
121
+
122
+ for (const runId of stage.workerRunIds) {
123
+ const run = store.getRun(runId);
124
+ if (run) runsById.set(run.id, run);
125
+ }
126
+
127
+ if (stage.leaderRunId) {
128
+ const leaderRun = store.getRun(stage.leaderRunId);
129
+ if (leaderRun) runsById.set(leaderRun.id, leaderRun);
130
+ }
131
+
132
+ if (stage.busId) {
133
+ for (const run of store.listRuns().filter((current) => current.busId === stage.busId)) {
134
+ runsById.set(run.id, run);
135
+ }
136
+ }
137
+
138
+ return [...runsById.values()];
139
+ }
140
+
141
+ function pluralize(noun: string, count: number): string {
142
+ return count === 1 ? noun : `${noun}s`;
143
+ }
@@ -65,6 +65,11 @@ export interface WorkflowToolDeps {
65
65
  store: AgentStore;
66
66
  }
67
67
 
68
+ export interface WorkflowPiToolOptions {
69
+ onWorkflowInput?: (ctx: ExtensionContext, input: WorkflowInput) => void;
70
+ onWorkflowOutput?: (ctx: ExtensionContext, output: WorkflowOutput) => void;
71
+ }
72
+
68
73
  const WorkflowStageParams = Type.Object(
69
74
  {
70
75
  name: Type.String({
@@ -181,7 +186,10 @@ export function createWorkflowTool({ orchestra, store }: WorkflowToolDeps): Work
181
186
  };
182
187
  }
183
188
 
184
- export function defineWorkflowPiTool(resolveTool: (ctx: ExtensionContext) => WorkflowTool) {
189
+ export function defineWorkflowPiTool(
190
+ resolveTool: (ctx: ExtensionContext) => WorkflowTool,
191
+ options: WorkflowPiToolOptions = {},
192
+ ) {
185
193
  return defineTool({
186
194
  name: "workflow",
187
195
  label: "Workflow",
@@ -196,9 +204,11 @@ export function defineWorkflowPiTool(resolveTool: (ctx: ExtensionContext) => Wor
196
204
  parameters: WorkflowToolParams,
197
205
  executionMode: "sequential",
198
206
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
199
- const output = await resolveTool(ctx).execute(
200
- withDefaultModels(toWorkflowInput(params as RawWorkflowParams), ctx),
201
- );
207
+ const input = withDefaultModels(toWorkflowInput(params as RawWorkflowParams), ctx);
208
+ options.onWorkflowInput?.(ctx, input);
209
+
210
+ const output = await resolveTool(ctx).execute(input);
211
+ if (output.workflow) options.onWorkflowOutput?.(ctx, output);
202
212
 
203
213
  return {
204
214
  content: [{ type: "text", text: output.message }],
@@ -646,15 +656,18 @@ function waitWorkflow(
646
656
  unsubscribe();
647
657
  };
648
658
 
649
- const unsubscribe = store.subscribeWorkflow(workflowId, (workflow) => {
650
- if (settled) return;
651
- latestWorkflow = workflow;
652
- if (!isTerminalAgentState(workflow.state)) return;
659
+ const unsubscribe = store.subscribeWorkflows(
660
+ (workflow) => {
661
+ if (settled) return;
662
+ latestWorkflow = workflow;
663
+ if (!isTerminalAgentState(workflow.state)) return;
653
664
 
654
- settled = true;
655
- cleanup();
656
- resolve({ workflow, timedOut: false });
657
- });
665
+ settled = true;
666
+ cleanup();
667
+ resolve({ workflow, timedOut: false });
668
+ },
669
+ (workflow) => workflow.id === workflowId,
670
+ );
658
671
 
659
672
  if (resolvedTimeoutMs !== null) {
660
673
  timeout = setTimeout(() => {