@fradser/pi-agent-teams 0.7.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.memory/MEMORY.md 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 stderr 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, runs deterministic verify gates, 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
 
@@ -27,25 +27,39 @@ Agent definitions are Markdown files with frontmatter; the body is the role prom
27
27
  name: isolated-security-auditor
28
28
  description: Read-only security reviewer in an isolated worktree
29
29
  tools: read,bash
30
- model: provider/model # optional
31
- verify: npm test # optional role-default completion gate
30
+ model: provider/model # optional; "inherit" = the leader's current model at spawn time
31
+ verify: review prompt # optional role-default gate; a fresh reviewer answers VERDICT: PASS/FAIL
32
32
  worktree: true # optional role-default Git isolation
33
33
  ---
34
34
  Review the assigned scope for exploitable security problems. Do not edit files.
35
35
  ```
36
36
 
37
- Discovery precedence per name (later overrides earlier):
37
+ Teammate model resolution at spawn time, in precedence order: an explicit
38
+ `provider/model` pin wins; `inherit` pins the leader session's current model;
39
+ without a pin, the unified teammate model set from `/agent-teams` (press `m`
40
+ in the roster page for a type-to-filter picker) applies to this session;
41
+ otherwise Pi picks its own default.
42
+
43
+ Discovery precedence per name (later overrides earlier): user < project <
44
+ project-local, with generated session roles filling only the names no file
45
+ defines. A re-spawn that supplies an explicit inline definition replaces a
46
+ previously generated session role of the same name; definition files are never
47
+ overwritten by inline input.
38
48
 
39
49
  There are no built-in roles. When a needed role has no definition, the
40
- leader creates one on demand: it derives the definition from the task using
41
- the abstract role reference in `references/agent-roles.md` (definition
42
- anatomy, archetype axes, invariants), writes the file, and then spawns.
50
+ leader creates it in memory for the current session: it derives the definition
51
+ from the task using the abstract role reference in `references/agent-roles.md`
52
+ (definition anatomy, archetype axes, invariants), registers it, and then spawns.
53
+ The role is not written to disk or reused in a later session unless the user
54
+ explicitly requests persistence; only then may the leader set `definition.persist`
55
+ and choose a project or project-local scope.
43
56
 
44
57
  | Scope | Location | Git semantics |
45
58
  |---|---|---|
46
59
  | user | `~/.pi/agent/agents/*.md` | system-level, never committed |
47
60
  | project | `<cwd>/.pi/agents/<name>.md` | **git-managed** — commit for the team |
48
61
  | project-local | `<cwd>/.pi/agents/<name>.local.md` | **local** — personal override, gitignore by convention |
62
+ | session | in-memory registry | **ephemeral** — disappears at session start, no source file |
49
63
 
50
64
  A teammate definition is just a Markdown file you own. Shared roles live in `.pi/agents/<name>.md`; a personal tweak to that exact role is `<name>.local.md` in the SAME directory — same teammate name, local scope wins, and discovery deduplicates the pair into one entry (never two). `resolveAgent` reports each definition's `scope` and `gitManaged`; guidance lists both so the leader knows provenance.
51
65
 
@@ -55,17 +69,20 @@ A teammate definition is just a Markdown file you own. Shared roles live in `.pi
55
69
  teammate_spawn({
56
70
  name: "security",
57
71
  agent: "reviewer",
58
- prompt: "Review the auth middleware"
72
+ prompt: "Review the auth middleware",
73
+ resources: ["packages/api/auth"]
59
74
  })
60
75
 
61
76
  teammate_spawn({ name: "backend", agent: "worker" })
62
77
  ```
63
78
 
64
- - Spawn has exactly three parameters: `name`, `agent`, and optional kickoff `prompt`.
65
- - 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.
66
83
  - Teammates stay alive between tasks and consume no model tokens while idle.
67
84
  - A session-wide cap of 8 living teammates applies.
68
- - 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>`).
69
86
  - `teammate_shutdown({ name })` stops one teammate and releases its claimed task back to the board.
70
87
 
71
88
  ## Coordinate Through One Message Primitive and the Board
@@ -80,7 +97,9 @@ task_create({
80
97
  subject: "Fix auth middleware findings",
81
98
  description: "Address the confirmed security findings in packages/api/src/auth.ts",
82
99
  dependsOn: ["t_1"],
83
- verify: "npm test"
100
+ resources: ["packages/api/auth"],
101
+ supersedes: ["old-auth-patch"],
102
+ verify: "Every scenario in features/gallery.feature holds in the built gallery; no horizontal overflow at 400px."
84
103
  })
85
104
  ```
86
105
 
@@ -90,22 +109,25 @@ task_create({
90
109
  send_message({ to, message, status? })
91
110
  ```
92
111
 
93
- - 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.
94
114
  - Peer mail: `to: "<teammate-name>"`; `status` is invalid.
95
- - 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.
96
118
  - The first non-empty line of `message` becomes the console title.
97
119
  - Peer traffic never enters the leader model context; inspect it in `/agent-teams` instead.
98
120
 
99
- Only the leader creates tasks. Idle teammates self-claim pending tasks whose dependencies are met. A task-level `verify` command overrides the claiming agent's frontmatter `verify`; zero exit completes the task, while failure returns stderr to the claimer for fix-and-resubmit.
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.
100
122
 
101
123
  ## Tools
102
124
 
103
125
  | Tool / command | Side | Description |
104
126
  |---|---|---|
105
- | `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 |
106
128
  | `teammate_shutdown(name)` | Leader | Stop one teammate and release its claimed work |
107
- | `send_message(to, message, status?)` | Both | The only messaging primitive; `to: "leader"` is reserved for worker reports |
108
- | `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 |
109
131
  | `task_list()` | Both | One shared board-view definition; leader view also includes the roster |
110
132
  | `task_claim(taskId?)` | Worker | Atomically self-claim a pending, unblocked task |
111
133
  | `task_submit(taskId, status, result?)` | Worker | Submit a claimed task outcome; completion passes through verify |
@@ -116,15 +138,19 @@ That is **7 unique tool names**. There are no `teammate_run`, `teammate_fanout`,
116
138
  ## Reliability Protocol
117
139
 
118
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.
119
- - **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`.
120
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.
121
- - **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.
122
- - **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.
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).
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.
123
149
  - **Failure semantics**: an unexpected crash marks the teammate stopped, reports a diagnostic, and releases its claimed tasks.
124
150
 
125
151
  ## State and Sessions
126
152
 
127
- 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.
128
154
 
129
155
  ## Structure
130
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fradser/pi-agent-teams",
3
- "version": "0.7.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.3.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
  ```
package/src/agents.ts CHANGED
@@ -13,8 +13,10 @@
13
13
  * name — unique agent id (required)
14
14
  * description — routing contract: when the leader should choose this agent
15
15
  * tools — comma-separated list or YAML list of Pi tool ids
16
- * model — optional provider/model pin
17
- * verify — optional role-default completion gate command (zero exit passes)
16
+ * model — optional provider/model pin, or "inherit" for the leader's
17
+ * current model at spawn time
18
+ * verify — optional role-default completion gate: a review prompt a
19
+ * fresh one-shot reviewer answers with VERDICT: PASS/FAIL
18
20
  * worktree — optional role-default Git worktree isolation (true/false)
19
21
  * The Markdown body is the worker's role prompt.
20
22
  */
@@ -24,7 +26,7 @@ import * as path from "node:path";
24
26
  import { fileURLToPath } from "node:url";
25
27
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
26
28
 
27
- export type AgentScope = "user" | "project" | "project-local";
29
+ export type AgentScope = "user" | "project" | "project-local" | "session";
28
30
 
29
31
  export interface AgentDefinition {
30
32
  name: string;
@@ -42,8 +44,97 @@ export interface AgentDefinition {
42
44
  /** True only for the shared project scope that is expected to be git-managed.
43
45
  * user and project-local definitions are machine/personal by definition. */
44
46
  gitManaged: boolean;
45
- /** Absolute path of the definition file. */
46
- source: string;
47
+ /** Absolute path of the definition file; absent for session-only roles. */
48
+ source?: string;
49
+ }
50
+
51
+ export interface AgentDefinitionInput {
52
+ name: string;
53
+ description: string;
54
+ tools: string[];
55
+ model?: string;
56
+ verify?: string;
57
+ worktree?: boolean;
58
+ prompt: string;
59
+ }
60
+
61
+ /** Reserved model value meaning "use the leader session's current model",
62
+ * resolved at spawn time (Claude-Code-style inherit). */
63
+ export const MODEL_INHERIT_ALIAS = "inherit";
64
+
65
+ const sessionAgents = new Map<string, AgentDefinition>();
66
+ const AGENT_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
67
+
68
+ function normalizeAgentInput(input: AgentDefinitionInput): AgentDefinitionInput {
69
+ const name = input.name.trim();
70
+ if (!AGENT_NAME_PATTERN.test(name)) {
71
+ throw new Error(`Invalid agent name "${name}". Use letters, digits, dots, dashes, underscores.`);
72
+ }
73
+ const prompt = input.prompt.trim();
74
+ if (!prompt) throw new Error(`Agent "${name}" prompt must not be empty.`);
75
+ return {
76
+ ...input,
77
+ name,
78
+ description: input.description.trim(),
79
+ tools: input.tools.map((tool) => tool.trim()).filter(Boolean),
80
+ model: input.model?.trim() || undefined,
81
+ verify: input.verify?.trim() || undefined,
82
+ worktree: input.worktree ?? false,
83
+ prompt,
84
+ };
85
+ }
86
+
87
+ /** Register a generated role for this session without writing a definition file. */
88
+ export function registerSessionAgent(input: AgentDefinitionInput): AgentDefinition {
89
+ const normalized = normalizeAgentInput(input);
90
+ const definition: AgentDefinition = {
91
+ ...normalized,
92
+ worktree: normalized.worktree ?? false,
93
+ scope: "session",
94
+ gitManaged: false,
95
+ source: undefined,
96
+ };
97
+ sessionAgents.set(definition.name, definition);
98
+ return definition;
99
+ }
100
+
101
+ /** Persist a generated role only when the user explicitly requests it. */
102
+ export function persistAgentDefinition(
103
+ input: AgentDefinitionInput,
104
+ scope: "project" | "project-local",
105
+ cwd = process.cwd(),
106
+ ): AgentDefinition {
107
+ const normalized = normalizeAgentInput(input);
108
+ const directory = agentsDir("project", cwd);
109
+ fs.mkdirSync(directory, { recursive: true });
110
+ const filename = `${normalized.name}${scope === "project-local" ? LOCAL_DEFINITION_SUFFIX : ".md"}`;
111
+ const source = path.join(directory, filename);
112
+ const frontmatter = [
113
+ "---",
114
+ `name: ${normalized.name}`,
115
+ `description: ${normalized.description.replace(/[\\r\\n]+/g, " ")}`,
116
+ `tools: ${normalized.tools.join(", ")}`,
117
+ ...(normalized.model ? [`model: ${normalized.model}`] : []),
118
+ ...(normalized.verify ? [`verify: ${normalized.verify.replace(/[\\r\\n]+/g, " ")}`] : []),
119
+ `worktree: ${normalized.worktree ? "true" : "false"}`,
120
+ "---",
121
+ normalized.prompt,
122
+ "",
123
+ ].join("\n");
124
+ fs.writeFileSync(source, frontmatter, { encoding: "utf8", mode: 0o600 });
125
+ sessionAgents.delete(normalized.name);
126
+ return {
127
+ ...normalized,
128
+ worktree: normalized.worktree ?? false,
129
+ scope,
130
+ gitManaged: scope === "project",
131
+ source,
132
+ };
133
+ }
134
+
135
+ /** Drop generated session roles; persistent user/project definitions remain discoverable. */
136
+ export function clearSessionAgents(): void {
137
+ sessionAgents.clear();
47
138
  }
48
139
 
49
140
  /** Shipped role templates: reference material for generating new definitions
@@ -172,6 +263,9 @@ export function discoverAgents(cwd?: string): Map<string, AgentDefinition> {
172
263
  const agents = new Map<string, AgentDefinition>();
173
264
  loadDir("user", agentsDir("user", cwd), agents);
174
265
  loadDir("project", agentsDir("project", cwd), agents);
266
+ // Generated roles fill gaps only: filesystem scopes keep precedence over
267
+ // session memory.
268
+ for (const [name, definition] of sessionAgents) if (!agents.has(name)) agents.set(name, definition);
175
269
  return agents;
176
270
  }
177
271
 
@@ -197,7 +291,7 @@ export function formatAgentGuidance(cwd?: string): string {
197
291
  agent.model ? `Model: ${agent.model}` : "",
198
292
  agent.verify ? `Verify: ${agent.verify}` : "",
199
293
  agent.worktree ? "Worktree: true" : "",
200
- agent.gitManaged ? "git-managed" : "local",
294
+ agent.scope === "session" ? "in-memory" : agent.gitManaged ? "git-managed" : "local",
201
295
  ].filter(Boolean);
202
296
  lines.push(`- **${agent.name}** (${agent.scope})`);
203
297
  if (agent.description) lines.push(` ${agent.description}`);
@@ -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
  }