@fradser/pi-agent-teams 0.8.0 → 0.8.3
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 +28 -18
- package/package.json +2 -2
- package/references/agent-roles.md +8 -2
- package/src/follow-up-queue.ts +84 -21
- package/src/guidance.ts +127 -42
- package/src/index.ts +88 -33
- package/src/spawner.ts +63 -3
- package/src/state.ts +224 -21
- package/src/statefile.ts +14 -7
- package/src/team-machine.ts +657 -81
- package/src/tool-render.ts +48 -0
- package/src/tools.ts +116 -78
- package/src/types.ts +51 -7
- package/src/ui.ts +62 -53
- 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,8 +1,9 @@
|
|
|
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,
|
|
6
7
|
hasAnnouncedFinish,
|
|
7
8
|
hasTerminalReport,
|
|
8
9
|
publishStateSnapshot,
|
|
@@ -11,11 +12,27 @@ import {
|
|
|
11
12
|
spawnTeammate,
|
|
12
13
|
} from "./team-machine.ts";
|
|
13
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
|
+
|
|
14
31
|
import { LEADER_RECIPIENT, SendMessageParams, TeammateShutdownParams, TeammateSpawnParams, TaskCreateParams } from "./types.ts";
|
|
15
32
|
import { registerTaskListTool } from "./worker.ts";
|
|
16
33
|
import { openTeamConsole, refreshTeamUI } from "./ui.ts";
|
|
17
34
|
import { discoverAgents } from "./agents.ts";
|
|
18
|
-
import {
|
|
35
|
+
import { emptyToolCall, renderLifecycleResult } from "./tool-render.ts";
|
|
19
36
|
|
|
20
37
|
function rosterSummary(): string {
|
|
21
38
|
const alive = livingTeammates();
|
|
@@ -23,36 +40,47 @@ function rosterSummary(): string {
|
|
|
23
40
|
return alive.map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
|
|
24
41
|
}
|
|
25
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
|
+
|
|
26
55
|
export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
27
56
|
pi.registerTool({
|
|
28
57
|
name: "teammate_spawn",
|
|
29
|
-
promptSnippet: "Spawn a named resident teammate",
|
|
58
|
+
promptSnippet: "Spawn a named resident teammate or sub-agent",
|
|
30
59
|
label: "Spawn 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.",
|
|
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.",
|
|
32
61
|
parameters: TeammateSpawnParams,
|
|
33
|
-
// Canonical lifecycle rows (same as packages/monitor): empty renderCall,
|
|
34
|
-
// ONE startup row owned by renderResult.
|
|
35
62
|
renderShell: "self",
|
|
36
|
-
renderCall:
|
|
63
|
+
renderCall: emptyToolCall,
|
|
37
64
|
renderResult(result, _options, theme, context) {
|
|
38
|
-
const text = result.content
|
|
39
|
-
if (context.isError)
|
|
40
|
-
return new Text(theme.fg("error", text.split("\n")[0] || "Failed to spawn teammate."), 0, 0);
|
|
41
|
-
}
|
|
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);
|
|
42
67
|
const params = context.args as { name: string; agent: string; prompt?: string };
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
};
|
|
68
|
+
const title = formatToolLifecycleTitle({
|
|
69
|
+
kind: "started",
|
|
70
|
+
tool: "agent",
|
|
71
|
+
subject: `@${params.name} started · ${spawnAssignment(params)}`,
|
|
72
|
+
}).replace(/^\[agent\]\s*/, "");
|
|
73
|
+
const prefix = theme.fg("customMessageLabel", theme.bold("[agent]"));
|
|
74
|
+
return new Text(`${prefix} ${title}`, 0, 0);
|
|
48
75
|
},
|
|
49
76
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
50
77
|
const result = spawnTeammate(params);
|
|
51
78
|
if (!result.ok) throw new Error(result.error);
|
|
52
79
|
refreshTeamUI(ctx);
|
|
80
|
+
refreshLeaderToolDisclosure();
|
|
53
81
|
const kickoffNote = params.prompt?.trim()
|
|
54
82
|
? "It received your kickoff prompt and is working on it."
|
|
55
|
-
: "It is
|
|
83
|
+
: "It received the standard board-check kickoff and is running its first turn; it idles once that settles.";
|
|
56
84
|
return {
|
|
57
85
|
content: [{ type: "text", text: `@${params.name} is alive as ${params.agent}.\n${kickoffNote}\n\n${rosterSummary()}` }],
|
|
58
86
|
details: { started: true },
|
|
@@ -67,33 +95,23 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
67
95
|
description: "Gracefully stop one named teammate. Its claimed task returns to the board; its worktree diff is captured before teardown.",
|
|
68
96
|
parameters: TeammateShutdownParams,
|
|
69
97
|
renderShell: "self",
|
|
70
|
-
renderCall:
|
|
98
|
+
renderCall: emptyToolCall,
|
|
71
99
|
renderResult(result, options, theme, context) {
|
|
72
|
-
const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
|
|
73
|
-
if (context.isError) {
|
|
74
|
-
return new Text(theme.fg("error", text.split("\n")[0] || "Failed to shut down teammate."), 0, 0);
|
|
75
|
-
}
|
|
76
100
|
const name = String((context.args as { name?: string }).name ?? "");
|
|
77
101
|
// The finish entry already announced this end of life (or its terminal
|
|
78
102
|
// report is queued to); a second event row is noise.
|
|
79
103
|
if (hasAnnouncedFinish(name) || hasTerminalReport(name)) return { render: () => [], invalidate: () => {} };
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
return [
|
|
87
|
-
truncateToWidth(title, width),
|
|
88
|
-
...text.split("\n").map((line) => truncateToWidth(theme.fg("customMessageText", safeDisplayText(line)), width)),
|
|
89
|
-
];
|
|
90
|
-
};
|
|
91
|
-
return { render, invalidate: () => {} };
|
|
104
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
105
|
+
"agent",
|
|
106
|
+
`@${name} shut down`,
|
|
107
|
+
{},
|
|
108
|
+
));
|
|
92
109
|
},
|
|
93
110
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
94
111
|
const result = await shutdownTeammate(params.name);
|
|
95
112
|
if (!result.ok) throw new Error(result.error);
|
|
96
113
|
refreshTeamUI(ctx);
|
|
114
|
+
refreshLeaderToolDisclosure();
|
|
97
115
|
return { content: [{ type: "text", text: result.body }], details: {} };
|
|
98
116
|
},
|
|
99
117
|
});
|
|
@@ -102,44 +120,57 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
102
120
|
name: "send_message",
|
|
103
121
|
promptSnippet: "Send a message to a teammate",
|
|
104
122
|
label: "Send Message",
|
|
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.
|
|
123
|
+
description: "The only messaging primitive. Address a living teammate by name; working teammates receive a steer immediately and idle teammates wake with the message.",
|
|
106
124
|
parameters: SendMessageParams,
|
|
107
125
|
// Canonical lifecycle rows (same as packages/monitor): empty call slot,
|
|
108
126
|
// ONE delivery row owned by renderResult.
|
|
109
127
|
renderShell: "self",
|
|
110
|
-
renderCall:
|
|
111
|
-
renderResult(result,
|
|
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
|
-
}
|
|
128
|
+
renderCall: emptyToolCall,
|
|
129
|
+
renderResult(result, options, theme, context) {
|
|
116
130
|
const to = String((context.args as { to?: string }).to ?? "");
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
|
|
125
|
-
invalidate: () => {},
|
|
126
|
-
};
|
|
131
|
+
const outcome = detailField<"steered" | "queued" | "not-sent">(result.details, "outcome") ?? "queued";
|
|
132
|
+
const subject = outcome === "not-sent" ? "terminal report available" : outcome;
|
|
133
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
134
|
+
"message",
|
|
135
|
+
subject,
|
|
136
|
+
{ label: `to @${to}`, detailLimit: outcome === "not-sent" ? "all" : undefined },
|
|
137
|
+
));
|
|
127
138
|
},
|
|
128
139
|
async execute(_toolCallId, params) {
|
|
129
140
|
if (params.to === LEADER_RECIPIENT) throw new Error('The leader cannot send a message to itself.');
|
|
130
|
-
|
|
131
|
-
|
|
141
|
+
const result = sendLeaderMessage(params.to, params.message, {
|
|
142
|
+
reopen: params.reopen,
|
|
143
|
+
resources: params.resources,
|
|
144
|
+
});
|
|
132
145
|
if (!result.ok) throw new Error(result.error);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
if (result.outcome === "not-sent") {
|
|
147
|
+
return {
|
|
148
|
+
content: [{
|
|
149
|
+
type: "text",
|
|
150
|
+
text: [
|
|
151
|
+
"ROUTING · not sent",
|
|
152
|
+
"NEXT · No new message was delivered. Read the recorded report below; use reopen=true only for a distinct new assignment.",
|
|
153
|
+
`NOTE · @${params.to} already sent a terminal report. Delivery to your context is automatic; asking it to resend produces a duplicate leader turn.`,
|
|
154
|
+
`RECORDED TERMINAL REPORT · ${result.terminalReport}`,
|
|
155
|
+
].join("\n"),
|
|
156
|
+
}],
|
|
157
|
+
details: { outcome: "not-sent", terminalReportAvailable: true },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const action = result.outcome === "steered"
|
|
161
|
+
? "active control stream accepted the steer"
|
|
162
|
+
: "harness will deliver the queued message on the next wake-up";
|
|
163
|
+
let text = `MESSAGING\n${result.outcome.toUpperCase()} · to=@${params.to}\nNEXT · ${action}`;
|
|
164
|
+
// A stray status field (copied from worker report patterns) must not
|
|
165
|
+
// block delivery — it carries no meaning on leader-sent messages.
|
|
166
|
+
if (params.status) text += `\nNOTE · status ignored for leader-directed steering`;
|
|
167
|
+
if (result.priorTerminalReport) {
|
|
168
|
+
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.`;
|
|
169
|
+
text += `\nPRIOR TERMINAL REPORT · ${result.priorTerminalReport}`;
|
|
137
170
|
}
|
|
138
171
|
return {
|
|
139
172
|
content: [{ type: "text", text }],
|
|
140
|
-
details:
|
|
141
|
-
? { queued: result.queued, stalledMs: result.stalledMs }
|
|
142
|
-
: { queued: result.queued },
|
|
173
|
+
details: { outcome: result.outcome },
|
|
143
174
|
};
|
|
144
175
|
},
|
|
145
176
|
});
|
|
@@ -148,37 +179,44 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
148
179
|
name: "task_create",
|
|
149
180
|
promptSnippet: "Create a shared board task",
|
|
150
181
|
label: "Create Task",
|
|
151
|
-
description: "Create one
|
|
182
|
+
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.",
|
|
152
183
|
parameters: TaskCreateParams,
|
|
153
184
|
// Canonical lifecycle rows (same as packages/monitor): empty call slot,
|
|
154
185
|
// ONE created row owned by renderResult.
|
|
155
186
|
renderShell: "self",
|
|
156
|
-
renderCall:
|
|
157
|
-
renderResult(result,
|
|
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
|
-
}
|
|
187
|
+
renderCall: emptyToolCall,
|
|
188
|
+
renderResult(result, options, theme, context) {
|
|
162
189
|
const subject = String((context.args as { subject?: string }).subject ?? "");
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
190
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
191
|
+
"board",
|
|
192
|
+
subject,
|
|
193
|
+
{ label: "created" },
|
|
194
|
+
));
|
|
168
195
|
},
|
|
169
196
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
170
197
|
const tasks = listTasks();
|
|
171
|
-
|
|
172
|
-
|
|
198
|
+
const referenced = [...(params.dependsOn ?? []), ...(params.supersedes ?? [])];
|
|
199
|
+
if (referenced.some((id) => !tasks.some((task) => task.id === id))) {
|
|
200
|
+
throw new Error(`Unknown task id in [${referenced.join(", ")}].`);
|
|
173
201
|
}
|
|
174
202
|
const created = createBoardTask(params);
|
|
175
203
|
if (!created.ok) throw new Error(created.error);
|
|
176
204
|
refreshTeamUI(ctx);
|
|
177
|
-
|
|
205
|
+
refreshLeaderToolDisclosure();
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text: formatBoardTaskCreation(params.subject, created) }],
|
|
208
|
+
details: {
|
|
209
|
+
notifiedTeammates: created.notifiedTeammates,
|
|
210
|
+
livingTeammates: created.livingTeammates,
|
|
211
|
+
claimable: created.claimable,
|
|
212
|
+
supersededTaskIds: created.supersededTaskIds,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
178
215
|
},
|
|
179
216
|
});
|
|
180
217
|
|
|
181
218
|
registerTaskListTool(pi);
|
|
219
|
+
leaderToolApi = pi;
|
|
182
220
|
}
|
|
183
221
|
|
|
184
222
|
function teamStatusSummary(): string {
|
|
@@ -194,7 +232,7 @@ export function registerTeamCommand(pi: ExtensionAPI): void {
|
|
|
194
232
|
description: "Agent Teams management console: session teammates and persistent agent roles",
|
|
195
233
|
handler: async (_args, ctx) => {
|
|
196
234
|
publishStateSnapshot();
|
|
197
|
-
if (ctx.mode !== "tui") { ctx.ui
|
|
235
|
+
if (ctx.mode !== "tui") { notifyPi(ctx.ui, teamStatusSummary(), "info"); return; }
|
|
198
236
|
await openTeamConsole(ctx);
|
|
199
237
|
refreshTeamUI(ctx);
|
|
200
238
|
},
|
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,12 @@ 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;
|
|
33
48
|
/** Effective launch model reference ("provider/model"); absent when Pi picks its default. */
|
|
34
49
|
model?: string;
|
|
35
50
|
/** Live assistant text assembled from the RPC stream. */
|
|
@@ -42,12 +57,20 @@ export interface Teammate {
|
|
|
42
57
|
turns?: number;
|
|
43
58
|
/** The child finished its current sequence and awaits the next prompt. */
|
|
44
59
|
sequenceEnded?: boolean;
|
|
60
|
+
/** Leader reports are closed until a new prompt starts after a terminal report. */
|
|
61
|
+
reportSequenceEnded?: boolean;
|
|
45
62
|
/** When the harness last sent a claimable-task notice to this teammate. */
|
|
46
63
|
lastNoticeAt?: number;
|
|
47
64
|
/** Claimable task ids already announced to this teammate; one notice per id until it re-arms. */
|
|
48
65
|
noticedTaskIds?: string[];
|
|
49
66
|
usage?: WorkerUsage;
|
|
50
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;
|
|
51
74
|
createdAt: number;
|
|
52
75
|
updatedAt: number;
|
|
53
76
|
stoppedAt?: number;
|
|
@@ -64,6 +87,7 @@ export const TaskStatus = Type.Union(
|
|
|
64
87
|
Type.Literal("pending"),
|
|
65
88
|
Type.Literal("claimed"),
|
|
66
89
|
Type.Literal("completed"),
|
|
90
|
+
Type.Literal("superseded"),
|
|
67
91
|
],
|
|
68
92
|
{ description: "Board lifecycle of a task" },
|
|
69
93
|
);
|
|
@@ -79,8 +103,13 @@ export interface BoardTask {
|
|
|
79
103
|
/** Completion gate: a review prompt a fresh one-shot reviewer answers with
|
|
80
104
|
* VERDICT: PASS or FAIL; overrides the agent-role default. */
|
|
81
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[];
|
|
82
109
|
status: TaskStatus;
|
|
83
110
|
claimedBy?: string;
|
|
111
|
+
/** Replacement task that made this task obsolete. */
|
|
112
|
+
supersededBy?: string;
|
|
84
113
|
result?: string;
|
|
85
114
|
errorMessage?: string;
|
|
86
115
|
createdAt: number;
|
|
@@ -119,6 +148,9 @@ export interface MailboxMessage {
|
|
|
119
148
|
subject: string;
|
|
120
149
|
body: string;
|
|
121
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;
|
|
122
154
|
timestamp: number;
|
|
123
155
|
}
|
|
124
156
|
|
|
@@ -131,6 +163,8 @@ export interface WorkerReportEvent {
|
|
|
131
163
|
spawnId: string;
|
|
132
164
|
body: string;
|
|
133
165
|
status?: "in_progress" | "completed" | "failed";
|
|
166
|
+
/** Wall-clock time the teammate wrote the event. */
|
|
167
|
+
timestamp?: number;
|
|
134
168
|
}
|
|
135
169
|
|
|
136
170
|
export type WorkerEvent = WorkerReportEvent;
|
|
@@ -144,7 +178,8 @@ export function isWorkerEvent(value: unknown): value is WorkerReportEvent {
|
|
|
144
178
|
&& typeof event.worker === "string"
|
|
145
179
|
&& typeof event.spawnId === "string"
|
|
146
180
|
&& typeof event.body === "string"
|
|
147
|
-
&& (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)));
|
|
148
183
|
}
|
|
149
184
|
|
|
150
185
|
/** Derive a display title from the first non-empty line of a message. */
|
|
@@ -167,18 +202,20 @@ export interface InboxMessage {
|
|
|
167
202
|
|
|
168
203
|
// ── Tool parameter schemas (typebox) ──────────────────────────────
|
|
169
204
|
|
|
170
|
-
/** Spawn one named resident teammate. */
|
|
205
|
+
/** Spawn one named resident teammate or sub-agent. */
|
|
171
206
|
export const TeammateSpawnParams = Type.Object({
|
|
172
207
|
name: Type.String({ minLength: 1, description: "Teammate name, unique among living teammates; used for messaging and claiming" }),
|
|
173
|
-
agent: Type.String({ description: "Agent definition name; an inline definition may create this role in memory for the current session" }),
|
|
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" })),
|
|
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." })),
|
|
175
212
|
definition: Type.Optional(Type.Object({
|
|
176
213
|
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" })),
|
|
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" })),
|
|
178
215
|
model: Type.Optional(Type.String({ description: 'Optional provider/model pin, or "inherit" to run on the leader\'s current model' })),
|
|
179
216
|
verify: Type.Optional(Type.String({ description: "Role-default completion gate: a review prompt a fresh reviewer answers with VERDICT: PASS or FAIL" })),
|
|
180
217
|
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" }),
|
|
218
|
+
prompt: Type.String({ minLength: 1, description: "Role prompt for this generated teammate / sub-agent" }),
|
|
182
219
|
persist: Type.Optional(Type.Boolean({ description: "Persist only when the user explicitly asks to keep this role for future sessions" })),
|
|
183
220
|
persistScope: Type.Optional(Type.Union([
|
|
184
221
|
Type.Literal("project"),
|
|
@@ -198,7 +235,9 @@ export const TaskCreateParams = Type.Object({
|
|
|
198
235
|
description: Type.Optional(Type.String({ description: "Full task description for the claiming teammate" }),
|
|
199
236
|
),
|
|
200
237
|
dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Task ids that must complete before this task is claimable" })),
|
|
201
|
-
verify: Type.Optional(Type.String({ description: "Completion gate: a
|
|
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." })),
|
|
202
241
|
});
|
|
203
242
|
|
|
204
243
|
/** Shared leader/worker read-only board view. */
|
|
@@ -212,6 +251,8 @@ export const LEADER_RECIPIENT = "leader";
|
|
|
212
251
|
export const SendMessageParams = Type.Object({
|
|
213
252
|
to: Type.String({ minLength: 1, description: 'Recipient: a teammate name on the roster, or "leader" to report to the team leader' }),
|
|
214
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." })),
|
|
215
256
|
status: Type.Optional(Type.Union([
|
|
216
257
|
Type.Literal("in_progress"),
|
|
217
258
|
Type.Literal("completed"),
|
|
@@ -249,4 +290,7 @@ export interface TeamState {
|
|
|
249
290
|
peerInboxOffsets: Record<string, number>;
|
|
250
291
|
/** Delivered peer message ids per inbox, capped FIFO for deduplication. */
|
|
251
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">;
|
|
252
296
|
}
|