@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.
Files changed (35) hide show
  1. package/.github/workflows/ci.yml +32 -0
  2. package/README.md +36 -6
  3. package/docs/claude-parity.md +16 -14
  4. package/docs/field-notes-teams-setup.md +6 -4
  5. package/docs/smoke-test-plan.md +130 -0
  6. package/extensions/teams/activity-tracker.ts +234 -0
  7. package/extensions/teams/fs-lock.ts +21 -5
  8. package/extensions/teams/leader-inbox.ts +175 -0
  9. package/extensions/teams/leader-info-commands.ts +139 -0
  10. package/extensions/teams/leader-lifecycle-commands.ts +330 -0
  11. package/extensions/teams/leader-messaging-commands.ts +149 -0
  12. package/extensions/teams/leader-plan-commands.ts +96 -0
  13. package/extensions/teams/leader-spawn-command.ts +73 -0
  14. package/extensions/teams/leader-task-commands.ts +417 -0
  15. package/extensions/teams/leader-teams-tool.ts +238 -0
  16. package/extensions/teams/leader.ts +396 -1422
  17. package/extensions/teams/mailbox.ts +54 -29
  18. package/extensions/teams/names.ts +87 -0
  19. package/extensions/teams/protocol.ts +221 -0
  20. package/extensions/teams/task-store.ts +32 -21
  21. package/extensions/teams/team-config.ts +71 -25
  22. package/extensions/teams/teammate-rpc.ts +56 -22
  23. package/extensions/teams/teams-panel.ts +698 -0
  24. package/extensions/teams/teams-style.ts +62 -0
  25. package/extensions/teams/teams-widget.ts +235 -0
  26. package/extensions/teams/worker.ts +100 -138
  27. package/extensions/teams/worktree.ts +4 -7
  28. package/package.json +25 -3
  29. package/scripts/integration-claim-test.mts +227 -0
  30. package/scripts/integration-todo-test.mts +583 -0
  31. package/scripts/smoke-test.mjs +1 -1
  32. package/scripts/smoke-test.mts +424 -0
  33. package/skills/agent-teams/SKILL.md +136 -0
  34. package/tsconfig.strict.json +22 -0
  35. 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
- All commands live under `/team`.
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
- ### Teammates
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 worker |
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 worker, runs a shutdown handshake, verifies cleanup. Sets `PI_TEAMS_ROOT_DIR` to a temp directory so nothing touches `~/.pi/agent/teams`.
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 worker for interactive testing.
192
+ Starts a leader + one tmux window per teammate for interactive testing.
163
193
 
164
194
  ## License
165
195
 
@@ -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 teammates
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 | Teammates can self-claim next unassigned, unblocked task; file locking | ✅ | Implemented: `claimNextAvailableTask()` + locks; enabled by default (`PI_TEAMS_DEFAULT_AUTO_CLAIM=1`). | P0 |
35
- | Explicit assign | Lead assigns task to teammate | ✅ | `/team task assign` sets owner + pings via mailbox. | P0 |
36
- | “Message” vs “broadcast” | Send to one teammate or all teammates | ✅ | `/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 |
37
- | Teammateteammate messaging | Teammates can message each other directly | ✅ | Workers register `team_message` LLM-callable tool; sends via mailbox + CC's leader with `peer_dm_sent` notification. | P1 |
38
- | Display modes | In-process selection (Shift+Up/Down); split panes (tmux/iTerm) | ❌ | Pi has a widget + commands, but no terminal-level teammate navigation/panes. | P2 |
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
+ | Comradecomrade 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 | Teammate 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 |
41
- | Shutdown handshake | Lead requests shutdown; teammate can approve/reject | ✅ | Full protocol: `shutdown_request` → `shutdown_approved` or `shutdown_rejected` (when worker is busy). `/team shutdown <name>` (graceful) + `/team kill` (force). | P1 |
42
- | Cleanup team | “Clean up the team” removes shared resources after teammates stopped | ✅ | `/team cleanup [--force]` deletes only `<teamsRoot>/<teamId>` after safety checks. | P1 |
43
- | Hooks / quality gates | `TeammateIdle`, `TaskCompleted` hooks | ❌ | Add optional hook runner in leader on idle/task-complete events (script execution + exit-code gating). | P2 |
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 teammates are running or there are `in_progress` tasks unless `--force`.
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 teammate interaction UX**
98
+ 10) **Better comrade interaction UX**
97
99
  - Explore whether Pi’s TUI API can support:
98
- - selecting a teammate from the widget
99
- - “entering” a teammate transcript view
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 teammates)” 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.
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 teammate UI like Claude’s split-pane mode.
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 teammates from `team config` and also auto-adds unknown senders on idle notifications (so manual tmux workers feel first-class).
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 teammates).
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 pollMs = opts.pollMs ?? 50;
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: any) {
36
- if (err?.code !== "EEXIST") throw 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
- if (Date.now() - start > timeoutMs) {
57
+ const elapsedMs = Date.now() - start;
58
+ if (elapsedMs > timeoutMs) {
51
59
  throw new Error(`Timeout acquiring lock: ${lockFilePath}`);
52
60
  }
53
- await sleep(pollMs);
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