@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/.memory/team-centric-design.md +1 -1
- package/README.md +33 -13
- package/package.json +3 -3
- package/references/agent-roles.md +77 -0
- package/src/agents.ts +141 -15
- package/src/follow-up-queue.ts +1 -0
- package/src/guidance.ts +46 -20
- package/src/index.ts +6 -4
- package/src/picker-keys.ts +30 -0
- package/src/state.ts +42 -5
- package/src/team-machine.ts +406 -54
- package/src/tools.ts +79 -93
- package/src/types.ts +23 -4
- package/src/ui.ts +369 -69
- package/src/worker.ts +3 -1
- package/agents/observer.md +0 -10
- package/agents/reviewer.md +0 -13
- package/agents/specialist.md +0 -10
- package/agents/worker.md +0 -11
package/src/tools.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import
|
|
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.
|
|
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 (
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 {
|
|
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
|
|
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
|
|
194
|
+
description: "Agent Teams management console: session teammates and persistent agent roles",
|
|
143
195
|
handler: async (_args, ctx) => {
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
/**
|
|
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
|
|
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: "
|
|
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. */
|