@tmustier/pi-agent-teams 0.1.2 → 0.2.0
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/.github/workflows/ci.yml +32 -0
- package/README.md +36 -6
- package/docs/claude-parity.md +16 -14
- package/docs/field-notes-teams-setup.md +6 -4
- package/docs/smoke-test-plan.md +130 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/fs-lock.ts +21 -5
- package/extensions/teams/leader-inbox.ts +175 -0
- package/extensions/teams/leader-info-commands.ts +139 -0
- package/extensions/teams/leader-lifecycle-commands.ts +330 -0
- package/extensions/teams/leader-messaging-commands.ts +149 -0
- package/extensions/teams/leader-plan-commands.ts +96 -0
- package/extensions/teams/leader-spawn-command.ts +73 -0
- package/extensions/teams/leader-task-commands.ts +417 -0
- package/extensions/teams/leader-teams-tool.ts +238 -0
- package/extensions/teams/leader.ts +396 -1422
- package/extensions/teams/mailbox.ts +54 -29
- package/extensions/teams/names.ts +87 -0
- package/extensions/teams/protocol.ts +221 -0
- package/extensions/teams/task-store.ts +32 -21
- package/extensions/teams/team-config.ts +71 -25
- package/extensions/teams/teammate-rpc.ts +56 -22
- package/extensions/teams/teams-panel.ts +698 -0
- package/extensions/teams/teams-style.ts +62 -0
- package/extensions/teams/teams-widget.ts +235 -0
- package/extensions/teams/worker.ts +100 -138
- package/extensions/teams/worktree.ts +4 -7
- package/package.json +25 -3
- package/scripts/integration-claim-test.mts +227 -0
- package/scripts/integration-todo-test.mts +583 -0
- package/scripts/smoke-test.mjs +1 -1
- package/scripts/smoke-test.mts +424 -0
- package/skills/agent-teams/SKILL.md +136 -0
- package/tsconfig.strict.json +22 -0
- package/extensions/teams/tasks.ts +0 -95
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
typecheck-and-smoke:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
timeout-minutes: 15
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- name: Checkout
|
|
15
|
+
uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Setup Node
|
|
18
|
+
uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: "20.x"
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: npm install
|
|
24
|
+
|
|
25
|
+
- name: Typecheck (strict)
|
|
26
|
+
run: npm run typecheck
|
|
27
|
+
|
|
28
|
+
- name: Smoke test
|
|
29
|
+
run: npm run smoke-test
|
|
30
|
+
|
|
31
|
+
- name: Package (dry run)
|
|
32
|
+
run: npm pack --dry-run
|
package/README.md
CHANGED
|
@@ -20,6 +20,17 @@ Additional Pi-specific capabilities:
|
|
|
20
20
|
- **Git worktrees** — optionally give each teammate its own worktree so they work on isolated branches without conflicting edits.
|
|
21
21
|
- **Session branching** — clone the leader's conversation context into a teammate so it starts with full awareness of the work so far, instead of from scratch.
|
|
22
22
|
|
|
23
|
+
## UI style
|
|
24
|
+
|
|
25
|
+
The extension supports two UI styles:
|
|
26
|
+
|
|
27
|
+
- **normal** (default): "Team leader" + "Teammate <name>"
|
|
28
|
+
- **soviet**: "Chairman" + "Comrade <name>" (in soviet mode, the system decides names for you)
|
|
29
|
+
|
|
30
|
+
Configure via:
|
|
31
|
+
- env: `PI_TEAMS_STYLE=normal|soviet`
|
|
32
|
+
- command: `/team style normal` or `/team style soviet`
|
|
33
|
+
|
|
23
34
|
## Install
|
|
24
35
|
|
|
25
36
|
**Option A — install from npm:**
|
|
@@ -46,9 +57,16 @@ Verify with `/team id` — it should print the current team info.
|
|
|
46
57
|
|
|
47
58
|
## Quick start
|
|
48
59
|
|
|
60
|
+
The fastest way to get going is `/swarm`:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
/swarm build the auth module # agent spawns a team and coordinates the work
|
|
64
|
+
/swarm # agent asks you what to do, then swarms on it
|
|
49
65
|
```
|
|
50
|
-
# In a Pi session with the extension loaded:
|
|
51
66
|
|
|
67
|
+
Or drive it manually:
|
|
68
|
+
|
|
69
|
+
```
|
|
52
70
|
/team spawn alice # spawn a teammate (fresh session, shared workspace)
|
|
53
71
|
/team spawn bob branch worktree # spawn with leader context + isolated worktree
|
|
54
72
|
|
|
@@ -58,6 +76,8 @@ Verify with `/team id` — it should print the current team info.
|
|
|
58
76
|
/team dm alice Check the edge cases too # direct message
|
|
59
77
|
/team broadcast Wrapping up soon # message everyone
|
|
60
78
|
|
|
79
|
+
/tw # open the interactive widget panel
|
|
80
|
+
|
|
61
81
|
/team shutdown alice # graceful shutdown (handshake)
|
|
62
82
|
/team cleanup # remove team artifacts when done
|
|
63
83
|
```
|
|
@@ -79,14 +99,23 @@ Or let the model drive it with the delegate tool:
|
|
|
79
99
|
|
|
80
100
|
## Commands
|
|
81
101
|
|
|
82
|
-
|
|
102
|
+
### Shortcuts
|
|
103
|
+
|
|
104
|
+
| Command | Description |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `/swarm [task]` | Tell the agent to spawn a team and work on a task |
|
|
107
|
+
| `/tw` | Open the interactive widget panel |
|
|
108
|
+
|
|
109
|
+
### Team management
|
|
83
110
|
|
|
84
|
-
|
|
111
|
+
All management commands live under `/team`.
|
|
85
112
|
|
|
86
113
|
| Command | Description |
|
|
87
114
|
| --- | --- |
|
|
88
115
|
| `/team spawn <name> [fresh\|branch] [shared\|worktree]` | Start a teammate |
|
|
89
116
|
| `/team list` | List teammates and their status |
|
|
117
|
+
| `/team panel` | Interactive widget panel (same as `/tw`) |
|
|
118
|
+
| `/team style <normal\|soviet>` | Set UI style (normal/soviet) |
|
|
90
119
|
| `/team send <name> <msg>` | Send a prompt over RPC |
|
|
91
120
|
| `/team steer <name> <msg>` | Redirect an in-flight run |
|
|
92
121
|
| `/team dm <name> <msg>` | Send a mailbox message |
|
|
@@ -97,7 +126,7 @@ All commands live under `/team`.
|
|
|
97
126
|
| `/team kill <name>` | Force-terminate |
|
|
98
127
|
| `/team cleanup [--force]` | Delete team artifacts |
|
|
99
128
|
| `/team id` | Print team/task-list IDs and paths |
|
|
100
|
-
| `/team env <name>` | Print env vars to start a manual
|
|
129
|
+
| `/team env <name>` | Print env vars to start a manual teammate |
|
|
101
130
|
|
|
102
131
|
### Tasks
|
|
103
132
|
|
|
@@ -119,6 +148,7 @@ All commands live under `/team`.
|
|
|
119
148
|
| --- | --- | --- |
|
|
120
149
|
| `PI_TEAMS_ROOT_DIR` | Storage root (absolute or relative to `~/.pi/agent`) | `~/.pi/agent/teams` |
|
|
121
150
|
| `PI_TEAMS_DEFAULT_AUTO_CLAIM` | Whether spawned teammates auto-claim tasks | `1` (on) |
|
|
151
|
+
| `PI_TEAMS_STYLE` | UI style (`normal` or `soviet`) | `normal` |
|
|
122
152
|
|
|
123
153
|
## Storage layout
|
|
124
154
|
|
|
@@ -150,7 +180,7 @@ Filesystem-level test of the task store, mailbox, and team config.
|
|
|
150
180
|
node scripts/e2e-rpc-test.mjs
|
|
151
181
|
```
|
|
152
182
|
|
|
153
|
-
Starts a leader in RPC mode, spawns a
|
|
183
|
+
Starts a leader in RPC mode, spawns a teammate, runs a shutdown handshake, verifies cleanup. Sets `PI_TEAMS_ROOT_DIR` to a temp directory so nothing touches `~/.pi/agent/teams`.
|
|
154
184
|
|
|
155
185
|
### tmux dogfooding
|
|
156
186
|
|
|
@@ -159,7 +189,7 @@ Starts a leader in RPC mode, spawns a worker, runs a shutdown handshake, verifie
|
|
|
159
189
|
tmux attach -t pi-teams
|
|
160
190
|
```
|
|
161
191
|
|
|
162
|
-
Starts a leader + one tmux window per
|
|
192
|
+
Starts a leader + one tmux window per teammate for interactive testing.
|
|
163
193
|
|
|
164
194
|
## License
|
|
165
195
|
|
package/docs/claude-parity.md
CHANGED
|
@@ -12,12 +12,14 @@ This document tracks **feature parity gaps** between:
|
|
|
12
12
|
|
|
13
13
|
- `pi-agent-teams` (Pi extension)
|
|
14
14
|
|
|
15
|
+
> Terminology note: the extension supports `PI_TEAMS_STYLE=normal|soviet`. These docs often say "comrade" for parity with Claude Teams, but in **normal** style you’ll see "teammate" and "team leader" in the UI.
|
|
16
|
+
|
|
15
17
|
## Scope / philosophy
|
|
16
18
|
|
|
17
19
|
- Target the **same coordination primitives** as Claude Teams:
|
|
18
20
|
- shared task list
|
|
19
21
|
- mailbox messaging
|
|
20
|
-
- long-lived
|
|
22
|
+
- long-lived comrades
|
|
21
23
|
- Prefer **inspectable, local-first artifacts** (files + lock files).
|
|
22
24
|
- Avoid guidance that bypasses Claude feature gating; we only document behavior.
|
|
23
25
|
- Accept that some Claude UX (terminal keybindings + split-pane integration) may not be achievable in Pi without deeper TUI/terminal integration.
|
|
@@ -31,16 +33,16 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
|
31
33
|
| Enablement | `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` + settings | N/A | Pi extension is always available when installed/loaded. | — |
|
|
32
34
|
| Team config | `~/.claude/teams/<team>/config.json` w/ members | ✅ | Implemented via `extensions/teams/team-config.ts` (stored under `~/.pi/agent/teams/...` or `PI_TEAMS_ROOT_DIR`). | P0 |
|
|
33
35
|
| Task list (shared) | `~/.claude/tasks/<taskListId>/` + states + deps | ✅ | File-per-task + deps (`blockedBy`/`blocks`); `/team task dep add|rm|ls`; self-claim skips blocked tasks. | P0 |
|
|
34
|
-
| Self-claim |
|
|
35
|
-
| Explicit assign | Lead assigns task to
|
|
36
|
-
| “Message” vs “broadcast” | Send to one
|
|
37
|
-
|
|
|
38
|
-
| Display modes | In-process selection (Shift+Up/Down); split panes (tmux/iTerm) | ❌ | Pi has a widget + commands, but no terminal-level
|
|
36
|
+
| Self-claim | Comrades can self-claim next unassigned, unblocked task; file locking | ✅ | Implemented: `claimNextAvailableTask()` + locks; enabled by default (`PI_TEAMS_DEFAULT_AUTO_CLAIM=1`). | P0 |
|
|
37
|
+
| Explicit assign | Lead assigns task to comrade | ✅ | `/team task assign` sets owner + pings via mailbox. | P0 |
|
|
38
|
+
| “Message” vs “broadcast” | Send to one comrade or all comrades | ✅ | `/team dm` + `/team broadcast` use mailbox; `/team send` uses RPC. Broadcast recipients = team config workers + RPC-spawned map + active task owners; manual tmux workers self-register into `config.json` on startup. | P0 |
|
|
39
|
+
| Comrade↔comrade messaging | Comrades can message each other directly | ✅ | Workers register `team_message` LLM-callable tool; sends via mailbox + CC's leader with `peer_dm_sent` notification. | P1 |
|
|
40
|
+
| Display modes | In-process selection (Shift+Up/Down); split panes (tmux/iTerm) | ❌ | Pi has a widget + commands, but no terminal-level comrade navigation/panes. | P2 |
|
|
39
41
|
| Delegate mode | Lead restricted to coordination-only tools | ✅ | `/team delegate [on|off]` toggles; `pi.on("tool_call")` blocks `bash/edit/write`; `PI_TEAMS_DELEGATE_MODE=1` env. Widget shows `[delegate]`. | P1 |
|
|
40
|
-
| Plan approval |
|
|
41
|
-
| Shutdown handshake | Lead requests shutdown;
|
|
42
|
-
| Cleanup team | “Clean up the team” removes shared resources after
|
|
43
|
-
| Hooks / quality gates | `
|
|
42
|
+
| Plan approval | Comrade can be "plan required" and needs lead approval to implement | ✅ | `/team spawn <name> plan` sets `PI_TEAMS_PLAN_REQUIRED=1`; worker restricted to read-only tools; submits plan via `plan_approval_request`; `/team plan approve|reject <name>`. | P1 |
|
|
43
|
+
| Shutdown handshake | Lead requests shutdown; comrade can approve/reject | ✅ | Full protocol: `shutdown_request` → `shutdown_approved` or `shutdown_rejected` (when worker is busy). `/team shutdown <name>` (graceful) + `/team kill` (force). | P1 |
|
|
44
|
+
| Cleanup team | “Clean up the team” removes shared resources after comrades stopped | ✅ | `/team cleanup [--force]` deletes only `<teamsRoot>/<teamId>` after safety checks. | P1 |
|
|
45
|
+
| Hooks / quality gates | `ComradeIdle`, `TaskCompleted` hooks | ❌ | Add optional hook runner in leader on idle/task-complete events (script execution + exit-code gating). | P2 |
|
|
44
46
|
| Task list UX | Ctrl+T toggle; show all/clear tasks by asking | 🟡 | Widget + `/team task list` show blocked/deps; `/team task show <id>`; `/team task clear [completed|all]`. No Ctrl+T toggle yet. | P0 |
|
|
45
47
|
| Shared task list across sessions | `CLAUDE_CODE_TASK_LIST_ID=...` | ✅ | `PI_TEAMS_TASK_LIST_ID` env on leader + worker; `/team task use <taskListId>` switches the leader (and newly spawned workers). Existing workers need a restart to pick up changes. Persisted in config.json. | P1 |
|
|
46
48
|
|
|
@@ -80,7 +82,7 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
|
80
82
|
|
|
81
83
|
7) **Cleanup** ✅
|
|
82
84
|
- `/team cleanup [--force]` deletes only `<teamsRoot>/<teamId>` after safety checks.
|
|
83
|
-
- Refuses if RPC
|
|
85
|
+
- Refuses if RPC comrades are running or there are `in_progress` tasks unless `--force`.
|
|
84
86
|
|
|
85
87
|
8) **Peer-to-peer messaging** ✅
|
|
86
88
|
- Workers register `team_message` LLM-callable tool (recipient + message params)
|
|
@@ -93,10 +95,10 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
|
93
95
|
|
|
94
96
|
### P2: UX + “product-level” parity
|
|
95
97
|
|
|
96
|
-
10) **Better
|
|
98
|
+
10) **Better comrade interaction UX**
|
|
97
99
|
- Explore whether Pi’s TUI API can support:
|
|
98
|
-
- selecting a
|
|
99
|
-
- “entering” a
|
|
100
|
+
- selecting a comrade from the widget
|
|
101
|
+
- “entering” a comrade transcript view
|
|
100
102
|
- (Optional) tmux integration for split panes.
|
|
101
103
|
|
|
102
104
|
11) **Hooks / quality gates**
|
|
@@ -4,12 +4,14 @@ Date: 2026-02-07
|
|
|
4
4
|
|
|
5
5
|
Goal: dogfood the Teams extension to implement its own roadmap (Claude parity), and capture anything that surprised/confused us while setting it up.
|
|
6
6
|
|
|
7
|
+
> Terminology note: the extension supports `PI_TEAMS_STYLE=normal|soviet`. In normal style the UI says "teammate" and "team leader"; in soviet style it says "comrade" and "chairman".
|
|
8
|
+
|
|
7
9
|
## Test run: test1
|
|
8
10
|
|
|
9
11
|
Decisions: tmux session `pi-teams-test1`; `PI_TEAMS_ROOT_DIR=~/projects/pi-agent-teams/test1`; `teamId=0baaa0e6-8020-4d9a-bf33-c1a65f99a2f7`; workers started manually in tmux (not `/team spawn`).
|
|
10
12
|
|
|
11
13
|
First impressions:
|
|
12
|
-
- Manual tmux workers are usable. Initially the leader showed “(no
|
|
14
|
+
- Manual tmux workers are usable. Initially the leader showed “(no comrades)” because it only tracked RPC-spawned workers; now manual workers **upsert themselves into `config.json` on startup**, and the leader widget renders online workers from team config.
|
|
13
15
|
- Pinning `PI_TEAMS_ROOT_DIR` made reruns/id discovery predictable (no “find the new folder” step).
|
|
14
16
|
- tmux workflow feels close to Claude-style split panes; bootstrap ergonomics still need smoothing.
|
|
15
17
|
- Surprise: `/team spawn <name> branch` failed once with `Entry <id> not found` (branch-from leaf missing on disk); `/team spawn <name> fresh` worked.
|
|
@@ -73,9 +75,9 @@ First impressions:
|
|
|
73
75
|
|
|
74
76
|
- **tmux vs `/team spawn`**: `/team spawn` uses `pi --mode rpc` subprocesses.
|
|
75
77
|
- Pros: simple, managed lifecycle.
|
|
76
|
-
- Cons: you don’t see a full interactive
|
|
78
|
+
- Cons: you don’t see a full interactive comrade UI like Claude’s split-pane mode.
|
|
77
79
|
- We manually started workers in separate tmux windows (setting `PI_TEAMS_WORKER=1`, `PI_TEAMS_TEAM_ID=...`, etc). This now shows up in the leader widget because workers upsert themselves into `config.json`, and the leader renders online workers from team config.
|
|
78
|
-
- Update: leader now renders
|
|
80
|
+
- Update: leader now renders comrades from `team config` and also auto-adds unknown senders on idle notifications (so manual tmux workers feel first-class).
|
|
79
81
|
- Improvement idea: optional spawn mode that starts a worker in a new tmux pane/window.
|
|
80
82
|
|
|
81
83
|
- **Two messaging paths** (`/team send` vs `/team dm`):
|
|
@@ -84,7 +86,7 @@ First impressions:
|
|
|
84
86
|
- Improvement idea: clearer naming and/or a single “message” command with a mode flag.
|
|
85
87
|
|
|
86
88
|
- **Runaway tasks / timeboxing**: a vague task prompt can turn into a long “research spiral”.
|
|
87
|
-
- In manual-tmux mode, there isn’t a great way (yet) for the leader to *steer* an in-flight run (unlike `/team steer` for RPC-spawned
|
|
89
|
+
- In manual-tmux mode, there isn’t a great way (yet) for the leader to *steer* an in-flight run (unlike `/team steer` for RPC-spawned comrades).
|
|
88
90
|
- Improvement idea: add a mailbox-level “steer” protocol message that workers can treat as an in-flight follow-up if they’re currently running.
|
|
89
91
|
|
|
90
92
|
- **Failure semantics are underspecified**: tool failures show up in the worker UI, but our task store currently only supports `pending|in_progress|completed`.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# pi-agent-teams — Runtime Smoke Test Plan
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- `pi` CLI installed (`pi --version` → `0.52.x+`)
|
|
6
|
+
- `node_modules/` present (run `npm install` or symlink from main repo)
|
|
7
|
+
- `npx tsx` available for running `.mts` test scripts
|
|
8
|
+
|
|
9
|
+
## 1. Automated Unit Smoke Test (no interactive session)
|
|
10
|
+
|
|
11
|
+
Exercises all core primitives directly via `tsx`:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx tsx scripts/smoke-test.mts
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**What it tests** (60 assertions):
|
|
18
|
+
|
|
19
|
+
| Module | Coverage |
|
|
20
|
+
|------------------|-----------------------------------------------------------------|
|
|
21
|
+
| `names.ts` | `sanitizeName` character replacement, edge cases |
|
|
22
|
+
| `fs-lock.ts` | `withLock` returns value, cleans up lock file |
|
|
23
|
+
| `mailbox.ts` | `writeToMailbox`, `popUnreadMessages`, read-once semantics |
|
|
24
|
+
| `task-store.ts` | CRUD, `startAssignedTask`, `completeTask`, `claimNextAvailable`,|
|
|
25
|
+
| | `unassignTasksForAgent`, dependencies, `clearTasks` |
|
|
26
|
+
| `team-config.ts` | `ensureTeamConfig` (idempotent), `upsertMember`, `setMemberStatus`, `loadTeamConfig` |
|
|
27
|
+
| `protocol.ts` | All 11 message parsers (valid + invalid JSON + wrong type) |
|
|
28
|
+
| Pi CLI | `pi --version` executes |
|
|
29
|
+
|
|
30
|
+
**Expected result:** `PASSED: 60 FAILED: 0`
|
|
31
|
+
|
|
32
|
+
## 2. Extension Loading Test
|
|
33
|
+
|
|
34
|
+
Verify Pi can load the extension entry point without crashing:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi --no-extensions -e extensions/teams/index.ts --help
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Expected:** exits 0, shows Pi help output (no TypeScript/import errors).
|
|
41
|
+
|
|
42
|
+
## 3. Interactive Smoke Test (manual)
|
|
43
|
+
|
|
44
|
+
### 3a. Launch Pi with the extension
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# From the repo root:
|
|
48
|
+
pi --no-extensions -e extensions/teams/index.ts
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or, if the extension is symlinked into `~/.pi/agent/extensions/pi-agent-teams`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pi # auto-loads from extensions dir
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 3b. Check extension is active
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
/team help
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Expected:** shows usage lines for `/team spawn`, `/team task`, etc.
|
|
64
|
+
|
|
65
|
+
### 3c. Spawn a teammate ("comrade" in soviet style)
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
/team spawn agent1 fresh shared
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Expected:** notification "Spawned agent1" or similar, widget shows `Teammate agent1: idle` (or `Comrade agent1: idle` in soviet style).
|
|
72
|
+
|
|
73
|
+
### 3d. Create and assign a task
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
/team task add agent1: Say hello
|
|
77
|
+
/team task list
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Expected:** task #1 created, assigned to agent1, status `pending` → `in_progress`.
|
|
81
|
+
|
|
82
|
+
### 3e. Verify mailbox delivery
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
/team dm agent1 ping from lead
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Expected:** "DM queued for agent1" notification.
|
|
89
|
+
|
|
90
|
+
### 3f. Delegate via tool
|
|
91
|
+
|
|
92
|
+
Ask the model:
|
|
93
|
+
> "Delegate a task to agent1: write a haiku about coding"
|
|
94
|
+
|
|
95
|
+
**Expected:** the `teams` tool is invoked, task created and assigned.
|
|
96
|
+
|
|
97
|
+
### 3g. Shutdown
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
/team shutdown agent1
|
|
101
|
+
/team kill agent1
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Expected:** agent1 goes offline, widget updates.
|
|
105
|
+
|
|
106
|
+
## 4. Worker-side Smoke (verifying child process)
|
|
107
|
+
|
|
108
|
+
To test the worker role directly:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
PI_TEAMS_WORKER=1 \
|
|
112
|
+
PI_TEAMS_TEAM_ID=test-team \
|
|
113
|
+
PI_TEAMS_AGENT_NAME=agent1 \
|
|
114
|
+
PI_TEAMS_LEAD_NAME=team-lead \
|
|
115
|
+
PI_TEAMS_STYLE=normal \
|
|
116
|
+
pi --no-extensions -e extensions/teams/index.ts --mode rpc
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Expected:** process starts in RPC mode, registers `team_message` tool, polls mailbox.
|
|
120
|
+
|
|
121
|
+
## 5. Checklist Summary
|
|
122
|
+
|
|
123
|
+
| # | Test | Method | Status |
|
|
124
|
+
|---|-------------------------------|------------|--------|
|
|
125
|
+
| 1 | Unit primitives (60 asserts) | Automated | ✅ |
|
|
126
|
+
| 2 | Extension loading | CLI | ✅ |
|
|
127
|
+
| 3 | Interactive spawn/task/dm | Manual | 📋 |
|
|
128
|
+
| 4 | Worker-side RPC | Manual | 📋 |
|
|
129
|
+
|
|
130
|
+
✅ = verified in this run, 📋 = documented for manual execution.
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
|
2
|
+
|
|
3
|
+
// ── Transcript types ──
|
|
4
|
+
|
|
5
|
+
export type TranscriptEntry =
|
|
6
|
+
| { kind: "text"; text: string; timestamp: number }
|
|
7
|
+
| { kind: "tool_start"; toolName: string; timestamp: number }
|
|
8
|
+
| { kind: "tool_end"; toolName: string; durationMs: number; timestamp: number }
|
|
9
|
+
| { kind: "turn_end"; turnNumber: number; tokens: number; timestamp: number };
|
|
10
|
+
|
|
11
|
+
const MAX_TRANSCRIPT = 200;
|
|
12
|
+
|
|
13
|
+
export class TranscriptLog {
|
|
14
|
+
private entries: TranscriptEntry[] = [];
|
|
15
|
+
|
|
16
|
+
push(entry: TranscriptEntry): void {
|
|
17
|
+
this.entries.push(entry);
|
|
18
|
+
if (this.entries.length > MAX_TRANSCRIPT) {
|
|
19
|
+
this.entries.splice(0, this.entries.length - MAX_TRANSCRIPT);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getEntries(): readonly TranscriptEntry[] {
|
|
24
|
+
return this.entries;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get length(): number {
|
|
28
|
+
return this.entries.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
reset(): void {
|
|
32
|
+
this.entries = [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TranscriptTracker {
|
|
37
|
+
private logs = new Map<string, TranscriptLog>();
|
|
38
|
+
private toolStarts = new Map<string, Map<string, number>>(); // name -> toolCallId -> startTimestamp
|
|
39
|
+
private pendingText = new Map<string, string>(); // name -> accumulated text
|
|
40
|
+
private turnCounts = new Map<string, number>();
|
|
41
|
+
private lastTokens = new Map<string, number>(); // name -> tokens from last message_end
|
|
42
|
+
|
|
43
|
+
handleEvent(name: string, ev: AgentEvent): void {
|
|
44
|
+
const log = this.getOrCreate(name);
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
|
|
47
|
+
if (ev.type === "message_update") {
|
|
48
|
+
const ame = ev.assistantMessageEvent;
|
|
49
|
+
if (ame.type === "text_delta") {
|
|
50
|
+
const cur = this.pendingText.get(name) ?? "";
|
|
51
|
+
this.pendingText.set(name, cur + ame.delta);
|
|
52
|
+
// Flush complete lines
|
|
53
|
+
this.flushText(name, log, now, false);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (ev.type === "tool_execution_start") {
|
|
59
|
+
// Flush any pending text before a tool starts
|
|
60
|
+
this.flushText(name, log, now, true);
|
|
61
|
+
const starts = this.toolStarts.get(name) ?? new Map<string, number>();
|
|
62
|
+
starts.set(ev.toolCallId, now);
|
|
63
|
+
this.toolStarts.set(name, starts);
|
|
64
|
+
log.push({ kind: "tool_start", toolName: ev.toolName, timestamp: now });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (ev.type === "tool_execution_end") {
|
|
69
|
+
const starts = this.toolStarts.get(name);
|
|
70
|
+
const startTs = starts?.get(ev.toolCallId);
|
|
71
|
+
const durationMs = startTs != null ? now - startTs : 0;
|
|
72
|
+
starts?.delete(ev.toolCallId);
|
|
73
|
+
log.push({ kind: "tool_end", toolName: ev.toolName, durationMs, timestamp: now });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (ev.type === "message_end") {
|
|
78
|
+
// Capture tokens for use in the turn_end entry
|
|
79
|
+
const msg: unknown = ev.message;
|
|
80
|
+
if (isRecord(msg)) {
|
|
81
|
+
const usage = msg.usage;
|
|
82
|
+
if (isRecord(usage) && typeof usage.totalTokens === "number") {
|
|
83
|
+
this.lastTokens.set(name, (this.lastTokens.get(name) ?? 0) + usage.totalTokens);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (ev.type === "agent_end") {
|
|
90
|
+
// Flush remaining text
|
|
91
|
+
this.flushText(name, log, now, true);
|
|
92
|
+
const turn = (this.turnCounts.get(name) ?? 0) + 1;
|
|
93
|
+
this.turnCounts.set(name, turn);
|
|
94
|
+
const tokens = this.lastTokens.get(name) ?? 0;
|
|
95
|
+
log.push({ kind: "turn_end", turnNumber: turn, tokens, timestamp: now });
|
|
96
|
+
this.lastTokens.set(name, 0);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get(name: string): TranscriptLog {
|
|
102
|
+
return this.logs.get(name) ?? new TranscriptLog();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
reset(name: string): void {
|
|
106
|
+
this.logs.delete(name);
|
|
107
|
+
this.toolStarts.delete(name);
|
|
108
|
+
this.pendingText.delete(name);
|
|
109
|
+
this.turnCounts.delete(name);
|
|
110
|
+
this.lastTokens.delete(name);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private getOrCreate(name: string): TranscriptLog {
|
|
114
|
+
const existing = this.logs.get(name);
|
|
115
|
+
if (existing) return existing;
|
|
116
|
+
const created = new TranscriptLog();
|
|
117
|
+
this.logs.set(name, created);
|
|
118
|
+
return created;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private flushText(name: string, log: TranscriptLog, timestamp: number, force: boolean): void {
|
|
122
|
+
const buf = this.pendingText.get(name);
|
|
123
|
+
if (!buf) return;
|
|
124
|
+
|
|
125
|
+
// Split into lines; keep the last incomplete line unless forced
|
|
126
|
+
const parts = buf.split("\n");
|
|
127
|
+
if (force) {
|
|
128
|
+
// Flush everything
|
|
129
|
+
for (const part of parts) {
|
|
130
|
+
const trimmed = part.trimEnd();
|
|
131
|
+
if (trimmed) log.push({ kind: "text", text: trimmed, timestamp });
|
|
132
|
+
}
|
|
133
|
+
this.pendingText.delete(name);
|
|
134
|
+
} else if (parts.length > 1) {
|
|
135
|
+
// Flush all complete lines, keep the last (potentially incomplete) part
|
|
136
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
137
|
+
const part = parts[i];
|
|
138
|
+
if (part == null) continue;
|
|
139
|
+
const trimmed = part.trimEnd();
|
|
140
|
+
if (trimmed) log.push({ kind: "text", text: trimmed, timestamp });
|
|
141
|
+
}
|
|
142
|
+
this.pendingText.set(name, parts[parts.length - 1] ?? "");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Activity types ──
|
|
148
|
+
|
|
149
|
+
type TrackedEventType = "tool_execution_start" | "tool_execution_end" | "agent_end" | "message_end";
|
|
150
|
+
|
|
151
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
152
|
+
return typeof v === "object" && v !== null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface TeammateActivity {
|
|
156
|
+
toolUseCount: number;
|
|
157
|
+
currentToolName: string | null;
|
|
158
|
+
lastToolName: string | null;
|
|
159
|
+
turnCount: number;
|
|
160
|
+
totalTokens: number;
|
|
161
|
+
recentEvents: Array<{ type: TrackedEventType; toolName?: string; timestamp: number }>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const MAX_RECENT = 10;
|
|
165
|
+
|
|
166
|
+
function emptyActivity(): TeammateActivity {
|
|
167
|
+
return {
|
|
168
|
+
toolUseCount: 0,
|
|
169
|
+
currentToolName: null,
|
|
170
|
+
lastToolName: null,
|
|
171
|
+
turnCount: 0,
|
|
172
|
+
totalTokens: 0,
|
|
173
|
+
recentEvents: [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class ActivityTracker {
|
|
178
|
+
private data = new Map<string, TeammateActivity>();
|
|
179
|
+
|
|
180
|
+
handleEvent(name: string, ev: AgentEvent): void {
|
|
181
|
+
const a = this.getOrCreate(name);
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
|
|
184
|
+
if (ev.type === "tool_execution_start") {
|
|
185
|
+
a.currentToolName = ev.toolName;
|
|
186
|
+
a.recentEvents.push({ type: ev.type, toolName: ev.toolName, timestamp: now });
|
|
187
|
+
if (a.recentEvents.length > MAX_RECENT) a.recentEvents.shift();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (ev.type === "tool_execution_end") {
|
|
192
|
+
const toolName = a.currentToolName ?? ev.toolName;
|
|
193
|
+
a.toolUseCount++;
|
|
194
|
+
a.lastToolName = toolName;
|
|
195
|
+
a.currentToolName = null;
|
|
196
|
+
a.recentEvents.push({ type: ev.type, toolName, timestamp: now });
|
|
197
|
+
if (a.recentEvents.length > MAX_RECENT) a.recentEvents.shift();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (ev.type === "agent_end") {
|
|
202
|
+
a.turnCount++;
|
|
203
|
+
a.recentEvents.push({ type: ev.type, timestamp: now });
|
|
204
|
+
if (a.recentEvents.length > MAX_RECENT) a.recentEvents.shift();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (ev.type === "message_end") {
|
|
209
|
+
const msg: unknown = ev.message;
|
|
210
|
+
if (!isRecord(msg)) return;
|
|
211
|
+
const usage = msg.usage;
|
|
212
|
+
if (!isRecord(usage)) return;
|
|
213
|
+
const totalTokens = usage.totalTokens;
|
|
214
|
+
if (typeof totalTokens === "number") a.totalTokens += totalTokens;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
get(name: string): TeammateActivity {
|
|
219
|
+
return this.data.get(name) ?? emptyActivity();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
reset(name: string): void {
|
|
223
|
+
this.data.delete(name);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private getOrCreate(name: string): TeammateActivity {
|
|
227
|
+
const existing = this.data.get(name);
|
|
228
|
+
if (existing) return existing;
|
|
229
|
+
|
|
230
|
+
const created = emptyActivity();
|
|
231
|
+
this.data.set(name, created);
|
|
232
|
+
return created;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -4,6 +4,10 @@ function sleep(ms: number): Promise<void> {
|
|
|
4
4
|
return new Promise((r) => setTimeout(r, ms));
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
8
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
export interface LockOptions {
|
|
8
12
|
/** How long to wait to acquire the lock before failing. */
|
|
9
13
|
timeoutMs?: number;
|
|
@@ -18,10 +22,12 @@ export interface LockOptions {
|
|
|
18
22
|
export async function withLock<T>(lockFilePath: string, fn: () => Promise<T>, opts: LockOptions = {}): Promise<T> {
|
|
19
23
|
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
20
24
|
const staleMs = opts.staleMs ?? 60_000;
|
|
21
|
-
const
|
|
25
|
+
const basePollMs = opts.pollMs ?? 50;
|
|
26
|
+
const maxPollMs = Math.max(basePollMs, 1_000);
|
|
22
27
|
const start = Date.now();
|
|
23
28
|
|
|
24
29
|
let fd: number | null = null;
|
|
30
|
+
let attempt = 0;
|
|
25
31
|
|
|
26
32
|
while (fd === null) {
|
|
27
33
|
try {
|
|
@@ -32,8 +38,8 @@ export async function withLock<T>(lockFilePath: string, fn: () => Promise<T>, op
|
|
|
32
38
|
label: opts.label,
|
|
33
39
|
};
|
|
34
40
|
fs.writeFileSync(fd, JSON.stringify(payload));
|
|
35
|
-
} catch (err:
|
|
36
|
-
if (err
|
|
41
|
+
} catch (err: unknown) {
|
|
42
|
+
if (!isErrnoException(err) || err.code !== "EEXIST") throw err;
|
|
37
43
|
|
|
38
44
|
// Stale lock handling
|
|
39
45
|
try {
|
|
@@ -41,16 +47,26 @@ export async function withLock<T>(lockFilePath: string, fn: () => Promise<T>, op
|
|
|
41
47
|
const age = Date.now() - st.mtimeMs;
|
|
42
48
|
if (age > staleMs) {
|
|
43
49
|
fs.unlinkSync(lockFilePath);
|
|
50
|
+
attempt = 0;
|
|
44
51
|
continue;
|
|
45
52
|
}
|
|
46
53
|
} catch {
|
|
47
54
|
// ignore: stat/unlink failures fall through to wait
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
const elapsedMs = Date.now() - start;
|
|
58
|
+
if (elapsedMs > timeoutMs) {
|
|
51
59
|
throw new Error(`Timeout acquiring lock: ${lockFilePath}`);
|
|
52
60
|
}
|
|
53
|
-
|
|
61
|
+
|
|
62
|
+
attempt += 1;
|
|
63
|
+
const expBackoff = Math.min(maxPollMs, basePollMs * 2 ** Math.min(attempt, 6));
|
|
64
|
+
const jitterFactor = 0.5 + Math.random(); // [0.5, 1.5)
|
|
65
|
+
const jitteredBackoff = Math.min(maxPollMs, Math.round(expBackoff * jitterFactor));
|
|
66
|
+
|
|
67
|
+
const remainingMs = timeoutMs - elapsedMs;
|
|
68
|
+
const sleepMs = Math.max(1, Math.min(remainingMs, jitteredBackoff));
|
|
69
|
+
await sleep(sleepMs);
|
|
54
70
|
}
|
|
55
71
|
}
|
|
56
72
|
|