@fradser/pi-agent-teams 0.5.1 → 0.5.3

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/src/types.ts CHANGED
@@ -42,13 +42,25 @@ export interface Node {
42
42
  access: "read" | "write";
43
43
  /** Optional per-node model pin (provider/model). */
44
44
  model?: string;
45
- /** Optional per-node hard wall-clock cap before the worker is killed. */
46
- timeoutMs?: number;
45
+ /** RPC mode keeps stdin available for runtime steering. */
46
+ mode?: "json" | "rpc";
47
+ /** Optional maximum number of assistant turns before the worker is stopped. */
48
+ turnBudget?: number;
47
49
  /** Node ids that must complete before this node may start. */
48
50
  dependsOn: string[];
51
+ /** Named upstream node ids whose results are included as fork context. */
52
+ forkContext?: string[];
53
+ /** Named input bindings using `nodeId#/json/pointer` sources. */
54
+ inputBindings?: Record<string, string>;
49
55
  status: NodeStatus;
50
56
  result?: string;
57
+ /** Named structured outputs emitted through teammate_message. */
58
+ namedOutputs?: Record<string, string>;
59
+ /** Bounded JSON output emitted through teammate_message for downstream data flow. */
60
+ structuredOutput?: unknown;
51
61
  errorMessage?: string;
62
+ /** True once the terminal node follow-up has been delivered. */
63
+ nodeFollowUpSent?: boolean;
52
64
  /** Real child-process execution info when this node was spawned. */
53
65
  spawn?: SpawnInfo;
54
66
  createdAt: number;
@@ -66,11 +78,7 @@ export interface Run {
66
78
  worktree: boolean;
67
79
  /** background=true returns immediately; false gathers in the tool call. */
68
80
  background: boolean;
69
- /** Optional run-level hard wall-clock cap before the run fails. */
70
- timeoutMs?: number;
71
- /** Absolute deadline for timeoutMs, when set. */
72
- deadlineAt?: number;
73
- /** Run-level failure detail (e.g. run timeout). */
81
+ /** Run-level failure detail. */
74
82
  errorMessage?: string;
75
83
  nodes: Record<string, Node>;
76
84
  createdAt: number;
@@ -102,6 +110,7 @@ export interface WorkerUsage {
102
110
  export interface SpawnInfo {
103
111
  /** Per-spawn capability identity, regenerated for every worker process. */
104
112
  spawnId: string;
113
+ mode?: "json" | "rpc";
105
114
  pid: number;
106
115
  status: SpawnStatus;
107
116
  startedAt: number;
@@ -125,7 +134,6 @@ export interface SpawnInfo {
125
134
  error?: string;
126
135
  /** Token/cost usage reported by the worker, when available. */
127
136
  usage?: WorkerUsage;
128
- /** True when the worker was killed by the spawn timeout. */
129
137
  timedOut?: boolean;
130
138
  /** Whether this spawn owns a dedicated Git worktree. */
131
139
  isolation?: "worktree" | "none";
@@ -151,6 +159,13 @@ export interface WorkerMessageEvent {
151
159
  subject: string;
152
160
  body: string;
153
161
  status?: "in_progress" | "completed" | "failed";
162
+ data?: {
163
+ kind?: "named_output" | "output";
164
+ name?: string;
165
+ value?: string;
166
+ output?: unknown;
167
+ message?: string;
168
+ };
154
169
  }
155
170
 
156
171
  export type WorkerEvent = WorkerMessageEvent;
@@ -174,7 +189,10 @@ export const RunTaskSpec = Type.Object({
174
189
  }),
175
190
  access: Type.Optional(NodeAccess),
176
191
  model: Type.Optional(Type.String({ description: "Optional per-node provider/model pin" })),
177
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Optional per-node hard wall-clock cap before the worker is killed (default: 30 minutes; do not set overly short timeouts for multi-file or reasoning tasks)" })),
192
+ mode: Type.Optional(Type.Union([Type.Literal("json"), Type.Literal("rpc")], { description: "Worker process mode; rpc enables runtime steering" })),
193
+ turnBudget: Type.Optional(Type.Integer({ minimum: 1, description: "Optional maximum assistant turns before the worker is stopped (default: 100; high safety cap for edge cases)" })),
194
+ forkContext: Type.Optional(Type.Array(Type.String(), { description: "Named upstream node ids whose results are included as fork context" })),
195
+ inputBindings: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Named inputs bound from dependency outputs using nodeId#/json/pointer" })),
178
196
  });
179
197
 
180
198
  /** Dispatch a dependency-aware task graph in one call. */
@@ -187,7 +205,6 @@ export const TeammateRunParams = Type.Object({
187
205
  concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 32, description: "Max nodes running at once (default: 4)" })),
188
206
  worktree: Type.Optional(Type.Boolean({ description: "Run every node in its own git worktree (default: false)" })),
189
207
  background: Type.Optional(Type.Boolean({ default: true, description: "Return immediately and deliver one completion follow-up. Default: true — teammates always run in the background; workers message team-leader with deliverables upon completion." })),
190
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Run-level hard wall-clock cap; the run fails when exceeded (default: none, nodes have their own caps)" })),
191
208
  summarize: Type.Optional(Type.Boolean({ description: "Append a __summary node after all leaf nodes. Default: true when the run has more than one user task, false for a single task." })),
192
209
  summaryAgent: Type.Optional(Type.String({ description: "Agent used for the summary node when summarize is on (default: observer)" })),
193
210
  cwd: Type.Optional(Type.String({ description: "Working directory for this run (default: the session cwd)" })),
@@ -214,6 +231,27 @@ export const TeammateMessageParams = Type.Object({
214
231
  Type.Literal("completed"),
215
232
  Type.Literal("failed"),
216
233
  ], { description: "Optional worker status; use completed or failed for the final deliverable" })),
234
+ data: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Structured teammate data: named_output or output" })),
235
+ });
236
+
237
+ /** Leader-only operation that fans out a completed node's structured array output. */
238
+ export const TeammateFanoutParams = Type.Object({
239
+ runId: Type.String({ description: "Source run id" }),
240
+ nodeId: Type.String({ description: "Completed source node whose structured output is an array" }),
241
+ agent: Type.String({ description: "Agent to run for each item" }),
242
+ prompt: Type.String({ minLength: 1, description: "Task prompt; each item is appended as JSON" }),
243
+ paths: Type.Array(Type.String(), { minItems: 1, description: "Repository-relative paths for each child run" }),
244
+ access: Type.Optional(NodeAccess),
245
+ model: Type.Optional(Type.String()),
246
+ turnBudget: Type.Optional(Type.Integer({ minimum: 1 })),
247
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 32 })),
248
+ background: Type.Optional(Type.Boolean({ default: true })),
249
+ });
250
+
251
+ /** Leader-only runtime steer sent through the existing teammate_message name. */
252
+ export const TeammateLeaderMessageParams = Type.Object({
253
+ target: Type.String({ description: "Run-qualified worker key, for example run_1:inspect" }),
254
+ body: Type.String({ minLength: 1, description: "Steering message for the running RPC worker" }),
217
255
  });
218
256
 
219
257
  // ── State snapshot for persistence ────────────────────────────────
package/src/ui.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
4
4
  import { createPiThemeStyle, PI_SPINNER_FRAMES, PI_SPINNER_INTERVAL_MS } from "@fradser/pi-kit";
5
- import type { ExtensionUIContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
6
6
  import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
7
7
  import {
8
8
  clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
@@ -19,18 +19,12 @@ function cap(text: string | undefined, maxBytes = DEFAULT_MAX_BYTES): string {
19
19
  }
20
20
 
21
21
  // ── Team UI: passive widget + full-screen console ──────────────────
22
- // The widget above the editor is DISPLAY-ONLY. ALL interaction happens in the
22
+ // The widget below the editor is DISPLAY-ONLY. ALL interaction happens in the
23
23
  // full-screen Team Console (/teammate), which owns input via ctx.ui.custom.
24
24
 
25
- const TEAM_COLORS = ["accent", "success", "warning", "error", "toolTitle", "mdLink"] as const;
26
- const PANEL_IDLE_COLLAPSE_MS = 30_000;
27
-
28
- let panelRequestRender: (() => void) | undefined;
25
+ const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
29
26
  let spinnerTimer: ReturnType<typeof setInterval> | undefined;
30
27
  let spinnerFrame = 0;
31
- let panelLastActivity = 0;
32
- let panelCollapseTimer: ReturnType<typeof setTimeout> | undefined;
33
- let widgetRegistered = false;
34
28
 
35
29
  /** Stable per-node color (independent of row order). */
36
30
  function hashName(name: string): number {
@@ -56,14 +50,10 @@ function runningTeammateLabel(node: Node): string {
56
50
  // Like the memory package's activity line: show what the worker is doing
57
51
  // right now — current tool when one is executing, else its live reasoning,
58
52
  // else the latest assistant text.
59
- const detail = tool
60
- ? ` ${tool}`
61
- : thinking
62
- ? ` ${truncateToWidth(thinking, 48)}`
63
- : live
64
- ? ` ${truncateToWidth(live, 48)}`
65
- : " working...";
66
- return `${frame}${detail}`;
53
+ const activity = tool
54
+ ?? (thinking ? truncateToWidth(thinking, 48) : undefined)
55
+ ?? (live ? truncateToWidth(live, 48) : "Working...");
56
+ return `${frame} ${activity}`;
67
57
  }
68
58
 
69
59
  function ensureSpinner(): void {
@@ -71,7 +61,6 @@ function ensureSpinner(): void {
71
61
  if (running && !spinnerTimer) {
72
62
  spinnerTimer = setInterval(() => {
73
63
  spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
74
- panelRequestRender?.();
75
64
  }, PI_SPINNER_INTERVAL_MS);
76
65
  spinnerTimer.unref?.();
77
66
  } else if (!running && spinnerTimer) {
@@ -80,79 +69,73 @@ function ensureSpinner(): void {
80
69
  }
81
70
  }
82
71
 
83
- function isPanelCollapsed(): boolean {
84
- return Date.now() - panelLastActivity > PANEL_IDLE_COLLAPSE_MS;
85
- }
86
-
87
- function scheduleIdleCollapse(): void {
88
- if (panelCollapseTimer) clearTimeout(panelCollapseTimer);
89
- panelCollapseTimer = setTimeout(() => panelRequestRender?.(), PANEL_IDLE_COLLAPSE_MS);
90
- panelCollapseTimer.unref?.();
91
- }
92
-
93
72
  export function stopUiTimers(): void {
94
73
  if (spinnerTimer) {
95
74
  clearInterval(spinnerTimer);
96
75
  spinnerTimer = undefined;
97
76
  }
98
- if (panelCollapseTimer) {
99
- clearTimeout(panelCollapseTimer);
100
- panelCollapseTimer = undefined;
101
- }
102
77
  }
103
78
 
104
79
  export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
105
- if (widgetRegistered || !ctx?.ui?.setWidget) return;
80
+ if (!ctx?.ui?.setWidget) return;
106
81
  if (ctx.mode && ctx.mode !== "tui") return;
107
- try {
108
- ctx.ui.setWidget("teammate", (tui, theme) => {
109
- panelRequestRender = () => tui.requestRender();
110
- ensureSpinner();
111
- return {
112
- render: (width) => panelRows(theme, width),
113
- invalidate: () => {},
114
- dispose: () => {
115
- stopUiTimers();
116
- panelRequestRender = undefined;
117
- widgetRegistered = false;
118
- },
119
- };
120
- });
121
- widgetRegistered = true;
122
- } catch {
123
- // Best effort if widget registration is unavailable in the current mode.
82
+
83
+ const running = listNodes().filter((node) => node.status === "running");
84
+ if (running.length === 0) {
85
+ ctx.ui.setWidget("teammate", undefined);
86
+ return;
124
87
  }
88
+
89
+ ctx.ui.setWidget("teammate", (tui, theme) => {
90
+ const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
91
+ timer.unref?.();
92
+ const style = createPiThemeStyle(theme);
93
+ const assignedColors = new Map<string, (typeof TEAM_COLORS)[number]>();
94
+ return {
95
+ placement: "belowEditor",
96
+ render: (width: number) => {
97
+ const running = listNodes().filter((n) => n.status === "running");
98
+ if (running.length === 0) return [];
99
+ const runningKeys = new Set(running.map((node) => node.workerKey));
100
+ for (const key of assignedColors.keys()) {
101
+ if (!runningKeys.has(key)) assignedColors.delete(key);
102
+ }
103
+ const usedColors = new Set(assignedColors.values());
104
+ for (const node of running) {
105
+ if (!assignedColors.has(node.workerKey)) {
106
+ const start = hashName(node.workerKey) % TEAM_COLORS.length;
107
+ const color = TEAM_COLORS.find((_, offset) => !usedColors.has(TEAM_COLORS[(start + offset) % TEAM_COLORS.length]));
108
+ assignedColors.set(node.workerKey, color ?? TEAM_COLORS[start]);
109
+ usedColors.add(color ?? TEAM_COLORS[start]);
110
+ }
111
+ }
112
+ const lines: string[] = [];
113
+ for (const node of running) {
114
+ const label = runningTeammateLabel(node);
115
+ const color = assignedColors.get(node.workerKey) ?? TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
116
+ const name = style.fg(color, node.id);
117
+ const role = style.dim(`(${node.agent})`);
118
+ const separator = label.indexOf(" ");
119
+ const spinner = separator === -1 ? label : label.slice(0, separator);
120
+ const activityText = separator === -1 ? "Working..." : label.slice(separator + 1).trim();
121
+ const activity = theme.bold(style.fg("accent", activityText));
122
+ const line = ` ${spinner} ${name} ${role} · ${activity}`;
123
+ lines.push(truncateToWidth(line, Math.max(10, width - 1)));
124
+ }
125
+ return lines;
126
+ },
127
+ invalidate: () => {},
128
+ dispose: () => clearInterval(timer),
129
+ };
130
+ });
125
131
  }
126
132
 
127
133
  export function refreshTeamUI(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
128
134
  ensureTeamWidget(ctx);
129
- panelLastActivity = Date.now();
130
- scheduleIdleCollapse();
131
135
  ensureLivePoll();
132
136
  ensureSpinner();
133
- panelRequestRender?.();
134
137
  }
135
138
 
136
- /** Passive widget rows (display only — no selection, no key handling). */
137
- function panelRows(theme: Theme, width?: number): string[] {
138
- const fg = (color: ThemeColor, s: string): string => theme.fg(color, s);
139
- const bold = (s: string): string => theme.bold(s);
140
- const fit = (line: string): string =>
141
- typeof width === "number" && width > 0 ? truncateToWidth(line, Math.max(10, width - 1)) : line;
142
- const running = listNodes().filter((node) => node.status === "running");
143
- // Stay out of the way until a teammate is actually working.
144
- if (running.length === 0) return [];
145
- if (isPanelCollapsed()) {
146
- return [` ${fit(fg("dim", `${running.length} teammate${running.length === 1 ? "" : "s"} working — /teammate`))}`];
147
- }
148
- const lines: string[] = [];
149
- for (const node of running) {
150
- const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
151
- lines.push(` ${fit(`${bold(fg(color, node.id))} ${fg("muted", `(${node.agent})`)} ${fg("warning", runningTeammateLabel(node))}`)}`);
152
- }
153
- lines.push(` ${fit(fg("dim", "/teammate — open console"))}`);
154
- return lines;
155
- }
156
139
 
157
140
  /** Full content of a node as shown on its detail page: spawn lifecycle, live
158
141
  * worker text, captured output, result and error. */
package/src/worker.ts CHANGED
@@ -27,7 +27,9 @@ export function isWorkerEvent(value: unknown): value is WorkerEvent {
27
27
  && typeof event.spawnId === "string"
28
28
  && typeof event.subject === "string"
29
29
  && typeof event.body === "string"
30
- && (event.status === undefined || ["in_progress", "completed", "failed"].includes(event.status));
30
+ && (event.status === undefined || ["in_progress", "completed", "failed"].includes(event.status))
31
+ && (event.data === undefined || typeof event.data === "object")
32
+ ;
31
33
  }
32
34
 
33
35
  export function registerWorkerCapabilities(pi: ExtensionAPI): void {
@@ -48,6 +50,7 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
48
50
  subject: params.subject,
49
51
  body: params.body,
50
52
  status: params.status,
53
+ data: params.data,
51
54
  });
52
55
  const statusNote = params.status ? ` with status "${params.status}"` : "";
53
56
  return { content: [{ type: "text", text: `Queued report to team-leader${statusNote}.` }], details: {} };