@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 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. Example:
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
@@ -1,6 +1,6 @@
1
1
  # Claude Agent Teams parity roadmap (pi-agent-teams)
2
2
 
3
- Last updated: 2026-02-07
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: the extension supports `PI_TEAMS_STYLE=<style>`. These docs often say "comrade" for parity with Claude Teams, but styles are configurable (built-ins include `normal`, `soviet`, `pirate`).
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 comrades
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 always available when installed/loaded. | — |
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 |
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 can self-claim next unassigned, unblocked task; file locking | ✅ | Implemented: `claimNextAvailableTask()` + locks; enabled by default (`PI_TEAMS_DEFAULT_AUTO_CLAIM=1`). | P0 |
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. 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 |
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 |
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 <name>` (force). | P1 |
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 | | Add optional hook runner in leader on idle/task-complete events (script execution + exit-code gating). | P2 |
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 |
47
- | Shared task list across sessions | `CLAUDE_CODE_TASK_LIST_ID=...` | ✅ | `PI_TEAMS_TASK_LIST_ID` env is **worker-side** (for manually started workers). The leader switches task lists via `/team task use <taskListId>` (persisted in `config.json`). Newly spawned workers inherit the new task list ID; existing workers need a restart to pick up changes. | P1 |
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
- - Full protocol: `shutdown_request` → `shutdown_approved` / `shutdown_rejected`
78
+ - `shutdown_request` → `shutdown_approved` / `shutdown_rejected`
68
79
  - Worker rejects when busy (streaming + active task), auto-approves when idle
69
- - Leader command: `/team shutdown <name> [reason...]` (graceful), `/team kill <name>` as force
80
+ - `/team shutdown <name> [reason...]` (graceful), `/team kill <name>` (force)
70
81
 
71
82
  5) **Plan approval** ✅
72
- - `/team spawn <name> [fresh|branch] [shared|worktree] plan` sets `PI_TEAMS_PLAN_REQUIRED=1`
73
- - Worker starts with read-only tools (`read`, `grep`, `find`, `ls`)
74
- - After first `agent_end`, sends `plan_approval_request` to leader via mailbox
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]` toggle (or `PI_TEAMS_DELEGATE_MODE=1` env)
80
- - `tool_call` hook blocks `bash`, `edit`, `write` when active
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
- - Workers register `team_message` LLM-callable tool (recipient + message params)
89
- - Messages go via mailbox in `team` namespace; leader CC'd with `peer_dm_sent` notification
95
+ - Worker tool `team_message`
96
+ - Mailbox transport; leader CC notifications
90
97
 
91
98
  9) **Shared task list across sessions** ✅
92
- - `PI_TEAMS_TASK_LIST_ID` env on leader + worker sides
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) **Better comrade interaction UX**
99
- - Explore whether Pi’s TUI API can support:
100
- - selecting a comrade from the widget
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) **Hooks / quality gates**
105
- - Support scripts that run on idle/task completion (similar to Claude hooks).
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 (deps, claiming, locking)
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)} shut down`, "info");
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)} refused shutdown: ${rejected.reason}`, "warning");
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
- memberTitle: "Member",
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(`Shutdown requested for ${formatMemberDisplayName(style, name)}`, "info");
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
- ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to shut down`, "info");
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 msg = `Stop all ${String(activeNames.size)} ${strings.memberTitle.toLowerCase()}${plural}?`;
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
- ctx.ui.notify(
395
- `Team ended: all ${strings.memberTitle.toLowerCase()}s stopped (leader session remains active)`,
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
- ctx.ui.notify(
521
- `Abort requested for ${formatMemberDisplayName(style, name)}${taskId ? ` (task #${taskId})` : ""}${t ? "" : " (mailbox only)"}`,
522
- "warning",
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