@fradser/pi-agent-teams 0.9.2 → 1.0.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/tool-copy.ts CHANGED
@@ -9,12 +9,13 @@
9
9
  */
10
10
 
11
11
  import { contentDetailLines, detailField, displayText, fieldBlock, fieldLine, safeDisplayText, scrubHandles } from "@fradser/pi-kit";
12
- import { resolveAgent } from "./agents.ts";
12
+ import { resolveAgent } from "@fradser/pi-subagents";
13
13
  import { runningTeammateActivity } from "./activity.ts";
14
14
  import { parseExactSessionRoute } from "./recipient.ts";
15
- import { getTask, getTeammate } from "./state.ts";
16
- import { resolveWorkerTools } from "./spawner.ts";
17
- import type { BoardTask, Teammate } from "./types.ts";
15
+ import { getTask, getAgent } from "./state.ts";
16
+ import { resolveAgentTools } from "@fradser/pi-subagents";
17
+ import { WORKER_CAPABILITY_TOOLS } from "./capability-tools.ts";
18
+ import type { Task, Agent } from "./types.ts";
18
19
 
19
20
  /** One rendered lifecycle row: the collapsed line plus its expanded body. */
20
21
  export interface CoordinationRow {
@@ -24,10 +25,6 @@ export interface CoordinationRow {
24
25
  body: string[];
25
26
  }
26
27
 
27
- const COLLAPSED_TASK_LIMIT = 90;
28
- /** A kickoff prompt is shown in full up to this size, then clipped. */
29
- const FIELD_LIMIT = 2000;
30
-
31
28
  /** Package resolver: a known Work handle becomes its subject. Only prefixed
32
29
  * handles are machine identifiers; a bare UUID can be a person's own text
33
30
  * (a commit, an id under review) and survives verbatim inside kit. */
@@ -55,11 +52,12 @@ function clipLines(value: unknown): string {
55
52
  return displayText(value).split("\n").map((line) => plainText(line)).join("\n");
56
53
  }
57
54
 
58
- function shortTask(value: unknown): string {
55
+ /** The first non-empty line, on one line: a title. The row's own width fit
56
+ * bounds it and expansion reveals the whole value, so no character cap here. */
57
+ function headline(value: unknown): string {
59
58
  const text = displayText(value);
60
59
  const firstLine = text.split("\n").map((l) => plainText(l).trim()).find(Boolean) ?? "";
61
- const line = collapse(firstLine);
62
- return line.length <= COLLAPSED_TASK_LIMIT ? line : `${line.slice(0, COLLAPSED_TASK_LIMIT - 1).trimEnd()}…`;
60
+ return collapse(firstLine);
63
61
  }
64
62
 
65
63
  /** Array fields also arrive untyped from a model call. */
@@ -72,8 +70,8 @@ function labeled(label: string, value: unknown): string {
72
70
  }
73
71
 
74
72
  /** Multi-line field: the label heads the first line, the rest follow verbatim. */
75
- function labeledBlock(label: string, value: unknown, limit = FIELD_LIMIT): string[] {
76
- return fieldBlock(label, clipLines(value), limit);
73
+ function labeledBlock(label: string, value: unknown): string[] {
74
+ return fieldBlock(label, clipLines(value));
77
75
  }
78
76
 
79
77
  function bodyLines(value: unknown): string[] {
@@ -94,7 +92,7 @@ const STATUS_WORDS: Record<string, string> = {
94
92
  idle: "idle",
95
93
  stopped: "stopped",
96
94
  pending: "pending",
97
- claimed: "claimed",
95
+ in_progress: "in progress",
98
96
  completed: "completed",
99
97
  superseded: "superseded",
100
98
  sent: "sent",
@@ -114,11 +112,11 @@ function stateWord(value: unknown): string {
114
112
  return STATUS_WORDS[key] ?? key.replace(/[_-]+/g, " ");
115
113
  }
116
114
 
117
- function workOf(teammate: Teammate | undefined, task?: BoardTask): string | undefined {
115
+ function workOf(teammate: Agent | undefined, task?: Task): string | undefined {
118
116
  const source = task ?? (teammate?.workId ? getTask(teammate.workId) : undefined);
119
117
  if (!source) return undefined;
120
118
  const owner = source.claimedBy ? ` · @${source.claimedBy}` : "";
121
- return `${shortTask(source.subject)} · ${stateWord(source.status)}${owner}`;
119
+ return `${headline(source.subject)} · ${stateWord(source.status)}${owner}`;
122
120
  }
123
121
 
124
122
  // ── agent ─────────────────────────────────────────────────────────
@@ -137,9 +135,6 @@ export interface AgentRowArgs {
137
135
  tools?: string[];
138
136
  model?: string;
139
137
  verify?: string;
140
- worktree?: boolean;
141
- persist?: boolean;
142
- persistScope?: string;
143
138
  };
144
139
  }
145
140
 
@@ -152,15 +147,13 @@ function agentName(args: AgentRowArgs, details: unknown): string {
152
147
  function roleLine(args: AgentRowArgs, name: string): string | undefined {
153
148
  const generated = args.definition;
154
149
  if (generated?.description) {
155
- return generated.persist
156
- ? `${oneLine(generated.description)} · saved as ${generated.persistScope ?? "project-local"} role`
157
- : `${oneLine(generated.description)} · new role for this session`;
150
+ return `${oneLine(generated.description)} · new role for this session`;
158
151
  }
159
152
  const resolved = resolveAgent(name);
160
153
  return resolved ? `${oneLine(resolved.description) || "role"} · ${resolved.scope} role` : undefined;
161
154
  }
162
155
 
163
- function agentStateWord(args: AgentRowArgs, teammate: Teammate | undefined, details: unknown, flags: { isError?: boolean; isPartial?: boolean }): string {
156
+ function agentStateWord(args: AgentRowArgs, teammate: Agent | undefined, details: unknown, flags: { isError?: boolean; isPartial?: boolean }): string {
164
157
  if (flags.isError) return "failed";
165
158
  const action = args.action ?? "agent";
166
159
  const live = teammate ? stateWord(teammate.status) : undefined;
@@ -187,7 +180,7 @@ export function agentRow(
187
180
  flags: { isError?: boolean; isPartial?: boolean } = {},
188
181
  ): CoordinationRow {
189
182
  const name = agentName(args, details);
190
- const teammate = getTeammate(name);
183
+ const teammate = getAgent(name);
191
184
  const action = args.action ?? "agent";
192
185
  let task: string | undefined;
193
186
  if (action === "delegate") {
@@ -203,7 +196,7 @@ export function agentRow(
203
196
  task = getTask(workId)?.subject;
204
197
  }
205
198
  }
206
- const subject = [`@${name}`, agentStateWord(args, teammate, details, flags), task ? shortTask(task) : ""]
199
+ const subject = [`@${name}`, agentStateWord(args, teammate, details, flags), task ? headline(task) : ""]
207
200
  .filter(Boolean)
208
201
  .join(" · ");
209
202
  return { subject, body: agentBody(args, name, teammate, details, flags) };
@@ -212,7 +205,7 @@ export function agentRow(
212
205
  function agentBody(
213
206
  args: AgentRowArgs,
214
207
  name: string,
215
- teammate: Teammate | undefined,
208
+ teammate: Agent | undefined,
216
209
  details: unknown,
217
210
  flags: { isError?: boolean },
218
211
  ): string[] {
@@ -230,7 +223,7 @@ function agentBody(
230
223
  // field would duplicate the header, and could contradict it when the live
231
224
  // teammate and the inspected snapshot disagree.
232
225
  return [
233
- ...(isWorking && teammate ? [labeled("now", shortTask(runningTeammateActivity(teammate)))] : []),
226
+ ...(isWorking && teammate ? [labeled("now", headline(runningTeammateActivity(teammate)))] : []),
234
227
  ...(work && isWorking ? [labeled("work", work)] : []),
235
228
  ...(sessionTools ? [labeled("tools", sessionTools.join(", "))] : []),
236
229
  ...(warning ? [labeled("warning", warning)] : []),
@@ -241,7 +234,7 @@ function agentBody(
241
234
  }
242
235
  const role = roleLine(args, name);
243
236
  const resources = strings(args.resources);
244
- const granted = teammate?.tools ?? resolveWorkerTools(strings(args.definition?.tools).length > 0 ? strings(args.definition?.tools) : resolveAgent(name)?.tools);
237
+ const granted = teammate?.tools ?? resolveAgentTools(strings(args.definition?.tools).length > 0 ? strings(args.definition?.tools) : resolveAgent(name)?.tools, WORKER_CAPABILITY_TOOLS);
245
238
  // The spawn receipt carries the same coordination-only warning as inspection,
246
239
  // so delegate and start rows state the narrow grant they actually produced.
247
240
  const sessionWarning = detailField<string>(detailField(details, "session"), "warning");
@@ -251,7 +244,6 @@ function agentBody(
251
244
  labeled("model", teammate?.model ?? args.model ?? args.definition?.model ?? "team default"),
252
245
  labeled("tools", granted.join(", ")),
253
246
  ...(sessionWarning ? [labeled("warning", sessionWarning)] : []),
254
- ...(teammate?.isolation === "worktree" ? [labeled("isolation", "dedicated git worktree")] : []),
255
247
  ...(teammate?.context === "fork" || args.fork ? [labeled("context", "forked from this session")] : []),
256
248
  ...(resources.length > 0 ? [labeled("resources", resources.join(", "))] : []),
257
249
  ...(() => {
@@ -276,7 +268,7 @@ export function leaderWorkRow(
276
268
  const items = Array.isArray(works) ? works : [];
277
269
  return {
278
270
  subject: `${items.length} work item${items.length === 1 ? "" : "s"}`,
279
- body: items.map((item) => `- ${shortTask(item.subject)} · ${stateWord(item.state)}${item.claimedBy ? ` · @${item.claimedBy}` : ""}`),
271
+ body: items.map((item) => `- ${headline(item.subject)} · ${stateWord(item.state)}${item.claimedBy ? ` · @${item.claimedBy}` : ""}`),
280
272
  };
281
273
  }
282
274
  const work = detailField<{ subject?: string; state?: string; resources?: string[] }>(details, "work");
@@ -293,7 +285,7 @@ export function leaderWorkRow(
293
285
  const workResources = strings(detailField<unknown>(work, "resources"));
294
286
  const stillRunning = detailField<string>(details, "holderStillRunning");
295
287
  return {
296
- subject: `${shortTask(subject)} · ${flags.isError ? "failed" : stateWord(outcome)}`,
288
+ subject: `${headline(subject)} · ${flags.isError ? "failed" : stateWord(outcome)}`,
297
289
  body: [
298
290
  ...(work?.state ? [labeled("state", stateWord(work.state))] : []),
299
291
  ...(stillRunning ? [labeled("risk", `@${stillRunning} was still working; an in-flight batch may still write inside the released scope`)] : []),
@@ -314,7 +306,7 @@ export function leaderWorkRow(
314
306
 
315
307
  /** One `agent_event` row: who receives it and what was actually said. */
316
308
  export function messageRow(
317
- args: { to?: string; message?: string; intent?: string },
309
+ args: { to?: string; body?: string; kind?: string },
318
310
  details: unknown,
319
311
  options: { isError?: boolean } = {},
320
312
  ): CoordinationRow {
@@ -322,7 +314,7 @@ export function messageRow(
322
314
  const outcome = detailField<string>(details, "outcome");
323
315
  // Messages are literal readbacks, not prose to clean up. Only terminal
324
316
  // controls and runtime handles change; the shared renderer owns truncation.
325
- const message = scrubHandles(safeDisplayText(displayText(args.message)), workHandle);
317
+ const message = scrubHandles(safeDisplayText(displayText(args.body)), workHandle);
326
318
  const prefix = [`to @${to}`, options.isError ? "failed" : outcome ? stateWord(outcome) : undefined]
327
319
  .filter(Boolean).join(" · ");
328
320
  return {
@@ -351,7 +343,7 @@ export function workerWorkRow(
351
343
  ? "current Work"
352
344
  : undefined;
353
345
  return {
354
- subject: `${shortTask(subject ?? leads ?? "Work")} · ${flags.isError ? "failed" : workerStateWord(action, outcome)}`,
346
+ subject: `${headline(subject ?? leads ?? "Work")} · ${flags.isError ? "failed" : workerStateWord(action, outcome)}`,
355
347
  body: bodyLines(content).slice(1),
356
348
  };
357
349
  }
package/src/tools.ts CHANGED
@@ -1,88 +1,26 @@
1
- import { truncateHead, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { CoordinationToolResult } from "./types.ts";
2
3
  import { notifyPi } from "@fradser/pi-kit";
3
4
  import {
4
- assignExistingWork,
5
- releaseExistingWork,
6
- reopenExistingWork,
7
- createBoardTask,
8
5
  publishStateSnapshot,
9
6
  sendLeaderMessage,
10
- shutdownTeammateExact,
11
- spawnTeammate,
7
+ shutdownAgentExact,
8
+ spawnAgent,
12
9
  } from "./team-machine.ts";
13
- import { listTasks, livingTeammates } from "./state.ts";
10
+ import { livingAgents } from "./state.ts";
14
11
 
15
- import { AgentActionParams, LEADER_RECIPIENT, AgentEventParams, WorkToolParams, normalizeCoordinationParams, requireParsedParams } from "./types.ts";
12
+ import { AgentEventParams, LEADER_RECIPIENT } from "./types.ts";
16
13
  import { openTeamConsole, refreshTeamUI } from "./ui.ts";
17
- import { discoverAgents } from "./agents.ts";
18
- import { agentRow, leaderWorkRow, messageRow, type AgentRowArgs } from "./tool-copy.ts";
14
+ import { discoverAgents } from "@fradser/pi-subagents";
15
+ import { messageRow } from "./tool-copy.ts";
19
16
  import { emptyToolCall, renderCoordinationRow } from "./tool-render.ts";
20
- import { runAgentAction, type AgentActionRuntime } from "./agent-actions.ts";
17
+ import type { AgentActionRuntime } from "./agent-actions.ts";
21
18
  import { resolveRecipient } from "./recipient.ts";
22
19
 
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");
37
- }
38
-
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");
56
- }
57
-
58
- export function registerLeaderTools(pi: ExtensionAPI, runtime: AgentActionRuntime & { sendLeaderMessage: typeof sendLeaderMessage } = { spawnTeammate, shutdownTeammateExact, sendLeaderMessage }): void {
59
- pi.registerTool({
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,
66
- renderShell: "self",
67
- renderCall: emptyToolCall,
68
- renderResult(result, options, theme, context) {
69
- return renderCoordinationRow(
70
- result, options, theme, context,
71
- "agent",
72
- agentRow(context.args as AgentRowArgs, result.details, { isError: context.isError, isPartial: options.isPartial }),
73
- );
74
- },
75
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
76
- const result = await runAgentAction(params as Parameters<typeof runAgentAction>[0], 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 };
81
- },
82
- });
83
-
20
+ /** Point-to-point communication. Stays with @fradser/pi-agent-teams. */
21
+ export function registerMessageTool(pi: ExtensionAPI, runtime: { sendLeaderMessage: typeof sendLeaderMessage } = { sendLeaderMessage }): void {
84
22
  pi.registerTool({
85
- name: "agent_event",
23
+ name: "message",
86
24
  promptSnippet: "Send an event or message to a participant",
87
25
  label: "Agent Event",
88
26
  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.",
@@ -93,152 +31,61 @@ export function registerLeaderTools(pi: ExtensionAPI, runtime: AgentActionRuntim
93
31
  return renderCoordinationRow(
94
32
  result, options, theme, context,
95
33
  "message",
96
- messageRow(context.args as { to?: string; message?: string }, result.details, { isError: context.isError }),
34
+ messageRow(context.args as { to?: string; body?: string }, result.details, { isError: context.isError }),
97
35
  );
98
36
  },
99
37
  async execute(_toolCallId, params) {
38
+ // A refusal is returned, not thrown, so every tool in the coordination
39
+ // surface reports failure the same way and a caller reads one shape
40
+ // whatever went wrong. Throwing here would make `message` the only tool
41
+ // that needs a try/catch.
42
+ const refuse = (error: string): CoordinationToolResult => ({
43
+ content: [{ type: "text", text: error }],
44
+ details: { ok: false, error },
45
+ isError: true,
46
+ });
100
47
  if (!params.to) {
101
- throw new Error("No bound reply route exists. Please specify 'to' explicitly.");
48
+ return refuse("No bound reply route exists. Please specify 'to' explicitly.");
102
49
  }
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 = "";
109
- return {
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" },
112
- };
113
- },
114
- });
115
-
116
- pi.registerTool({
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,
122
- renderShell: "self",
123
- renderCall: emptyToolCall,
124
- renderResult(result, options, theme, context) {
125
- return renderCoordinationRow(
126
- result, options, theme, context,
127
- "work",
128
- leaderWorkRow(context.args as Record<string, unknown>, result.details, { isError: context.isError }),
129
- );
130
- },
131
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
132
- // Union-root schema: some harnesses deliver target/arrays as JSON strings.
133
- params = requireParsedParams(normalizeCoordinationParams(params as Record<string, unknown>, ["target", "dependsOn", "resources", "supersedes"]), ["target", "dependsOn", "resources", "supersedes"]) as typeof params;
134
- if (params.action === "supersede") {
135
- const tasks = listTasks();
136
- const referenced = [...((params.dependsOn as string[] | undefined) ?? []), ...(params.supersedes as string[])];
137
- if (referenced.some((id) => !tasks.some((task) => task.id === id))) {
138
- throw new Error(`Unknown work id in [${referenced.join(", ")}].`);
139
- }
140
- const created = createBoardTask(params as Parameters<typeof createBoardTask>[0]);
141
- if (!created.ok) throw new Error(created.error);
142
- const work = listTasks().find((task) => task.id === created.id);
143
- if (!work) throw new Error(`Replacement Work Item "${created.id}" is unavailable.`);
144
- refreshTeamUI(ctx);
145
- return {
146
- 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"}` }],
147
- details: {
148
- action: "supersede", outcome: "superseded", state: work.status,
149
- 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 },
150
- supersededWorkIds: created.supersededTaskIds, notifiedTeammates: created.notifiedTeammates, claimable: created.claimable,
151
- },
152
- };
153
- }
154
- if (params.action === "reopen") {
155
- const reopened = reopenExistingWork(params.id);
156
- if (!reopened.ok) throw new Error(reopened.error);
157
- refreshTeamUI(ctx);
158
- return {
159
- content: [{ type: "text", text: `WORK · current session\nREOPENED · ${reopened.workId} · pending\nREASON · ${params.reason}` }],
160
- details: { action: "reopen", outcome: "reopened", state: "pending", work: { id: reopened.workId, subject: reopened.subject, resources: reopened.resources, state: "pending" }, reason: params.reason },
161
- };
162
- }
163
- if (params.action === "release") {
164
- const released = releaseExistingWork(params.id, params.reason);
165
- if (!released.ok) throw new Error(released.error);
166
- refreshTeamUI(ctx);
167
- const residual = released.holderStillRunning
168
- ? `\nRISK · @${released.holderStillRunning} was still working; an in-flight tool batch may still write inside ${released.resources.join(", ") || "the released scope"}.`
169
- : "";
170
- return {
171
- content: [{ type: "text", text: `WORK · current session\nRELEASED · ${released.workId} · pending\nREASON · ${params.reason}${residual}` }],
172
- 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 } : {}) },
173
- };
174
- }
175
- if (params.action === "assign") {
176
- const assigned = assignExistingWork(params.id, (params.target as { session: string }).session);
177
- if (!assigned.ok) throw new Error(assigned.error);
178
- const task = listTasks().find((entry) => entry.id === assigned.workId);
179
- if (!task) throw new Error(`Assigned Work Item "${assigned.workId}" is unavailable.`);
180
- refreshTeamUI(ctx);
181
- return {
182
- content: [{ type: "text", text: `WORK · current session\nASSIGNED · ${task.id} · claimed · owner=@${assigned.owner}\nATTEMPT · ${assigned.assignmentId} · fresh-session-pending` }],
183
- details: {
184
- action: "assign", outcome: "assigned", state: task.status,
185
- work: { id: task.id, subject: task.subject, resources: task.resources, state: task.status, claimedBy: task.claimedBy },
186
- assignment: { id: assigned.assignmentId, owner: assigned.owner, kind: "direct" },
187
- target: params.target as { session: string }, delivery: "fresh-session-pending",
188
- },
189
- };
190
- }
191
- if (params.action === "list") {
192
- const works = listTasks().map((task) => ({
193
- id: task.id,
194
- subject: task.subject,
195
- ...(task.description ? { description: task.description } : {}),
196
- dependsOn: task.dependsOn,
197
- resources: task.resources,
198
- ...(task.verify ? { verify: task.verify } : {}),
199
- state: task.status,
200
- ...(task.claimedBy ? { claimedBy: task.claimedBy } : {}),
201
- ...(task.result ? { result: task.result } : {}),
202
- }));
203
- return {
204
- content: [{ type: "text", text: formatWorkList(listTasks()) }],
205
- details: { action: "list", outcome: "listed", works, count: works.length },
206
- };
50
+ if (params.to === LEADER_RECIPIENT) return refuse("The leader cannot send a message to itself.");
51
+ let to: string;
52
+ try {
53
+ to = resolveRecipient(params.to, livingAgents()).name;
54
+ } catch (error) {
55
+ return refuse(error instanceof Error ? error.message : String(error));
207
56
  }
208
- const tasks = listTasks();
209
- const referenced = (params.dependsOn as string[] | undefined) ?? [];
210
- if (referenced.some((id) => !tasks.some((task) => task.id === id))) {
211
- throw new Error(`Unknown work id in [${referenced.join(", ")}].`);
212
- }
213
- const created = createBoardTask(params as Parameters<typeof createBoardTask>[0]);
214
- if (!created.ok) throw new Error(created.error);
215
- const work = listTasks().find((task) => task.id === created.id);
216
- if (!work) throw new Error(`Created Work Item "${created.id}" is unavailable.`);
217
- refreshTeamUI(ctx);
57
+ const result = runtime.sendLeaderMessage(to, params.body, {});
58
+ if (!result.ok) return refuse(result.error);
59
+ const recorded = result.outcome === "not-sent" ? `\nRECORDED TERMINAL REPORT · ${result.terminalReport}` : "";
218
60
  return {
219
- content: [{ type: "text", text: formatWorkCreation(params.subject, created) }],
220
- details: {
221
- action: "create",
222
- outcome: "created",
223
- state: work.status,
224
- work: {
225
- id: work.id,
226
- subject: work.subject,
227
- ...(work.description ? { description: work.description } : {}),
228
- dependsOn: work.dependsOn,
229
- resources: work.resources,
230
- ...(work.verify ? { verify: work.verify } : {}),
231
- state: work.status,
232
- },
233
- notifiedTeammates: created.notifiedTeammates,
234
- claimable: created.claimable,
235
- supersededWorkIds: created.supersededTaskIds,
236
- },
61
+ content: [{ type: "text", text: `MESSAGE ROUTING · ${result.outcome} · to=@${to}\nKIND · ${params.kind ?? "inform"}${recorded}` }],
62
+ details: { to, outcome: result.outcome, kind: params.kind ?? "inform", ok: true },
237
63
  };
238
64
  },
239
65
  });
66
+ }
240
67
 
68
+ // The `task` tool is registered by @fradser/pi-tasks' own extension entry. This
69
+ // package loads it through its manifest rather than calling its registrar, so
70
+ // there is exactly one registrant of that tool in every install combination.
71
+ // The leader learns about tasks transitions on `pi-tasks:*` events instead.
241
72
 
73
+ /** Temporary seam. Each registrar moves to its owning package; this keeps the
74
+ * existing entry point and its test fixtures intact until that lands. */
75
+ /** Register this package's own tool, and only that one.
76
+ *
77
+ * `agent` is registered by @fradser/pi-subagents and `task` by
78
+ * @fradser/pi-tasks. This package's manifest loads those two extension entries
79
+ * alongside its own, which is how one install ends up with three tools and each
80
+ * tool still has exactly one registrant. A tool with two registrants either
81
+ * collides or is silently dropped depending on load order, and neither failure is
82
+ * visible from here.
83
+ *
84
+ * The leader learns about tasks transitions on `pi-tasks:*` events rather than
85
+ * being called by pi-tasks, so the domain never has to know a resident exists.
86
+ */
87
+ export function registerLeaderTools(pi: ExtensionAPI, runtime: AgentActionRuntime & { sendLeaderMessage: typeof sendLeaderMessage } = { spawnAgent, shutdownAgentExact, sendLeaderMessage }): void {
88
+ registerMessageTool(pi, runtime);
242
89
  }
243
90
 
244
91
  function teamStatusSummary(): string {
@@ -246,13 +93,13 @@ function teamStatusSummary(): string {
246
93
  const rolesLine = roles.length > 0
247
94
  ? `${roles.length} persistent agent role${roles.length === 1 ? "" : "s"}: ${roles.join(", ")}`
248
95
  : "No agent roles discovered.";
249
- const roster = livingTeammates().map((t) => `@${t.name} (${t.agent}, ${t.status})`).join("\n") || "No living sessions.";
96
+ const roster = livingAgents().map((t) => `@${t.name} (${t.role}, ${t.status})`).join("\n") || "No living sessions.";
250
97
  return `${roster}\n${rolesLine}`;
251
98
  }
252
99
 
253
100
  export function registerTeamCommand(pi: ExtensionAPI): void {
254
101
  pi.registerCommand("agent-teams", {
255
- description: "Agent Teams management console: session teammates and persistent agent roles",
102
+ description: "Agent Teams management console: session agents and persistent agent roles",
256
103
  handler: async (_args, ctx) => {
257
104
  publishStateSnapshot();
258
105
  if (ctx.mode !== "tui") { notifyPi(ctx.ui, teamStatusSummary(), "info"); return; }