@fradser/pi-agent-teams 0.5.2 → 0.6.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,154 +1,217 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { discoverAgents, resolveAgent } from "./agents";
2
+ import { formatAgentMessagePrefix, formatAgentTaskName, formatToolEventLabel } 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, markRunCompletionDelivered, retryRun } from "./state";
14
- import { TeammateCancelParams, TeammateRetryParams, TeammateRunParams } from "./types";
15
- 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 { Text, truncateToWidth } from "@earendil-works/pi-tui";
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
16
18
 
17
- function dispatchContext(ctx: ExtensionContext): DispatchCtx {
18
- return { ui: ctx.ui, sessionManager: ctx.sessionManager, cwd: ctx.cwd };
19
+ function isValidAgentName(name: string): boolean {
20
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && !name.includes("..") && name !== "con";
19
21
  }
20
22
 
21
- function sleep(ms: number): Promise<void> {
22
- return new Promise((resolve) => setTimeout(resolve, ms));
23
- }
24
-
25
- async function gatherForeground(runId: string, signal: AbortSignal | undefined): Promise<string> {
26
- const foregroundTimeoutMs = 5 * 60 * 1000;
27
- const deadline = Date.now() + foregroundTimeoutMs;
28
- while (true) {
29
- applyWorkerEvents();
30
- const run = getRun(runId);
31
- if (!run || run.status !== "running") break;
32
- if (signal?.aborted) {
33
- run.background = true;
34
- run.completionNotified = false;
35
- publishStateSnapshot();
36
- throw new Error(`Run [${runId}] continues in the background — workers will message team-leader upon completion.`);
37
- }
38
- if (Date.now() >= deadline) {
39
- run.background = true;
40
- run.completionNotified = false;
41
- publishStateSnapshot();
42
- return `Run [${runId}] is still running after ${Math.round(foregroundTimeoutMs / 1000)}s — detached to background. Workers will message team-leader upon completion.`;
43
- }
44
- await sleep(150);
45
- }
46
- markRunCompletionDelivered(runId);
47
- publishStateSnapshot();
48
- return buildRunSummary(runId);
23
+ function rosterSummary(): string {
24
+ const alive = livingTeammates();
25
+ if (alive.length === 0) return "No living teammates.";
26
+ return alive.map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
49
27
  }
50
28
 
51
29
  export function registerLeaderTools(pi: ExtensionAPI): void {
52
30
  pi.registerTool({
53
- name: "teammate_run",
54
- promptSnippet: "Dispatch a dependency-aware task graph in one call",
55
- label: "Run Tasks",
56
- 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.",
57
- parameters: TeammateRunParams,
58
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
59
- const cwd = params.cwd ?? ctx.cwd ?? process.cwd();
60
- const dctx = dispatchContext(ctx);
61
- ensureRunContext(dctx);
62
- ensureTeamWidget(ctx);
63
- for (const task of params.tasks) {
64
- if (!resolveAgent(task.agent, cwd)) {
65
- const available = [...discoverAgents(cwd).keys()].join(", ");
66
- throw new Error(`Agent "${task.agent}" not found in any scope. Available agents: ${available || "(none)"}.`);
67
- }
31
+ name: "teammate_spawn",
32
+ promptSnippet: "Spawn a named resident teammate",
33
+ label: "Spawn Teammate",
34
+ description: "Spawn one named resident teammate. Model and worktree behavior come from its declarative agent definition; the kickoff prompt is optional.",
35
+ parameters: TeammateSpawnParams,
36
+ // Canonical lifecycle rows (same as packages/monitor): empty renderCall,
37
+ // ONE startup row owned by renderResult.
38
+ renderShell: "self",
39
+ renderCall: () => new Text("", 0, 0),
40
+ renderResult(result, _options, theme, context) {
41
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
42
+ if ((result as { isError?: boolean }).isError) {
43
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to spawn teammate."), 0, 0);
68
44
  }
69
- const created = createRun({
70
- cwd,
71
- concurrency: params.concurrency ?? 4,
72
- worktree: params.worktree ?? false,
73
- background: params.background ?? true,
74
- timeoutMs: params.timeoutMs,
75
- summarize: params.summarize,
76
- summaryAgent: params.summaryAgent,
77
- nodes: params.tasks.map((task) => ({
78
- id: task.id,
79
- agent: task.agent,
80
- prompt: task.prompt,
81
- paths: task.paths,
82
- access: task.access ?? "read",
83
- model: task.model,
84
- timeoutMs: task.timeoutMs,
85
- dependsOn: task.dependsOn ?? [],
86
- })),
87
- });
88
- if (!created.ok) throw new Error(created.error ?? "Failed to create run.");
89
- const run = created.run;
90
- publishStateSnapshot();
45
+ const params = context.args as { name: string; agent: string; prompt?: string };
46
+ 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)))}`;
47
+ return {
48
+ render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
49
+ invalidate: () => {},
50
+ };
51
+ },
52
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
53
+ const result = spawnTeammate(params);
54
+ if (!result.ok) throw new Error(result.error);
91
55
  refreshTeamUI(ctx);
92
- scheduleRun(run.id, dctx);
56
+ const kickoffNote = params.prompt?.trim()
57
+ ? "It received your kickoff prompt and is working on it."
58
+ : "It is idle: it wakes for inbox messages and claimable board tasks.";
59
+ return {
60
+ content: [{ type: "text", text: `@${params.name} is alive as ${params.agent}.\n${kickoffNote}\n\n${rosterSummary()}` }],
61
+ details: { started: true },
62
+ };
63
+ },
64
+ });
93
65
 
94
- if (run.background) {
95
- return {
96
- 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.` }],
97
- details: {},
98
- };
66
+ pi.registerTool({
67
+ name: "teammate_shutdown",
68
+ promptSnippet: "Shut down a resident teammate",
69
+ label: "Shutdown Teammate",
70
+ description: "Gracefully stop one named teammate. Its claimed task returns to the board; its worktree diff is captured before teardown.",
71
+ parameters: TeammateShutdownParams,
72
+ renderShell: "self",
73
+ renderCall: () => new Text("", 0, 0),
74
+ renderResult(result, _options, theme, context) {
75
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
76
+ if ((result as { isError?: boolean }).isError) {
77
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to shut down teammate."), 0, 0);
99
78
  }
100
- return { content: [{ type: "text", text: await gatherForeground(run.id, signal) }], details: {} };
79
+ const name = String((context.args as { name?: string }).name ?? "");
80
+ const line = `${theme.fg("toolTitle", theme.bold(formatToolEventLabel("event", `@${name} shut down`).trimEnd()))}`;
81
+ return {
82
+ render: (width: number) => [truncateToWidth(line, Math.max(1, width))],
83
+ invalidate: () => {},
84
+ };
85
+ },
86
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
87
+ const result = await shutdownTeammate(params.name);
88
+ if (!result.ok) throw new Error(result.error);
89
+ refreshTeamUI(ctx);
90
+ return { content: [{ type: "text", text: result.body }], details: {} };
101
91
  },
102
92
  });
103
93
 
104
94
  pi.registerTool({
105
- name: "teammate_cancel",
106
- promptSnippet: "Cancel a run and stop its workers",
107
- label: "Cancel Run",
108
- description: "Cancel a run or one node. Running workers receive SIGTERM with bounded SIGKILL escalation; pending nodes are cancelled.",
109
- parameters: TeammateCancelParams,
110
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
111
- const dctx = dispatchContext(ctx);
112
- ensureRunContext(dctx);
113
- const result = params.nodeId
114
- ? await cancelNodeAndTerminate(params.runId, params.nodeId, dctx)
115
- : await cancelRunAndTerminate(params.runId, dctx);
95
+ name: "send_message",
96
+ promptSnippet: "Send a message to a teammate",
97
+ label: "Send Message",
98
+ 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.",
99
+ parameters: SendMessageParams,
100
+ renderCall(args, theme) {
101
+ const prefix = theme.fg("toolTitle", theme.bold(formatAgentMessagePrefix("to")));
102
+ const recipient = theme.fg("accent", `@${String(args.to ?? "")}`);
103
+ return new Text(`${prefix}${recipient}`, 0, 0);
104
+ },
105
+ async execute(_toolCallId, params) {
106
+ if (params.to === LEADER_RECIPIENT) throw new Error('The leader cannot send a message to itself.');
107
+ if (params.status) throw new Error('status is reserved for worker reports to="leader".');
108
+ const result = sendLeaderMessage(params.to, params.message);
116
109
  if (!result.ok) throw new Error(result.error);
117
- return { content: [{ type: "text", text: params.nodeId
118
- ? `Node [${params.runId}/${params.nodeId}] cancelled — the rest of the run continues.`
119
- : `Run [${params.runId}] cancelled — pending nodes were cancelled and running workers were stopped.` }], details: {} };
110
+ const disposition = result.queued ? "Queued for its next wake-up." : "Delivered to its running turn.";
111
+ let text = `Message to @${params.to}: ${disposition}`;
112
+ if (!result.queued && result.stalledMs !== undefined) {
113
+ 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.`;
114
+ }
115
+ return { content: [{ type: "text", text }], details: {} };
120
116
  },
121
117
  });
122
118
 
123
119
  pi.registerTool({
124
- name: "teammate_retry",
125
- promptSnippet: "Retry the failed or cancelled nodes of a settled run",
126
- label: "Retry Run Nodes",
127
- description: "Retry failed and cancelled nodes of a settled run without re-running completed nodes.",
128
- parameters: TeammateRetryParams,
120
+ name: "task_create",
121
+ promptSnippet: "Create a shared board task",
122
+ label: "Create Task",
123
+ description: "Create one shared board task. Resident teammates self-claim it when dependencies are met; an optional verify command gates completion.",
124
+ parameters: TaskCreateParams,
129
125
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
130
- const dctx = dispatchContext(ctx);
131
- ensureRunContext(dctx);
132
- const retried = retryRun(params.runId, params.nodeIds);
133
- if (!retried.ok) throw new Error(retried.error ?? "Failed to retry run.");
134
- publishStateSnapshot();
135
- scheduleRun(params.runId, dctx);
126
+ const tasks = listTasks();
127
+ if (params.dependsOn?.some((dep) => !tasks.some((task) => task.id === dep))) {
128
+ throw new Error(`Unknown dependency id in [${params.dependsOn.join(", ")}].`);
129
+ }
130
+ const created = createBoardTask(params);
131
+ if (!created.ok) throw new Error(created.error);
136
132
  refreshTeamUI(ctx);
137
- return { content: [{ type: "text", text: `Run [${params.runId}] retried — reset ${retried.reset.length} node(s): ${retried.reset.join(", ")}. Completed nodes are retained.` }], details: {} };
133
+ return { content: [{ type: "text", text: `Created [${created.id}] "${params.subject}". Idle teammates are notified automatically.` }], details: {} };
138
134
  },
139
135
  });
136
+
137
+ registerTaskListTool(pi);
140
138
  }
141
139
 
142
140
  export function registerTeamCommand(pi: ExtensionAPI): void {
143
- pi.registerCommand("teammate", {
144
- description: "Open the full-screen teammate team console: run/node status, node details, interrupt/stop",
141
+ pi.registerCommand("agent-teams", {
142
+ description: "Agent Teams menu: console and create agents from session history",
145
143
  handler: async (_args, ctx) => {
146
- if (ctx.mode !== "tui") {
147
- ctx.ui.notify(getSummary() ?? "No runs yet — dispatch work with teammate_run.", "info");
144
+ const choice = await ctx.ui.select("agent-teams", ["console", "project", "local"]);
145
+ if (!choice) return;
146
+ if (choice === "console") {
147
+ publishStateSnapshot();
148
+ if (ctx.mode !== "tui") { ctx.ui.notify(rosterSummary(), "info"); return; }
149
+ await openTeamConsole(ctx);
150
+ refreshTeamUI(ctx);
148
151
  return;
149
152
  }
150
- await openTeamConsole(ctx);
151
- refreshTeamUI(ctx);
153
+ await createAgentFromHistory(ctx, choice === "local");
152
154
  },
153
155
  });
154
156
  }
157
+
158
+ interface GeneratedAgent { name?: string; description?: string; tools?: string[]; prompt?: string; content?: string; }
159
+
160
+ function parseGeneratedAgents(raw: string): GeneratedAgent[] {
161
+ const cleaned = raw.trim().replace(/^```(?:json)?\s*|\s*```$/g, "");
162
+ const start = cleaned.indexOf("[");
163
+ const end = cleaned.lastIndexOf("]");
164
+ if (start < 0 || end <= start) return [];
165
+ try {
166
+ const value: unknown = JSON.parse(cleaned.slice(start, end + 1));
167
+ return Array.isArray(value) ? value.filter((item): item is GeneratedAgent => Boolean(item && typeof item === "object")) : [];
168
+ } catch { return []; }
169
+ }
170
+
171
+ function renderAgentDefinition(agent: GeneratedAgent): string {
172
+ const name = agent.name!.trim();
173
+ const description = (agent.description || "Agent created from session history").replace(/[\r\n]+/g, " ").trim();
174
+ const tools = Array.isArray(agent.tools) ? agent.tools.filter((tool) => typeof tool === "string" && tool.trim()).join(", ") : "";
175
+ return `---\nname: ${name}\ndescription: ${description}${tools ? `\ntools: ${tools}` : ""}\n---\n\n${(agent.prompt || "").trim()}`;
176
+ }
177
+
178
+ async function createAgentFromHistory(ctx: ExtensionContext, local: boolean): Promise<void> {
179
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
180
+ const history = branch.map((entry: any) => {
181
+ const text = typeof entry?.text === "string" ? entry.text : typeof entry?.content === "string" ? entry.content : "";
182
+ return text ? text : Array.isArray(entry?.content) ? entry.content.map((part: any) => part?.text ?? "").join("") : "";
183
+ }).filter(Boolean).slice(-20).join("\n\n");
184
+ const generated = await generateAgentPrompt(ctx, history);
185
+ if (!generated) return;
186
+ const dir = path.join(ctx.cwd || process.cwd(), ".pi", "agents");
187
+ fs.mkdirSync(dir, { recursive: true });
188
+ const definitions = parseGeneratedAgents(generated)
189
+ .filter((item) => typeof item.name === "string" && (item.content?.trim() || item.prompt?.trim()))
190
+ .map((item) => ({ name: item.name!.trim(), content: item.content?.trim() || renderAgentDefinition(item) }));
191
+ if (definitions.length === 0) { ctx.ui.notify("Model returned no usable agent definitions", "error"); return; }
192
+ let created = 0;
193
+ for (const definition of definitions) {
194
+ if (!isValidAgentName(definition.name)) continue;
195
+ const filename = `${definition.name}${local ? ".local" : ""}.md`;
196
+ try { fs.writeFileSync(path.join(dir, filename), definition.content, { encoding: "utf8", flag: "wx" }); created++; } catch (error) {
197
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
198
+ }
199
+ }
200
+ ctx.ui.notify(`Created ${created} agent definition${created === 1 ? "" : "s"}`, created ? "info" : "warning");
201
+ }
202
+
203
+ async function generateAgentPrompt(ctx: ExtensionContext, history: string): Promise<string | undefined> {
204
+ const model = ctx.model;
205
+ const registry = ctx.modelRegistry;
206
+ if (!model || !registry?.complete) {
207
+ ctx.ui.notify("No model available to generate an agent", "error");
208
+ return undefined;
209
+ }
210
+ const response = await registry.complete(model, {
211
+ systemPrompt: "Design a complementary resident coding agent team from session history. Return a JSON array of 2-4 complementary agent definitions. Each item must have name, description, tools, and prompt fields. Names use letters, digits, dots, dashes, underscores. Return JSON only, with no code fences.",
212
+ messages: [{ role: "user", content: `Session history:\n${history || "(empty)"}`, timestamp: Date.now() }],
213
+ }, { maxTokens: 2000, temperature: 0, cacheRetention: "none" });
214
+ const content = response.content?.filter((part: any) => part.type === "text").map((part: any) => part.text).join("\\n").trim();
215
+ if (!content) { ctx.ui.notify("Model returned no agent definition", "error"); return undefined; }
216
+ return content;
217
+ }