@fradser/pi-agent-teams 0.8.2 → 0.9.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,221 +1,242 @@
1
- import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
3
- import { detailField, eventToolLifecycle, formatAgentTaskName, formatToolLifecycleTitle, notifyPi } from "@fradser/pi-kit";
1
+ import { truncateHead, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { notifyPi } from "@fradser/pi-kit";
4
3
  import {
4
+ assignExistingWork,
5
+ releaseExistingWork,
6
+ reopenExistingWork,
5
7
  createBoardTask,
6
- formatBoardTaskCreation,
7
- hasAnnouncedFinish,
8
- hasTerminalReport,
9
8
  publishStateSnapshot,
10
9
  sendLeaderMessage,
11
- shutdownTeammate,
10
+ shutdownTeammateExact,
12
11
  spawnTeammate,
13
12
  } from "./team-machine.ts";
14
13
  import { listTasks, livingTeammates } from "./state.ts";
15
14
 
16
- const DYNAMIC_LEADER_TOOLS = ["teammate_shutdown", "send_message", "task_list"] as const;
17
- let leaderToolApi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools"> | undefined;
18
-
19
- /** Keep spawn as the entry point; reveal follow-on controls only when relevant. */
20
- export function refreshLeaderToolDisclosure(): void {
21
- if (!leaderToolApi || typeof leaderToolApi.getActiveTools !== "function" || typeof leaderToolApi.setActiveTools !== "function") return;
22
- const active = leaderToolApi.getActiveTools();
23
- const withoutDynamic = active.filter((tool) => !DYNAMIC_LEADER_TOOLS.includes(tool as typeof DYNAMIC_LEADER_TOOLS[number]));
24
- const revealed = [
25
- ...(livingTeammates().length > 0 ? ["teammate_shutdown", "send_message"] : []),
26
- ...(listTasks().length > 0 ? ["task_list"] : []),
27
- ];
28
- leaderToolApi.setActiveTools([...withoutDynamic, ...revealed]);
29
- }
30
-
31
- import { LEADER_RECIPIENT, SendMessageParams, TeammateShutdownParams, TeammateSpawnParams, TaskCreateParams } from "./types.ts";
32
- import { registerTaskListTool } from "./worker.ts";
15
+ import { AgentActionParams, LEADER_RECIPIENT, AgentEventParams, WorkToolParams } from "./types.ts";
33
16
  import { openTeamConsole, refreshTeamUI } from "./ui.ts";
34
17
  import { discoverAgents } from "./agents.ts";
35
- import { emptyToolCall, renderLifecycleResult } from "./tool-render.ts";
18
+ import { agentRow, leaderWorkRow, messageRow, type AgentRowArgs } from "./tool-copy.ts";
19
+ import { emptyToolCall, renderCoordinationRow } from "./tool-render.ts";
20
+ import { runAgentAction, type AgentActionRuntime } from "./agent-actions.ts";
21
+ import { resolveRecipient } from "./recipient.ts";
36
22
 
37
- function rosterSummary(): string {
38
- const alive = livingTeammates();
39
- if (alive.length === 0) return "No living teammates.";
40
- return alive.map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
23
+ function formatWorkList(tasks: ReturnType<typeof listTasks>): string {
24
+ const works = tasks.map((task) => {
25
+ const dependencies = task.dependsOn.length > 0 ? ` · depends=${task.dependsOn.join(",")}` : "";
26
+ const holder = task.claimedBy ? ` · owner=@${task.claimedBy}` : "";
27
+ const result = task.result ? truncateHead(task.result, { maxBytes: 4096, maxLines: 40 }) : undefined;
28
+ const evidence = result ? `\n RESULT · ${result.content}${result.truncated ? "\n [Result preview truncated; complete evidence is retained in Work details.]" : ""}` : "";
29
+ return `- ${task.id} · ${task.status} · ${task.subject}${holder}${dependencies}${evidence}`;
30
+ });
31
+ return [
32
+ "WORK · current session",
33
+ `SUMMARY · ${tasks.length} work item${tasks.length === 1 ? "" : "s"}`,
34
+ "WORK ITEMS",
35
+ ...(works.length > 0 ? works : ["(none)"]),
36
+ ].join("\n");
41
37
  }
42
38
 
43
- function spawnAssignment(params: { name: string; agent: string; prompt?: string }): string {
44
- const prompt = params.prompt?.trim();
45
- if (!prompt) return "check task board";
46
- const normalizedPrompt = prompt.replace(/^@/, "").trim().toLowerCase();
47
- const name = params.name.replace(/^@/, "").toLowerCase();
48
- const agent = params.agent.replace(/^@/, "").toLowerCase();
49
- if (normalizedPrompt === name || normalizedPrompt === agent) {
50
- return "check task board";
51
- }
52
- return formatAgentTaskName(prompt, "check task board");
39
+ function formatWorkCreation(subject: string, created: {
40
+ id: string;
41
+ claimable: boolean;
42
+ resourceBlocked: boolean;
43
+ notifiedTeammates: string[];
44
+ }): string {
45
+ const availability = created.claimable ? "pending/claimable" : "pending/blocked";
46
+ const routing = created.notifiedTeammates.length > 0
47
+ ? `eligible residents notified: ${created.notifiedTeammates.map((name) => `@${name}`).join(", ")}`
48
+ : created.resourceBlocked
49
+ ? "resource conflict blocks assignment"
50
+ : "no eligible resident notified";
51
+ return [
52
+ "WORK · current session",
53
+ `CREATED · ${created.id} · ${availability} · ${subject}`,
54
+ `ROUTING · ${routing}`,
55
+ ].join("\n");
53
56
  }
54
57
 
55
- export function registerLeaderTools(pi: ExtensionAPI): void {
56
- pi.registerTool({
57
- name: "teammate_spawn",
58
- promptSnippet: "Spawn a named resident teammate or sub-agent",
59
- label: "Spawn Teammate",
60
- description: "Spawn one named resident teammate / sub-agent. Use this tool whenever instructions, third-party skills, or workflows ask to launch or delegate work to an agent, sub-agent (subagent), worker, or teammate. Generated role definitions stay in memory by default; persist one only when the user explicitly asks to keep it for future sessions.",
61
- parameters: TeammateSpawnParams,
62
- renderShell: "self",
63
- renderCall: emptyToolCall,
64
- renderResult(result, _options, theme, context) {
65
- const text = result.content.find((part) => part.type === "text")?.text ?? "";
66
- if (context.isError) return new Text(theme.fg("error", text.split("\n")[0] || "Failed to spawn teammate."), 0, 0);
67
- const params = context.args as { name: string; agent: string; prompt?: string };
68
- const title = formatToolLifecycleTitle({
69
- kind: "started",
70
- tool: "agent",
71
- subject: `@${params.name} started · ${spawnAssignment(params)}`,
72
- });
73
- return new Text(theme.fg("customMessageLabel", theme.bold(title)), 0, 0);
74
- },
75
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
76
- const result = spawnTeammate(params);
77
- if (!result.ok) throw new Error(result.error);
78
- refreshTeamUI(ctx);
79
- refreshLeaderToolDisclosure();
80
- const kickoffNote = params.prompt?.trim()
81
- ? "It received your kickoff prompt and is working on it."
82
- : "It received the standard board-check kickoff and is running its first turn; it idles once that settles.";
83
- return {
84
- content: [{ type: "text", text: `@${params.name} is alive as ${params.agent}.\n${kickoffNote}\n\n${rosterSummary()}` }],
85
- details: { started: true },
86
- };
87
- },
88
- });
89
-
58
+ export function registerLeaderTools(pi: ExtensionAPI, runtime: AgentActionRuntime & { sendLeaderMessage: typeof sendLeaderMessage } = { spawnTeammate, shutdownTeammateExact, sendLeaderMessage }): void {
90
59
  pi.registerTool({
91
- name: "teammate_shutdown",
92
- promptSnippet: "Shut down a resident teammate",
93
- label: "Shutdown Teammate",
94
- description: "Gracefully stop one named teammate. Its claimed task returns to the board; its worktree diff is captured before teardown.",
95
- parameters: TeammateShutdownParams,
60
+ name: "agent",
61
+ promptSnippet: "Delegate, start, inspect, or stop an Agent session",
62
+ label: "Agent",
63
+ description: "Strict Agent lifecycle interface. Delegate creates independent Work; start creates an unassigned resident; inspect and stop use incarnation-bound session handles. Results arrive automatically; inspect is for deliberate diagnosis, not waiting.",
64
+ promptGuidelines: ["After agent delegation, results arrive automatically. Continue independent work or end the turn; do not wait with sleep, repeated agent inspect, or work list polling."],
65
+ parameters: AgentActionParams,
96
66
  renderShell: "self",
97
67
  renderCall: emptyToolCall,
98
68
  renderResult(result, options, theme, context) {
99
- const name = String((context.args as { name?: string }).name ?? "");
100
- // The finish entry already announced this end of life (or its terminal
101
- // report is queued to); a second event row is noise.
102
- if (hasAnnouncedFinish(name) || hasTerminalReport(name)) return { render: () => [], invalidate: () => {} };
103
- return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
69
+ return renderCoordinationRow(
70
+ result, options, theme, context,
104
71
  "agent",
105
- `@${name} shut down`,
106
- {},
107
- ));
72
+ agentRow(context.args as AgentRowArgs, result.details, { isError: context.isError, isPartial: options.isPartial }),
73
+ );
108
74
  },
109
75
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
110
- const result = await shutdownTeammate(params.name);
111
- if (!result.ok) throw new Error(result.error);
112
- refreshTeamUI(ctx);
113
- refreshLeaderToolDisclosure();
114
- return { content: [{ type: "text", text: result.body }], details: {} };
76
+ const result = await runAgentAction(params, ctx.cwd, runtime, ctx.sessionManager);
77
+ if (params.action !== "inspect") {
78
+ refreshTeamUI(ctx);
79
+ }
80
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
115
81
  },
116
82
  });
117
83
 
118
84
  pi.registerTool({
119
- name: "send_message",
120
- promptSnippet: "Send a message to a teammate",
121
- label: "Send Message",
122
- description: "The only messaging primitive. Address a living teammate by name; working teammates receive a steer immediately and idle teammates wake with the message.",
123
- parameters: SendMessageParams,
124
- // Canonical lifecycle rows (same as packages/monitor): empty call slot,
125
- // ONE delivery row owned by renderResult.
85
+ name: "agent_event",
86
+ promptSnippet: "Send an event or message to a participant",
87
+ label: "Agent Event",
88
+ description: "Shared communication interface across Leader, Worker, and Peers. Leader-to-Agent messages carry only new evidence, changed constraints, or decisions — never kickoff echoes, progress requests, completion requests, or confirmation probes.",
89
+ parameters: AgentEventParams,
126
90
  renderShell: "self",
127
91
  renderCall: emptyToolCall,
128
92
  renderResult(result, options, theme, context) {
129
- const to = String((context.args as { to?: string }).to ?? "");
130
- const outcome = detailField<"steered" | "queued" | "not-sent">(result.details, "outcome") ?? "queued";
131
- const subject = outcome === "not-sent" ? "terminal report available" : outcome;
132
- return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
93
+ return renderCoordinationRow(
94
+ result, options, theme, context,
133
95
  "message",
134
- subject,
135
- { label: `to @${to}`, detailLimit: outcome === "not-sent" ? "all" : undefined },
136
- ));
96
+ messageRow(context.args as { to?: string; message?: string }, result.details, { isError: context.isError }),
97
+ );
137
98
  },
138
99
  async execute(_toolCallId, params) {
139
- if (params.to === LEADER_RECIPIENT) throw new Error('The leader cannot send a message to itself.');
140
- const result = sendLeaderMessage(params.to, params.message, {
141
- reopen: params.reopen,
142
- resources: params.resources,
143
- });
144
- if (!result.ok) throw new Error(result.error);
145
- if (result.outcome === "not-sent") {
146
- return {
147
- content: [{
148
- type: "text",
149
- text: [
150
- "ROUTING · not sent",
151
- "NEXT · No new message was delivered. Read the recorded report below; use reopen=true only for a distinct new assignment.",
152
- `NOTE · @${params.to} already sent a terminal report. Delivery to your context is automatic; asking it to resend produces a duplicate leader turn.`,
153
- `RECORDED TERMINAL REPORT · ${result.terminalReport}`,
154
- ].join("\n"),
155
- }],
156
- details: { outcome: "not-sent", terminalReportAvailable: true },
157
- };
158
- }
159
- const action = result.outcome === "steered"
160
- ? "active control stream accepted the steer"
161
- : "harness will deliver the queued message on the next wake-up";
162
- let text = `MESSAGING\n${result.outcome.toUpperCase()} · to=@${params.to}\nNEXT · ${action}`;
163
- // A stray status field (copied from worker report patterns) must not
164
- // block delivery — it carries no meaning on leader-sent messages.
165
- if (params.status) text += `\nNOTE · status ignored for leader-directed steering`;
166
- if (result.priorTerminalReport) {
167
- text += `\nNOTE · @${params.to} already sent a terminal report (below); it reaches your context automatically. A steer asking it to repeat that report produces a duplicate delivery — use reopen only for a distinct new assignment.`;
168
- text += `\nPRIOR TERMINAL REPORT · ${result.priorTerminalReport}`;
100
+ if (!params.to) {
101
+ throw new Error("No bound reply route exists. Please specify 'to' explicitly.");
169
102
  }
103
+ if (params.to === LEADER_RECIPIENT) throw new Error("The leader cannot send an event to itself.");
104
+ const to = resolveRecipient(params.to, livingTeammates()).name;
105
+ const result = runtime.sendLeaderMessage(to, params.message, {});
106
+ if (!result.ok) throw new Error(result.error);
107
+ const recorded = result.outcome === "not-sent" ? `\nRECORDED TERMINAL REPORT · ${result.terminalReport}` : "";
108
+ const next = "";
170
109
  return {
171
- content: [{ type: "text", text }],
172
- details: { outcome: result.outcome },
110
+ content: [{ type: "text", text: `EVENT ROUTING · ${result.outcome} · to=@${to}\nINTENT · ${params.intent ?? "inform"}${recorded}${next}` }],
111
+ details: { to, outcome: result.outcome, intent: params.intent ?? "inform" },
173
112
  };
174
113
  },
175
114
  });
176
115
 
177
116
  pi.registerTool({
178
- name: "task_create",
179
- promptSnippet: "Create a shared board task",
180
- label: "Create Task",
181
- description: "Create one pending task on the current session board. Existing idle teammates are notified immediately and may self-claim it; this tool never spawns teammates. An optional verify prompt gates completion through a fresh reviewer.",
182
- parameters: TaskCreateParams,
183
- // Canonical lifecycle rows (same as packages/monitor): empty call slot,
184
- // ONE created row owned by renderResult.
117
+ name: "work",
118
+ promptSnippet: "Create, list, assign, release, reopen, or supersede Work Items",
119
+ label: "Work",
120
+ description: "Manage Work Items: create (subject), list, assign (id, target.session), release (id, reason), reopen (id, reason), or supersede (subject, supersedes). Creation uses the session's single-writer Work state and never starts a resident.",
121
+ parameters: WorkToolParams,
185
122
  renderShell: "self",
186
123
  renderCall: emptyToolCall,
187
124
  renderResult(result, options, theme, context) {
188
- const subject = String((context.args as { subject?: string }).subject ?? "");
189
- return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
190
- "board",
191
- subject,
192
- { label: "created" },
193
- ));
125
+ return renderCoordinationRow(
126
+ result, options, theme, context,
127
+ "work",
128
+ leaderWorkRow(context.args as Record<string, unknown>, result.details, { isError: context.isError }),
129
+ );
194
130
  },
195
131
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
132
+ if (params.action === "supersede") {
133
+ const tasks = listTasks();
134
+ const referenced = [...(params.dependsOn ?? []), ...params.supersedes];
135
+ if (referenced.some((id) => !tasks.some((task) => task.id === id))) {
136
+ throw new Error(`Unknown work id in [${referenced.join(", ")}].`);
137
+ }
138
+ const created = createBoardTask(params);
139
+ if (!created.ok) throw new Error(created.error);
140
+ const work = listTasks().find((task) => task.id === created.id);
141
+ if (!work) throw new Error(`Replacement Work Item "${created.id}" is unavailable.`);
142
+ refreshTeamUI(ctx);
143
+ return {
144
+ content: [{ type: "text", text: `WORK · current session\nSUPERSEDED · ${work.id} · ${work.status}\nREPLACED · ${created.supersededTaskIds.join(", ")}\nROUTING · ${created.notifiedTeammates.length > 0 ? `eligible residents notified: ${created.notifiedTeammates.map((name) => `@${name}`).join(", ")}` : "no eligible resident notified"}` }],
145
+ details: {
146
+ action: "supersede", outcome: "superseded", state: work.status,
147
+ work: { id: work.id, subject: work.subject, ...(work.description ? { description: work.description } : {}), dependsOn: work.dependsOn, resources: work.resources, ...(work.verify ? { verify: work.verify } : {}), state: work.status },
148
+ supersededWorkIds: created.supersededTaskIds, notifiedTeammates: created.notifiedTeammates, claimable: created.claimable,
149
+ },
150
+ };
151
+ }
152
+ if (params.action === "reopen") {
153
+ const reopened = reopenExistingWork(params.id);
154
+ if (!reopened.ok) throw new Error(reopened.error);
155
+ refreshTeamUI(ctx);
156
+ return {
157
+ content: [{ type: "text", text: `WORK · current session\nREOPENED · ${reopened.workId} · pending\nREASON · ${params.reason}` }],
158
+ details: { action: "reopen", outcome: "reopened", state: "pending", work: { id: reopened.workId, subject: reopened.subject, resources: reopened.resources, state: "pending" }, reason: params.reason },
159
+ };
160
+ }
161
+ if (params.action === "release") {
162
+ const released = releaseExistingWork(params.id, params.reason);
163
+ if (!released.ok) throw new Error(released.error);
164
+ refreshTeamUI(ctx);
165
+ const residual = released.holderStillRunning
166
+ ? `\nRISK · @${released.holderStillRunning} was still working; an in-flight tool batch may still write inside ${released.resources.join(", ") || "the released scope"}.`
167
+ : "";
168
+ return {
169
+ content: [{ type: "text", text: `WORK · current session\nRELEASED · ${released.workId} · pending\nREASON · ${params.reason}${residual}` }],
170
+ details: { action: "release", outcome: "released", state: "pending", work: { id: released.workId, subject: released.subject, resources: released.resources, state: "pending" }, reason: params.reason, ...(released.holderStillRunning ? { holderStillRunning: released.holderStillRunning } : {}) },
171
+ };
172
+ }
173
+ if (params.action === "assign") {
174
+ const assigned = assignExistingWork(params.id, params.target.session);
175
+ if (!assigned.ok) throw new Error(assigned.error);
176
+ const task = listTasks().find((entry) => entry.id === assigned.workId);
177
+ if (!task) throw new Error(`Assigned Work Item "${assigned.workId}" is unavailable.`);
178
+ refreshTeamUI(ctx);
179
+ return {
180
+ content: [{ type: "text", text: `WORK · current session\nASSIGNED · ${task.id} · claimed · owner=@${assigned.owner}\nATTEMPT · ${assigned.assignmentId} · fresh-session-pending` }],
181
+ details: {
182
+ action: "assign", outcome: "assigned", state: task.status,
183
+ work: { id: task.id, subject: task.subject, resources: task.resources, state: task.status, claimedBy: task.claimedBy },
184
+ assignment: { id: assigned.assignmentId, owner: assigned.owner, kind: "direct" },
185
+ target: params.target, delivery: "fresh-session-pending",
186
+ },
187
+ };
188
+ }
189
+ if (params.action === "list") {
190
+ const works = listTasks().map((task) => ({
191
+ id: task.id,
192
+ subject: task.subject,
193
+ ...(task.description ? { description: task.description } : {}),
194
+ dependsOn: task.dependsOn,
195
+ resources: task.resources,
196
+ ...(task.verify ? { verify: task.verify } : {}),
197
+ state: task.status,
198
+ ...(task.claimedBy ? { claimedBy: task.claimedBy } : {}),
199
+ ...(task.result ? { result: task.result } : {}),
200
+ }));
201
+ return {
202
+ content: [{ type: "text", text: formatWorkList(listTasks()) }],
203
+ details: { action: "list", outcome: "listed", works, count: works.length },
204
+ };
205
+ }
196
206
  const tasks = listTasks();
197
- const referenced = [...(params.dependsOn ?? []), ...(params.supersedes ?? [])];
207
+ const referenced = params.dependsOn ?? [];
198
208
  if (referenced.some((id) => !tasks.some((task) => task.id === id))) {
199
- throw new Error(`Unknown task id in [${referenced.join(", ")}].`);
209
+ throw new Error(`Unknown work id in [${referenced.join(", ")}].`);
200
210
  }
201
211
  const created = createBoardTask(params);
202
212
  if (!created.ok) throw new Error(created.error);
213
+ const work = listTasks().find((task) => task.id === created.id);
214
+ if (!work) throw new Error(`Created Work Item "${created.id}" is unavailable.`);
203
215
  refreshTeamUI(ctx);
204
- refreshLeaderToolDisclosure();
205
216
  return {
206
- content: [{ type: "text", text: formatBoardTaskCreation(params.subject, created) }],
217
+ content: [{ type: "text", text: formatWorkCreation(params.subject, created) }],
207
218
  details: {
219
+ action: "create",
220
+ outcome: "created",
221
+ state: work.status,
222
+ work: {
223
+ id: work.id,
224
+ subject: work.subject,
225
+ ...(work.description ? { description: work.description } : {}),
226
+ dependsOn: work.dependsOn,
227
+ resources: work.resources,
228
+ ...(work.verify ? { verify: work.verify } : {}),
229
+ state: work.status,
230
+ },
208
231
  notifiedTeammates: created.notifiedTeammates,
209
- livingTeammates: created.livingTeammates,
210
232
  claimable: created.claimable,
211
- supersededTaskIds: created.supersededTaskIds,
233
+ supersededWorkIds: created.supersededTaskIds,
212
234
  },
213
235
  };
214
236
  },
215
237
  });
216
238
 
217
- registerTaskListTool(pi);
218
- leaderToolApi = pi;
239
+
219
240
  }
220
241
 
221
242
  function teamStatusSummary(): string {
@@ -223,7 +244,8 @@ function teamStatusSummary(): string {
223
244
  const rolesLine = roles.length > 0
224
245
  ? `${roles.length} persistent agent role${roles.length === 1 ? "" : "s"}: ${roles.join(", ")}`
225
246
  : "No agent roles discovered.";
226
- return `${rosterSummary()}\n${rolesLine}`;
247
+ const roster = livingTeammates().map((t) => `@${t.name} (${t.agent}, ${t.status})`).join("\n") || "No living sessions.";
248
+ return `${roster}\n${rolesLine}`;
227
249
  }
228
250
 
229
251
  export function registerTeamCommand(pi: ExtensionAPI): void {