@fradser/pi-agent-teams 0.5.2 → 0.6.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/MEMORY.md +3 -1
- package/.memory/follow-up-queue.md +17 -0
- package/.memory/node-terminal-followups.md +18 -0
- package/.memory/team-centric-design.md +21 -0
- package/README.md +99 -62
- package/agents/observer.md +1 -1
- package/agents/reviewer.md +4 -2
- package/agents/specialist.md +1 -1
- package/agents/worker.md +3 -3
- package/package.json +3 -3
- package/src/activity.ts +86 -0
- package/src/agents.ts +58 -21
- package/src/console-viewport.ts +2 -1
- package/src/follow-up-queue.ts +237 -0
- package/src/guidance.ts +81 -57
- package/src/index.ts +119 -38
- package/src/spawner.ts +217 -393
- package/src/state.ts +253 -568
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +946 -0
- package/src/tools.ts +181 -118
- package/src/types.ts +157 -155
- package/src/ui.ts +327 -268
- package/src/worker.ts +172 -31
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/src/run-machine.ts +0 -573
- package/src/terminal.ts +0 -34
package/src/tools.ts
CHANGED
|
@@ -1,154 +1,217 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { formatAgentMessagePrefix, formatAgentTaskName, formatToolEventLabel } from "@fradser/pi-kit";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
cancelNodeAndTerminate,
|
|
7
|
-
cancelRunAndTerminate,
|
|
8
|
-
ensureRunContext,
|
|
4
|
+
createBoardTask,
|
|
5
|
+
formatSilenceDuration,
|
|
9
6
|
publishStateSnapshot,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
7
|
+
sendLeaderMessage,
|
|
8
|
+
shutdownTeammate,
|
|
9
|
+
spawnTeammate,
|
|
10
|
+
} from "./team-machine.ts";
|
|
11
|
+
import { listTasks, livingTeammates } from "./state.ts";
|
|
12
|
+
import { LEADER_RECIPIENT, SendMessageParams, TeammateShutdownParams, TeammateSpawnParams, TaskCreateParams } from "./types.ts";
|
|
13
|
+
import { registerTaskListTool } from "./worker.ts";
|
|
14
|
+
import { openTeamConsole, refreshTeamUI } from "./ui.ts";
|
|
15
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
16
18
|
|
|
17
|
-
function
|
|
18
|
-
return
|
|
19
|
+
function isValidAgentName(name: string): boolean {
|
|
20
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && !name.includes("..") && name !== "con";
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
async function gatherForeground(runId: string, signal: AbortSignal | undefined): Promise<string> {
|
|
26
|
-
const foregroundTimeoutMs = 5 * 60 * 1000;
|
|
27
|
-
const deadline = Date.now() + foregroundTimeoutMs;
|
|
28
|
-
while (true) {
|
|
29
|
-
applyWorkerEvents();
|
|
30
|
-
const run = getRun(runId);
|
|
31
|
-
if (!run || run.status !== "running") break;
|
|
32
|
-
if (signal?.aborted) {
|
|
33
|
-
run.background = true;
|
|
34
|
-
run.completionNotified = false;
|
|
35
|
-
publishStateSnapshot();
|
|
36
|
-
throw new Error(`Run [${runId}] continues in the background — workers will message team-leader upon completion.`);
|
|
37
|
-
}
|
|
38
|
-
if (Date.now() >= deadline) {
|
|
39
|
-
run.background = true;
|
|
40
|
-
run.completionNotified = false;
|
|
41
|
-
publishStateSnapshot();
|
|
42
|
-
return `Run [${runId}] is still running after ${Math.round(foregroundTimeoutMs / 1000)}s — detached to background. Workers will message team-leader upon completion.`;
|
|
43
|
-
}
|
|
44
|
-
await sleep(150);
|
|
45
|
-
}
|
|
46
|
-
markRunCompletionDelivered(runId);
|
|
47
|
-
publishStateSnapshot();
|
|
48
|
-
return buildRunSummary(runId);
|
|
23
|
+
function rosterSummary(): string {
|
|
24
|
+
const alive = livingTeammates();
|
|
25
|
+
if (alive.length === 0) return "No living teammates.";
|
|
26
|
+
return alive.map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
|
|
49
27
|
}
|
|
50
28
|
|
|
51
29
|
export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
52
30
|
pi.registerTool({
|
|
53
|
-
name: "
|
|
54
|
-
promptSnippet: "
|
|
55
|
-
label: "
|
|
56
|
-
description: "
|
|
57
|
-
parameters:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
throw new Error(`Agent "${task.agent}" not found in any scope. Available agents: ${available || "(none)"}.`);
|
|
67
|
-
}
|
|
31
|
+
name: "teammate_spawn",
|
|
32
|
+
promptSnippet: "Spawn a named resident teammate",
|
|
33
|
+
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.",
|
|
35
|
+
parameters: TeammateSpawnParams,
|
|
36
|
+
// Canonical lifecycle rows (same as packages/monitor): empty renderCall,
|
|
37
|
+
// ONE startup row owned by renderResult.
|
|
38
|
+
renderShell: "self",
|
|
39
|
+
renderCall: () => new Text("", 0, 0),
|
|
40
|
+
renderResult(result, _options, theme, context) {
|
|
41
|
+
const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
|
|
42
|
+
if ((result as { isError?: boolean }).isError) {
|
|
43
|
+
return new Text(theme.fg("error", text.split("\n")[0] || "Failed to spawn teammate."), 0, 0);
|
|
68
44
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
agent: task.agent,
|
|
80
|
-
prompt: task.prompt,
|
|
81
|
-
paths: task.paths,
|
|
82
|
-
access: task.access ?? "read",
|
|
83
|
-
model: task.model,
|
|
84
|
-
timeoutMs: task.timeoutMs,
|
|
85
|
-
dependsOn: task.dependsOn ?? [],
|
|
86
|
-
})),
|
|
87
|
-
});
|
|
88
|
-
if (!created.ok) throw new Error(created.error ?? "Failed to create run.");
|
|
89
|
-
const run = created.run;
|
|
90
|
-
publishStateSnapshot();
|
|
45
|
+
const params = context.args as { name: string; agent: string; prompt?: string };
|
|
46
|
+
const line = `${theme.fg("toolTitle", theme.bold(formatToolEventLabel("started", "", "agent").trimEnd()))} ${theme.fg("accent", `@${params.name}`)} ${theme.fg("dim", "·")} ${theme.fg("customMessageText", theme.bold(formatAgentTaskName(params.prompt ?? "", params.name)))}`;
|
|
47
|
+
return {
|
|
48
|
+
render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
|
|
49
|
+
invalidate: () => {},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
53
|
+
const result = spawnTeammate(params);
|
|
54
|
+
if (!result.ok) throw new Error(result.error);
|
|
91
55
|
refreshTeamUI(ctx);
|
|
92
|
-
|
|
56
|
+
const kickoffNote = params.prompt?.trim()
|
|
57
|
+
? "It received your kickoff prompt and is working on it."
|
|
58
|
+
: "It is idle: it wakes for inbox messages and claimable board tasks.";
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: "text", text: `@${params.name} is alive as ${params.agent}.\n${kickoffNote}\n\n${rosterSummary()}` }],
|
|
61
|
+
details: { started: true },
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
});
|
|
93
65
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
66
|
+
pi.registerTool({
|
|
67
|
+
name: "teammate_shutdown",
|
|
68
|
+
promptSnippet: "Shut down a resident teammate",
|
|
69
|
+
label: "Shutdown Teammate",
|
|
70
|
+
description: "Gracefully stop one named teammate. Its claimed task returns to the board; its worktree diff is captured before teardown.",
|
|
71
|
+
parameters: TeammateShutdownParams,
|
|
72
|
+
renderShell: "self",
|
|
73
|
+
renderCall: () => new Text("", 0, 0),
|
|
74
|
+
renderResult(result, _options, theme, context) {
|
|
75
|
+
const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
|
|
76
|
+
if ((result as { isError?: boolean }).isError) {
|
|
77
|
+
return new Text(theme.fg("error", text.split("\n")[0] || "Failed to shut down teammate."), 0, 0);
|
|
99
78
|
}
|
|
100
|
-
|
|
79
|
+
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: () => {},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
87
|
+
const result = await shutdownTeammate(params.name);
|
|
88
|
+
if (!result.ok) throw new Error(result.error);
|
|
89
|
+
refreshTeamUI(ctx);
|
|
90
|
+
return { content: [{ type: "text", text: result.body }], details: {} };
|
|
101
91
|
},
|
|
102
92
|
});
|
|
103
93
|
|
|
104
94
|
pi.registerTool({
|
|
105
|
-
name: "
|
|
106
|
-
promptSnippet: "
|
|
107
|
-
label: "
|
|
108
|
-
description: "
|
|
109
|
-
parameters:
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
95
|
+
name: "send_message",
|
|
96
|
+
promptSnippet: "Send a message to a teammate",
|
|
97
|
+
label: "Send Message",
|
|
98
|
+
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
|
+
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);
|
|
104
|
+
},
|
|
105
|
+
async execute(_toolCallId, params) {
|
|
106
|
+
if (params.to === LEADER_RECIPIENT) throw new Error('The leader cannot send a message to itself.');
|
|
107
|
+
if (params.status) throw new Error('status is reserved for worker reports to="leader".');
|
|
108
|
+
const result = sendLeaderMessage(params.to, params.message);
|
|
116
109
|
if (!result.ok) throw new Error(result.error);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
110
|
+
const disposition = result.queued ? "Queued for its next wake-up." : "Delivered to its running turn.";
|
|
111
|
+
let text = `Message to @${params.to}: ${disposition}`;
|
|
112
|
+
if (!result.queued && result.stalledMs !== undefined) {
|
|
113
|
+
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
|
+
}
|
|
115
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
120
116
|
},
|
|
121
117
|
});
|
|
122
118
|
|
|
123
119
|
pi.registerTool({
|
|
124
|
-
name: "
|
|
125
|
-
promptSnippet: "
|
|
126
|
-
label: "
|
|
127
|
-
description: "
|
|
128
|
-
parameters:
|
|
120
|
+
name: "task_create",
|
|
121
|
+
promptSnippet: "Create a shared board task",
|
|
122
|
+
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.",
|
|
124
|
+
parameters: TaskCreateParams,
|
|
129
125
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
126
|
+
const tasks = listTasks();
|
|
127
|
+
if (params.dependsOn?.some((dep) => !tasks.some((task) => task.id === dep))) {
|
|
128
|
+
throw new Error(`Unknown dependency id in [${params.dependsOn.join(", ")}].`);
|
|
129
|
+
}
|
|
130
|
+
const created = createBoardTask(params);
|
|
131
|
+
if (!created.ok) throw new Error(created.error);
|
|
136
132
|
refreshTeamUI(ctx);
|
|
137
|
-
return { content: [{ type: "text", text: `
|
|
133
|
+
return { content: [{ type: "text", text: `Created [${created.id}] "${params.subject}". Idle teammates are notified automatically.` }], details: {} };
|
|
138
134
|
},
|
|
139
135
|
});
|
|
136
|
+
|
|
137
|
+
registerTaskListTool(pi);
|
|
140
138
|
}
|
|
141
139
|
|
|
142
140
|
export function registerTeamCommand(pi: ExtensionAPI): void {
|
|
143
|
-
pi.registerCommand("
|
|
144
|
-
description: "
|
|
141
|
+
pi.registerCommand("agent-teams", {
|
|
142
|
+
description: "Agent Teams menu: console and create agents from session history",
|
|
145
143
|
handler: async (_args, ctx) => {
|
|
146
|
-
|
|
147
|
-
|
|
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);
|
|
148
151
|
return;
|
|
149
152
|
}
|
|
150
|
-
await
|
|
151
|
-
refreshTeamUI(ctx);
|
|
153
|
+
await createAgentFromHistory(ctx, choice === "local");
|
|
152
154
|
},
|
|
153
155
|
});
|
|
154
156
|
}
|
|
157
|
+
|
|
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
|
+
}
|