@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.
@@ -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 { keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { formatAgentMessagePrefix, formatAgentTaskName, formatExpandHint, formatToolEventLabel, safeDisplayText } from "@fradser/pi-kit";
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
- formatSilenceDuration,
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 { Text, truncateToWidth } from "@earendil-works/pi-tui";
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: () => new Text("", 0, 0),
63
+ renderCall: emptyToolCall,
37
64
  renderResult(result, _options, theme, context) {
38
- const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
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 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)))}`;
44
- return {
45
- render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
46
- invalidate: () => {},
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 idle: it wakes for inbox messages and claimable board tasks.";
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: () => new Text("", 0, 0),
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
- 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
- ];
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. status is reserved for worker reports to leader.",
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: () => 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
- }
128
+ renderCall: emptyToolCall,
129
+ renderResult(result, options, theme, context) {
116
130
  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
- };
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
- if (params.status) throw new Error('status is reserved for worker reports to="leader".');
131
- const result = sendLeaderMessage(params.to, params.message);
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
- const disposition = result.queued ? "Queued for its next wake-up." : "Delivered to its running turn.";
134
- let text = `Message to @${params.to}: ${disposition}`;
135
- if (!result.queued && result.stalledMs !== undefined) {
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.`;
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: result.stalledMs !== undefined && !result.queued
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 shared board task. Resident teammates self-claim it when dependencies are met; an optional verify prompt gates completion through a fresh reviewer.",
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: () => 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
- }
187
+ renderCall: emptyToolCall,
188
+ renderResult(result, options, theme, context) {
162
189
  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
- };
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
- if (params.dependsOn?.some((dep) => !tasks.some((task) => task.id === dep))) {
172
- throw new Error(`Unknown dependency id in [${params.dependsOn.join(", ")}].`);
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
- return { content: [{ type: "text", text: `Created [${created.id}] "${params.subject}". Idle teammates are notified automatically.` }], details: {} };
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.notify(teamStatusSummary(), "info"); return; }
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 review prompt a fresh reviewer answers with VERDICT: PASS or FAIL. Overrides any agent-role default verify." })),
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
  }