@tmustier/pi-agent-teams 0.4.0-beta.0 → 0.4.0-beta.1
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/README.md +50 -2
- package/docs/claude-parity.md +44 -39
- package/extensions/teams/hooks.ts +258 -0
- package/extensions/teams/leader-inbox.ts +42 -3
- package/extensions/teams/leader-lifecycle-commands.ts +18 -23
- package/extensions/teams/leader-spawn-command.ts +89 -7
- package/extensions/teams/leader-team-command.ts +1 -1
- package/extensions/teams/leader-teams-tool.ts +22 -0
- package/extensions/teams/leader.ts +150 -6
- package/extensions/teams/paths.ts +10 -0
- package/extensions/teams/spawn-types.ts +19 -4
- package/extensions/teams/teams-panel.ts +4 -4
- package/extensions/teams/teams-style.ts +50 -1
- package/package.json +1 -1
- package/scripts/integration-todo-test.mts +3 -2
- package/scripts/smoke-test.mts +64 -2
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Additional Pi-specific capabilities:
|
|
|
19
19
|
|
|
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
|
+
- **Hooks / quality gates** — optional leader-side hooks on idle / task completion to run scripts (opt-in).
|
|
22
23
|
|
|
23
24
|
## UI style (terminology + naming)
|
|
24
25
|
|
|
@@ -38,7 +39,11 @@ You can add your own styles by creating JSON files under:
|
|
|
38
39
|
|
|
39
40
|
- `~/.pi/agent/teams/_styles/<style>.json`
|
|
40
41
|
|
|
41
|
-
The file can override strings and naming rules.
|
|
42
|
+
The file can override strings and naming rules.
|
|
43
|
+
|
|
44
|
+
Strings include both terminology **and lifecycle copy**, e.g. `killedVerb`, `shutdownRequestedVerb`, `shutdownCompletedVerb`, `shutdownRefusedVerb`, `abortRequestedVerb`, plus templates like `teamEndedAllStopped`.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
42
47
|
|
|
43
48
|
```json
|
|
44
49
|
{
|
|
@@ -113,6 +118,8 @@ Or let the model drive it with the delegate tool:
|
|
|
113
118
|
"action": "delegate",
|
|
114
119
|
"contextMode": "branch",
|
|
115
120
|
"workspaceMode": "worktree",
|
|
121
|
+
"model": "anthropic/claude-sonnet-4",
|
|
122
|
+
"thinking": "high",
|
|
116
123
|
"teammates": ["alice", "bob"],
|
|
117
124
|
"tasks": [
|
|
118
125
|
{ "text": "Fix failing unit tests" },
|
|
@@ -137,7 +144,7 @@ All management commands live under `/team`.
|
|
|
137
144
|
|
|
138
145
|
| Command | Description |
|
|
139
146
|
| --- | --- |
|
|
140
|
-
| `/team spawn <name> [fresh\|branch] [shared\|worktree]` | Start a teammate |
|
|
147
|
+
| `/team spawn <name> [fresh\|branch] [shared\|worktree] [plan] [--model <provider>/<modelId>] [--thinking <level>]` | Start a teammate |
|
|
141
148
|
| `/team list` | List teammates and their status |
|
|
142
149
|
| `/team panel` | Interactive widget panel (same as `/tw`) |
|
|
143
150
|
| `/team style` | Show current style + usage |
|
|
@@ -178,6 +185,10 @@ All management commands live under `/team`.
|
|
|
178
185
|
| `PI_TEAMS_ROOT_DIR` | Storage root (absolute or relative to `~/.pi/agent`) | `~/.pi/agent/teams` |
|
|
179
186
|
| `PI_TEAMS_DEFAULT_AUTO_CLAIM` | Whether spawned teammates auto-claim tasks | `1` (on) |
|
|
180
187
|
| `PI_TEAMS_STYLE` | UI style id (built-in: `normal`, `soviet`, `pirate`, or custom) | `normal` |
|
|
188
|
+
| `PI_TEAMS_HOOKS_ENABLED` | Enable leader-side hooks/quality gates | `0` (off) |
|
|
189
|
+
| `PI_TEAMS_HOOKS_DIR` | Hooks directory (absolute or relative to `PI_TEAMS_ROOT_DIR`) | `<teamsRoot>/_hooks` |
|
|
190
|
+
| `PI_TEAMS_HOOK_TIMEOUT_MS` | Hook execution timeout (ms) | `60000` |
|
|
191
|
+
| `PI_TEAMS_HOOKS_CREATE_TASK_ON_FAILURE` | If `1`, create a follow-up task when a task hook fails | `0` (off) |
|
|
181
192
|
|
|
182
193
|
## Storage layout
|
|
183
194
|
|
|
@@ -191,6 +202,43 @@ All management commands live under `/team`.
|
|
|
191
202
|
<agent>.json # per-agent inbox
|
|
192
203
|
sessions/ # teammate session files
|
|
193
204
|
worktrees/<agent>/ # git worktrees (when enabled)
|
|
205
|
+
|
|
206
|
+
<teamsRoot>/_hooks/
|
|
207
|
+
on_idle.{js,sh} # optional hook (see below)
|
|
208
|
+
on_task_completed.{js,sh} # optional quality gate
|
|
209
|
+
on_task_failed.{js,sh} # optional hook
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Hooks / quality gates (optional)
|
|
213
|
+
|
|
214
|
+
Enable hooks:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
export PI_TEAMS_HOOKS_ENABLED=1
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Then create hook scripts under:
|
|
221
|
+
|
|
222
|
+
- `<teamsRoot>/_hooks/` (default: `~/.pi/agent/teams/_hooks/`)
|
|
223
|
+
|
|
224
|
+
Recognized hook names:
|
|
225
|
+
|
|
226
|
+
- `on_idle.(js|mjs|sh)`
|
|
227
|
+
- `on_task_completed.(js|mjs|sh)`
|
|
228
|
+
- `on_task_failed.(js|mjs|sh)`
|
|
229
|
+
|
|
230
|
+
Hooks run with working directory = the **leader session cwd** and receive context via env vars:
|
|
231
|
+
|
|
232
|
+
- `PI_TEAMS_HOOK_EVENT`
|
|
233
|
+
- `PI_TEAMS_TEAM_ID`, `PI_TEAMS_TEAM_DIR`, `PI_TEAMS_TASK_LIST_ID`
|
|
234
|
+
- `PI_TEAMS_STYLE`
|
|
235
|
+
- `PI_TEAMS_MEMBER`
|
|
236
|
+
- `PI_TEAMS_TASK_ID`, `PI_TEAMS_TASK_SUBJECT`, `PI_TEAMS_TASK_OWNER`, `PI_TEAMS_TASK_STATUS`
|
|
237
|
+
|
|
238
|
+
If you want hook failures to create a follow-up task automatically:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
export PI_TEAMS_HOOKS_CREATE_TASK_ON_FAILURE=1
|
|
194
242
|
```
|
|
195
243
|
|
|
196
244
|
## Development
|
package/docs/claude-parity.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Claude Agent Teams parity roadmap (pi-agent-teams)
|
|
2
2
|
|
|
3
|
-
Last updated: 2026-02-
|
|
3
|
+
Last updated: 2026-02-10
|
|
4
4
|
|
|
5
5
|
This document tracks **feature parity gaps** between:
|
|
6
6
|
|
|
@@ -12,39 +12,50 @@ This document tracks **feature parity gaps** between:
|
|
|
12
12
|
|
|
13
13
|
- `pi-agent-teams` (Pi extension)
|
|
14
14
|
|
|
15
|
-
> Terminology note:
|
|
15
|
+
> Terminology note: this extension supports `PI_TEAMS_STYLE=<style>`.
|
|
16
|
+
> This doc often uses “comrade” as a generic stand-in for “worker/teammate”, but **styles can customize terminology, naming, and lifecycle copy**.
|
|
17
|
+
> Built-ins: `normal`, `soviet`, `pirate`. Custom styles live under `~/.pi/agent/teams/_styles/`.
|
|
16
18
|
|
|
17
19
|
## Scope / philosophy
|
|
18
20
|
|
|
19
21
|
- Target the **same coordination primitives** as Claude Teams:
|
|
20
22
|
- shared task list
|
|
21
23
|
- mailbox messaging
|
|
22
|
-
- long-lived
|
|
24
|
+
- long-lived workers
|
|
23
25
|
- Prefer **inspectable, local-first artifacts** (files + lock files).
|
|
24
26
|
- Avoid guidance that bypasses Claude feature gating; we only document behavior.
|
|
25
27
|
- Accept that some Claude UX (terminal keybindings + split-pane integration) may not be achievable in Pi without deeper TUI/terminal integration.
|
|
26
28
|
|
|
29
|
+
## Pi-specific extras (not Claude parity)
|
|
30
|
+
|
|
31
|
+
These are intentional differences / additions:
|
|
32
|
+
|
|
33
|
+
- **Configurable styles** (`/team style …`) for terminology + naming + lifecycle copy.
|
|
34
|
+
- **Git worktrees** for isolation (`/team spawn <name> … worktree`).
|
|
35
|
+
- **Session branching** (clone leader context into a teammate).
|
|
36
|
+
- A **status widget + interactive panel** (`/tw`, `/team panel`).
|
|
37
|
+
|
|
27
38
|
## Parity matrix (docs-oriented)
|
|
28
39
|
|
|
29
40
|
Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
30
41
|
|
|
31
42
|
| Area | Claude docs behavior | Pi Teams status | Notes / next step | Priority |
|
|
32
43
|
| --- | --- | --- | --- | --- |
|
|
33
|
-
| Enablement | `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` + settings | N/A | Pi extension is
|
|
34
|
-
| Team config | `~/.claude/teams/<team>/config.json` w/ members | ✅ |
|
|
44
|
+
| Enablement | `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` + settings | N/A | Pi extension is available when installed/loaded. | — |
|
|
45
|
+
| Team config | `~/.claude/teams/<team>/config.json` w/ members | ✅ | `extensions/teams/team-config.ts` (under `~/.pi/agent/teams/...` or `PI_TEAMS_ROOT_DIR`). | P0 |
|
|
35
46
|
| 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 |
|
|
36
|
-
| Self-claim | Comrades
|
|
47
|
+
| Self-claim | Comrades self-claim next unassigned, unblocked task; file locking | ✅ | `claimNextAvailableTask()` + locks; enabled by default (`PI_TEAMS_DEFAULT_AUTO_CLAIM=1`). | P0 |
|
|
37
48
|
| 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.
|
|
39
|
-
| Comrade↔comrade messaging | Comrades
|
|
40
|
-
| Display modes | In-process selection (Shift+Up/Down); split panes (tmux/iTerm) | ❌ | Pi has
|
|
41
|
-
| Delegate mode | Lead restricted to coordination-only tools | ✅ | `/team delegate [on|off]
|
|
42
|
-
| Plan approval | Comrade can be
|
|
43
|
-
| Shutdown handshake | Lead requests shutdown; comrade can approve/reject | ✅ |
|
|
49
|
+
| “Message” vs “broadcast” | Send to one comrade or all comrades | ✅ | `/team dm` + `/team broadcast` use mailbox; `/team send` uses RPC. Recipients = config workers + RPC map + active task owners. | P0 |
|
|
50
|
+
| Comrade↔comrade messaging | Comrades message each other directly | ✅ | Worker tool `team_message`; messages via mailbox + CC leader via `peer_dm_sent`. | P1 |
|
|
51
|
+
| Display modes | In-process selection (Shift+Up/Down); split panes (tmux/iTerm) | ❌ | Pi has widget/panel + commands, but no terminal-level comrade navigation/panes. | P2 |
|
|
52
|
+
| Delegate mode | Lead restricted to coordination-only tools | ✅ | `/team delegate [on|off]`; `tool_call` blocks `bash/edit/write`; widget shows `[delegate]`. | P1 |
|
|
53
|
+
| Plan approval | Comrade can be “plan required” and needs lead approval to implement | ✅ | `/team spawn <name> plan` → read-only tools; sends `plan_approval_request`; `/team plan approve|reject`. | P1 |
|
|
54
|
+
| Shutdown handshake | Lead requests shutdown; comrade can approve/reject | ✅ | Protocol: `shutdown_request` → `shutdown_approved` / `shutdown_rejected`. `/team shutdown <name>` (graceful), `/team kill <name>` (SIGTERM). Wording is style-controlled (e.g. “was asked to shut down”, “walked the plank”). | P1 |
|
|
44
55
|
| 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 |
|
|
46
|
-
| Task list UX | Ctrl+T toggle; show all/clear tasks by asking | 🟡 | Widget + `/team task list`
|
|
47
|
-
| Shared task list across sessions | `CLAUDE_CODE_TASK_LIST_ID=...` | ✅ | `PI_TEAMS_TASK_LIST_ID`
|
|
56
|
+
| Hooks / quality gates | `ComradeIdle`, `TaskCompleted` hooks | 🟡 | Optional leader-side hook runner (idle/task-complete/task-fail) via `PI_TEAMS_HOOKS_ENABLED=1` + scripts under `_hooks/`. Still missing richer gating UX + standardized hook contract. | P2 |
|
|
57
|
+
| Task list UX | Ctrl+T toggle; show all/clear tasks by asking | 🟡 | Widget + `/team task list` + `/team task show` + `/team task clear`. No Ctrl+T toggle yet. | P0 |
|
|
58
|
+
| Shared task list across sessions | `CLAUDE_CODE_TASK_LIST_ID=...` | ✅ | Worker env: `PI_TEAMS_TASK_LIST_ID` (manual workers). Leader: `/team task use <taskListId>` (persisted). Newly spawned workers inherit; existing workers need restart. | P1 |
|
|
48
59
|
|
|
49
60
|
## Prioritized roadmap
|
|
50
61
|
|
|
@@ -64,45 +75,38 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
|
64
75
|
### P1 (done): governance + lifecycle parity
|
|
65
76
|
|
|
66
77
|
4) **Shutdown handshake** ✅
|
|
67
|
-
-
|
|
78
|
+
- `shutdown_request` → `shutdown_approved` / `shutdown_rejected`
|
|
68
79
|
- Worker rejects when busy (streaming + active task), auto-approves when idle
|
|
69
|
-
-
|
|
80
|
+
- `/team shutdown <name> [reason...]` (graceful), `/team kill <name>` (force)
|
|
70
81
|
|
|
71
82
|
5) **Plan approval** ✅
|
|
72
|
-
- `/team spawn <name>
|
|
73
|
-
- Worker starts
|
|
74
|
-
-
|
|
75
|
-
- `/team plan approve <name>` → worker gets full tools and proceeds
|
|
76
|
-
- `/team plan reject <name> [feedback...]` → worker revises plan (stays read-only)
|
|
83
|
+
- `/team spawn <name> ... plan` sets `PI_TEAMS_PLAN_REQUIRED=1`
|
|
84
|
+
- Worker starts read-only; submits `plan_approval_request`
|
|
85
|
+
- `/team plan approve|reject <name>`
|
|
77
86
|
|
|
78
87
|
6) **Delegate mode (leader)** ✅
|
|
79
|
-
- `/team delegate [on|off]`
|
|
80
|
-
-
|
|
81
|
-
- Widget shows `[delegate]` indicator
|
|
88
|
+
- `/team delegate [on|off]` (or `PI_TEAMS_DELEGATE_MODE=1`)
|
|
89
|
+
- Blocks `bash/edit/write` while active
|
|
82
90
|
|
|
83
91
|
7) **Cleanup** ✅
|
|
84
92
|
- `/team cleanup [--force]` deletes only `<teamsRoot>/<teamId>` after safety checks.
|
|
85
|
-
- Refuses if RPC comrades are running or there are `in_progress` tasks unless `--force`.
|
|
86
93
|
|
|
87
94
|
8) **Peer-to-peer messaging** ✅
|
|
88
|
-
-
|
|
89
|
-
-
|
|
95
|
+
- Worker tool `team_message`
|
|
96
|
+
- Mailbox transport; leader CC notifications
|
|
90
97
|
|
|
91
98
|
9) **Shared task list across sessions** ✅
|
|
92
|
-
-
|
|
93
|
-
- `/team task use <taskListId>` switches the leader (and newly spawned workers); restart existing workers to pick up changes
|
|
94
|
-
- Task list ID persisted in team config
|
|
99
|
+
- `/team task use <taskListId>` + persisted `config.json`
|
|
95
100
|
|
|
96
101
|
### P2: UX + “product-level” parity
|
|
97
102
|
|
|
98
|
-
10) **
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
- “entering” a comrade transcript view
|
|
102
|
-
- (Optional) tmux integration for split panes.
|
|
103
|
+
10) **Hooks / quality gates** 🟡 (partial)
|
|
104
|
+
- Implemented: optional leader-side hook runner (opt-in + timeout + logs).
|
|
105
|
+
- Still missing: richer gating UX (e.g. surfacing hook failures inline, controlling whether failures reopen tasks / block future work).
|
|
103
106
|
|
|
104
|
-
11) **
|
|
105
|
-
-
|
|
107
|
+
11) **Better comrade interaction UX (within Pi constraints)**
|
|
108
|
+
- Improve the existing widget/panel affordances (selection, transcript view, actions)
|
|
109
|
+
- (Optional) tmux integration for split panes.
|
|
106
110
|
|
|
107
111
|
12) **Join/attach flow**
|
|
108
112
|
- Allow a running session to attach to an existing team (discover + approve join).
|
|
@@ -116,11 +120,12 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
|
116
120
|
- Task store + locking: `extensions/teams/task-store.ts`, `extensions/teams/fs-lock.ts`
|
|
117
121
|
- Mailbox store + locking: `extensions/teams/mailbox.ts`
|
|
118
122
|
- Team config: `extensions/teams/team-config.ts`
|
|
123
|
+
- Styles + naming: `extensions/teams/teams-style.ts`
|
|
119
124
|
- Optional workspace isolation: `extensions/teams/worktree.ts`
|
|
120
125
|
|
|
121
126
|
## Testing strategy
|
|
122
127
|
|
|
123
128
|
- Keep tests hermetic by setting `PI_TEAMS_ROOT_DIR` to a temp directory.
|
|
124
129
|
- Extend:
|
|
125
|
-
- `scripts/smoke-test.mts` (run via `npm run smoke-test`) for filesystem-only behaviors
|
|
130
|
+
- `scripts/smoke-test.mts` (run via `npm run smoke-test`) for filesystem-only behaviors
|
|
126
131
|
- `scripts/e2e-rpc-test.mjs` for protocol flows (shutdown handshake, plan approval)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { getTeamsHooksDir } from "./paths.js";
|
|
5
|
+
import type { TeamTask } from "./task-store.js";
|
|
6
|
+
|
|
7
|
+
export type TeamsHookEvent = "idle" | "task_completed" | "task_failed";
|
|
8
|
+
|
|
9
|
+
export type TeamsHookInvocation = {
|
|
10
|
+
event: TeamsHookEvent;
|
|
11
|
+
teamId: string;
|
|
12
|
+
teamDir: string;
|
|
13
|
+
taskListId: string;
|
|
14
|
+
style: string;
|
|
15
|
+
memberName?: string;
|
|
16
|
+
timestamp?: string;
|
|
17
|
+
completedTask?: TeamTask | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type TeamsHookRunResult = {
|
|
21
|
+
ran: boolean;
|
|
22
|
+
hookPath?: string;
|
|
23
|
+
command?: readonly string[];
|
|
24
|
+
exitCode: number | null;
|
|
25
|
+
timedOut: boolean;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
33
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isExecutable(st: fs.Stats): boolean {
|
|
37
|
+
// Owner/group/other execute bit.
|
|
38
|
+
return (st.mode & 0o111) !== 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function trimOutput(s: string, limit = 12_000): string {
|
|
42
|
+
if (s.length <= limit) return s;
|
|
43
|
+
return s.slice(0, limit) + `\n… (truncated, ${s.length - limit} bytes omitted)`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
47
|
+
const raw = env.PI_TEAMS_HOOK_TIMEOUT_MS;
|
|
48
|
+
if (!raw) return 60_000;
|
|
49
|
+
const n = Number.parseInt(raw, 10);
|
|
50
|
+
return Number.isFinite(n) && n > 0 ? n : 60_000;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function areTeamsHooksEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
54
|
+
return env.PI_TEAMS_HOOKS_ENABLED === "1";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getHookBaseName(event: TeamsHookEvent): string {
|
|
58
|
+
switch (event) {
|
|
59
|
+
case "idle":
|
|
60
|
+
return "on_idle";
|
|
61
|
+
case "task_completed":
|
|
62
|
+
return "on_task_completed";
|
|
63
|
+
case "task_failed":
|
|
64
|
+
return "on_task_failed";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type HookCommand = { cmd: string; args: string[]; hookPath: string; display: readonly string[] };
|
|
69
|
+
|
|
70
|
+
function resolveHookCommand(hooksDir: string, event: TeamsHookEvent): HookCommand | null {
|
|
71
|
+
const base = getHookBaseName(event);
|
|
72
|
+
const candidates = [
|
|
73
|
+
path.join(hooksDir, base),
|
|
74
|
+
path.join(hooksDir, `${base}.sh`),
|
|
75
|
+
path.join(hooksDir, `${base}.js`),
|
|
76
|
+
path.join(hooksDir, `${base}.mjs`),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
for (const file of candidates) {
|
|
80
|
+
try {
|
|
81
|
+
if (!fs.existsSync(file)) continue;
|
|
82
|
+
const st = fs.statSync(file);
|
|
83
|
+
if (!st.isFile()) continue;
|
|
84
|
+
|
|
85
|
+
const ext = path.extname(file).toLowerCase();
|
|
86
|
+
if (ext === ".js" || ext === ".mjs") {
|
|
87
|
+
return { cmd: "node", args: [file], hookPath: file, display: ["node", file] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (ext === ".sh") {
|
|
91
|
+
return { cmd: "bash", args: [file], hookPath: file, display: ["bash", file] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (isExecutable(st)) {
|
|
95
|
+
return { cmd: file, args: [], hookPath: file, display: [file] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Non-executable with unknown extension: ignore.
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function runWithTimeout(opts: {
|
|
108
|
+
cmd: string;
|
|
109
|
+
args: readonly string[];
|
|
110
|
+
cwd: string;
|
|
111
|
+
env: NodeJS.ProcessEnv;
|
|
112
|
+
timeoutMs: number;
|
|
113
|
+
}): Promise<{ exitCode: number | null; timedOut: boolean; stdout: string; stderr: string; error?: string }> {
|
|
114
|
+
return await new Promise((resolve) => {
|
|
115
|
+
let stdout = "";
|
|
116
|
+
let stderr = "";
|
|
117
|
+
let timedOut = false;
|
|
118
|
+
|
|
119
|
+
const child = spawn(opts.cmd, [...opts.args], {
|
|
120
|
+
cwd: opts.cwd,
|
|
121
|
+
env: opts.env,
|
|
122
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
126
|
+
stdout += d.toString("utf8");
|
|
127
|
+
});
|
|
128
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
129
|
+
stderr += d.toString("utf8");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const timeout = setTimeout(() => {
|
|
133
|
+
timedOut = true;
|
|
134
|
+
try {
|
|
135
|
+
child.kill("SIGTERM");
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
setTimeout(() => {
|
|
140
|
+
try {
|
|
141
|
+
child.kill("SIGKILL");
|
|
142
|
+
} catch {
|
|
143
|
+
// ignore
|
|
144
|
+
}
|
|
145
|
+
}, 1000);
|
|
146
|
+
}, opts.timeoutMs);
|
|
147
|
+
|
|
148
|
+
child.on("close", (code) => {
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
resolve({
|
|
151
|
+
exitCode: code,
|
|
152
|
+
timedOut,
|
|
153
|
+
stdout: trimOutput(stdout),
|
|
154
|
+
stderr: trimOutput(stderr),
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
child.on("error", (err: unknown) => {
|
|
159
|
+
clearTimeout(timeout);
|
|
160
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
161
|
+
resolve({ exitCode: null, timedOut: false, stdout: trimOutput(stdout), stderr: trimOutput(stderr), error: msg });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function runTeamsHook(opts: {
|
|
167
|
+
invocation: TeamsHookInvocation;
|
|
168
|
+
cwd: string;
|
|
169
|
+
env?: NodeJS.ProcessEnv;
|
|
170
|
+
}): Promise<TeamsHookRunResult> {
|
|
171
|
+
const env = opts.env ?? process.env;
|
|
172
|
+
if (!areTeamsHooksEnabled(env)) {
|
|
173
|
+
return {
|
|
174
|
+
ran: false,
|
|
175
|
+
exitCode: null,
|
|
176
|
+
timedOut: false,
|
|
177
|
+
durationMs: 0,
|
|
178
|
+
stdout: "",
|
|
179
|
+
stderr: "",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const hooksDir = getTeamsHooksDir();
|
|
184
|
+
const hook = resolveHookCommand(hooksDir, opts.invocation.event);
|
|
185
|
+
if (!hook) {
|
|
186
|
+
return {
|
|
187
|
+
ran: false,
|
|
188
|
+
exitCode: null,
|
|
189
|
+
timedOut: false,
|
|
190
|
+
durationMs: 0,
|
|
191
|
+
stdout: "",
|
|
192
|
+
stderr: "",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const timeoutMs = parseTimeoutMs(env);
|
|
197
|
+
const start = Date.now();
|
|
198
|
+
|
|
199
|
+
const baseEnv: NodeJS.ProcessEnv = {
|
|
200
|
+
...env,
|
|
201
|
+
PI_TEAMS_HOOK_EVENT: opts.invocation.event,
|
|
202
|
+
PI_TEAMS_TEAM_ID: opts.invocation.teamId,
|
|
203
|
+
PI_TEAMS_TEAM_DIR: opts.invocation.teamDir,
|
|
204
|
+
PI_TEAMS_TASK_LIST_ID: opts.invocation.taskListId,
|
|
205
|
+
PI_TEAMS_STYLE: opts.invocation.style,
|
|
206
|
+
...(opts.invocation.memberName ? { PI_TEAMS_MEMBER: opts.invocation.memberName } : {}),
|
|
207
|
+
...(opts.invocation.timestamp ? { PI_TEAMS_EVENT_TIMESTAMP: opts.invocation.timestamp } : {}),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const t = opts.invocation.completedTask;
|
|
211
|
+
const envWithTask: NodeJS.ProcessEnv = {
|
|
212
|
+
...baseEnv,
|
|
213
|
+
...(t?.id ? { PI_TEAMS_TASK_ID: t.id } : {}),
|
|
214
|
+
...(t?.subject ? { PI_TEAMS_TASK_SUBJECT: t.subject } : {}),
|
|
215
|
+
...(t?.owner ? { PI_TEAMS_TASK_OWNER: t.owner } : {}),
|
|
216
|
+
...(t?.status ? { PI_TEAMS_TASK_STATUS: t.status } : {}),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
let res;
|
|
220
|
+
try {
|
|
221
|
+
res = await runWithTimeout({ cmd: hook.cmd, args: hook.args, cwd: opts.cwd, env: envWithTask, timeoutMs });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
224
|
+
return {
|
|
225
|
+
ran: true,
|
|
226
|
+
hookPath: hook.hookPath,
|
|
227
|
+
command: hook.display,
|
|
228
|
+
exitCode: null,
|
|
229
|
+
timedOut: false,
|
|
230
|
+
durationMs: Date.now() - start,
|
|
231
|
+
stdout: "",
|
|
232
|
+
stderr: "",
|
|
233
|
+
error: msg,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
ran: true,
|
|
239
|
+
hookPath: hook.hookPath,
|
|
240
|
+
command: hook.display,
|
|
241
|
+
exitCode: res.exitCode,
|
|
242
|
+
timedOut: res.timedOut,
|
|
243
|
+
durationMs: Date.now() - start,
|
|
244
|
+
stdout: res.stdout,
|
|
245
|
+
stderr: res.stderr,
|
|
246
|
+
error: res.error,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function ensureHooksDirExists(): Promise<void> {
|
|
251
|
+
const dir = getTeamsHooksDir();
|
|
252
|
+
try {
|
|
253
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// ignore permission errors; caller can surface if needed
|
|
256
|
+
if (isErrnoException(err) && err.code === "EACCES") return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
isShutdownRejected,
|
|
11
11
|
} from "./protocol.js";
|
|
12
12
|
import { ensureTeamConfig, setMemberStatus, upsertMember } from "./team-config.js";
|
|
13
|
+
import { getTask } from "./task-store.js";
|
|
13
14
|
|
|
15
|
+
import type { TeamsHookInvocation } from "./hooks.js";
|
|
14
16
|
import type { TeamsStyle } from "./teams-style.js";
|
|
15
17
|
import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
16
18
|
|
|
@@ -22,8 +24,9 @@ export async function pollLeaderInbox(opts: {
|
|
|
22
24
|
leadName: string;
|
|
23
25
|
style: TeamsStyle;
|
|
24
26
|
pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
|
|
27
|
+
enqueueHook?: (invocation: TeamsHookInvocation) => void;
|
|
25
28
|
}): Promise<void> {
|
|
26
|
-
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals } = opts;
|
|
29
|
+
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook } = opts;
|
|
27
30
|
const strings = getTeamsStrings(style);
|
|
28
31
|
|
|
29
32
|
let msgs: Awaited<ReturnType<typeof popUnreadMessages>>;
|
|
@@ -55,7 +58,7 @@ export async function pollLeaderInbox(opts: {
|
|
|
55
58
|
shutdownApprovedAt: approved.timestamp ?? new Date().toISOString(),
|
|
56
59
|
},
|
|
57
60
|
});
|
|
58
|
-
ctx.ui.notify(`${formatMemberDisplayName(style, name)}
|
|
61
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} ${strings.shutdownCompletedVerb}`, "info");
|
|
59
62
|
continue;
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -69,7 +72,7 @@ export async function pollLeaderInbox(opts: {
|
|
|
69
72
|
shutdownRejectedReason: rejected.reason,
|
|
70
73
|
},
|
|
71
74
|
});
|
|
72
|
-
ctx.ui.notify(`${formatMemberDisplayName(style, name)}
|
|
75
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} ${strings.shutdownRefusedVerb}: ${rejected.reason}`, "warning");
|
|
73
76
|
continue;
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -95,6 +98,42 @@ export async function pollLeaderInbox(opts: {
|
|
|
95
98
|
const idle = isIdleNotification(m.text);
|
|
96
99
|
if (idle) {
|
|
97
100
|
const name = sanitizeName(idle.from);
|
|
101
|
+
|
|
102
|
+
// Hook: always emit "idle" (best-effort, non-blocking)
|
|
103
|
+
try {
|
|
104
|
+
enqueueHook?.({
|
|
105
|
+
event: "idle",
|
|
106
|
+
teamId,
|
|
107
|
+
teamDir,
|
|
108
|
+
taskListId,
|
|
109
|
+
style,
|
|
110
|
+
memberName: name,
|
|
111
|
+
timestamp: idle.timestamp,
|
|
112
|
+
completedTask: null,
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
// ignore hook enqueue errors
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Hook: task completion / failure
|
|
119
|
+
if (idle.completedTaskId) {
|
|
120
|
+
const completedTask = await getTask(teamDir, taskListId, idle.completedTaskId);
|
|
121
|
+
try {
|
|
122
|
+
enqueueHook?.({
|
|
123
|
+
event: idle.completedStatus === "failed" ? "task_failed" : "task_completed",
|
|
124
|
+
teamId,
|
|
125
|
+
teamDir,
|
|
126
|
+
taskListId,
|
|
127
|
+
style,
|
|
128
|
+
memberName: name,
|
|
129
|
+
timestamp: idle.timestamp,
|
|
130
|
+
completedTask,
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore hook enqueue errors
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
98
137
|
if (idle.failureReason) {
|
|
99
138
|
const cfg = await ensureTeamConfig(teamDir, {
|
|
100
139
|
teamId,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
listAvailableTeamsStyles,
|
|
17
17
|
normalizeTeamsStyleId,
|
|
18
18
|
resolveTeamsStyleDefinition,
|
|
19
|
+
formatTeamsTemplate,
|
|
19
20
|
} from "./teams-style.js";
|
|
20
21
|
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
21
22
|
|
|
@@ -101,20 +102,11 @@ export async function handleTeamStyleCommand(opts: {
|
|
|
101
102
|
const file = path.join(dir, `${styleId}.json`);
|
|
102
103
|
try {
|
|
103
104
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
105
|
+
const base = resolveTeamsStyleDefinition(extendsId);
|
|
104
106
|
const template = {
|
|
105
107
|
extends: extendsId,
|
|
106
|
-
strings:
|
|
107
|
-
|
|
108
|
-
memberPrefix: "Member ",
|
|
109
|
-
},
|
|
110
|
-
naming: {
|
|
111
|
-
requireExplicitSpawnName: false,
|
|
112
|
-
autoNameStrategy: {
|
|
113
|
-
kind: "pool",
|
|
114
|
-
pool: ["member1", "member2"],
|
|
115
|
-
fallbackBase: "member",
|
|
116
|
-
},
|
|
117
|
-
},
|
|
108
|
+
strings: base.strings,
|
|
109
|
+
naming: base.naming,
|
|
118
110
|
};
|
|
119
111
|
await fs.promises.writeFile(file, JSON.stringify(template, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
|
|
120
112
|
} catch (err) {
|
|
@@ -295,7 +287,7 @@ export async function handleTeamShutdownCommand(opts: {
|
|
|
295
287
|
},
|
|
296
288
|
});
|
|
297
289
|
|
|
298
|
-
ctx.ui.notify(
|
|
290
|
+
ctx.ui.notify(`${formatMemberDisplayName(style, name)} ${strings.shutdownRequestedVerb}`, "info");
|
|
299
291
|
|
|
300
292
|
// Optional fallback for RPC teammates: force stop if it doesn't exit.
|
|
301
293
|
const t = teammates.get(name);
|
|
@@ -333,13 +325,18 @@ export async function handleTeamShutdownCommand(opts: {
|
|
|
333
325
|
for (const m of cfgWorkersOnline) activeNames.add(m.name);
|
|
334
326
|
|
|
335
327
|
if (activeNames.size === 0) {
|
|
336
|
-
|
|
328
|
+
const members = `${strings.memberTitle.toLowerCase()}s`;
|
|
329
|
+
ctx.ui.notify(formatTeamsTemplate(strings.noMembersToShutdown, { members, count: "0" }), "info");
|
|
337
330
|
return;
|
|
338
331
|
}
|
|
339
332
|
|
|
340
333
|
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
341
334
|
const plural = activeNames.size === 1 ? "" : "s";
|
|
342
|
-
const
|
|
335
|
+
const members = `${strings.memberTitle.toLowerCase()}${plural}`;
|
|
336
|
+
const msg = formatTeamsTemplate(strings.shutdownAllPrompt, {
|
|
337
|
+
count: String(activeNames.size),
|
|
338
|
+
members,
|
|
339
|
+
});
|
|
343
340
|
const ok = await ctx.ui.confirm("Shutdown team", msg);
|
|
344
341
|
if (!ok) return;
|
|
345
342
|
}
|
|
@@ -391,10 +388,8 @@ export async function handleTeamShutdownCommand(opts: {
|
|
|
391
388
|
}
|
|
392
389
|
|
|
393
390
|
renderWidget();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
"info",
|
|
397
|
-
);
|
|
391
|
+
const members = `${strings.memberTitle.toLowerCase()}s`;
|
|
392
|
+
ctx.ui.notify(formatTeamsTemplate(strings.teamEndedAllStopped, { members, count: String(activeNames.size) }), "info");
|
|
398
393
|
}
|
|
399
394
|
|
|
400
395
|
export async function handleTeamPruneCommand(opts: {
|
|
@@ -517,10 +512,10 @@ export async function handleTeamStopCommand(opts: {
|
|
|
517
512
|
await t.abort();
|
|
518
513
|
}
|
|
519
514
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
);
|
|
515
|
+
let msg = `${formatMemberDisplayName(style, name)} ${getTeamsStrings(style).abortRequestedVerb}`;
|
|
516
|
+
if (taskId) msg += ` (task #${taskId})`;
|
|
517
|
+
if (!t) msg += " (mailbox only)";
|
|
518
|
+
ctx.ui.notify(msg, "warning");
|
|
524
519
|
renderWidget();
|
|
525
520
|
}
|
|
526
521
|
|