@fradser/pi-agent-teams 0.5.3 → 0.7.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/src/tools.ts CHANGED
@@ -1,213 +1,198 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { discoverAgents, resolveAgent } from "./agents";
1
+ import { keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { formatAgentMessagePrefix, formatAgentTaskName, formatExpandHint, formatToolEventLabel, safeDisplayText } from "@fradser/pi-kit";
3
3
  import {
4
- applyWorkerEvents,
5
- buildRunSummary,
6
- cancelNodeAndTerminate,
7
- cancelRunAndTerminate,
8
- ensureRunContext,
4
+ createBoardTask,
5
+ formatSilenceDuration,
9
6
  publishStateSnapshot,
10
- scheduleRun,
11
- type DispatchCtx,
12
- } from "./run-machine";
13
- import { createRun, getRun, getSummary, listNodes, markRunCompletionDelivered, retryRun, MAX_FANOUT_ITEMS } from "./state";
14
- import { TeammateCancelParams, TeammateFanoutParams, TeammateLeaderMessageParams, TeammateRetryParams, TeammateRunParams } from "./types";
15
- import { sendWorkerSteer } from "./spawner";
16
- import { ensureTeamWidget, openTeamConsole, refreshTeamUI } from "./ui";
7
+ sendLeaderMessage,
8
+ shutdownTeammate,
9
+ spawnTeammate,
10
+ } from "./team-machine.ts";
11
+ import { listTasks, livingTeammates } from "./state.ts";
12
+ import { LEADER_RECIPIENT, SendMessageParams, TeammateShutdownParams, TeammateSpawnParams, TaskCreateParams } from "./types.ts";
13
+ import { registerTaskListTool } from "./worker.ts";
14
+ import { openTeamConsole, refreshTeamUI } from "./ui.ts";
15
+ import { discoverAgents } from "./agents.ts";
16
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
17
17
 
18
- function dispatchContext(ctx: ExtensionContext): DispatchCtx {
19
- return { ui: ctx.ui, sessionManager: ctx.sessionManager, cwd: ctx.cwd };
20
- }
21
-
22
- function sleep(ms: number): Promise<void> {
23
- return new Promise((resolve) => setTimeout(resolve, ms));
24
- }
25
-
26
- async function gatherForeground(runId: string, signal: AbortSignal | undefined): Promise<string> {
27
- const foregroundTimeoutMs = 5 * 60 * 1000;
28
- const deadline = Date.now() + foregroundTimeoutMs;
29
- while (true) {
30
- applyWorkerEvents();
31
- const run = getRun(runId);
32
- if (!run || run.status !== "running") break;
33
- if (signal?.aborted) {
34
- run.background = true;
35
- run.completionNotified = false;
36
- publishStateSnapshot();
37
- throw new Error(`Run [${runId}] continues in the background — workers will message team-leader upon completion.`);
38
- }
39
- if (Date.now() >= deadline) {
40
- run.background = true;
41
- run.completionNotified = false;
42
- publishStateSnapshot();
43
- return `Run [${runId}] is still running after ${Math.round(foregroundTimeoutMs / 1000)}s — detached to background. Workers will message team-leader upon completion.`;
44
- }
45
- await sleep(150);
46
- }
47
- markRunCompletionDelivered(runId);
48
- publishStateSnapshot();
49
- return buildRunSummary(runId);
50
- }
51
-
52
- function resolveTargetNode(target: string): { node: import("./types").Node } | undefined {
53
- return listNodes().map((node) => ({ node })).find(({ node }) => node.id === target || node.workerKey === target);
18
+ function rosterSummary(): string {
19
+ const alive = livingTeammates();
20
+ if (alive.length === 0) return "No living teammates.";
21
+ return alive.map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
54
22
  }
55
23
 
56
24
  export function registerLeaderTools(pi: ExtensionAPI): void {
57
25
  pi.registerTool({
58
- name: "teammate_run",
59
- promptSnippet: "Dispatch a dependency-aware task graph in one call",
60
- label: "Run Tasks",
61
- description: "Dispatch a dependency-aware task graph in one call. Root nodes start immediately; downstream nodes auto-start after dependencies complete. Concurrency is bounded per run and across the session. Teammates run in the background by default and deliver completion through a follow-up. Do not sleep to wait.",
62
- parameters: TeammateRunParams,
63
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
64
- const cwd = params.cwd ?? ctx.cwd ?? process.cwd();
65
- const dctx = dispatchContext(ctx);
66
- ensureRunContext(dctx);
67
- ensureTeamWidget(ctx);
68
- for (const task of params.tasks) {
69
- if (!resolveAgent(task.agent, cwd)) {
70
- const available = [...discoverAgents(cwd).keys()].join(", ");
71
- throw new Error(`Agent "${task.agent}" not found in any scope. Available agents: ${available || "(none)"}.`);
72
- }
26
+ name: "teammate_spawn",
27
+ promptSnippet: "Spawn a named resident teammate",
28
+ label: "Spawn Teammate",
29
+ description: "Spawn one named resident teammate. Model and worktree behavior come from its declarative agent definition; the kickoff prompt is optional.",
30
+ parameters: TeammateSpawnParams,
31
+ // Canonical lifecycle rows (same as packages/monitor): empty renderCall,
32
+ // ONE startup row owned by renderResult.
33
+ renderShell: "self",
34
+ renderCall: () => new Text("", 0, 0),
35
+ renderResult(result, _options, theme, context) {
36
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
37
+ if (context.isError) {
38
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to spawn teammate."), 0, 0);
73
39
  }
74
- const created = createRun({
75
- cwd,
76
- concurrency: params.concurrency ?? 4,
77
- worktree: params.worktree ?? false,
78
- background: params.background ?? true,
79
- summarize: params.summarize,
80
- summaryAgent: params.summaryAgent,
81
- nodes: params.tasks.map((task) => ({
82
- id: task.id,
83
- agent: task.agent,
84
- prompt: task.prompt,
85
- paths: task.paths,
86
- access: task.access ?? "read",
87
- model: task.model,
88
- mode: task.mode,
89
- turnBudget: task.turnBudget,
90
- forkContext: task.forkContext,
91
- inputBindings: task.inputBindings,
92
- dependsOn: task.dependsOn ?? [],
93
- })),
94
- });
95
- if (!created.ok) throw new Error(created.error ?? "Failed to create run.");
96
- const run = created.run;
97
- publishStateSnapshot();
40
+ const params = context.args as { name: string; agent: string; prompt?: string };
41
+ const line = `${theme.fg("toolTitle", theme.bold(formatToolEventLabel("started", "", "agent").trimEnd()))} ${theme.fg("accent", `@${params.name}`)} ${theme.fg("dim", "·")} ${theme.fg("customMessageText", theme.bold(formatAgentTaskName(params.prompt ?? "", params.name)))}`;
42
+ return {
43
+ render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
44
+ invalidate: () => {},
45
+ };
46
+ },
47
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
48
+ const result = spawnTeammate(params);
49
+ if (!result.ok) throw new Error(result.error);
98
50
  refreshTeamUI(ctx);
99
- scheduleRun(run.id, dctx);
100
-
101
- if (run.background) {
102
- return {
103
- content: [{ type: "text", text: `Started run [${run.id}] "${Object.keys(run.nodes).length} node(s)" — background.\nConcurrency: ${run.concurrency} | Worktree: ${run.worktree ? "yes" : "no"}\n\nWorkers will report their deliverables through the automatic completion follow-up. Do not sleep to wait.` }],
104
- details: {},
105
- };
106
- }
107
- return { content: [{ type: "text", text: await gatherForeground(run.id, signal) }], details: {} };
51
+ const kickoffNote = params.prompt?.trim()
52
+ ? "It received your kickoff prompt and is working on it."
53
+ : "It is idle: it wakes for inbox messages and claimable board tasks.";
54
+ return {
55
+ content: [{ type: "text", text: `@${params.name} is alive as ${params.agent}.\n${kickoffNote}\n\n${rosterSummary()}` }],
56
+ details: { started: true },
57
+ };
108
58
  },
109
59
  });
110
60
 
111
61
  pi.registerTool({
112
- name: "teammate_fanout",
113
- promptSnippet: "Fan out a completed node's structured array output",
114
- label: "Fan Out Tasks",
115
- description: "Leader-only bounded fanout. Validates a completed structured array before creating a separate child run.",
116
- parameters: TeammateFanoutParams,
62
+ name: "teammate_shutdown",
63
+ promptSnippet: "Shut down a resident teammate",
64
+ label: "Shutdown Teammate",
65
+ description: "Gracefully stop one named teammate. Its claimed task returns to the board; its worktree diff is captured before teardown.",
66
+ parameters: TeammateShutdownParams,
67
+ renderShell: "self",
68
+ renderCall: () => new Text("", 0, 0),
69
+ renderResult(result, options, theme, context) {
70
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
71
+ if (context.isError) {
72
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to shut down teammate."), 0, 0);
73
+ }
74
+ const name = String((context.args as { name?: string }).name ?? "");
75
+ const title = theme.fg("toolTitle", theme.bold(formatToolEventLabel("event", `@${name} shut down`, "agent")));
76
+ const render = (width: number) => {
77
+ if (width <= 0) return [];
78
+ if (!options.expanded) {
79
+ return [truncateToWidth(`${title}${formatExpandHint(keyHint("app.tools.expand", "to expand"), theme)}`, width)];
80
+ }
81
+ return [
82
+ truncateToWidth(title, width),
83
+ ...text.split("\n").map((line) => truncateToWidth(theme.fg("customMessageText", safeDisplayText(line)), width)),
84
+ ];
85
+ };
86
+ return { render, invalidate: () => {} };
87
+ },
117
88
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
118
- const source = getRun(params.runId)?.nodes[params.nodeId];
119
- if (!source || source.status !== "completed") throw new Error("Fanout source must be a completed node.");
120
- if (!Array.isArray(source.structuredOutput)) throw new Error("Fanout source must be a structured array.");
121
- if (source.structuredOutput.length > MAX_FANOUT_ITEMS) throw new Error(`Fanout is limited to ${MAX_FANOUT_ITEMS} items.`);
122
- const cwd = ctx.cwd ?? process.cwd();
123
- const dctx = dispatchContext(ctx);
124
- ensureRunContext(dctx);
125
- const tasks = source.structuredOutput.map((item, index) => ({
126
- id: `${params.nodeId}-${index + 1}`,
127
- agent: params.agent,
128
- prompt: `${params.prompt}\n\nFanout item:\n${JSON.stringify(item)}`,
129
- paths: params.paths,
130
- access: params.access ?? "read" as const,
131
- model: params.model,
132
- mode: "json" as const,
133
- turnBudget: params.turnBudget,
134
- dependsOn: [],
135
- }));
136
- if (!resolveAgent(params.agent, cwd)) throw new Error(`Agent "${params.agent}" not found.`);
137
- const created = createRun({ cwd, concurrency: params.concurrency ?? 4, worktree: false, background: params.background ?? true, summarize: true, nodes: tasks });
138
- if (!created.ok) throw new Error(created.error ?? "Failed to create fanout run.");
139
- publishStateSnapshot();
140
- scheduleRun(created.run.id, dctx);
141
- return { content: [{ type: "text", text: `Started fanout run [${created.run.id}] with ${tasks.length} item(s).` }], details: {} };
89
+ const result = await shutdownTeammate(params.name);
90
+ if (!result.ok) throw new Error(result.error);
91
+ refreshTeamUI(ctx);
92
+ return { content: [{ type: "text", text: result.body }], details: {} };
142
93
  },
143
94
  });
144
95
 
145
96
  pi.registerTool({
146
- name: "teammate_message",
147
- promptSnippet: "Send a runtime steer to a running RPC teammate",
148
- label: "Message Teammate",
149
- description: "Leader-only runtime steer. Sends through the existing teammate_message protocol to a running RPC worker; no message is available for JSON-mode workers.",
150
- parameters: TeammateLeaderMessageParams,
151
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
152
- if (!params.target.includes(":")) throw new Error("Target must be a run-qualified worker key.");
153
- const target = resolveTargetNode(params.target);
154
- if (!target?.node.spawn || target.node.status !== "running" || target.node.spawn.mode !== "rpc") {
155
- throw new Error("Target worker is not a running RPC teammate.");
97
+ name: "send_message",
98
+ promptSnippet: "Send a message to a teammate",
99
+ label: "Send Message",
100
+ description: "The only messaging primitive. Address a living teammate by name; working teammates receive a steer immediately and idle teammates wake with the message. status is reserved for worker reports to leader.",
101
+ parameters: SendMessageParams,
102
+ // Canonical lifecycle rows (same as packages/monitor): empty call slot,
103
+ // ONE delivery row owned by renderResult.
104
+ renderShell: "self",
105
+ renderCall: () => new Text("", 0, 0),
106
+ renderResult(result, _options, theme, context) {
107
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
108
+ if (context.isError) {
109
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to send message."), 0, 0);
110
+ }
111
+ const to = String((context.args as { to?: string }).to ?? "");
112
+ const details = result.details as { queued?: boolean; stalledMs?: number } | undefined;
113
+ const suffix = details?.queued ? "queued" : "delivered";
114
+ let line = `${theme.fg("toolTitle", theme.bold(formatAgentMessagePrefix("to")))}${theme.fg("accent", `@${to}`)}${theme.fg("dim", ` · ${suffix}`)}`;
115
+ if (!details?.queued && details?.stalledMs !== undefined) {
116
+ line += theme.fg("dim", ` · stalled ${formatSilenceDuration(details.stalledMs)}`);
156
117
  }
157
- const sent = sendWorkerSteer(target.node.workerKey, params.body);
158
- if (!sent) throw new Error("Target worker steering stream is unavailable.");
159
- return { content: [{ type: "text", text: `Steer sent to ${target.node.workerKey}.` }], details: {} };
118
+ return {
119
+ render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
120
+ invalidate: () => {},
121
+ };
160
122
  },
161
- });
162
-
163
- pi.registerTool({
164
- name: "teammate_cancel",
165
- promptSnippet: "Cancel a run and stop its workers",
166
- label: "Cancel Run",
167
- description: "Cancel a run or one node. Running workers receive SIGTERM with bounded SIGKILL escalation; pending nodes are cancelled.",
168
- parameters: TeammateCancelParams,
169
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
170
- const dctx = dispatchContext(ctx);
171
- ensureRunContext(dctx);
172
- const result = params.nodeId
173
- ? await cancelNodeAndTerminate(params.runId, params.nodeId, dctx)
174
- : await cancelRunAndTerminate(params.runId, dctx);
123
+ async execute(_toolCallId, params) {
124
+ if (params.to === LEADER_RECIPIENT) throw new Error('The leader cannot send a message to itself.');
125
+ if (params.status) throw new Error('status is reserved for worker reports to="leader".');
126
+ const result = sendLeaderMessage(params.to, params.message);
175
127
  if (!result.ok) throw new Error(result.error);
176
- return { content: [{ type: "text", text: params.nodeId
177
- ? `Node [${params.runId}/${params.nodeId}] cancelled — the rest of the run continues.`
178
- : `Run [${params.runId}] cancelled — pending nodes were cancelled and running workers were stopped.` }], details: {} };
128
+ const disposition = result.queued ? "Queued for its next wake-up." : "Delivered to its running turn.";
129
+ let text = `Message to @${params.to}: ${disposition}`;
130
+ if (!result.queued && result.stalledMs !== undefined) {
131
+ text += `\nWarning: @${params.to} has been stalled with no output for ${formatSilenceDuration(result.stalledMs)} — the control-stream write succeeded, but the teammate may be wedged. Consider teammate_shutdown if it stays silent.`;
132
+ }
133
+ return {
134
+ content: [{ type: "text", text }],
135
+ details: result.stalledMs !== undefined && !result.queued
136
+ ? { queued: result.queued, stalledMs: result.stalledMs }
137
+ : { queued: result.queued },
138
+ };
179
139
  },
180
140
  });
181
141
 
182
142
  pi.registerTool({
183
- name: "teammate_retry",
184
- promptSnippet: "Retry the failed or cancelled nodes of a settled run",
185
- label: "Retry Run Nodes",
186
- description: "Retry failed and cancelled nodes of a settled run without re-running completed nodes.",
187
- parameters: TeammateRetryParams,
143
+ name: "task_create",
144
+ promptSnippet: "Create a shared board task",
145
+ label: "Create Task",
146
+ description: "Create one shared board task. Resident teammates self-claim it when dependencies are met; an optional verify command gates completion.",
147
+ parameters: TaskCreateParams,
148
+ // Canonical lifecycle rows (same as packages/monitor): empty call slot,
149
+ // ONE created row owned by renderResult.
150
+ renderShell: "self",
151
+ renderCall: () => new Text("", 0, 0),
152
+ renderResult(result, _options, theme, context) {
153
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
154
+ if (context.isError) {
155
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to create task."), 0, 0);
156
+ }
157
+ const subject = String((context.args as { subject?: string }).subject ?? "");
158
+ const line = theme.fg("toolTitle", theme.bold(formatToolEventLabel("created", subject, "board")));
159
+ return {
160
+ render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
161
+ invalidate: () => {},
162
+ };
163
+ },
188
164
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
189
- const dctx = dispatchContext(ctx);
190
- ensureRunContext(dctx);
191
- const retried = retryRun(params.runId, params.nodeIds);
192
- if (!retried.ok) throw new Error(retried.error ?? "Failed to retry run.");
193
- publishStateSnapshot();
194
- scheduleRun(params.runId, dctx);
165
+ const tasks = listTasks();
166
+ if (params.dependsOn?.some((dep) => !tasks.some((task) => task.id === dep))) {
167
+ throw new Error(`Unknown dependency id in [${params.dependsOn.join(", ")}].`);
168
+ }
169
+ const created = createBoardTask(params);
170
+ if (!created.ok) throw new Error(created.error);
195
171
  refreshTeamUI(ctx);
196
- return { content: [{ type: "text", text: `Run [${params.runId}] retried — reset ${retried.reset.length} node(s): ${retried.reset.join(", ")}. Completed nodes are retained.` }], details: {} };
172
+ return { content: [{ type: "text", text: `Created [${created.id}] "${params.subject}". Idle teammates are notified automatically.` }], details: {} };
197
173
  },
198
174
  });
175
+
176
+ registerTaskListTool(pi);
177
+ }
178
+
179
+ function teamStatusSummary(): string {
180
+ const roles = [...discoverAgents().keys()].map((name) => `@${name}`);
181
+ const rolesLine = roles.length > 0
182
+ ? `${roles.length} persistent agent role${roles.length === 1 ? "" : "s"}: ${roles.join(", ")}`
183
+ : "No agent roles discovered.";
184
+ return `${rosterSummary()}\n${rolesLine}`;
199
185
  }
200
186
 
201
187
  export function registerTeamCommand(pi: ExtensionAPI): void {
202
- pi.registerCommand("teammate", {
203
- description: "Open the full-screen teammate team console: run/node status, node details, interrupt/stop",
188
+ pi.registerCommand("agent-teams", {
189
+ description: "Agent Teams management console: session teammates and persistent agent roles",
204
190
  handler: async (_args, ctx) => {
205
- if (ctx.mode !== "tui") {
206
- ctx.ui.notify(getSummary() ?? "No runs yet — dispatch work with teammate_run.", "info");
207
- return;
208
- }
191
+ publishStateSnapshot();
192
+ if (ctx.mode !== "tui") { ctx.ui.notify(teamStatusSummary(), "info"); return; }
209
193
  await openTeamConsole(ctx);
210
194
  refreshTeamUI(ctx);
211
195
  },
212
196
  });
213
197
  }
198
+