@fradser/pi-agent-teams 0.8.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 CHANGED
@@ -3,3 +3,4 @@
3
3
  - `openai_teammate_api_reference.md` — OpenAI external Teammate / Workspace Agents API reference (Workspace Agents API, Files API, file search, vector stores, Threads API). Reference only, not for Pi extension usage guidance.
4
4
 
5
5
  - `team-centric-design.md` — Agent Teams current architecture: named resident RPC teammates, shared task board with atomic self-claim, direct P2P inboxes, verify-gated completion, harness-driven wake-ups. Supersedes the deleted run-centric DAG design.
6
+ - `follow-up-queue.md` — Leader-report FIFO handoff: dispatch the first report to Pi's native follow-up queue while the leader is active; native follow-up does not interrupt tool loops.
@@ -1,17 +1,21 @@
1
1
  ---
2
2
  name: follow-up-queue
3
- description: Agent Teams serializes automatic teammate reports through Pi's void sendUserMessage API with lifecycle matching, retry backoff, and session invalidation
3
+ description: Agent Teams serializes automatic teammate reports through Pi custom-message follow-ups, including reports arriving while the leader is active
4
4
  type: project
5
5
  ---
6
6
 
7
7
  ## Why
8
8
 
9
- Pi's extension `sendUserMessage` API returns `void`; asynchronous preflight failures are routed to the runtime error handler rather than thrown to the caller. Concurrent automatic reports can also race Pi's prompt startup and produce `Agent is already processing a prompt`.
9
+ Pi accepts a custom `sendMessage(..., { triggerTurn: true, deliverAs: "followUp" })` while the leader is streaming and retains it in its native follow-up queue. Agent Teams must not add an idle-only gate that delays that handoff until the entire leader run settles. Doing so leaves reports in the package queue during a leader tool-call loop instead of at least making them visible to Pi's proper queue.
10
+
11
+ A native follow-up still runs only after the current tool/steering loop can naturally finish; it does not interrupt an in-flight tool or an unbounded leader loop. The leader guidance therefore forbids sleep-based polling, and urgent interruption needs an explicit steer/abort design rather than changing ordinary reports.
10
12
 
11
13
  ## How to apply
12
14
 
13
15
  - `packages/agent-teams/src/follow-up-queue.ts` owns a FIFO report queue and one active dispatch.
14
- - A dispatch is matched by its exact prompt in `before_agent_start`, confirmed by `agent_start`, and released only by the corresponding `agent_settled` path. Reports arriving during an active leader run remain queued.
15
- - A watchdog restores a batch when no `agent_start` occurs; retries use bounded exponential backoff rather than a microtask spin.
16
- - `reset()` increments a generation and clears timers, so delayed callbacks from an old session cannot deliver to a replacement session.
17
- - Keep `sendMainSessionFollowUp` as a synchronous enqueue operation; do not call Pi's void API directly from worker completion paths.
16
+ - `pump()` dispatches the first pending report whether the leader is idle or active. Active leaders receive it through Pi's native `followUp` channel; idle leaders begin a new turn.
17
+ - Keep only one package dispatch active. `agent_settled` releases it and schedules the next pending report, preserving FIFO and one-report-per-leader-turn semantics.
18
+ - Match idle-started dispatches in `before_agent_start`/`agent_start`; a watchdog restores a dispatch when no start occurs. Active-run dispatches are already accepted by Pi's follow-up queue and wait for settlement.
19
+ - `sendMainSessionFollowUp` only enqueues. Do not call Pi's void `sendMessage` directly from worker completion paths.
20
+ - The team machine suppresses every report after the first accepted terminal status in a wake-up sequence; a new wake-up resets that boundary. Distinct intermediate reports, including identical bodies before terminal status, remain deliverable, and the same content is not suppressed across assignments.
21
+ - Keep BDD coverage in `features/agent-teams.feature` and the busy-leader serialization regression in `tests/test_teammate_package.py`.
@@ -11,10 +11,10 @@ Exploratory / competitive-hypothesis work (multi-hypothesis debugging, three-way
11
11
 
12
12
  **How to apply:**
13
13
  1. **Seven tool names**: leader gets `teammate_spawn(name, agent, prompt?)`, `teammate_shutdown(name)`, `send_message(to, message, status?)`, `task_create`, and shared `task_list`; workers get the same `send_message`, shared `task_list`, `task_claim`, and `task_submit`. There is no `teammate_message`, cancel/retry/run/fanout, or polling tool.
14
- 2. **Unified messaging**: `send_message(to="leader", message, status?)` is a worker report written to the validated outbox; `send_message(to="<peer>", message)` writes the recipient inbox directly; leader-addressed teammate messages route through a running control stream or idle wake queue. `status` is invalid except for `to="leader"`. The first non-empty message line is the console title.
14
+ 2. **Unified messaging**: `send_message(to="leader", message, status?)` is a worker report written to the validated outbox; `send_message(to="<peer>", message)` writes the recipient inbox directly; leader-addressed teammate messages route through a running control stream or idle wake queue. Synchronous routing outcomes are deliberately narrow: `steered` means the active control stream accepted the write, while `queued` means the harness still owns delivery into a recipient turn. Peer inbox and leader outbox writes are therefore queued, not delivered; no routing label claims the recipient read or processed the message. `status` is invalid except for `to="leader"`. The first non-empty message line is the console title.
15
15
  3. **Declarative role attributes**: agent frontmatter carries `model`, optional `verify`, and boolean `worktree`.Definitions live in one project directory with filename-based scopes: `<name>.local.md` is project-local (personal, never committed) and dedupes against its `<name>.md` counterpart (project scope, git-managed, team-shared); user-level sits in `~/.pi/agent/agents`; precedence is project-local > project > user > bundled and `resolveAgent` exposes scope + gitManaged for provenance. Spawn has no cwd/model/worktree override; create a role variant instead. `worktree: true` gives every instance of that role a dedicated Git worktree and diff capture at shutdown.
16
- 4. **Residents/wake-ups (constitution: detect and notify, never terminate)**: long-lived RPC children, 8-process session cap; wake-up sequences have NO turn-count or duration caps — no configuration may automatically terminate a working teammate. Turn counts and silence durations are telemetry only. The 500ms harness poll drains outboxes, applies intents, routes inboxes, wakes only for queued deliveries or a paced claimable-task notice, and runs the stall heartbeat: 30 minutes without any RPC output (`PI_TEAMMATE_STALL_NOTICE_MS`, 0 disables) sends the leader one actionable notice per silence episode — that notice is the LAST automatic action; continuing, steering, shutting down, or respawning a context-carrying successor (original kickoff + mailbox reports + board claims + liveText tail) belongs to the leader model alone. Any stream event or prompt delivery resets `lastOutputAt` and clears `stallNoticeSentAt`; steering a silent working teammate returns `stalledMs` so the send_message result warns delivery is uncertain.
17
- 5. **Board**: only leader writes `~/.pi/agent/tasks/<sessionKey>/board.json`; workers use exclusive-create claim/submission marker files. Task completion uses effective task verify or agent verify; failed verification returns the reviewer's findings to the claimer. The board persists across restarts; runtime state does not.
16
+ 4. **Residents/wake-ups (constitution: detect and notify, never terminate)**: long-lived RPC children, 8-process session cap; wake-up sequences have NO turn-count or duration caps — no configuration may automatically terminate a working teammate. Turn counts and silence durations are telemetry only. The 500ms harness poll drains outboxes, applies intents, routes inboxes, wakes only for queued deliveries or a paced claimable-task notice, and runs the stall heartbeat: 30 minutes without any RPC output (`PI_TEAMMATE_STALL_NOTICE_MS`, 0 disables) sends the leader one actionable notice per silence episode — that notice is the LAST automatic action; continuing, steering, shutting down, or respawning a context-carrying successor (original kickoff + mailbox reports + board claims + liveText tail) belongs to the leader model alone. Any stream event or prompt delivery resets `lastOutputAt` and clears `stallNoticeSentAt`. Stall diagnostics render independently as `[agent] event` health notices and never decorate a `[message]` routing row.
17
+ 5. **Board**: only leader writes `~/.pi/agent/tasks/<sessionKey>/board.json`; workers use exclusive-create claim/submission marker files. Task completion uses effective task verify or agent verify; failed verification returns the reviewer's findings to the claimer. The board persists across restarts; runtime state does not. `task_create` never spawns a teammate: it immediately offers newly claimable work to existing idle teammates and reports whether anyone was notified; with no living teammates it tells the leader to call `teammate_spawn`. Boards are keyed by the Pi session file (or cwd without one), so fresh sessions do not import another session's tasks unless they resume the same board directory.
18
18
  6. **Reliability/UI**: per-spawn identity validation, event/message dedup, crash releases claimed tasks, role worktree diff at shutdown. Widget above the input box shows ONLY working/starting teammates as spinner rows with live activity and hides when nobody is working; idle/stopped teammates live only in the /teammate console roster; spawn prints a legacy-style `[agent] started · @name · task` transcript line; `/teammate` offers roster/board pages and message/task detail.
19
19
  7. **Node-native TS**: relative imports use explicit `.ts` extensions (root tsconfig permits them), allowing node to strip-types modules in package tests.
20
20
 
package/README.md CHANGED
@@ -6,7 +6,7 @@ Claude-Code-style collaborative agent teams for Pi: named resident teammates, a
6
6
 
7
7
  ## What This Package Does
8
8
 
9
- The team leader (your Pi session) spawns named teammates as long-lived child Pi processes in RPC mode. Each teammate has an isolated context, a peer inbox, and access to a shared task board. The harness—not the leader model—polls for activity: it drains leader reports, routes peer mail, applies task claims and submissions, judges verify prompts with fresh one-shot reviewers, and wakes idle teammates only when work or mail exists.
9
+ The team leader (your Pi session) spawns named teammates (isolated resident agents / sub-agents) as long-lived child Pi processes in RPC mode. Each teammate has an isolated context, a peer inbox, and access to a shared task board. When third-party skills or engineering workflows instruct to spawn an agent, call a subagent, or delegate work to a worker agent, `teammate_spawn` is the tool to invoke. The harness—not the leader model—polls for activity: it drains leader reports, routes peer mail, applies task claims and submissions, judges verify prompts with fresh one-shot reviewers, and wakes idle teammates only when work or mail exists.
10
10
 
11
11
  The message surface is deliberately singular: `send_message` is the only messaging primitive. Workers report with `to: "leader"`; teammates address peers by name; the leader addresses teammates by name. The routing implementation varies by destination (outbox, inbox, or control stream), but callers use one schema.
12
12
 
@@ -69,17 +69,20 @@ A teammate definition is just a Markdown file you own. Shared roles live in `.pi
69
69
  teammate_spawn({
70
70
  name: "security",
71
71
  agent: "reviewer",
72
- prompt: "Review the auth middleware"
72
+ prompt: "Review the auth middleware",
73
+ resources: ["packages/api/auth"]
73
74
  })
74
75
 
75
76
  teammate_spawn({ name: "backend", agent: "worker" })
76
77
  ```
77
78
 
78
- - Spawn has exactly three parameters: `name`, `agent`, and optional kickoff `prompt`.
79
- - Without a kickoff prompt, the teammate idles until messaged or until claimable work appears.
79
+ - Spawn accepts `name`, `agent`, an optional kickoff `prompt`, optional `resources`, and optional `handoffFrom`. Resources are stable path-like tags used to prevent overlapping mutating assignments; they are advisory to the filesystem but enforced by the task/assignment state machine.
80
+ - A kickoff creates one direct assignment. After its terminal report, that worker cannot claim board work until the leader explicitly sends `reopen: true` with a distinct next assignment.
81
+ - `handoffFrom` builds a successor kickoff from the predecessor's latest assignment, board claim, and recent leader reports; it never silently transfers a board claim.
82
+ - Without a kickoff prompt, the teammate idles until messaged or until eligible claimable work appears.
80
83
  - Teammates stay alive between tasks and consume no model tokens while idle.
81
84
  - A session-wide cap of 8 living teammates applies.
82
- - An agent declaring `worktree: true` gets its own Git worktree; its diff is captured at shutdown.
85
+ - An agent declaring `worktree: true` gets its own Git worktree; at shutdown its changes are committed onto the worktree branch, the directory is removed, and the branch is kept so captured work stays retrievable (`git diff <base>..<branch>`).
83
86
  - `teammate_shutdown({ name })` stops one teammate and releases its claimed task back to the board.
84
87
 
85
88
  ## Coordinate Through One Message Primitive and the Board
@@ -94,6 +97,8 @@ task_create({
94
97
  subject: "Fix auth middleware findings",
95
98
  description: "Address the confirmed security findings in packages/api/src/auth.ts",
96
99
  dependsOn: ["t_1"],
100
+ resources: ["packages/api/auth"],
101
+ supersedes: ["old-auth-patch"],
97
102
  verify: "Every scenario in features/gallery.feature holds in the built gallery; no horizontal overflow at 400px."
98
103
  })
99
104
  ```
@@ -104,22 +109,25 @@ task_create({
104
109
  send_message({ to, message, status? })
105
110
  ```
106
111
 
107
- - Worker report: `to: "leader"`; use optional `status: "completed" | "failed"` for a terminal report.
112
+ - Worker report: `to: "leader"`; use optional `status: "completed" | "failed"` for a terminal report. Every accepted leader-bound report — intermediate or terminal — enters Pi's follow-up queue as its own turn, even while the leader is active; Pi processes it when the current run can naturally end. After the first accepted terminal report in a wake-up sequence, later reports are suppressed until a new wake-up; distinct intermediate reports, including identical bodies before terminal status, remain deliverable, and the same content is not suppressed across assignments. For bounded reviewer assignments, put findings, the recommendation, verification evidence, and remaining risks in one concise terminal report. Use earlier reports only for genuinely new blockers, plan-changing facts, or evidence that changes the conclusion; do not send a separate status-only assignment-complete message or repeat unchanged findings. A terminal report ends the current worker turn. After a terminal report, report to the leader again only for a new assignment or decision-useful fact.
113
+ - Harness lifecycle events use a separate `<harness-event>` envelope. A requested `teammate_shutdown` stays in the tool lifecycle row and console mailbox; it does not start a leader follow-up turn. Unexpected stops and actionable harness events may still wake the leader.
108
114
  - Peer mail: `to: "<teammate-name>"`; `status` is invalid.
109
- - Leader steering or direct assignment: `to: "<teammate-name>"`; a working recipient gets a control-stream steer, an idle recipient wakes with the message.
115
+ - Leader steering or direct assignment: `to: "<teammate-name>"`; the synchronous routing result is `steered` only when the active control stream accepts the message, otherwise `queued` while the harness owns delivery on the next wake-up.
116
+ - Peer mail and reports to the leader are written to an inbox or outbox first, so their synchronous routing result is `queued`; none of these labels claims the recipient has read, understood, or processed the message.
117
+ - Teammate health is reported separately as an `[agent] event` such as `@name stalled · silent 5m`; health never appears as a message-routing suffix.
110
118
  - The first non-empty line of `message` becomes the console title.
111
119
  - Peer traffic never enters the leader model context; inspect it in `/agent-teams` instead.
112
120
 
113
- Only the leader creates tasks. Idle teammates self-claim pending tasks whose dependencies are met. A task-level `verify` prompt overrides the claiming agent's frontmatter `verify`. The harness runs it as a fresh one-shot reviewer that inspects the work independently and answers `VERDICT: PASS` or `VERDICT: FAIL - <reasons>`; PASS completes the task, FAIL feeds the findings back to the claimer for fix-and-resubmit. Write acceptance criteria a reviewer can check, not shell commands.
121
+ Only the leader creates tasks. `task_create` adds pending work to the current session's board; it never spawns a teammate. An idle teammate receives a board notice only when it owns no assignment and the task's resources do not overlap another active assignment. A worker that claims a task owns it until `task_submit` completes or releases it; a terminal leader report alone never completes a board task or authorizes more claims. `supersedes` marks obsolete pending/claimed tasks as `superseded`, retargets pending downstream dependencies to the replacement, and permanently removes obsolete work from notices, claims, and completion. A live holder retains its resource lock until it acknowledges cancellation with `task_submit(status="failed")` or stops; a resumed board clears dead holders. With no living teammates, the result explicitly tells the leader to call `teammate_spawn`; with no eligible idle recipient, the task remains pending until the leader creates/opens a compatible assignment. Boards are session-keyed, so a task created in another Pi session is not automatically imported into the current session. A task-level `verify` prompt overrides the claiming agent's frontmatter `verify`. The harness runs it as a fresh one-shot reviewer: explicit `VERDICT: PASS` completes; explicit `VERDICT: FAIL - <reasons>` returns findings to the claimer. A missing verdict is **inconclusive**: the harness requests one verdict-only clarification, then escalates without counting it as a verification failure. Write acceptance criteria a reviewer can check, not shell commands.
114
122
 
115
123
  ## Tools
116
124
 
117
125
  | Tool / command | Side | Description |
118
126
  |---|---|---|
119
- | `teammate_spawn(name, agent, prompt?)` | Leader | Start one named resident teammate; model/worktree come from agent frontmatter |
127
+ | `teammate_spawn(name, agent, prompt?, resources?, handoffFrom?)` | Leader | Start one named resident teammate; model/worktree come from agent frontmatter |
120
128
  | `teammate_shutdown(name)` | Leader | Stop one teammate and release its claimed work |
121
- | `send_message(to, message, status?)` | Both | The only messaging primitive; `to: "leader"` is reserved for worker reports |
122
- | `task_create(subject, description?, dependsOn?, verify?)` | Leader | Add a shared board task |
129
+ | `send_message(to, message, reopen?, resources?, status?)` | Both | The only messaging primitive; `to: "leader"` is reserved for worker reports |
130
+ | `task_create(subject, description?, dependsOn?, verify?, resources?, supersedes?)` | Leader | Add a resource-scoped shared board task |
123
131
  | `task_list()` | Both | One shared board-view definition; leader view also includes the roster |
124
132
  | `task_claim(taskId?)` | Worker | Atomically self-claim a pending, unblocked task |
125
133
  | `task_submit(taskId, status, result?)` | Worker | Submit a claimed task outcome; completion passes through verify |
@@ -130,17 +138,19 @@ That is **7 unique tool names**. There are no `teammate_run`, `teammate_fanout`,
130
138
  ## Reliability Protocol
131
139
 
132
140
  - **Per-spawn identity validation**: every leader report must match the teammate's current spawn id; stale callbacks and events cannot affect a replacement with the same name.
133
- - **Sent means written**: peer `send_message` succeeds only after the recipient inbox write succeeds. The harness owns delivery into a recipient turn.
141
+ - **Queued means written**: peer `send_message` succeeds only after the recipient inbox write succeeds. The harness still owns delivery into a recipient turn; only an accepted active control-stream steer is labeled `steered`.
134
142
  - **One writer per state file**: only the leader process writes runtime and board snapshots (atomic tmp+rename). Workers append leader reports to their outbox, peer mail to recipient inboxes, and task intent via exclusive-create marker files.
135
- - **Completion is gated, not self-reported**: a task completes only after its effective verify gate passes when one exists; no gate means the submission itself completes it. A gate that keeps failing parks the task with its holder after the second consecutive failure and escalates to the leader once instead of looping.
136
- - **No caps, heartbeat only**: teammates run without turn-count or duration ceilings. The harness heartbeat tracks silence per working teammate and — after 30 minutes without any RPC output (`PI_TEAMMATE_STALL_NOTICE_MS`, 0 disables) — sends the leader one actionable notice per silence episode. The notice is the last automatic action: continuing, steering, shutting down, or respawning a context-carrying successor belongs to the leader alone. Any output or prompt delivery re-arms it, and steering a silent teammate warns that delivery is uncertain.
143
+ - **One assignment at a time**: direct assignments and board claims are mutually exclusive. A terminal direct report closes that assignment until an explicit `reopen`; a board claim stays open until `task_submit`.
144
+ - **Resource-safe board work**: overlapping path-like resource tags cannot run concurrently. Superseded tasks stay visible for audit but cannot be claimed, completed, or re-noticed.
145
+ - **Completion is gated, not self-reported**: a task completes only after its effective verify gate passes when one exists; no gate means the submission itself completes it. Explicit FAIL twice parks the task with its holder and escalates once. Missing verdict text is inconclusive, gets one clarification, then escalates without incrementing the failure count.
146
+ - **No caps, heartbeat only**: teammates run without turn-count or duration ceilings. The harness heartbeat tracks silence per working teammate and — after 30 minutes without any RPC output (`PI_TEAMMATE_STALL_NOTICE_MS`, 0 disables) — sends the leader one actionable health event per silence episode. The notice is the last automatic action: continuing, steering, shutting down, or respawning a context-carrying successor belongs to the leader alone. Any output or prompt delivery re-arms it. Health events carry diagnostics separately from message routing: silence duration, spawn age, and lifetime token/cost usage. A teammate with zero lifetime model output and no tool running gets flagged much earlier (5 minutes, `PI_TEAMMATE_SILENT_STALL_MS`, 0 disables): an in-flight request stuck on the provider will not recover by steering, so that notice names shutdown plus respawn as the effective remedy.
137
147
  - **One-shot board notices**: an idle teammate is told about a claimable task exactly once; declined tasks never re-wake it, and released tasks re-arm. Notices are paced at least five minutes apart per teammate (`PI_TEAMMATE_NOTICE_PACE_MS` overrides in milliseconds).
138
- - **One end-of-life line per teammate**: the first terminal report of a spawn incarnation renders the finish entry; shutting that incarnation down afterwards adds no second event row.
148
+ - **One end-of-life line per teammate**: the first terminal report of a spawn incarnation renders the finish entry; shutting that incarnation down afterwards adds no second event row. Requested shutdown is process cleanup only, not proof of assignment completion; wait for the worker's terminal status report when possible. After the first accepted terminal report, later reports are suppressed until a new assignment prompt; distinct intermediate reports remain deliverable.
139
149
  - **Failure semantics**: an unexpected crash marks the teammate stopped, reports a diagnostic, and releases its claimed tasks.
140
150
 
141
151
  ## State and Sessions
142
152
 
143
- Runtime state lives in `~/.pi/agent/teammate/<sessionKey>/` and dies with the session (`state.json`, report outboxes, peer inboxes, roster). The task board lives in `~/.pi/agent/tasks/<sessionKey>/board.json` and persists for later inspection; nothing auto-deletes it. Claims held by dead teammates return to pending on reload.
153
+ Runtime state lives in `~/.pi/agent/teammate/<sessionKey>/` and dies with the session (`state.json`, report outboxes, peer inboxes, roster). The task board lives in `~/.pi/agent/tasks/<sessionKey>/board.json` and persists for later inspection; nothing auto-deletes it. The board key is derived from the Pi session file (or cwd when no session file exists), so a fresh session with a different session file does not import another session's tasks automatically; resume the same board directory to continue them. Claims held by dead teammates return to pending on reload.
144
154
 
145
155
  ## Structure
146
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fradser/pi-agent-teams",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Multi-agent team system for Pi — resident teammates, shared task board, and peer-to-peer messaging",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -39,6 +39,6 @@
39
39
  ]
40
40
  },
41
41
  "dependencies": {
42
- "@fradser/pi-kit": "0.4.0"
42
+ "@fradser/pi-kit": "0.4.2"
43
43
  }
44
44
  }
@@ -72,6 +72,12 @@ tools: <minimal set from the mutability axis>
72
72
  ---
73
73
  You are a <role-name> agent. <Method: the procedure to follow, the criteria
74
74
  to evaluate against, or the targets to observe>. <Boundary statement>.
75
- Report <expected artifacts> with <evidence requirements> via
76
- send_message(to="leader", message=...) with status="completed".
75
+ For bounded reviewer assignments, report findings, the recommendation,
76
+ verification evidence, and remaining risks in one concise terminal message;
77
+ allow earlier messages only for genuinely new blockers, plan-changing facts,
78
+ or evidence that changes the conclusion. Do not send a separate status-only
79
+ assignment-complete message or repeat unchanged findings. After a terminal
80
+ report, report to the leader again only for a new assignment or decision-useful
81
+ fact. The terminal message ends the current worker turn. Send it via
82
+ send_message(to="leader", message=...) with status="completed" or status="failed".
77
83
  ```
@@ -1,12 +1,30 @@
1
1
  export const TEAMMATE_REPORT_MESSAGE_TYPE = "agent-teams-report";
2
+ export const TEAMMATE_HARNESS_MESSAGE_TYPE = "agent-teams-harness";
2
3
 
3
4
  export interface FollowUpReport {
4
5
  teammate?: string;
5
6
  agent?: string;
6
7
  spawnId?: string;
7
8
  body: string;
9
+ /** Worker-authored reports are the default; harness events use a separate envelope. */
10
+ origin?: "teammate" | "harness";
11
+ harnessEvent?: {
12
+ type: string;
13
+ subject: string;
14
+ };
8
15
  finished?: boolean;
16
+ /** Original append-only outbox event identifier, retained for session forensics. */
17
+ eventId?: string;
18
+ /** Original worker status, including omitted status, retained for session forensics. */
19
+ status?: "in_progress" | "completed" | "failed";
20
+ health?: {
21
+ state: "stalled";
22
+ silenceMs: number;
23
+ };
9
24
  runId?: string;
25
+ /** Wall-clock time the message was authored; rendered in the envelope so a
26
+ * report delivered after a busy period keeps its original timestamp. */
27
+ timestamp?: number;
10
28
  }
11
29
 
12
30
  export interface FollowUpReportGroup {
@@ -18,20 +36,25 @@ export function groupReportsByTeammate(reports: FollowUpReport[]): FollowUpRepor
18
36
  const groups = new Map<string, FollowUpReportGroup>();
19
37
  for (const report of reports) {
20
38
  const teammate = report.teammate ?? report.agent ?? "teammate";
21
- const group = groups.get(teammate);
39
+ if (report.health) {
40
+ groups.set(`health:${groups.size}:${teammate}`, { teammate, reports: [report] });
41
+ continue;
42
+ }
43
+ if (report.origin === "harness" || report.harnessEvent) {
44
+ groups.set(`harness:${groups.size}:${teammate}`, { teammate, reports: [report] });
45
+ continue;
46
+ }
47
+ const key = `message:${teammate}`;
48
+ const group = groups.get(key);
22
49
  if (group) {
23
50
  group.reports.push(report);
24
51
  } else {
25
- groups.set(teammate, { teammate, reports: [report] });
52
+ groups.set(key, { teammate, reports: [report] });
26
53
  }
27
54
  }
28
55
  return [...groups.values()];
29
56
  }
30
57
 
31
- export function splitReportsByTeammate(reports: FollowUpReport[]): FollowUpReport[][] {
32
- return groupReportsByTeammate(reports).map((group) => group.reports);
33
- }
34
-
35
58
  export interface FollowUpQueueOptions {
36
59
  isIdle: () => boolean;
37
60
  dispatch: (reports: FollowUpReport[], content: string) => void;
@@ -59,11 +82,13 @@ interface PendingDispatch {
59
82
  }
60
83
 
61
84
  /**
62
- * Serializes automatic reports sent through Pi's void sendUserMessage API.
85
+ * Serializes automatic reports sent through Pi's void sendMessage API.
63
86
  *
64
87
  * The API reports asynchronous preflight failures through the runtime error
65
88
  * channel, so this queue uses agent lifecycle events for success and a bounded
66
- * watchdog for attempts that never reach agent_start.
89
+ * watchdog for attempts that never reach agent_start. A report arriving during
90
+ * an active leader run is dispatched to Pi's native follow-up queue; this
91
+ * queue still serializes later reports until that run settles.
67
92
  */
68
93
  export class FollowUpQueue {
69
94
  private readonly isIdle: FollowUpQueueOptions["isIdle"];
@@ -81,6 +106,10 @@ export class FollowUpQueue {
81
106
  private watchdogTimer: ReturnType<typeof setTimeout> | undefined;
82
107
  private generation = 0;
83
108
  private deadLetter: FollowUpReport[] = [];
109
+ /** Reports suppressed after their teammate was intentionally stopped. They
110
+ * remain inspectable in runtime diagnostics but never begin a leader turn. */
111
+ private archived: FollowUpReport[] = [];
112
+ private archivedSpawnIds = new Set<string>();
84
113
 
85
114
  constructor(options: FollowUpQueueOptions) {
86
115
  this.isIdle = options.isIdle;
@@ -94,10 +123,31 @@ export class FollowUpQueue {
94
123
  }
95
124
 
96
125
  enqueue(report: FollowUpReport): void {
126
+ if (report.spawnId && this.archivedSpawnIds.has(report.spawnId)) {
127
+ this.archived.push(report);
128
+ return;
129
+ }
97
130
  this.pending.push({ reports: [report], attempts: 0, retrying: false });
98
131
  this.schedulePump();
99
132
  }
100
133
 
134
+ /** Archive pending reports from one stopped spawn before they can wake the leader.
135
+ * A dispatch already accepted by Pi cannot be retracted, so it is deliberately
136
+ * left alone; this method only changes transitions the harness still owns. */
137
+ archiveSpawn(spawnId: string): FollowUpReport[] {
138
+ this.archivedSpawnIds.add(spawnId);
139
+ const moved: FollowUpReport[] = [];
140
+ for (const batch of this.pending) {
141
+ const retained = batch.reports.filter((report) => report.spawnId !== spawnId);
142
+ const archived = batch.reports.filter((report) => report.spawnId === spawnId);
143
+ this.archived.push(...archived);
144
+ moved.push(...archived);
145
+ batch.reports = retained;
146
+ }
147
+ this.pending = this.pending.filter((batch) => batch.reports.length > 0);
148
+ return moved;
149
+ }
150
+
101
151
  /** Match the active dispatch to the prompt that Pi is about to start. */
102
152
  onBeforeAgentStart(prompt: string): void {
103
153
  if (!this.active || this.active.prepared || prompt !== this.active.content) return;
@@ -123,6 +173,8 @@ export class FollowUpQueue {
123
173
  this.generation++;
124
174
  this.pending = [];
125
175
  this.deadLetter = [];
176
+ this.archived = [];
177
+ this.archivedSpawnIds.clear();
126
178
  this.active = undefined;
127
179
  this.pumpScheduled = false;
128
180
  this.clearRetryTimer();
@@ -138,6 +190,14 @@ export class FollowUpQueue {
138
190
  return this.deadLetter.length;
139
191
  }
140
192
 
193
+ get archivedCount(): number {
194
+ return this.archived.length;
195
+ }
196
+
197
+ archivedReportsFor(spawnId: string): FollowUpReport[] {
198
+ return this.archived.filter((report) => report.spawnId === spawnId);
199
+ }
200
+
141
201
  private schedulePump(): void {
142
202
  if (this.pumpScheduled) return;
143
203
  this.pumpScheduled = true;
@@ -150,19 +210,13 @@ export class FollowUpQueue {
150
210
  }
151
211
 
152
212
  private pump(): void {
153
- if (this.active || this.retryTimer || this.pending.length === 0 || !this.isIdle()) return;
213
+ if (this.active || this.retryTimer || this.pending.length === 0) return;
154
214
  const first = this.pending.shift();
155
215
  if (!first) return;
156
- const batches = first.retrying ? [first] : [first, ...this.pending.splice(0)];
157
- const allReports = batches.flatMap((batch) => batch.reports);
158
- const groups = groupReportsByTeammate(allReports);
159
- const reports = groups[0]?.reports ?? [];
160
- const remainingGroups = groups.slice(1).map((group) => ({
161
- reports: group.reports,
162
- attempts: 0,
163
- retrying: false,
164
- }));
165
- if (remainingGroups.length > 0) this.pending.unshift(...remainingGroups);
216
+ // One report per dispatch: every teammate-authored message becomes its own
217
+ // leader turn, in arrival order. Coalescing would blur when each message
218
+ // was sent and what the leader was expected to do about each one.
219
+ const reports = first.reports;
166
220
  const content = formatReports(reports);
167
221
  const queuedIntoActiveRun = !this.isIdle();
168
222
  this.active = {
@@ -226,9 +280,18 @@ export class FollowUpQueue {
226
280
 
227
281
  export function formatReports(reports: FollowUpReport[]): string {
228
282
  return reports
229
- .map(({ teammate, body }) => {
283
+ .map((report) => {
284
+ const { teammate, body, timestamp, origin, harnessEvent } = report;
285
+ const at = timestamp !== undefined && Number.isFinite(timestamp)
286
+ ? ` at="${new Date(timestamp).toISOString()}"`
287
+ : "";
288
+ if (origin === "harness" || harnessEvent) {
289
+ const type = escapeAttribute(harnessEvent?.type ?? "event");
290
+ const subject = escapeAttribute(harnessEvent?.subject ?? "Agent Teams event");
291
+ return `<harness-event type="${type}" subject="${subject}"${at}>\n${body}\n</harness-event>`;
292
+ }
230
293
  const name = teammate ?? "teammate";
231
- return `<agent-message from="${escapeAttribute(name)}">\n${body}\n</agent-message>`;
294
+ return `<agent-message from="${escapeAttribute(name)}"${at}>\n${body}\n</agent-message>`;
232
295
  })
233
296
  .join("\n\n");
234
297
  }