@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/index.ts CHANGED
@@ -5,44 +5,60 @@
5
5
  * passive widget and console live in ui.ts.
6
6
  */
7
7
 
8
- import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
8
+ import { getMarkdownTheme, keyHint, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { buildTeamLeaderGuidance, TEAMMATE_SPAWN_GUIDANCE, WORKER_GUIDANCE } from "./guidance.ts";
10
+ import { buildIdleLeaderGuidance, buildTeamLeaderGuidance, WORKER_GUIDANCE } from "./guidance.ts";
11
11
  import { clearSessionAgents } from "./agents.ts";
12
- import { formatSilenceDuration, initTeamMachine, markTeammateFinished, removeRuntimeDir, shutdownTeamMachine, teardownTeammates } from "./team-machine.ts";
12
+ import { getConfirmedStopTime, initTeamMachine, markTeammateFinished, removeRuntimeDir, shutdownTeamMachine, syncLeaderContext, teardownTeammates } from "./team-machine.ts";
13
13
  import { cleanupExpiredStateDirs } from "./statefile.ts";
14
14
  import { livingTeammates, listTasks, resetState } from "./state.ts";
15
15
  import { ensureTeamWidget, refreshTeamUI, stopUiTimers } from "./ui.ts";
16
- import { refreshLeaderToolDisclosure, registerLeaderTools, registerTeamCommand } from "./tools.ts";
16
+ import { registerLeaderTools, registerTeamCommand } from "./tools.ts";
17
17
  import { registerWorkerCapabilities, workerBinding } from "./worker.ts";
18
- import { agentColor, clearPiStatus, createStaticToolLifecycleMessageRenderer, eventToolLifecycle, formatAgentMessagePrefix, notifyPi, renderAgentMessageBand } from "@fradser/pi-kit";
19
- import { FollowUpQueue, groupReportsByTeammate, TEAMMATE_HARNESS_MESSAGE_TYPE, TEAMMATE_REPORT_MESSAGE_TYPE, type FollowUpReport } from "./follow-up-queue.ts";
20
- import { Box, Markdown, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
+ import { plainText } from "./tool-copy.ts";
19
+ import { agentColor, bindLifecycleRenderers, clearPiStatus, createToolExecutionWrapper, eventToolLifecycle, formatAgentMessagePrefix, notifyPi, renderAgentMessageBand } from "@fradser/pi-kit";
20
+ import { annotateReportDelivery, formatReports, groupReportsByTeammate, TEAMMATE_HARNESS_MESSAGE_TYPE, TEAMMATE_REPORT_MESSAGE_TYPE, type LeaderReport } from "./leader-reports.ts";
21
+ import { Box, Markdown, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
22
+
23
+ /** Geometry bound once: harness event rows share hint and wrapping. */
24
+ const harnessRows = bindLifecycleRenderers({
25
+ fit: truncateToWidth,
26
+ visibleWidth,
27
+ wrapDetail: (line, width) => wrapTextWithAnsi(line, Math.max(1, width)),
28
+ expandHint: () => keyHint("app.tools.expand", "to expand"),
29
+ });
21
30
 
22
31
  const STATE_DIR_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
23
32
 
24
33
  let leaderPi: ExtensionAPI | undefined;
25
34
  let leaderCtx: ExtensionContext | undefined;
26
- let followUpQueue: FollowUpQueue | undefined;
27
35
 
28
36
  export const TEAMMATE_FINISHED_ENTRY_TYPE = "agent-teams-teammate-finished";
29
- export const TEAMMATE_HEALTH_MESSAGE_TYPE = "agent-teams-health";
30
37
 
31
- function sendMainSessionFollowUp(report: FollowUpReport): void {
32
- followUpQueue?.enqueue(report);
33
- }
34
-
35
- function extractHealthReport(details: unknown): FollowUpReport | undefined {
36
- const report = details as FollowUpReport | undefined;
37
- return report?.health?.state === "stalled" ? report : undefined;
38
+ function sendLeaderReport(report: LeaderReport): void {
39
+ try {
40
+ leaderPi?.sendMessage({
41
+ customType: report.origin === "harness" || report.harnessEvent
42
+ ? TEAMMATE_HARNESS_MESSAGE_TYPE
43
+ : TEAMMATE_REPORT_MESSAGE_TYPE,
44
+ content: formatReports([report]),
45
+ display: true,
46
+ details: report,
47
+ }, { triggerTurn: true, deliverAs: "steer" });
48
+ } catch (error) {
49
+ if (leaderCtx) {
50
+ const detail = error instanceof Error ? error.message : String(error);
51
+ notifyPi(leaderCtx.ui, `Agent Teams report delivery failed: ${detail}. The report remains in /agent-teams.`, "warning");
52
+ }
53
+ }
38
54
  }
39
55
 
40
56
  export function hasActiveTeamState(): boolean {
41
57
  return livingTeammates().length > 0 || listTasks().length > 0;
42
58
  }
43
59
 
44
- function extractHarnessReport(details: unknown): FollowUpReport | undefined {
45
- const report = details as FollowUpReport | undefined;
60
+ function extractHarnessReport(details: unknown): LeaderReport | undefined {
61
+ const report = details as LeaderReport | undefined;
46
62
  return report?.origin === "harness" || report?.harnessEvent ? report : undefined;
47
63
  }
48
64
 
@@ -63,135 +79,137 @@ export default function (pi: ExtensionAPI) {
63
79
  }
64
80
 
65
81
  leaderPi = pi;
82
+ const deliveries = new WeakMap<object, LeaderReport[]>();
66
83
  pi.registerEntryRenderer(TEAMMATE_FINISHED_ENTRY_TYPE, (entry, _options, theme) => {
67
84
  const data = entry.data as { teammate?: string; agent?: string } | undefined;
68
85
  const name = data?.teammate ?? data?.agent ?? "teammate";
69
- return new Text(theme.fg("success", `Teammate @${name} finished.`), 0, 0);
70
- });
71
- pi.registerMessageRenderer(TEAMMATE_HEALTH_MESSAGE_TYPE, (message, { expanded }, theme) => {
72
- const healthReport = extractHealthReport(message.details);
73
- const health = healthReport?.health;
74
- if (!health || !healthReport) return new Text(String(message.content), 0, 0);
75
- const subject = `@${healthReport.teammate} ${health.state} · silent ${formatSilenceDuration(health.silenceMs)}`;
76
- return createStaticToolLifecycleMessageRenderer({
77
- createSpec: () => eventToolLifecycle("agent", subject, {
78
- details: healthReport.body.split("\n").filter((line) => line.trim()),
79
- }),
80
- expandHint: keyHint("app.tools.expand", "to expand"),
81
- fit: truncateToWidth,
82
- visibleWidth,
83
- })(message, { expanded }, theme);
86
+ return new Text(theme.fg("success", `Assignment for @${name} finished.`), 0, 0);
84
87
  });
85
88
  pi.registerMessageRenderer(TEAMMATE_HARNESS_MESSAGE_TYPE, (message, { expanded }, theme) => {
86
89
  const report = extractHarnessReport(message.details);
87
90
  if (!report) return new Text(String(message.content), 0, 0);
88
91
  const event = report.harnessEvent;
89
- return createStaticToolLifecycleMessageRenderer({
90
- createSpec: () => eventToolLifecycle("agent", event?.subject ?? "Agent Teams event"),
91
- expandHint: keyHint("app.tools.expand", "to expand"),
92
- fit: truncateToWidth,
93
- visibleWidth,
94
- })(message, { expanded }, theme);
92
+ return harnessRows.message(
93
+ () => eventToolLifecycle("agent", plainText(event?.subject ?? "Agent Teams event")),
94
+ { hostComponent: ToolExecutionComponent, ui: leaderCtx?.ui, cwd: leaderCtx?.cwd },
95
+ )(message, { expanded }, theme);
95
96
  });
96
97
  pi.registerMessageRenderer(TEAMMATE_REPORT_MESSAGE_TYPE, (message, { expanded, outputPad }, theme) => {
97
- const reports = extractReports(message.details);
98
- const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
98
+ const reports = deliveries.get(message) ?? extractReports(message.details);
99
99
  if (reports.length === 0) {
100
+ const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
100
101
  box.addChild(new Markdown(String(message.content), 0, 0, getMarkdownTheme()));
101
102
  return box;
102
103
  }
103
- if (!expanded) {
104
- const groups = groupReportsByTeammate(reports);
105
- return renderAgentMessageBand(
106
- groups.map((group) => ({ direction: "from", teammate: group.teammate, count: group.reports.length })),
107
- { theme, fit: truncateToWidth, expandHint: keyHint("app.tools.expand", "to expand") },
108
- );
109
- }
110
- for (const [index, report] of reports.entries()) {
111
- const teammate = report.teammate ?? report.agent ?? "teammate";
112
- const prefix = theme.fg("customMessageLabel", theme.bold(formatAgentMessagePrefix("from")));
113
- const name = theme.fg(agentColor(teammate), `@${teammate}`);
114
- box.addChild(new Text(`${prefix}${name}`, 0, 0));
115
- box.addChild(new Markdown(report.body, 0, 0, getMarkdownTheme(), {
116
- color: (text) => theme.fg("customMessageText", text),
117
- }));
118
- if (index < reports.length - 1) box.addChild(new Text("", 0, 0));
119
- }
120
- return box;
104
+
105
+ return createToolExecutionWrapper(
106
+ (opt, currentTheme) => {
107
+ const effTheme = currentTheme ?? theme;
108
+ if (!opt.expanded) {
109
+ const groups = groupReportsByTeammate(reports);
110
+ const band = renderAgentMessageBand(
111
+ groups.map((group) => ({ direction: "from", teammate: group.teammate, count: group.reports.length })),
112
+ { theme: effTheme, fit: truncateToWidth, visibleWidth, wrapDetail: wrapTextWithAnsi, expandHint: keyHint("app.tools.expand", "to expand") },
113
+ );
114
+ if (!reports.some((report) => report.deliveredAfterStop)) return band;
115
+ const box = new Box(0, 0);
116
+ box.addChild(band);
117
+ box.addChild(new Text(effTheme.fg("warning", "Late delivery: process already stopped."), 0, 0));
118
+ return box;
119
+ }
120
+ const box = new Box(outputPad, 1, (text) => effTheme.bg("customMessageBg", text));
121
+ for (const [index, report] of reports.entries()) {
122
+ const teammate = report.teammate ?? report.agent ?? "teammate";
123
+ const prefix = effTheme.fg("customMessageLabel", effTheme.bold(formatAgentMessagePrefix("from")));
124
+ const name = effTheme.fg(agentColor(teammate), `@${teammate}`);
125
+ const delivery = report.deliveredAfterStop ? effTheme.fg("warning", " (late delivery; process stopped)") : "";
126
+ box.addChild(new Text(`${prefix}${name}${delivery}`, 0, 0));
127
+ box.addChild(new Markdown(report.body, 0, 0, getMarkdownTheme(), {
128
+ color: (text) => effTheme.fg("customMessageText", text),
129
+ }));
130
+ if (index < reports.length - 1) box.addChild(new Text("", 0, 0));
131
+ }
132
+ return box;
133
+ },
134
+ {
135
+ hostComponent: ToolExecutionComponent,
136
+ toolName: "message",
137
+ expanded,
138
+ ui: leaderCtx?.ui,
139
+ cwd: leaderCtx?.cwd,
140
+ },
141
+ theme,
142
+ );
121
143
  });
122
144
  registerLeaderTools(pi);
123
145
  registerTeamCommand(pi);
124
146
 
147
+ pi.on("message_start", (event) => {
148
+ if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
149
+ const deliveredAt = Date.now();
150
+ deliveries.set(event.message, extractReports(event.message.details).map((report) => annotateReportDelivery(
151
+ report, deliveredAt, report.spawnId ? getConfirmedStopTime(report.spawnId) : undefined,
152
+ )));
153
+ });
154
+
125
155
  pi.on("message_end", async (event) => {
126
156
  if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
127
- const reports = extractReports(event.message.details);
157
+ const deliveredAt = Date.now();
158
+ const reports = deliveries.get(event.message) ?? extractReports(event.message.details).map((report) => annotateReportDelivery(
159
+ report, deliveredAt, report.spawnId ? getConfirmedStopTime(report.spawnId) : undefined,
160
+ ));
161
+ if (reports.length === 0) return;
128
162
  for (const report of reports) {
129
163
  if (!markTeammateFinished(report)) continue;
130
164
  pi.appendEntry(TEAMMATE_FINISHED_ENTRY_TYPE, {
131
165
  teammate: report.teammate ?? report.agent,
132
166
  agent: report.agent,
167
+ assignmentId: report.assignmentId,
168
+ workId: report.workId,
169
+ spawnId: report.spawnId,
133
170
  });
134
171
  }
172
+ return {
173
+ message: { ...event.message, content: formatReports(reports), details: reports.length === 1 ? reports[0] : { reports } },
174
+ };
175
+ });
176
+
177
+ pi.on("model_select", async (_event, ctx) => {
178
+ leaderCtx = ctx;
179
+ syncLeaderContext(ctx);
180
+ });
181
+
182
+ pi.on("thinking_level_select", async (_event, ctx) => {
183
+ leaderCtx = ctx;
184
+ syncLeaderContext(ctx);
135
185
  });
136
186
 
137
187
  pi.on("session_start", async (_event, ctx) => {
138
188
  clearSessionAgents();
139
189
  resetState();
140
- refreshLeaderToolDisclosure();
141
- followUpQueue?.reset();
142
190
  leaderCtx = ctx;
143
- followUpQueue = new FollowUpQueue({
144
- isIdle: () => Boolean(leaderCtx?.isIdle()),
145
- prepareOnDispatch: true,
146
- dispatch: (reports, content) => leaderPi?.sendMessage({
147
- customType: reports.length === 1 && reports[0]?.health
148
- ? TEAMMATE_HEALTH_MESSAGE_TYPE
149
- : reports.length === 1 && (reports[0]?.origin === "harness" || reports[0]?.harnessEvent)
150
- ? TEAMMATE_HARNESS_MESSAGE_TYPE
151
- : TEAMMATE_REPORT_MESSAGE_TYPE,
152
- // Health remains a compact diagnostic for its dedicated renderer;
153
- // other harness events retain their explicit envelope in model context.
154
- content: reports.length === 1 && reports[0]?.health ? reports[0].body : content,
155
- display: true,
156
- details: reports.length === 1 ? reports[0] : { reports },
157
- }, { triggerTurn: true, deliverAs: "followUp" }),
158
- onFailure: (message) => {
159
- if (leaderCtx) notifyPi(leaderCtx.ui, message, "warning");
160
- },
161
- });
162
191
  ensureTeamWidget(ctx);
163
192
  initTeamMachine(ctx, {
164
- sendUpdate: sendMainSessionFollowUp,
165
- archiveQueuedReports: (spawnId) => followUpQueue?.archiveSpawn(spawnId) ?? [],
193
+ sendUpdate: sendLeaderReport,
166
194
  notifyChange: () => {
167
195
  refreshTeamUI(leaderCtx);
168
- refreshLeaderToolDisclosure();
169
196
  },
170
197
  });
171
198
  clearPiStatus(ctx.ui, "teammate");
172
199
  refreshTeamUI(ctx);
173
- refreshLeaderToolDisclosure();
174
200
  void cleanupExpiredStateDirs(STATE_DIR_MAX_AGE_MS);
175
201
  });
176
202
 
177
203
  pi.on("before_agent_start", async (event, ctx) => {
178
- followUpQueue?.onBeforeAgentStart(event.prompt);
204
+ leaderCtx = ctx;
179
205
  const teamIsActive = hasActiveTeamState();
180
206
  return {
181
207
  systemPrompt: event.systemPrompt + (teamIsActive
182
208
  ? buildTeamLeaderGuidance(ctx?.cwd ?? process.cwd())
183
- : TEAMMATE_SPAWN_GUIDANCE),
209
+ : buildIdleLeaderGuidance(ctx?.cwd ?? process.cwd())),
184
210
  };
185
211
  });
186
212
 
187
- pi.on("agent_start", async () => {
188
- followUpQueue?.onAgentStart();
189
- });
190
-
191
- pi.on("agent_settled", async () => {
192
- followUpQueue?.onAgentSettled();
193
- });
194
-
195
213
  pi.on("session_shutdown", async (_event, ctx) => {
196
214
  stopUiTimers();
197
215
  const diagnostics = await teardownTeammates();
@@ -200,18 +218,16 @@ export default function (pi: ExtensionAPI) {
200
218
  }
201
219
  shutdownTeamMachine();
202
220
  removeRuntimeDir(ctx);
203
- followUpQueue?.reset();
204
- followUpQueue = undefined;
205
221
  leaderPi = undefined;
206
222
  leaderCtx = undefined;
207
223
  resetState();
208
- refreshLeaderToolDisclosure();
209
224
  });
210
225
  }
211
226
 
212
- function extractReports(details: unknown): FollowUpReport[] {
213
- const typed = details as FollowUpReport | { reports?: FollowUpReport[] } | undefined;
227
+ function extractReports(details: unknown): LeaderReport[] {
228
+ if (Array.isArray(details)) return details as LeaderReport[];
229
+ const typed = details as LeaderReport | { reports?: LeaderReport[] } | undefined;
214
230
  if (typed && "reports" in typed && Array.isArray(typed.reports)) return typed.reports;
215
- if (typed && "teammate" in typed) return [typed as FollowUpReport];
231
+ if (typed && "teammate" in typed) return [typed as LeaderReport];
216
232
  return [];
217
233
  }
@@ -0,0 +1,92 @@
1
+ export const TEAMMATE_REPORT_MESSAGE_TYPE = "agent-teams-report";
2
+ export const TEAMMATE_HARNESS_MESSAGE_TYPE = "agent-teams-harness";
3
+
4
+ export interface LeaderReport {
5
+ teammate?: string;
6
+ agent?: string;
7
+ spawnId?: string;
8
+ assignmentId?: string;
9
+ workId?: string;
10
+ body: string;
11
+ /** Worker-authored reports are the default; harness events use a separate envelope. */
12
+ origin?: "teammate" | "harness";
13
+ harnessEvent?: {
14
+ type: string;
15
+ subject: string;
16
+ };
17
+ finished?: boolean;
18
+ /** Original append-only outbox event identifier, retained for session forensics. */
19
+ eventId?: string;
20
+ /** Original worker status, including omitted status, retained for session forensics. */
21
+ status?: "in_progress" | "completed" | "failed" | "inform" | "request" | "handoff";
22
+ runId?: string;
23
+ /** Wall-clock time the message was authored, not when Pi consumed it. */
24
+ timestamp?: number;
25
+ deliveredAt?: number;
26
+ processStoppedAt?: number;
27
+ deliveredAfterStop?: boolean;
28
+ }
29
+
30
+ export interface LeaderReportGroup {
31
+ teammate: string;
32
+ reports: LeaderReport[];
33
+ }
34
+
35
+ export function groupReportsByTeammate(reports: LeaderReport[]): LeaderReportGroup[] {
36
+ const groups = new Map<string, LeaderReportGroup>();
37
+ for (const report of reports) {
38
+ const teammate = report.teammate ?? report.agent ?? "teammate";
39
+ if (report.origin === "harness" || report.harnessEvent) {
40
+ groups.set(`harness:${groups.size}:${teammate}`, { teammate, reports: [report] });
41
+ continue;
42
+ }
43
+ const key = `message:${teammate}`;
44
+ const group = groups.get(key);
45
+ if (group) {
46
+ group.reports.push(report);
47
+ } else {
48
+ groups.set(key, { teammate, reports: [report] });
49
+ }
50
+ }
51
+ return [...groups.values()];
52
+ }
53
+
54
+ export function annotateReportDelivery(report: LeaderReport, deliveredAt: number, processStoppedAt?: number): LeaderReport {
55
+ if (processStoppedAt === undefined || deliveredAt < processStoppedAt) return { ...report, deliveredAt };
56
+ return { ...report, deliveredAt, processStoppedAt, deliveredAfterStop: true };
57
+ }
58
+
59
+ function timestampAttribute(name: string, timestamp: number | undefined): string {
60
+ return timestamp !== undefined && Number.isFinite(timestamp)
61
+ ? ` ${name}="${new Date(timestamp).toISOString()}"`
62
+ : "";
63
+ }
64
+
65
+ export function formatReports(reports: LeaderReport[]): string {
66
+ return reports
67
+ .map((report) => {
68
+ const { teammate, body, timestamp, origin, harnessEvent } = report;
69
+ const at = timestampAttribute("at", timestamp);
70
+ if (origin === "harness" || harnessEvent) {
71
+ const type = escapeAttribute(harnessEvent?.type ?? "event");
72
+ const subject = escapeAttribute(harnessEvent?.subject ?? "Agent Teams event");
73
+ return `<harness-event type="${type}" subject="${subject}"${at}>\n${body}\n</harness-event>`;
74
+ }
75
+ const name = teammate ?? "teammate";
76
+ const delivery = timestampAttribute("delivered-at", report.deliveredAt);
77
+ const stopped = timestampAttribute("process-stopped-at", report.processStoppedAt);
78
+ const late = report.deliveredAfterStop ? ' delivery="after-stop"' : "";
79
+ const work = report.workId ? ` work="${escapeAttribute(report.workId)}"` : "";
80
+ const assignment = report.assignmentId ? ` assignment="${escapeAttribute(report.assignmentId)}"` : "";
81
+ const status = report.status ? ` status="${escapeAttribute(report.status)}"` : "";
82
+ const note = report.deliveredAfterStop
83
+ ? "Delivery note: this report was queued before shutdown and delivered after the process stopped. It does not reopen the assignment.\n\n"
84
+ : "";
85
+ return `<agent-message from="${escapeAttribute(name)}"${at}${delivery}${stopped}${late}${work}${assignment}${status}>\n${note}${body}\n</agent-message>`;
86
+ })
87
+ .join("\n\n");
88
+ }
89
+
90
+ function escapeAttribute(value: string): string {
91
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
92
+ }
@@ -0,0 +1,44 @@
1
+ import type { Teammate } from "./types.ts";
2
+
3
+ export function sessionRoute(name: string): string {
4
+ return `session:${name}`;
5
+ }
6
+
7
+ export function exactSessionRoute(name: string, spawnId: string): string {
8
+ return `session:${name}:${spawnId}`;
9
+ }
10
+
11
+ export function parseExactSessionRoute(route: string): { name: string; spawnId: string } | undefined {
12
+ const match = /^session:([^:]+):([^:]+)$/.exec(route);
13
+ return match ? { name: match[1], spawnId: match[2] } : undefined;
14
+ }
15
+
16
+ export function resolveExactSession(route: string, roster: readonly Teammate[]): Teammate | undefined {
17
+ const parsed = parseExactSessionRoute(route);
18
+ if (!parsed) return undefined;
19
+ return roster.find((teammate) => teammate.name === parsed.name && teammate.spawnId === parsed.spawnId && teammate.status !== "stopped");
20
+ }
21
+
22
+ /** Resolve a model-facing recipient route to its live roster entry. The entry's
23
+ * spawn id travels with the message so a later replacement resident of the same
24
+ * name cannot consume mail addressed to its predecessor. */
25
+ export function resolveRecipient(
26
+ to: string,
27
+ roster: ReadonlyArray<{ name: string; agent: string; status: string; spawnId?: string }>,
28
+ ): { name: string; spawnId?: string } {
29
+ const exact = parseExactSessionRoute(to);
30
+ if (exact) {
31
+ const matched = roster.find((entry) => entry.name === exact.name && entry.spawnId === exact.spawnId && entry.status !== "stopped");
32
+ if (matched) return { name: matched.name, spawnId: matched.spawnId };
33
+ throw new Error(`No living session named "${to}".`);
34
+ }
35
+ const explicit = to.startsWith("session:");
36
+ const name = explicit ? to.slice("session:".length) : to;
37
+ const living = roster.filter((entry) => entry.status !== "stopped");
38
+ const recipients = living.filter((entry) => entry.name === name || (!explicit && entry.agent === to));
39
+ if (recipients.length > 1) {
40
+ throw new Error(`Ambiguous Agent @${to}. Use a precise route: ${recipients.map((entry) => entry.spawnId ? exactSessionRoute(entry.name, entry.spawnId) : sessionRoute(entry.name)).join(", ")}.`);
41
+ }
42
+ if (recipients.length === 1) return { name: recipients[0].name, spawnId: recipients[0].spawnId };
43
+ throw new Error(`No living teammate named "${to}".`);
44
+ }