@fradser/pi-agent-teams 0.6.0 → 0.8.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,8 +1,10 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { formatAgentMessagePrefix, formatAgentTaskName, formatToolEventLabel } from "@fradser/pi-kit";
1
+ import { keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { formatAgentMessagePrefix, formatAgentTaskName, formatExpandHint, formatToolEventLabel, safeDisplayText } from "@fradser/pi-kit";
3
3
  import {
4
4
  createBoardTask,
5
5
  formatSilenceDuration,
6
+ hasAnnouncedFinish,
7
+ hasTerminalReport,
6
8
  publishStateSnapshot,
7
9
  sendLeaderMessage,
8
10
  shutdownTeammate,
@@ -12,13 +14,8 @@ import { listTasks, livingTeammates } from "./state.ts";
12
14
  import { LEADER_RECIPIENT, SendMessageParams, TeammateShutdownParams, TeammateSpawnParams, TaskCreateParams } from "./types.ts";
13
15
  import { registerTaskListTool } from "./worker.ts";
14
16
  import { openTeamConsole, refreshTeamUI } from "./ui.ts";
17
+ import { discoverAgents } from "./agents.ts";
15
18
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
16
- import * as fs from "node:fs";
17
- import * as path from "node:path";
18
-
19
- function isValidAgentName(name: string): boolean {
20
- return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && !name.includes("..") && name !== "con";
21
- }
22
19
 
23
20
  function rosterSummary(): string {
24
21
  const alive = livingTeammates();
@@ -31,7 +28,7 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
31
28
  name: "teammate_spawn",
32
29
  promptSnippet: "Spawn a named resident teammate",
33
30
  label: "Spawn Teammate",
34
- description: "Spawn one named resident teammate. Model and worktree behavior come from its declarative agent definition; the kickoff prompt is optional.",
31
+ description: "Spawn one named resident teammate. Generated role definitions stay in memory by default; persist one only when the user explicitly asks to keep it for future sessions.",
35
32
  parameters: TeammateSpawnParams,
36
33
  // Canonical lifecycle rows (same as packages/monitor): empty renderCall,
37
34
  // ONE startup row owned by renderResult.
@@ -39,7 +36,7 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
39
36
  renderCall: () => new Text("", 0, 0),
40
37
  renderResult(result, _options, theme, context) {
41
38
  const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
42
- if ((result as { isError?: boolean }).isError) {
39
+ if (context.isError) {
43
40
  return new Text(theme.fg("error", text.split("\n")[0] || "Failed to spawn teammate."), 0, 0);
44
41
  }
45
42
  const params = context.args as { name: string; agent: string; prompt?: string };
@@ -71,17 +68,27 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
71
68
  parameters: TeammateShutdownParams,
72
69
  renderShell: "self",
73
70
  renderCall: () => new Text("", 0, 0),
74
- renderResult(result, _options, theme, context) {
71
+ renderResult(result, options, theme, context) {
75
72
  const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
76
- if ((result as { isError?: boolean }).isError) {
73
+ if (context.isError) {
77
74
  return new Text(theme.fg("error", text.split("\n")[0] || "Failed to shut down teammate."), 0, 0);
78
75
  }
79
76
  const name = String((context.args as { name?: string }).name ?? "");
80
- const line = `${theme.fg("toolTitle", theme.bold(formatToolEventLabel("event", `@${name} shut down`).trimEnd()))}`;
81
- return {
82
- render: (width: number) => [truncateToWidth(line, Math.max(1, width))],
83
- invalidate: () => {},
77
+ // The finish entry already announced this end of life (or its terminal
78
+ // report is queued to); a second event row is noise.
79
+ if (hasAnnouncedFinish(name) || hasTerminalReport(name)) return { render: () => [], invalidate: () => {} };
80
+ const title = theme.fg("toolTitle", theme.bold(formatToolEventLabel("event", `@${name} shut down`, "agent")));
81
+ const render = (width: number) => {
82
+ if (width <= 0) return [];
83
+ if (!options.expanded) {
84
+ return [truncateToWidth(`${title}${formatExpandHint(keyHint("app.tools.expand", "to expand"), theme)}`, width)];
85
+ }
86
+ return [
87
+ truncateToWidth(title, width),
88
+ ...text.split("\n").map((line) => truncateToWidth(theme.fg("customMessageText", safeDisplayText(line)), width)),
89
+ ];
84
90
  };
91
+ return { render, invalidate: () => {} };
85
92
  },
86
93
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
87
94
  const result = await shutdownTeammate(params.name);
@@ -97,10 +104,26 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
97
104
  label: "Send Message",
98
105
  description: "The only messaging primitive. Address a living teammate by name; working teammates receive a steer immediately and idle teammates wake with the message. status is reserved for worker reports to leader.",
99
106
  parameters: SendMessageParams,
100
- renderCall(args, theme) {
101
- const prefix = theme.fg("toolTitle", theme.bold(formatAgentMessagePrefix("to")));
102
- const recipient = theme.fg("accent", `@${String(args.to ?? "")}`);
103
- return new Text(`${prefix}${recipient}`, 0, 0);
107
+ // Canonical lifecycle rows (same as packages/monitor): empty call slot,
108
+ // ONE delivery row owned by renderResult.
109
+ renderShell: "self",
110
+ renderCall: () => new Text("", 0, 0),
111
+ renderResult(result, _options, theme, context) {
112
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
113
+ if (context.isError) {
114
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to send message."), 0, 0);
115
+ }
116
+ const to = String((context.args as { to?: string }).to ?? "");
117
+ const details = result.details as { queued?: boolean; stalledMs?: number } | undefined;
118
+ const suffix = details?.queued ? "queued" : "delivered";
119
+ let line = `${theme.fg("toolTitle", theme.bold(formatAgentMessagePrefix("to")))}${theme.fg("accent", `@${to}`)}${theme.fg("dim", ` · ${suffix}`)}`;
120
+ if (!details?.queued && details?.stalledMs !== undefined) {
121
+ line += theme.fg("dim", ` · stalled ${formatSilenceDuration(details.stalledMs)}`);
122
+ }
123
+ return {
124
+ render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
125
+ invalidate: () => {},
126
+ };
104
127
  },
105
128
  async execute(_toolCallId, params) {
106
129
  if (params.to === LEADER_RECIPIENT) throw new Error('The leader cannot send a message to itself.');
@@ -112,7 +135,12 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
112
135
  if (!result.queued && result.stalledMs !== undefined) {
113
136
  text += `\nWarning: @${params.to} has been stalled with no output for ${formatSilenceDuration(result.stalledMs)} — the control-stream write succeeded, but the teammate may be wedged. Consider teammate_shutdown if it stays silent.`;
114
137
  }
115
- return { content: [{ type: "text", text }], details: {} };
138
+ return {
139
+ content: [{ type: "text", text }],
140
+ details: result.stalledMs !== undefined && !result.queued
141
+ ? { queued: result.queued, stalledMs: result.stalledMs }
142
+ : { queued: result.queued },
143
+ };
116
144
  },
117
145
  });
118
146
 
@@ -120,8 +148,24 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
120
148
  name: "task_create",
121
149
  promptSnippet: "Create a shared board task",
122
150
  label: "Create Task",
123
- description: "Create one shared board task. Resident teammates self-claim it when dependencies are met; an optional verify command gates completion.",
151
+ description: "Create one shared board task. Resident teammates self-claim it when dependencies are met; an optional verify prompt gates completion through a fresh reviewer.",
124
152
  parameters: TaskCreateParams,
153
+ // Canonical lifecycle rows (same as packages/monitor): empty call slot,
154
+ // ONE created row owned by renderResult.
155
+ renderShell: "self",
156
+ renderCall: () => new Text("", 0, 0),
157
+ renderResult(result, _options, theme, context) {
158
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
159
+ if (context.isError) {
160
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to create task."), 0, 0);
161
+ }
162
+ const subject = String((context.args as { subject?: string }).subject ?? "");
163
+ const line = theme.fg("toolTitle", theme.bold(formatToolEventLabel("created", subject, "board")));
164
+ return {
165
+ render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
166
+ invalidate: () => {},
167
+ };
168
+ },
125
169
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
126
170
  const tasks = listTasks();
127
171
  if (params.dependsOn?.some((dep) => !tasks.some((task) => task.id === dep))) {
@@ -137,81 +181,23 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
137
181
  registerTaskListTool(pi);
138
182
  }
139
183
 
184
+ function teamStatusSummary(): string {
185
+ const roles = [...discoverAgents().keys()].map((name) => `@${name}`);
186
+ const rolesLine = roles.length > 0
187
+ ? `${roles.length} persistent agent role${roles.length === 1 ? "" : "s"}: ${roles.join(", ")}`
188
+ : "No agent roles discovered.";
189
+ return `${rosterSummary()}\n${rolesLine}`;
190
+ }
191
+
140
192
  export function registerTeamCommand(pi: ExtensionAPI): void {
141
193
  pi.registerCommand("agent-teams", {
142
- description: "Agent Teams menu: console and create agents from session history",
194
+ description: "Agent Teams management console: session teammates and persistent agent roles",
143
195
  handler: async (_args, ctx) => {
144
- const choice = await ctx.ui.select("agent-teams", ["console", "project", "local"]);
145
- if (!choice) return;
146
- if (choice === "console") {
147
- publishStateSnapshot();
148
- if (ctx.mode !== "tui") { ctx.ui.notify(rosterSummary(), "info"); return; }
149
- await openTeamConsole(ctx);
150
- refreshTeamUI(ctx);
151
- return;
152
- }
153
- await createAgentFromHistory(ctx, choice === "local");
196
+ publishStateSnapshot();
197
+ if (ctx.mode !== "tui") { ctx.ui.notify(teamStatusSummary(), "info"); return; }
198
+ await openTeamConsole(ctx);
199
+ refreshTeamUI(ctx);
154
200
  },
155
201
  });
156
202
  }
157
203
 
158
- interface GeneratedAgent { name?: string; description?: string; tools?: string[]; prompt?: string; content?: string; }
159
-
160
- function parseGeneratedAgents(raw: string): GeneratedAgent[] {
161
- const cleaned = raw.trim().replace(/^```(?:json)?\s*|\s*```$/g, "");
162
- const start = cleaned.indexOf("[");
163
- const end = cleaned.lastIndexOf("]");
164
- if (start < 0 || end <= start) return [];
165
- try {
166
- const value: unknown = JSON.parse(cleaned.slice(start, end + 1));
167
- return Array.isArray(value) ? value.filter((item): item is GeneratedAgent => Boolean(item && typeof item === "object")) : [];
168
- } catch { return []; }
169
- }
170
-
171
- function renderAgentDefinition(agent: GeneratedAgent): string {
172
- const name = agent.name!.trim();
173
- const description = (agent.description || "Agent created from session history").replace(/[\r\n]+/g, " ").trim();
174
- const tools = Array.isArray(agent.tools) ? agent.tools.filter((tool) => typeof tool === "string" && tool.trim()).join(", ") : "";
175
- return `---\nname: ${name}\ndescription: ${description}${tools ? `\ntools: ${tools}` : ""}\n---\n\n${(agent.prompt || "").trim()}`;
176
- }
177
-
178
- async function createAgentFromHistory(ctx: ExtensionContext, local: boolean): Promise<void> {
179
- const branch = ctx.sessionManager?.getBranch?.() ?? [];
180
- const history = branch.map((entry: any) => {
181
- const text = typeof entry?.text === "string" ? entry.text : typeof entry?.content === "string" ? entry.content : "";
182
- return text ? text : Array.isArray(entry?.content) ? entry.content.map((part: any) => part?.text ?? "").join("") : "";
183
- }).filter(Boolean).slice(-20).join("\n\n");
184
- const generated = await generateAgentPrompt(ctx, history);
185
- if (!generated) return;
186
- const dir = path.join(ctx.cwd || process.cwd(), ".pi", "agents");
187
- fs.mkdirSync(dir, { recursive: true });
188
- const definitions = parseGeneratedAgents(generated)
189
- .filter((item) => typeof item.name === "string" && (item.content?.trim() || item.prompt?.trim()))
190
- .map((item) => ({ name: item.name!.trim(), content: item.content?.trim() || renderAgentDefinition(item) }));
191
- if (definitions.length === 0) { ctx.ui.notify("Model returned no usable agent definitions", "error"); return; }
192
- let created = 0;
193
- for (const definition of definitions) {
194
- if (!isValidAgentName(definition.name)) continue;
195
- const filename = `${definition.name}${local ? ".local" : ""}.md`;
196
- try { fs.writeFileSync(path.join(dir, filename), definition.content, { encoding: "utf8", flag: "wx" }); created++; } catch (error) {
197
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
198
- }
199
- }
200
- ctx.ui.notify(`Created ${created} agent definition${created === 1 ? "" : "s"}`, created ? "info" : "warning");
201
- }
202
-
203
- async function generateAgentPrompt(ctx: ExtensionContext, history: string): Promise<string | undefined> {
204
- const model = ctx.model;
205
- const registry = ctx.modelRegistry;
206
- if (!model || !registry?.complete) {
207
- ctx.ui.notify("No model available to generate an agent", "error");
208
- return undefined;
209
- }
210
- const response = await registry.complete(model, {
211
- systemPrompt: "Design a complementary resident coding agent team from session history. Return a JSON array of 2-4 complementary agent definitions. Each item must have name, description, tools, and prompt fields. Names use letters, digits, dots, dashes, underscores. Return JSON only, with no code fences.",
212
- messages: [{ role: "user", content: `Session history:\n${history || "(empty)"}`, timestamp: Date.now() }],
213
- }, { maxTokens: 2000, temperature: 0, cacheRetention: "none" });
214
- const content = response.content?.filter((part: any) => part.type === "text").map((part: any) => part.text).join("\\n").trim();
215
- if (!content) { ctx.ui.notify("Model returned no agent definition", "error"); return undefined; }
216
- return content;
217
- }
package/src/types.ts CHANGED
@@ -30,6 +30,8 @@ export interface Teammate {
30
30
  isolation: "worktree" | "none";
31
31
  /** Board task currently claimed by this teammate, if any. */
32
32
  currentTaskId?: string;
33
+ /** Effective launch model reference ("provider/model"); absent when Pi picks its default. */
34
+ model?: string;
33
35
  /** Live assistant text assembled from the RPC stream. */
34
36
  liveText?: string;
35
37
  /** Current child tool name, if a tool is executing. */
@@ -42,6 +44,8 @@ export interface Teammate {
42
44
  sequenceEnded?: boolean;
43
45
  /** When the harness last sent a claimable-task notice to this teammate. */
44
46
  lastNoticeAt?: number;
47
+ /** Claimable task ids already announced to this teammate; one notice per id until it re-arms. */
48
+ noticedTaskIds?: string[];
45
49
  usage?: WorkerUsage;
46
50
  error?: string;
47
51
  createdAt: number;
@@ -72,7 +76,8 @@ export interface BoardTask {
72
76
  description?: string;
73
77
  /** Task ids that must complete before this task is claimable. */
74
78
  dependsOn: string[];
75
- /** Deterministic completion gate; overrides the agent-role default. */
79
+ /** Completion gate: a review prompt a fresh one-shot reviewer answers with
80
+ * VERDICT: PASS or FAIL; overrides the agent-role default. */
76
81
  verify?: string;
77
82
  status: TaskStatus;
78
83
  claimedBy?: string;
@@ -165,8 +170,21 @@ export interface InboxMessage {
165
170
  /** Spawn one named resident teammate. */
166
171
  export const TeammateSpawnParams = Type.Object({
167
172
  name: Type.String({ minLength: 1, description: "Teammate name, unique among living teammates; used for messaging and claiming" }),
168
- agent: Type.String({ description: "Agent definition name (bundled, user, or project scope); its frontmatter pins model and optional worktree isolation" }),
173
+ agent: Type.String({ description: "Agent definition name; an inline definition may create this role in memory for the current session" }),
169
174
  prompt: Type.Optional(Type.String({ description: "Optional kickoff prompt delivered as the teammate's first turn; omit to let it wait for messages or board claims" })),
175
+ definition: Type.Optional(Type.Object({
176
+ description: Type.String({ description: "Routing contract for the generated role" }),
177
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Pi tool ids for the generated role" })),
178
+ model: Type.Optional(Type.String({ description: 'Optional provider/model pin, or "inherit" to run on the leader\'s current model' })),
179
+ verify: Type.Optional(Type.String({ description: "Role-default completion gate: a review prompt a fresh reviewer answers with VERDICT: PASS or FAIL" })),
180
+ worktree: Type.Optional(Type.Boolean({ description: "Whether this role receives a dedicated Git worktree" })),
181
+ prompt: Type.String({ minLength: 1, description: "Role prompt for this generated teammate" }),
182
+ persist: Type.Optional(Type.Boolean({ description: "Persist only when the user explicitly asks to keep this role for future sessions" })),
183
+ persistScope: Type.Optional(Type.Union([
184
+ Type.Literal("project"),
185
+ Type.Literal("project-local"),
186
+ ], { description: "Persistence scope; defaults to project-local when persist is true" })),
187
+ }, { description: "Optional generated role definition; kept in memory unless explicitly persisted" })),
170
188
  });
171
189
 
172
190
  /** Shut down one living teammate. */
@@ -180,7 +198,7 @@ export const TaskCreateParams = Type.Object({
180
198
  description: Type.Optional(Type.String({ description: "Full task description for the claiming teammate" }),
181
199
  ),
182
200
  dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Task ids that must complete before this task is claimable" })),
183
- verify: Type.Optional(Type.String({ description: "Deterministic completion gate command; zero exit completes the task. Overrides any agent-role default verify." })),
201
+ verify: Type.Optional(Type.String({ description: "Completion gate: a review prompt a fresh reviewer answers with VERDICT: PASS or FAIL. Overrides any agent-role default verify." })),
184
202
  });
185
203
 
186
204
  /** Shared leader/worker read-only board view. */
@@ -218,10 +236,11 @@ export const TaskSubmitParams = Type.Object({
218
236
  export interface TeamState {
219
237
  teammates: Record<string, Teammate>;
220
238
  tasks: Record<string, BoardTask>;
239
+ /** Unified teammate model for this session; spawns without a role pin use it. */
240
+ defaultModel?: string;
221
241
  /** Single leader inbox for teammate reports and harness diagnostics. */
222
242
  leaderMailbox: MailboxMessage[];
223
243
  messageCounter: number;
224
- taskCounter: number;
225
244
  /** Byte offsets consumed by the parent from each teammate's outbox. */
226
245
  workerEventOffsets: Record<string, number>;
227
246
  /** Report event ids already applied, keyed by teammate spawn. */