@fradser/pi-agent-teams 0.7.0 → 0.8.2
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 +1 -0
- package/.memory/follow-up-queue.md +10 -6
- package/.memory/team-centric-design.md +3 -3
- package/README.md +48 -22
- package/package.json +2 -2
- package/references/agent-roles.md +8 -2
- package/src/agents.ts +100 -6
- package/src/follow-up-queue.ts +84 -21
- package/src/guidance.ts +141 -44
- package/src/index.ts +91 -56
- package/src/picker-keys.ts +30 -0
- package/src/spawner.ts +63 -3
- package/src/state.ts +266 -26
- package/src/statefile.ts +14 -7
- package/src/team-machine.ts +1012 -106
- package/src/tool-render.ts +48 -0
- package/src/tools.ts +120 -78
- package/src/types.ts +70 -7
- package/src/ui.ts +204 -52
- package/src/worker.ts +182 -19
- package/src/worktree.ts +46 -9
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
createStaticToolLifecycleResultRenderer,
|
|
5
|
+
formatToolErrorLine,
|
|
6
|
+
type ToolLifecycleSpec,
|
|
7
|
+
} from "@fradser/pi-kit";
|
|
8
|
+
|
|
9
|
+
export interface ToolResultText {
|
|
10
|
+
content: Array<{ type: string; text?: string }>;
|
|
11
|
+
details?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function textOf(result: ToolResultText): string {
|
|
15
|
+
return result.content.find((part) => part.type === "text")?.text ?? "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function emptyToolCall(): Text {
|
|
19
|
+
return new Text("", 0, 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderLifecycleResult(
|
|
23
|
+
result: ToolResultText,
|
|
24
|
+
options: { expanded?: boolean },
|
|
25
|
+
theme: Pick<Theme, "fg" | "bold" | "bg">,
|
|
26
|
+
context: { isError?: boolean },
|
|
27
|
+
spec: ToolLifecycleSpec,
|
|
28
|
+
details?: readonly string[],
|
|
29
|
+
): { render: (width: number) => string[]; invalidate: () => void } | Text {
|
|
30
|
+
const text = textOf(result);
|
|
31
|
+
// One expansion-body rule: detail lines derive from the model-facing content
|
|
32
|
+
// unless a caller passes explicit lines (only when the human body must
|
|
33
|
+
// differ from what the model sees).
|
|
34
|
+
const effectiveDetails = details ?? text.split("\n").filter((line) => line.trim());
|
|
35
|
+
if (context.isError) return new Text(theme.fg("error", formatToolErrorLine(text)), 0, 0);
|
|
36
|
+
return createStaticToolLifecycleResultRenderer({
|
|
37
|
+
createSpec: () => ({ ...spec, details: effectiveDetails }),
|
|
38
|
+
expandHint: keyHint("app.tools.expand", "to expand"),
|
|
39
|
+
fit: truncateToWidth,
|
|
40
|
+
visibleWidth,
|
|
41
|
+
wrapDetail: (line, width) => wrapTextWithAnsi(line, Math.max(1, width)),
|
|
42
|
+
renderError: (line, currentTheme) => new Text(currentTheme.fg("error", line), 0, 0),
|
|
43
|
+
})(result, options, theme, context);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resultDetails(result: ToolResultText): string[] {
|
|
47
|
+
return textOf(result).split("\n").filter((line) => line.trim());
|
|
48
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { detailField, eventToolLifecycle, formatAgentTaskName, formatToolLifecycleTitle, notifyPi } from "@fradser/pi-kit";
|
|
3
4
|
import {
|
|
4
5
|
createBoardTask,
|
|
5
|
-
|
|
6
|
+
formatBoardTaskCreation,
|
|
7
|
+
hasAnnouncedFinish,
|
|
8
|
+
hasTerminalReport,
|
|
6
9
|
publishStateSnapshot,
|
|
7
10
|
sendLeaderMessage,
|
|
8
11
|
shutdownTeammate,
|
|
9
12
|
spawnTeammate,
|
|
10
13
|
} from "./team-machine.ts";
|
|
11
14
|
import { listTasks, livingTeammates } from "./state.ts";
|
|
15
|
+
|
|
16
|
+
const DYNAMIC_LEADER_TOOLS = ["teammate_shutdown", "send_message", "task_list"] as const;
|
|
17
|
+
let leaderToolApi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools"> | undefined;
|
|
18
|
+
|
|
19
|
+
/** Keep spawn as the entry point; reveal follow-on controls only when relevant. */
|
|
20
|
+
export function refreshLeaderToolDisclosure(): void {
|
|
21
|
+
if (!leaderToolApi || typeof leaderToolApi.getActiveTools !== "function" || typeof leaderToolApi.setActiveTools !== "function") return;
|
|
22
|
+
const active = leaderToolApi.getActiveTools();
|
|
23
|
+
const withoutDynamic = active.filter((tool) => !DYNAMIC_LEADER_TOOLS.includes(tool as typeof DYNAMIC_LEADER_TOOLS[number]));
|
|
24
|
+
const revealed = [
|
|
25
|
+
...(livingTeammates().length > 0 ? ["teammate_shutdown", "send_message"] : []),
|
|
26
|
+
...(listTasks().length > 0 ? ["task_list"] : []),
|
|
27
|
+
];
|
|
28
|
+
leaderToolApi.setActiveTools([...withoutDynamic, ...revealed]);
|
|
29
|
+
}
|
|
30
|
+
|
|
12
31
|
import { LEADER_RECIPIENT, SendMessageParams, TeammateShutdownParams, TeammateSpawnParams, TaskCreateParams } from "./types.ts";
|
|
13
32
|
import { registerTaskListTool } from "./worker.ts";
|
|
14
33
|
import { openTeamConsole, refreshTeamUI } from "./ui.ts";
|
|
15
34
|
import { discoverAgents } from "./agents.ts";
|
|
16
|
-
import {
|
|
35
|
+
import { emptyToolCall, renderLifecycleResult } from "./tool-render.ts";
|
|
17
36
|
|
|
18
37
|
function rosterSummary(): string {
|
|
19
38
|
const alive = livingTeammates();
|
|
@@ -21,36 +40,46 @@ function rosterSummary(): string {
|
|
|
21
40
|
return alive.map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
|
|
22
41
|
}
|
|
23
42
|
|
|
43
|
+
function spawnAssignment(params: { name: string; agent: string; prompt?: string }): string {
|
|
44
|
+
const prompt = params.prompt?.trim();
|
|
45
|
+
if (!prompt) return "check task board";
|
|
46
|
+
const normalizedPrompt = prompt.replace(/^@/, "").trim().toLowerCase();
|
|
47
|
+
const name = params.name.replace(/^@/, "").toLowerCase();
|
|
48
|
+
const agent = params.agent.replace(/^@/, "").toLowerCase();
|
|
49
|
+
if (normalizedPrompt === name || normalizedPrompt === agent) {
|
|
50
|
+
return "check task board";
|
|
51
|
+
}
|
|
52
|
+
return formatAgentTaskName(prompt, "check task board");
|
|
53
|
+
}
|
|
54
|
+
|
|
24
55
|
export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
25
56
|
pi.registerTool({
|
|
26
57
|
name: "teammate_spawn",
|
|
27
|
-
promptSnippet: "Spawn a named resident teammate",
|
|
58
|
+
promptSnippet: "Spawn a named resident teammate or sub-agent",
|
|
28
59
|
label: "Spawn Teammate",
|
|
29
|
-
description: "Spawn one named resident teammate.
|
|
60
|
+
description: "Spawn one named resident teammate / sub-agent. Use this tool whenever instructions, third-party skills, or workflows ask to launch or delegate work to an agent, sub-agent (subagent), worker, or teammate. Generated role definitions stay in memory by default; persist one only when the user explicitly asks to keep it for future sessions.",
|
|
30
61
|
parameters: TeammateSpawnParams,
|
|
31
|
-
// Canonical lifecycle rows (same as packages/monitor): empty renderCall,
|
|
32
|
-
// ONE startup row owned by renderResult.
|
|
33
62
|
renderShell: "self",
|
|
34
|
-
renderCall:
|
|
63
|
+
renderCall: emptyToolCall,
|
|
35
64
|
renderResult(result, _options, theme, context) {
|
|
36
|
-
const text = result.content
|
|
37
|
-
if (context.isError)
|
|
38
|
-
return new Text(theme.fg("error", text.split("\n")[0] || "Failed to spawn teammate."), 0, 0);
|
|
39
|
-
}
|
|
65
|
+
const text = result.content.find((part) => part.type === "text")?.text ?? "";
|
|
66
|
+
if (context.isError) return new Text(theme.fg("error", text.split("\n")[0] || "Failed to spawn teammate."), 0, 0);
|
|
40
67
|
const params = context.args as { name: string; agent: string; prompt?: string };
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
};
|
|
68
|
+
const title = formatToolLifecycleTitle({
|
|
69
|
+
kind: "started",
|
|
70
|
+
tool: "agent",
|
|
71
|
+
subject: `@${params.name} started · ${spawnAssignment(params)}`,
|
|
72
|
+
});
|
|
73
|
+
return new Text(theme.fg("customMessageLabel", theme.bold(title)), 0, 0);
|
|
46
74
|
},
|
|
47
75
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
48
76
|
const result = spawnTeammate(params);
|
|
49
77
|
if (!result.ok) throw new Error(result.error);
|
|
50
78
|
refreshTeamUI(ctx);
|
|
79
|
+
refreshLeaderToolDisclosure();
|
|
51
80
|
const kickoffNote = params.prompt?.trim()
|
|
52
81
|
? "It received your kickoff prompt and is working on it."
|
|
53
|
-
: "It is
|
|
82
|
+
: "It received the standard board-check kickoff and is running its first turn; it idles once that settles.";
|
|
54
83
|
return {
|
|
55
84
|
content: [{ type: "text", text: `@${params.name} is alive as ${params.agent}.\n${kickoffNote}\n\n${rosterSummary()}` }],
|
|
56
85
|
details: { started: true },
|
|
@@ -65,30 +94,23 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
65
94
|
description: "Gracefully stop one named teammate. Its claimed task returns to the board; its worktree diff is captured before teardown.",
|
|
66
95
|
parameters: TeammateShutdownParams,
|
|
67
96
|
renderShell: "self",
|
|
68
|
-
renderCall:
|
|
97
|
+
renderCall: emptyToolCall,
|
|
69
98
|
renderResult(result, options, theme, context) {
|
|
70
|
-
const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
|
|
71
|
-
if (context.isError) {
|
|
72
|
-
return new Text(theme.fg("error", text.split("\n")[0] || "Failed to shut down teammate."), 0, 0);
|
|
73
|
-
}
|
|
74
99
|
const name = String((context.args as { name?: string }).name ?? "");
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
...text.split("\n").map((line) => truncateToWidth(theme.fg("customMessageText", safeDisplayText(line)), width)),
|
|
84
|
-
];
|
|
85
|
-
};
|
|
86
|
-
return { render, invalidate: () => {} };
|
|
100
|
+
// The finish entry already announced this end of life (or its terminal
|
|
101
|
+
// report is queued to); a second event row is noise.
|
|
102
|
+
if (hasAnnouncedFinish(name) || hasTerminalReport(name)) return { render: () => [], invalidate: () => {} };
|
|
103
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
104
|
+
"agent",
|
|
105
|
+
`@${name} shut down`,
|
|
106
|
+
{},
|
|
107
|
+
));
|
|
87
108
|
},
|
|
88
109
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
89
110
|
const result = await shutdownTeammate(params.name);
|
|
90
111
|
if (!result.ok) throw new Error(result.error);
|
|
91
112
|
refreshTeamUI(ctx);
|
|
113
|
+
refreshLeaderToolDisclosure();
|
|
92
114
|
return { content: [{ type: "text", text: result.body }], details: {} };
|
|
93
115
|
},
|
|
94
116
|
});
|
|
@@ -97,44 +119,57 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
97
119
|
name: "send_message",
|
|
98
120
|
promptSnippet: "Send a message to a teammate",
|
|
99
121
|
label: "Send Message",
|
|
100
|
-
description: "The only messaging primitive. Address a living teammate by name; working teammates receive a steer immediately and idle teammates wake with the message.
|
|
122
|
+
description: "The only messaging primitive. Address a living teammate by name; working teammates receive a steer immediately and idle teammates wake with the message.",
|
|
101
123
|
parameters: SendMessageParams,
|
|
102
124
|
// Canonical lifecycle rows (same as packages/monitor): empty call slot,
|
|
103
125
|
// ONE delivery row owned by renderResult.
|
|
104
126
|
renderShell: "self",
|
|
105
|
-
renderCall:
|
|
106
|
-
renderResult(result,
|
|
107
|
-
const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
|
|
108
|
-
if (context.isError) {
|
|
109
|
-
return new Text(theme.fg("error", text.split("\n")[0] || "Failed to send message."), 0, 0);
|
|
110
|
-
}
|
|
127
|
+
renderCall: emptyToolCall,
|
|
128
|
+
renderResult(result, options, theme, context) {
|
|
111
129
|
const to = String((context.args as { to?: string }).to ?? "");
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
|
|
120
|
-
invalidate: () => {},
|
|
121
|
-
};
|
|
130
|
+
const outcome = detailField<"steered" | "queued" | "not-sent">(result.details, "outcome") ?? "queued";
|
|
131
|
+
const subject = outcome === "not-sent" ? "terminal report available" : outcome;
|
|
132
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
133
|
+
"message",
|
|
134
|
+
subject,
|
|
135
|
+
{ label: `to @${to}`, detailLimit: outcome === "not-sent" ? "all" : undefined },
|
|
136
|
+
));
|
|
122
137
|
},
|
|
123
138
|
async execute(_toolCallId, params) {
|
|
124
139
|
if (params.to === LEADER_RECIPIENT) throw new Error('The leader cannot send a message to itself.');
|
|
125
|
-
|
|
126
|
-
|
|
140
|
+
const result = sendLeaderMessage(params.to, params.message, {
|
|
141
|
+
reopen: params.reopen,
|
|
142
|
+
resources: params.resources,
|
|
143
|
+
});
|
|
127
144
|
if (!result.ok) throw new Error(result.error);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
145
|
+
if (result.outcome === "not-sent") {
|
|
146
|
+
return {
|
|
147
|
+
content: [{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: [
|
|
150
|
+
"ROUTING · not sent",
|
|
151
|
+
"NEXT · No new message was delivered. Read the recorded report below; use reopen=true only for a distinct new assignment.",
|
|
152
|
+
`NOTE · @${params.to} already sent a terminal report. Delivery to your context is automatic; asking it to resend produces a duplicate leader turn.`,
|
|
153
|
+
`RECORDED TERMINAL REPORT · ${result.terminalReport}`,
|
|
154
|
+
].join("\n"),
|
|
155
|
+
}],
|
|
156
|
+
details: { outcome: "not-sent", terminalReportAvailable: true },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const action = result.outcome === "steered"
|
|
160
|
+
? "active control stream accepted the steer"
|
|
161
|
+
: "harness will deliver the queued message on the next wake-up";
|
|
162
|
+
let text = `MESSAGING\n${result.outcome.toUpperCase()} · to=@${params.to}\nNEXT · ${action}`;
|
|
163
|
+
// A stray status field (copied from worker report patterns) must not
|
|
164
|
+
// block delivery — it carries no meaning on leader-sent messages.
|
|
165
|
+
if (params.status) text += `\nNOTE · status ignored for leader-directed steering`;
|
|
166
|
+
if (result.priorTerminalReport) {
|
|
167
|
+
text += `\nNOTE · @${params.to} already sent a terminal report (below); it reaches your context automatically. A steer asking it to repeat that report produces a duplicate delivery — use reopen only for a distinct new assignment.`;
|
|
168
|
+
text += `\nPRIOR TERMINAL REPORT · ${result.priorTerminalReport}`;
|
|
132
169
|
}
|
|
133
170
|
return {
|
|
134
171
|
content: [{ type: "text", text }],
|
|
135
|
-
details:
|
|
136
|
-
? { queued: result.queued, stalledMs: result.stalledMs }
|
|
137
|
-
: { queued: result.queued },
|
|
172
|
+
details: { outcome: result.outcome },
|
|
138
173
|
};
|
|
139
174
|
},
|
|
140
175
|
});
|
|
@@ -143,37 +178,44 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
143
178
|
name: "task_create",
|
|
144
179
|
promptSnippet: "Create a shared board task",
|
|
145
180
|
label: "Create Task",
|
|
146
|
-
description: "Create one
|
|
181
|
+
description: "Create one pending task on the current session board. Existing idle teammates are notified immediately and may self-claim it; this tool never spawns teammates. An optional verify prompt gates completion through a fresh reviewer.",
|
|
147
182
|
parameters: TaskCreateParams,
|
|
148
183
|
// Canonical lifecycle rows (same as packages/monitor): empty call slot,
|
|
149
184
|
// ONE created row owned by renderResult.
|
|
150
185
|
renderShell: "self",
|
|
151
|
-
renderCall:
|
|
152
|
-
renderResult(result,
|
|
153
|
-
const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
|
|
154
|
-
if (context.isError) {
|
|
155
|
-
return new Text(theme.fg("error", text.split("\n")[0] || "Failed to create task."), 0, 0);
|
|
156
|
-
}
|
|
186
|
+
renderCall: emptyToolCall,
|
|
187
|
+
renderResult(result, options, theme, context) {
|
|
157
188
|
const subject = String((context.args as { subject?: string }).subject ?? "");
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
189
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
190
|
+
"board",
|
|
191
|
+
subject,
|
|
192
|
+
{ label: "created" },
|
|
193
|
+
));
|
|
163
194
|
},
|
|
164
195
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
165
196
|
const tasks = listTasks();
|
|
166
|
-
|
|
167
|
-
|
|
197
|
+
const referenced = [...(params.dependsOn ?? []), ...(params.supersedes ?? [])];
|
|
198
|
+
if (referenced.some((id) => !tasks.some((task) => task.id === id))) {
|
|
199
|
+
throw new Error(`Unknown task id in [${referenced.join(", ")}].`);
|
|
168
200
|
}
|
|
169
201
|
const created = createBoardTask(params);
|
|
170
202
|
if (!created.ok) throw new Error(created.error);
|
|
171
203
|
refreshTeamUI(ctx);
|
|
172
|
-
|
|
204
|
+
refreshLeaderToolDisclosure();
|
|
205
|
+
return {
|
|
206
|
+
content: [{ type: "text", text: formatBoardTaskCreation(params.subject, created) }],
|
|
207
|
+
details: {
|
|
208
|
+
notifiedTeammates: created.notifiedTeammates,
|
|
209
|
+
livingTeammates: created.livingTeammates,
|
|
210
|
+
claimable: created.claimable,
|
|
211
|
+
supersededTaskIds: created.supersededTaskIds,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
173
214
|
},
|
|
174
215
|
});
|
|
175
216
|
|
|
176
217
|
registerTaskListTool(pi);
|
|
218
|
+
leaderToolApi = pi;
|
|
177
219
|
}
|
|
178
220
|
|
|
179
221
|
function teamStatusSummary(): string {
|
|
@@ -189,7 +231,7 @@ export function registerTeamCommand(pi: ExtensionAPI): void {
|
|
|
189
231
|
description: "Agent Teams management console: session teammates and persistent agent roles",
|
|
190
232
|
handler: async (_args, ctx) => {
|
|
191
233
|
publishStateSnapshot();
|
|
192
|
-
if (ctx.mode !== "tui") { ctx.ui
|
|
234
|
+
if (ctx.mode !== "tui") { notifyPi(ctx.ui, teamStatusSummary(), "info"); return; }
|
|
193
235
|
await openTeamConsole(ctx);
|
|
194
236
|
refreshTeamUI(ctx);
|
|
195
237
|
},
|
package/src/types.ts
CHANGED
|
@@ -14,6 +14,15 @@ export const TeammateStatus = Type.Union(
|
|
|
14
14
|
);
|
|
15
15
|
export type TeammateStatus = Static<typeof TeammateStatus>;
|
|
16
16
|
|
|
17
|
+
export interface WorkerAssignment {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: "direct" | "board";
|
|
20
|
+
/** Resource tags prevent concurrent mutating assignments from overlapping. */
|
|
21
|
+
resources: string[];
|
|
22
|
+
/** A terminal direct assignment is closed until the leader explicitly reopens it. */
|
|
23
|
+
closed?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
/** A named, long-lived child Pi process on the team roster. */
|
|
18
27
|
export interface Teammate {
|
|
19
28
|
/** Unique among living teammates; also the mailbox and roster key. */
|
|
@@ -30,6 +39,14 @@ export interface Teammate {
|
|
|
30
39
|
isolation: "worktree" | "none";
|
|
31
40
|
/** Board task currently claimed by this teammate, if any. */
|
|
32
41
|
currentTaskId?: string;
|
|
42
|
+
/** The one assignment this worker is allowed to execute. Direct assignments
|
|
43
|
+
* and board claims are mutually exclusive until the leader opens another. */
|
|
44
|
+
assignment?: WorkerAssignment;
|
|
45
|
+
/** Most recent assignment retained for successor handoff after release or shutdown. */
|
|
46
|
+
lastAssignment?: WorkerAssignment;
|
|
47
|
+
lastTaskId?: string;
|
|
48
|
+
/** Effective launch model reference ("provider/model"); absent when Pi picks its default. */
|
|
49
|
+
model?: string;
|
|
33
50
|
/** Live assistant text assembled from the RPC stream. */
|
|
34
51
|
liveText?: string;
|
|
35
52
|
/** Current child tool name, if a tool is executing. */
|
|
@@ -40,10 +57,20 @@ export interface Teammate {
|
|
|
40
57
|
turns?: number;
|
|
41
58
|
/** The child finished its current sequence and awaits the next prompt. */
|
|
42
59
|
sequenceEnded?: boolean;
|
|
60
|
+
/** Leader reports are closed until a new prompt starts after a terminal report. */
|
|
61
|
+
reportSequenceEnded?: boolean;
|
|
43
62
|
/** When the harness last sent a claimable-task notice to this teammate. */
|
|
44
63
|
lastNoticeAt?: number;
|
|
64
|
+
/** Claimable task ids already announced to this teammate; one notice per id until it re-arms. */
|
|
65
|
+
noticedTaskIds?: string[];
|
|
45
66
|
usage?: WorkerUsage;
|
|
46
67
|
error?: string;
|
|
68
|
+
/** Effective tool allowlist granted to the child process (role tools plus
|
|
69
|
+
* the capability set). Absent only for legacy snapshots. */
|
|
70
|
+
tools?: string[];
|
|
71
|
+
/** True once recognized model/stream activity was observed for this
|
|
72
|
+
* incarnation; the stall classifier uses this, not usage totals. */
|
|
73
|
+
modelOutputSeen?: boolean;
|
|
47
74
|
createdAt: number;
|
|
48
75
|
updatedAt: number;
|
|
49
76
|
stoppedAt?: number;
|
|
@@ -60,6 +87,7 @@ export const TaskStatus = Type.Union(
|
|
|
60
87
|
Type.Literal("pending"),
|
|
61
88
|
Type.Literal("claimed"),
|
|
62
89
|
Type.Literal("completed"),
|
|
90
|
+
Type.Literal("superseded"),
|
|
63
91
|
],
|
|
64
92
|
{ description: "Board lifecycle of a task" },
|
|
65
93
|
);
|
|
@@ -72,10 +100,16 @@ export interface BoardTask {
|
|
|
72
100
|
description?: string;
|
|
73
101
|
/** Task ids that must complete before this task is claimable. */
|
|
74
102
|
dependsOn: string[];
|
|
75
|
-
/**
|
|
103
|
+
/** Completion gate: a review prompt a fresh one-shot reviewer answers with
|
|
104
|
+
* VERDICT: PASS or FAIL; overrides the agent-role default. */
|
|
76
105
|
verify?: string;
|
|
106
|
+
/** Mutating work must use stable resource tags such as `firmware/sub-node`.
|
|
107
|
+
* A resource conflicts with itself and any slash-prefixed descendant. */
|
|
108
|
+
resources: string[];
|
|
77
109
|
status: TaskStatus;
|
|
78
110
|
claimedBy?: string;
|
|
111
|
+
/** Replacement task that made this task obsolete. */
|
|
112
|
+
supersededBy?: string;
|
|
79
113
|
result?: string;
|
|
80
114
|
errorMessage?: string;
|
|
81
115
|
createdAt: number;
|
|
@@ -114,6 +148,9 @@ export interface MailboxMessage {
|
|
|
114
148
|
subject: string;
|
|
115
149
|
body: string;
|
|
116
150
|
status?: "in_progress" | "completed" | "failed";
|
|
151
|
+
/** The harness retained this report for console inspection after shutdown;
|
|
152
|
+
* it never initiated a leader follow-up. */
|
|
153
|
+
archived?: boolean;
|
|
117
154
|
timestamp: number;
|
|
118
155
|
}
|
|
119
156
|
|
|
@@ -126,6 +163,8 @@ export interface WorkerReportEvent {
|
|
|
126
163
|
spawnId: string;
|
|
127
164
|
body: string;
|
|
128
165
|
status?: "in_progress" | "completed" | "failed";
|
|
166
|
+
/** Wall-clock time the teammate wrote the event. */
|
|
167
|
+
timestamp?: number;
|
|
129
168
|
}
|
|
130
169
|
|
|
131
170
|
export type WorkerEvent = WorkerReportEvent;
|
|
@@ -139,7 +178,8 @@ export function isWorkerEvent(value: unknown): value is WorkerReportEvent {
|
|
|
139
178
|
&& typeof event.worker === "string"
|
|
140
179
|
&& typeof event.spawnId === "string"
|
|
141
180
|
&& typeof event.body === "string"
|
|
142
|
-
&& (event.status === undefined || ["in_progress", "completed", "failed"].includes(event.status))
|
|
181
|
+
&& (event.status === undefined || ["in_progress", "completed", "failed"].includes(event.status))
|
|
182
|
+
&& (event.timestamp === undefined || (typeof event.timestamp === "number" && Number.isFinite(event.timestamp)));
|
|
143
183
|
}
|
|
144
184
|
|
|
145
185
|
/** Derive a display title from the first non-empty line of a message. */
|
|
@@ -162,11 +202,26 @@ export interface InboxMessage {
|
|
|
162
202
|
|
|
163
203
|
// ── Tool parameter schemas (typebox) ──────────────────────────────
|
|
164
204
|
|
|
165
|
-
/** Spawn one named resident teammate. */
|
|
205
|
+
/** Spawn one named resident teammate or sub-agent. */
|
|
166
206
|
export const TeammateSpawnParams = Type.Object({
|
|
167
207
|
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
|
|
169
|
-
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" })),
|
|
208
|
+
agent: Type.String({ description: "Agent definition name or role id; an inline definition may create this role in memory for the current session" }),
|
|
209
|
+
prompt: Type.Optional(Type.String({ description: "Optional kickoff prompt delivered as the agent / teammate's first turn; omit to let it wait for messages or board claims" })),
|
|
210
|
+
resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Stable resource tags owned by this direct assignment. Overlapping board claims are rejected until it closes." })),
|
|
211
|
+
handoffFrom: Type.Optional(Type.String({ minLength: 1, description: "Stopped teammate whose assignment, claim, and recent reports should be summarized in this successor kickoff." })),
|
|
212
|
+
definition: Type.Optional(Type.Object({
|
|
213
|
+
description: Type.String({ description: "Routing contract for the generated role" }),
|
|
214
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Pi tool ids for the generated role; only pi built-ins (read, bash, edit, write, grep, find, ls, powershell) are grantable — teammates run without extensions, so MCP/extension ids fail the spawn" })),
|
|
215
|
+
model: Type.Optional(Type.String({ description: 'Optional provider/model pin, or "inherit" to run on the leader\'s current model' })),
|
|
216
|
+
verify: Type.Optional(Type.String({ description: "Role-default completion gate: a review prompt a fresh reviewer answers with VERDICT: PASS or FAIL" })),
|
|
217
|
+
worktree: Type.Optional(Type.Boolean({ description: "Whether this role receives a dedicated Git worktree" })),
|
|
218
|
+
prompt: Type.String({ minLength: 1, description: "Role prompt for this generated teammate / sub-agent" }),
|
|
219
|
+
persist: Type.Optional(Type.Boolean({ description: "Persist only when the user explicitly asks to keep this role for future sessions" })),
|
|
220
|
+
persistScope: Type.Optional(Type.Union([
|
|
221
|
+
Type.Literal("project"),
|
|
222
|
+
Type.Literal("project-local"),
|
|
223
|
+
], { description: "Persistence scope; defaults to project-local when persist is true" })),
|
|
224
|
+
}, { description: "Optional generated role definition; kept in memory unless explicitly persisted" })),
|
|
170
225
|
});
|
|
171
226
|
|
|
172
227
|
/** Shut down one living teammate. */
|
|
@@ -180,7 +235,9 @@ export const TaskCreateParams = Type.Object({
|
|
|
180
235
|
description: Type.Optional(Type.String({ description: "Full task description for the claiming teammate" }),
|
|
181
236
|
),
|
|
182
237
|
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: "
|
|
238
|
+
verify: Type.Optional(Type.String({ description: "Completion gate: a fresh reviewer judges the acceptance criteria. Explicit PASS/FAIL completes or returns the task; an inconclusive review escalates without counting as failure." })),
|
|
239
|
+
resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Stable resource tags this task mutates, e.g. firmware/sub-node. Overlapping active tags cannot be claimed concurrently." })),
|
|
240
|
+
supersedes: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Obsolete task ids this new task replaces. Superseded work cannot be claimed or completed." })),
|
|
184
241
|
});
|
|
185
242
|
|
|
186
243
|
/** Shared leader/worker read-only board view. */
|
|
@@ -194,6 +251,8 @@ export const LEADER_RECIPIENT = "leader";
|
|
|
194
251
|
export const SendMessageParams = Type.Object({
|
|
195
252
|
to: Type.String({ minLength: 1, description: 'Recipient: a teammate name on the roster, or "leader" to report to the team leader' }),
|
|
196
253
|
message: Type.String({ description: "Message content; the first line becomes the title shown in the console" }),
|
|
254
|
+
reopen: Type.Optional(Type.Boolean({ description: "Leader only: explicitly start a new assignment after the teammate reported completion" })),
|
|
255
|
+
resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Leader only: resource tags for a reopened direct assignment. Ignored for worker reports." })),
|
|
197
256
|
status: Type.Optional(Type.Union([
|
|
198
257
|
Type.Literal("in_progress"),
|
|
199
258
|
Type.Literal("completed"),
|
|
@@ -218,10 +277,11 @@ export const TaskSubmitParams = Type.Object({
|
|
|
218
277
|
export interface TeamState {
|
|
219
278
|
teammates: Record<string, Teammate>;
|
|
220
279
|
tasks: Record<string, BoardTask>;
|
|
280
|
+
/** Unified teammate model for this session; spawns without a role pin use it. */
|
|
281
|
+
defaultModel?: string;
|
|
221
282
|
/** Single leader inbox for teammate reports and harness diagnostics. */
|
|
222
283
|
leaderMailbox: MailboxMessage[];
|
|
223
284
|
messageCounter: number;
|
|
224
|
-
taskCounter: number;
|
|
225
285
|
/** Byte offsets consumed by the parent from each teammate's outbox. */
|
|
226
286
|
workerEventOffsets: Record<string, number>;
|
|
227
287
|
/** Report event ids already applied, keyed by teammate spawn. */
|
|
@@ -230,4 +290,7 @@ export interface TeamState {
|
|
|
230
290
|
peerInboxOffsets: Record<string, number>;
|
|
231
291
|
/** Delivered peer message ids per inbox, capped FIFO for deduplication. */
|
|
232
292
|
peerDeliveredIds: Record<string, string[]>;
|
|
293
|
+
/** Harness-owned peer-delivery transition by message id. This records queueing
|
|
294
|
+
* or control-stream acceptance only; it never asserts recipient processing. */
|
|
295
|
+
peerDeliveryStates: Record<string, "queued" | "routed">;
|
|
233
296
|
}
|