@fradser/pi-agent-teams 0.5.3 → 0.7.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
@@ -1,38 +1,47 @@
1
1
  /**
2
- * pi-agent-teams-fradser — Pi extension for run-centric agent teams.
3
- *
4
- * The entry point is composition only: worker capability registration,
5
- * session lifecycle, and delegation to tools.ts. Scheduling lives in
6
- * run-machine.ts; the passive widget and console live in ui.ts.
2
+ * pi-agent-teams-fradser — Pi extension for Claude-style resident agent
3
+ * teams. Composition only: worker capability registration, session lifecycle,
4
+ * and delegation to tools.ts. Coordination lives in team-machine.ts; the
5
+ * passive widget and console live in ui.ts.
7
6
  */
8
7
 
8
+ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { buildTeamLeaderGuidance, WORKER_GUIDANCE } from "./guidance";
11
- import { initRunMachine, shutdownRunMachine, type DispatchCtx } from "./run-machine";
12
- import { cleanupExpiredStateDirs, removeSessionStateDir, stateFilePath } from "./statefile";
13
- import { resetState } from "./state";
14
- import { ensureTeamWidget, refreshTeamUI, stopUiTimers } from "./ui";
15
- import { registerLeaderTools, registerTeamCommand } from "./tools";
16
- import { registerWorkerCapabilities, workerOutboxBinding } from "./worker";
17
- import { terminateAllWorkers } from "./spawner";
18
- import { FollowUpQueue } from "./follow-up-queue";
10
+ import { buildTeamLeaderGuidance, WORKER_GUIDANCE } from "./guidance.ts";
11
+ import { initTeamMachine, removeRuntimeDir, shutdownTeamMachine, teardownTeammates } from "./team-machine.ts";
12
+ import { cleanupExpiredStateDirs } from "./statefile.ts";
13
+ import { resetState } from "./state.ts";
14
+ import { ensureTeamWidget, refreshTeamUI, stopUiTimers } from "./ui.ts";
15
+ import { registerLeaderTools, registerTeamCommand } from "./tools.ts";
16
+ import { registerWorkerCapabilities, workerBinding } from "./worker.ts";
17
+ import { formatAgentMessagePrefix, formatExpandHint } from "@fradser/pi-kit";
18
+ import { FollowUpQueue, groupReportsByTeammate, TEAMMATE_REPORT_MESSAGE_TYPE, type FollowUpReport } from "./follow-up-queue.ts";
19
+ import { Box, Markdown, Text } from "@earendil-works/pi-tui";
19
20
 
20
21
  const STATE_DIR_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
21
22
 
22
23
  let leaderPi: ExtensionAPI | undefined;
23
24
  let leaderCtx: ExtensionContext | undefined;
24
25
  let followUpQueue: FollowUpQueue | undefined;
26
+ // One "Teammate finished" announcement per spawn incarnation; repeated
27
+ // terminal reports from the same resident stay ordinary report rows.
28
+ const announcedFinishKeys = new Set<string>();
25
29
 
26
- function sendMainSessionFollowUp(subject: string, body: string, runId?: string): void {
27
- followUpQueue?.enqueue({ subject, body, runId });
30
+ const REPORT_COLORS = ["success", "warning", "error", "mdLink"] as const;
31
+ export const TEAMMATE_FINISHED_ENTRY_TYPE = "agent-teams-teammate-finished";
32
+
33
+ function reportColor(name: string): (typeof REPORT_COLORS)[number] {
34
+ let hash = 0;
35
+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
36
+ return REPORT_COLORS[Math.abs(hash) % REPORT_COLORS.length];
28
37
  }
29
38
 
30
- function dispatchContext(ctx: ExtensionContext): DispatchCtx {
31
- return { ui: ctx.ui, sessionManager: ctx.sessionManager, cwd: ctx.cwd };
39
+ function sendMainSessionFollowUp(report: FollowUpReport): void {
40
+ followUpQueue?.enqueue(report);
32
41
  }
33
42
 
34
43
  export default function (pi: ExtensionAPI) {
35
- if (workerOutboxBinding()) {
44
+ if (workerBinding()) {
36
45
  pi.on("before_agent_start", async (event) => ({
37
46
  systemPrompt: event.systemPrompt + WORKER_GUIDANCE,
38
47
  }));
@@ -41,23 +50,73 @@ export default function (pi: ExtensionAPI) {
41
50
  }
42
51
 
43
52
  leaderPi = pi;
53
+ pi.registerEntryRenderer(TEAMMATE_FINISHED_ENTRY_TYPE, (entry, _options, theme) => {
54
+ const data = entry.data as { teammate?: string; agent?: string } | undefined;
55
+ const name = data?.teammate ?? data?.agent ?? "teammate";
56
+ return new Text(theme.fg("success", `Teammate @${name} finished.`), 0, 0);
57
+ });
58
+ pi.registerMessageRenderer(TEAMMATE_REPORT_MESSAGE_TYPE, (message, { expanded, outputPad }, theme) => {
59
+ const reports = extractReports(message.details);
60
+ const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
61
+ if (reports.length === 0) {
62
+ box.addChild(new Markdown(String(message.content), 0, 0, getMarkdownTheme()));
63
+ return box;
64
+ }
65
+ if (!expanded) {
66
+ const groups = groupReportsByTeammate(reports);
67
+ const hint = formatExpandHint(keyHint("app.tools.expand", "to expand"), theme);
68
+ for (const group of groups) {
69
+ const prefix = theme.fg("customMessageLabel", theme.bold(formatAgentMessagePrefix("from", group.reports.length)));
70
+ const name = theme.fg(reportColor(group.teammate), `@${group.teammate}`);
71
+ box.addChild(new Text(`${prefix}${name}${hint}`, 0, 0));
72
+ }
73
+ return box;
74
+ }
75
+ for (const [index, report] of reports.entries()) {
76
+ const teammate = report.teammate ?? report.agent ?? "teammate";
77
+ const prefix = theme.fg("customMessageLabel", theme.bold(formatAgentMessagePrefix("from")));
78
+ const name = theme.fg(reportColor(teammate), `@${teammate}`);
79
+ box.addChild(new Text(`${prefix}${name}`, 0, 0));
80
+ box.addChild(new Markdown(report.body, 0, 0, getMarkdownTheme(), {
81
+ color: (text) => theme.fg("customMessageText", text),
82
+ }));
83
+ if (index < reports.length - 1) box.addChild(new Text("", 0, 0));
84
+ }
85
+ return box;
86
+ });
44
87
  registerLeaderTools(pi);
45
88
  registerTeamCommand(pi);
46
89
 
90
+ pi.on("message_end", async (event) => {
91
+ if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
92
+ const reports = extractReports(event.message.details);
93
+ for (const report of reports) {
94
+ if (!markTeammateFinished(announcedFinishKeys, report)) continue;
95
+ pi.appendEntry(TEAMMATE_FINISHED_ENTRY_TYPE, {
96
+ teammate: report.teammate ?? report.agent,
97
+ agent: report.agent,
98
+ });
99
+ }
100
+ });
101
+
47
102
  pi.on("session_start", async (_event, ctx) => {
48
103
  resetState();
49
104
  followUpQueue?.reset();
105
+ announcedFinishKeys.clear();
50
106
  leaderCtx = ctx;
51
- const sessionQueue = new FollowUpQueue({
107
+ followUpQueue = new FollowUpQueue({
52
108
  isIdle: () => Boolean(leaderCtx?.isIdle()),
53
- dispatch: (content) => leaderPi?.sendUserMessage(content, { deliverAs: "followUp" }),
109
+ prepareOnDispatch: true,
110
+ dispatch: (reports, content) => leaderPi?.sendMessage({
111
+ customType: TEAMMATE_REPORT_MESSAGE_TYPE,
112
+ content,
113
+ display: true,
114
+ details: reports.length === 1 ? reports[0] : { reports },
115
+ }, { triggerTurn: true, deliverAs: "followUp" }),
54
116
  onFailure: (message) => leaderCtx?.ui.notify(message, "warning"),
55
117
  });
56
- followUpQueue = sessionQueue;
57
- const dispatchCtx = dispatchContext(ctx);
58
118
  ensureTeamWidget(ctx);
59
- const stateFile = stateFilePath(ctx.sessionManager.getSessionFile(), ctx.cwd || process.cwd());
60
- initRunMachine(dispatchCtx, stateFile, {
119
+ initTeamMachine(ctx, {
61
120
  sendUpdate: sendMainSessionFollowUp,
62
121
  notifyChange: () => refreshTeamUI(leaderCtx),
63
122
  });
@@ -83,14 +142,41 @@ export default function (pi: ExtensionAPI) {
83
142
 
84
143
  pi.on("session_shutdown", async (_event, ctx) => {
85
144
  stopUiTimers();
86
- await terminateAllWorkers();
87
- shutdownRunMachine();
88
- removeSessionStateDir(ctx.sessionManager.getSessionFile(), ctx.cwd || process.cwd());
145
+ const diagnostics = await teardownTeammates();
146
+ for (const message of diagnostics) {
147
+ ctx.ui.notify(message, "warning");
148
+ }
149
+ shutdownTeamMachine();
150
+ removeRuntimeDir(ctx);
89
151
  followUpQueue?.reset();
90
152
  followUpQueue = undefined;
91
153
  leaderPi = undefined;
92
154
  leaderCtx = undefined;
93
155
  resetState();
94
156
  });
157
+ }
158
+
159
+ function extractReports(details: unknown): FollowUpReport[] {
160
+ const typed = details as FollowUpReport | { reports?: FollowUpReport[] } | undefined;
161
+ if (typed && "reports" in typed && Array.isArray(typed.reports)) return typed.reports;
162
+ if (typed && "teammate" in typed) return [typed as FollowUpReport];
163
+ return [];
164
+ }
95
165
 
166
+ /**
167
+ * Announce a finished entry only on the first terminal report of a spawn
168
+ * incarnation. Reports without a spawn identity key on the session scope so
169
+ * crash diagnostics always stay visible. Returns true when the caller should
170
+ * append the finished entry.
171
+ */
172
+ export function markTeammateFinished(
173
+ seen: Set<string>,
174
+ report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
175
+ ): boolean {
176
+ if (!report.finished) return false;
177
+ const name = report.teammate ?? report.agent ?? "teammate";
178
+ const key = `${name}:${report.spawnId ?? "session"}`;
179
+ if (seen.has(key)) return false;
180
+ seen.add(key);
181
+ return true;
96
182
  }