@fradser/pi-agent-teams 0.8.3 → 0.9.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/src/tools.ts CHANGED
@@ -1,222 +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
- }).replace(/^\[agent\]\s*/, "");
73
- const prefix = theme.fg("customMessageLabel", theme.bold("[agent]"));
74
- return new Text(`${prefix} ${title}`, 0, 0);
75
- },
76
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
77
- const result = spawnTeammate(params);
78
- if (!result.ok) throw new Error(result.error);
79
- refreshTeamUI(ctx);
80
- refreshLeaderToolDisclosure();
81
- const kickoffNote = params.prompt?.trim()
82
- ? "It received your kickoff prompt and is working on it."
83
- : "It received the standard board-check kickoff and is running its first turn; it idles once that settles.";
84
- return {
85
- content: [{ type: "text", text: `@${params.name} is alive as ${params.agent}.\n${kickoffNote}\n\n${rosterSummary()}` }],
86
- details: { started: true },
87
- };
88
- },
89
- });
90
-
58
+ export function registerLeaderTools(pi: ExtensionAPI, runtime: AgentActionRuntime & { sendLeaderMessage: typeof sendLeaderMessage } = { spawnTeammate, shutdownTeammateExact, sendLeaderMessage }): void {
91
59
  pi.registerTool({
92
- name: "teammate_shutdown",
93
- promptSnippet: "Shut down a resident teammate",
94
- label: "Shutdown Teammate",
95
- description: "Gracefully stop one named teammate. Its claimed task returns to the board; its worktree diff is captured before teardown.",
96
- 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,
97
66
  renderShell: "self",
98
67
  renderCall: emptyToolCall,
99
68
  renderResult(result, options, theme, context) {
100
- const name = String((context.args as { name?: string }).name ?? "");
101
- // The finish entry already announced this end of life (or its terminal
102
- // report is queued to); a second event row is noise.
103
- if (hasAnnouncedFinish(name) || hasTerminalReport(name)) return { render: () => [], invalidate: () => {} };
104
- return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
69
+ return renderCoordinationRow(
70
+ result, options, theme, context,
105
71
  "agent",
106
- `@${name} shut down`,
107
- {},
108
- ));
72
+ agentRow(context.args as AgentRowArgs, result.details, { isError: context.isError, isPartial: options.isPartial }),
73
+ );
109
74
  },
110
75
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
111
- const result = await shutdownTeammate(params.name);
112
- if (!result.ok) throw new Error(result.error);
113
- refreshTeamUI(ctx);
114
- refreshLeaderToolDisclosure();
115
- 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 };
116
81
  },
117
82
  });
118
83
 
119
84
  pi.registerTool({
120
- name: "send_message",
121
- promptSnippet: "Send a message to a teammate",
122
- label: "Send Message",
123
- description: "The only messaging primitive. Address a living teammate by name; working teammates receive a steer immediately and idle teammates wake with the message.",
124
- parameters: SendMessageParams,
125
- // Canonical lifecycle rows (same as packages/monitor): empty call slot,
126
- // 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,
127
90
  renderShell: "self",
128
91
  renderCall: emptyToolCall,
129
92
  renderResult(result, options, theme, context) {
130
- const to = String((context.args as { to?: string }).to ?? "");
131
- const outcome = detailField<"steered" | "queued" | "not-sent">(result.details, "outcome") ?? "queued";
132
- const subject = outcome === "not-sent" ? "terminal report available" : outcome;
133
- return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
93
+ return renderCoordinationRow(
94
+ result, options, theme, context,
134
95
  "message",
135
- subject,
136
- { label: `to @${to}`, detailLimit: outcome === "not-sent" ? "all" : undefined },
137
- ));
96
+ messageRow(context.args as { to?: string; message?: string }, result.details, { isError: context.isError }),
97
+ );
138
98
  },
139
99
  async execute(_toolCallId, params) {
140
- if (params.to === LEADER_RECIPIENT) throw new Error('The leader cannot send a message to itself.');
141
- const result = sendLeaderMessage(params.to, params.message, {
142
- reopen: params.reopen,
143
- resources: params.resources,
144
- });
145
- if (!result.ok) throw new Error(result.error);
146
- if (result.outcome === "not-sent") {
147
- return {
148
- content: [{
149
- type: "text",
150
- text: [
151
- "ROUTING · not sent",
152
- "NEXT · No new message was delivered. Read the recorded report below; use reopen=true only for a distinct new assignment.",
153
- `NOTE · @${params.to} already sent a terminal report. Delivery to your context is automatic; asking it to resend produces a duplicate leader turn.`,
154
- `RECORDED TERMINAL REPORT · ${result.terminalReport}`,
155
- ].join("\n"),
156
- }],
157
- details: { outcome: "not-sent", terminalReportAvailable: true },
158
- };
159
- }
160
- const action = result.outcome === "steered"
161
- ? "active control stream accepted the steer"
162
- : "harness will deliver the queued message on the next wake-up";
163
- let text = `MESSAGING\n${result.outcome.toUpperCase()} · to=@${params.to}\nNEXT · ${action}`;
164
- // A stray status field (copied from worker report patterns) must not
165
- // block delivery — it carries no meaning on leader-sent messages.
166
- if (params.status) text += `\nNOTE · status ignored for leader-directed steering`;
167
- if (result.priorTerminalReport) {
168
- 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.`;
169
- text += `\nPRIOR TERMINAL REPORT · ${result.priorTerminalReport}`;
100
+ if (!params.to) {
101
+ throw new Error("No bound reply route exists. Please specify 'to' explicitly.");
170
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 = "";
171
109
  return {
172
- content: [{ type: "text", text }],
173
- 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" },
174
112
  };
175
113
  },
176
114
  });
177
115
 
178
116
  pi.registerTool({
179
- name: "task_create",
180
- promptSnippet: "Create a shared board task",
181
- label: "Create Task",
182
- 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.",
183
- parameters: TaskCreateParams,
184
- // Canonical lifecycle rows (same as packages/monitor): empty call slot,
185
- // 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,
186
122
  renderShell: "self",
187
123
  renderCall: emptyToolCall,
188
124
  renderResult(result, options, theme, context) {
189
- const subject = String((context.args as { subject?: string }).subject ?? "");
190
- return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
191
- "board",
192
- subject,
193
- { label: "created" },
194
- ));
125
+ return renderCoordinationRow(
126
+ result, options, theme, context,
127
+ "work",
128
+ leaderWorkRow(context.args as Record<string, unknown>, result.details, { isError: context.isError }),
129
+ );
195
130
  },
196
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
+ }
197
206
  const tasks = listTasks();
198
- const referenced = [...(params.dependsOn ?? []), ...(params.supersedes ?? [])];
207
+ const referenced = params.dependsOn ?? [];
199
208
  if (referenced.some((id) => !tasks.some((task) => task.id === id))) {
200
- throw new Error(`Unknown task id in [${referenced.join(", ")}].`);
209
+ throw new Error(`Unknown work id in [${referenced.join(", ")}].`);
201
210
  }
202
211
  const created = createBoardTask(params);
203
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.`);
204
215
  refreshTeamUI(ctx);
205
- refreshLeaderToolDisclosure();
206
216
  return {
207
- content: [{ type: "text", text: formatBoardTaskCreation(params.subject, created) }],
217
+ content: [{ type: "text", text: formatWorkCreation(params.subject, created) }],
208
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
+ },
209
231
  notifiedTeammates: created.notifiedTeammates,
210
- livingTeammates: created.livingTeammates,
211
232
  claimable: created.claimable,
212
- supersededTaskIds: created.supersededTaskIds,
233
+ supersededWorkIds: created.supersededTaskIds,
213
234
  },
214
235
  };
215
236
  },
216
237
  });
217
238
 
218
- registerTaskListTool(pi);
219
- leaderToolApi = pi;
239
+
220
240
  }
221
241
 
222
242
  function teamStatusSummary(): string {
@@ -224,7 +244,8 @@ function teamStatusSummary(): string {
224
244
  const rolesLine = roles.length > 0
225
245
  ? `${roles.length} persistent agent role${roles.length === 1 ? "" : "s"}: ${roles.join(", ")}`
226
246
  : "No agent roles discovered.";
227
- 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}`;
228
249
  }
229
250
 
230
251
  export function registerTeamCommand(pi: ExtensionAPI): void {