@tmustier/pi-agent-teams 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,6 +79,7 @@ Or drive it manually:
79
79
  /tw # open the interactive widget panel
80
80
 
81
81
  /team shutdown alice # graceful shutdown (handshake)
82
+ /team shutdown # stop all teammates (leader session remains active)
82
83
  /team cleanup # remove team artifacts when done
83
84
  ```
84
85
 
@@ -105,6 +106,7 @@ Or let the model drive it with the delegate tool:
105
106
  | --- | --- |
106
107
  | `/swarm [task]` | Tell the agent to spawn a team and work on a task |
107
108
  | `/tw` | Open the interactive widget panel |
109
+ | `/team-widget` | Open the interactive widget panel (alias for `/tw`) |
108
110
 
109
111
  ### Team management
110
112
 
@@ -122,7 +124,7 @@ All management commands live under `/team`.
122
124
  | `/team broadcast <msg>` | Message all teammates |
123
125
  | `/team stop <name> [reason]` | Abort current work (resets task to pending) |
124
126
  | `/team shutdown <name> [reason]` | Graceful shutdown (handshake) |
125
- | `/team shutdown` | Shutdown leader + all teammates |
127
+ | `/team shutdown` | Stop all teammates (leader session remains active) |
126
128
  | `/team kill <name>` | Force-terminate |
127
129
  | `/team cleanup [--force]` | Delete team artifacts |
128
130
  | `/team id` | Print team/task-list IDs and paths |
@@ -166,13 +168,22 @@ All management commands live under `/team`.
166
168
 
167
169
  ## Development
168
170
 
171
+ ### Quality gate
172
+
173
+ ```bash
174
+ npm run check
175
+ ```
176
+
177
+ Runs strict TypeScript typechecking (`npm run typecheck`) and ESLint (`npm run lint`).
178
+
169
179
  ### Smoke test (no API keys)
170
180
 
171
181
  ```bash
172
- node scripts/smoke-test.mjs
182
+ npm run smoke-test
183
+ # or: npx tsx scripts/smoke-test.mts
173
184
  ```
174
185
 
175
- Filesystem-level test of the task store, mailbox, and team config.
186
+ Filesystem-level smoke test of the task store, mailbox, team config, and protocol parsers.
176
187
 
177
188
  ### E2E RPC test (spawns pi + one teammate)
178
189
 
@@ -40,11 +40,11 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
40
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
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
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 |
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 |
44
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
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
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 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 |
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 |
48
48
 
49
49
  ## Prioritized roadmap
50
50
 
@@ -66,7 +66,7 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
66
66
  4) **Shutdown handshake** ✅
67
67
  - Full protocol: `shutdown_request` → `shutdown_approved` / `shutdown_rejected`
68
68
  - Worker rejects when busy (streaming + active task), auto-approves when idle
69
- - Leader command: `/team shutdown <name> [reason...]` (graceful), `/team kill` as force
69
+ - Leader command: `/team shutdown <name> [reason...]` (graceful), `/team kill <name>` as force
70
70
 
71
71
  5) **Plan approval** ✅
72
72
  - `/team spawn <name> [fresh|branch] [shared|worktree] plan` sets `PI_TEAMS_PLAN_REQUIRED=1`
@@ -109,7 +109,9 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
109
109
 
110
110
  ## Where changes would land (code map)
111
111
 
112
- - Leader orchestration + commands + tool: `extensions/teams/leader.ts`
112
+ - Leader orchestration: `extensions/teams/leader.ts`
113
+ - Leader `/team` command dispatch: `extensions/teams/leader-team-command.ts`
114
+ - Leader LLM tool (`teams`): `extensions/teams/leader-teams-tool.ts`
113
115
  - Worker mailbox polling + self-claim + protocols: `extensions/teams/worker.ts`
114
116
  - Task store + locking: `extensions/teams/task-store.ts`, `extensions/teams/fs-lock.ts`
115
117
  - Mailbox store + locking: `extensions/teams/mailbox.ts`
@@ -120,5 +122,5 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
120
122
 
121
123
  - Keep tests hermetic by setting `PI_TEAMS_ROOT_DIR` to a temp directory.
122
124
  - Extend:
123
- - `scripts/smoke-test.mjs` for filesystem-only behaviors (deps, claiming, locking)
125
+ - `scripts/smoke-test.mts` (run via `npm run smoke-test`) for filesystem-only behaviors (deps, claiming, locking)
124
126
  - `scripts/e2e-rpc-test.mjs` for protocol flows (shutdown handshake, plan approval)
@@ -12,9 +12,10 @@ Exercises all core primitives directly via `tsx`:
12
12
 
13
13
  ```bash
14
14
  npx tsx scripts/smoke-test.mts
15
+ # or: npm run smoke-test
15
16
  ```
16
17
 
17
- **What it tests** (60 assertions):
18
+ **What it tests** (overview):
18
19
 
19
20
  | Module | Coverage |
20
21
  |------------------|-----------------------------------------------------------------|
@@ -24,10 +25,10 @@ npx tsx scripts/smoke-test.mts
24
25
  | `task-store.ts` | CRUD, `startAssignedTask`, `completeTask`, `claimNextAvailable`,|
25
26
  | | `unassignTasksForAgent`, dependencies, `clearTasks` |
26
27
  | `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 |
28
+ | `protocol.ts` | Structured message parsers (valid + invalid JSON + wrong type) |
29
+ | Pi CLI | `pi --version` executes (skipped in CI if `pi` not on PATH) |
29
30
 
30
- **Expected result:** `PASSED: 60 FAILED: 0`
31
+ **Expected result:** `PASSED: <n> FAILED: 0`
31
32
 
32
33
  ## 2. Extension Loading Test
33
34
 
@@ -103,6 +104,14 @@ Ask the model:
103
104
 
104
105
  **Expected:** agent1 goes offline, widget updates.
105
106
 
107
+ Optional: stop all teammates without ending the leader session:
108
+
109
+ ```
110
+ /team shutdown
111
+ ```
112
+
113
+ **Expected:** all teammates stop; leader remains active until you exit it (e.g. ctrl+d).
114
+
106
115
  ## 4. Worker-side Smoke (verifying child process)
107
116
 
108
117
  To test the worker role directly:
@@ -0,0 +1,74 @@
1
+ // @ts-check
2
+ import eslint from "@eslint/js";
3
+ import tseslint from "typescript-eslint";
4
+
5
+ export default tseslint.config(
6
+ {
7
+ ignores: [
8
+ "node_modules/**",
9
+ "dist/**",
10
+ "build/**",
11
+ "coverage/**",
12
+ ".artifacts/**",
13
+ ".research/**",
14
+ ".resume-sessions/**",
15
+ ],
16
+ },
17
+
18
+ eslint.configs.recommended,
19
+ ...tseslint.configs.recommended,
20
+
21
+ {
22
+ files: ["extensions/**/*.ts", "scripts/**/*.ts", "scripts/**/*.mts"],
23
+ rules: {
24
+ // ━━ Project invariants (AGENTS.md) ━━
25
+ "@typescript-eslint/no-explicit-any": "error",
26
+ "@typescript-eslint/no-non-null-assertion": "error",
27
+ "@typescript-eslint/ban-ts-comment": [
28
+ "error",
29
+ {
30
+ "ts-ignore": true,
31
+ "ts-expect-error": true,
32
+ "ts-nocheck": true,
33
+ "ts-check": false,
34
+ },
35
+ ],
36
+
37
+ // Imports/types
38
+ "@typescript-eslint/consistent-type-imports": [
39
+ "error",
40
+ { prefer: "type-imports", disallowTypeAnnotations: false },
41
+ ],
42
+
43
+ // General correctness
44
+ eqeqeq: ["error", "always"],
45
+
46
+ // Prefer TS-aware unused-vars
47
+ "no-unused-vars": "off",
48
+ "@typescript-eslint/no-unused-vars": [
49
+ "warn",
50
+ {
51
+ argsIgnorePattern: "^_",
52
+ varsIgnorePattern: "^_",
53
+ caughtErrorsIgnorePattern: "^_",
54
+ },
55
+ ],
56
+ },
57
+ },
58
+
59
+ // Scripts/tests: console is expected
60
+ {
61
+ files: ["scripts/**/*.ts", "scripts/**/*.mts"],
62
+ rules: {
63
+ "no-console": "off",
64
+ },
65
+ },
66
+
67
+ // Extension source: discourage stray console.log
68
+ {
69
+ files: ["extensions/**/*.ts"],
70
+ rules: {
71
+ "no-console": "warn",
72
+ },
73
+ },
74
+ );
@@ -68,7 +68,7 @@ export class TranscriptTracker {
68
68
  if (ev.type === "tool_execution_end") {
69
69
  const starts = this.toolStarts.get(name);
70
70
  const startTs = starts?.get(ev.toolCallId);
71
- const durationMs = startTs != null ? now - startTs : 0;
71
+ const durationMs = startTs === undefined ? 0 : now - startTs;
72
72
  starts?.delete(ev.toolCallId);
73
73
  log.push({ kind: "tool_end", toolName: ev.toolName, durationMs, timestamp: now });
74
74
  return;
@@ -135,7 +135,7 @@ export class TranscriptTracker {
135
135
  // Flush all complete lines, keep the last (potentially incomplete) part
136
136
  for (let i = 0; i < parts.length - 1; i++) {
137
137
  const part = parts[i];
138
- if (part == null) continue;
138
+ if (part === undefined) continue;
139
139
  const trimmed = part.trimEnd();
140
140
  if (trimmed) log.push({ kind: "text", text: trimmed, timestamp });
141
141
  }
@@ -144,8 +144,11 @@ export async function handleTeamShutdownCommand(opts: {
144
144
  leadName: string;
145
145
  style: TeamsStyle;
146
146
  getCurrentCtx: () => ExtensionContext | null;
147
+ stopAllTeammates: (ctx: ExtensionContext, reason: string) => Promise<void>;
148
+ refreshTasks: () => Promise<void>;
149
+ renderWidget: () => void;
147
150
  }): Promise<void> {
148
- const { ctx, rest, teammates, leadName, style, getCurrentCtx } = opts;
151
+ const { ctx, rest, teammates, leadName, style, getCurrentCtx, stopAllTeammates, refreshTasks, renderWidget } = opts;
149
152
  const strings = getTeamsStrings(style);
150
153
  const nameRaw = rest[0];
151
154
 
@@ -216,21 +219,32 @@ export async function handleTeamShutdownCommand(opts: {
216
219
  return;
217
220
  }
218
221
 
219
- // /team shutdown (no args) = shutdown leader + all teammates
220
- // Only prompt in interactive TTY mode. In RPC mode, confirm() would require
221
- // the host to send extension_ui_response messages.
222
+ // /team shutdown (no args) = stop all teammates but keep the leader session alive
223
+ if (teammates.size === 0) {
224
+ ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to shut down`, "info");
225
+ return;
226
+ }
227
+
222
228
  if (process.stdout.isTTY && process.stdin.isTTY) {
223
229
  const msg =
224
230
  style === "soviet"
225
- ? `Dissolve the ${strings.teamNoun} and dismiss all ${strings.memberTitle.toLowerCase()}s?`
226
- : "Shutdown this team and stop all teammates?";
227
- const ok = await ctx.ui.confirm("Shutdown", msg);
231
+ ? `Dismiss all ${strings.memberTitle.toLowerCase()}s from the ${strings.teamNoun}?`
232
+ : `Stop all ${String(teammates.size)} teammate${teammates.size === 1 ? "" : "s"}?`;
233
+ const ok = await ctx.ui.confirm("Shutdown team", msg);
228
234
  if (!ok) return;
229
235
  }
230
- // In RPC mode, shutdown is deferred until the next input line is handled.
231
- // Teammates are stopped in the session_shutdown handler.
232
- ctx.ui.notify("Shutdown requested", "info");
233
- ctx.shutdown();
236
+
237
+ const reason =
238
+ style === "soviet"
239
+ ? `The ${strings.teamNoun} is dissolved by the chairman`
240
+ : "Stopped by /team shutdown";
241
+ await stopAllTeammates(ctx, reason);
242
+ await refreshTasks();
243
+ renderWidget();
244
+ ctx.ui.notify(
245
+ `Team ended: all ${strings.memberTitle.toLowerCase()}s stopped (leader session remains active)`,
246
+ "info",
247
+ );
234
248
  }
235
249
 
236
250
  export async function handleTeamStopCommand(opts: {
@@ -244,7 +258,6 @@ export async function handleTeamStopCommand(opts: {
244
258
  renderWidget: () => void;
245
259
  }): Promise<void> {
246
260
  const { ctx, rest, teammates, leadName, style, refreshTasks, getTasks, renderWidget } = opts;
247
- const strings = getTeamsStrings(style);
248
261
 
249
262
  const nameRaw = rest[0];
250
263
  const reason = rest.slice(1).join(" ").trim();
@@ -300,7 +313,7 @@ export async function handleTeamKillCommand(opts: {
300
313
  refreshTasks: () => Promise<void>;
301
314
  renderWidget: () => void;
302
315
  }): Promise<void> {
303
- const { ctx, rest, teammates, taskListId, leadName, style, refreshTasks, renderWidget } = opts;
316
+ const { ctx, rest, teammates, taskListId, leadName: _leadName, style, refreshTasks, renderWidget } = opts;
304
317
  const strings = getTeamsStrings(style);
305
318
 
306
319
  const nameRaw = rest[0];
@@ -71,7 +71,6 @@ export async function handleTeamDmCommand(opts: {
71
71
  style: TeamsStyle;
72
72
  }): Promise<void> {
73
73
  const { ctx, rest, leadName, style } = opts;
74
- const strings = getTeamsStrings(style);
75
74
 
76
75
  const nameRaw = rest[0];
77
76
  const msg = rest.slice(1).join(" ").trim();
@@ -4,7 +4,7 @@ import { sanitizeName } from "./names.js";
4
4
  import { getTeamDir } from "./paths.js";
5
5
  import { TEAM_MAILBOX_NS } from "./protocol.js";
6
6
  import type { TeamsStyle } from "./teams-style.js";
7
- import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
7
+ import { formatMemberDisplayName } from "./teams-style.js";
8
8
 
9
9
  export async function handleTeamPlanCommand(opts: {
10
10
  ctx: ExtensionCommandContext;
@@ -1,32 +1,16 @@
1
- import type { ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
2
  import { pickComradeNames } from "./names.js";
3
3
  import type { TeammateRpc } from "./teammate-rpc.js";
4
4
  import type { TeamsStyle } from "./teams-style.js";
5
5
  import { formatMemberDisplayName, getTeamsStrings, isSovietStyle } from "./teams-style.js";
6
-
7
- export type ContextMode = "fresh" | "branch";
8
- export type WorkspaceMode = "shared" | "worktree";
9
-
10
- export type SpawnTeammateResult =
11
- | {
12
- ok: true;
13
- name: string;
14
- mode: ContextMode;
15
- workspaceMode: WorkspaceMode;
16
- note?: string;
17
- warnings: string[];
18
- }
19
- | { ok: false; error: string };
6
+ import type { ContextMode, WorkspaceMode, SpawnTeammateFn } from "./spawn-types.js";
20
7
 
21
8
  export async function handleTeamSpawnCommand(opts: {
22
9
  ctx: ExtensionCommandContext;
23
10
  rest: string[];
24
11
  teammates: Map<string, TeammateRpc>;
25
12
  style: TeamsStyle;
26
- spawnTeammate: (
27
- ctx: ExtensionContext,
28
- opts: { name: string; mode?: ContextMode; workspaceMode?: WorkspaceMode; planRequired?: boolean },
29
- ) => Promise<SpawnTeammateResult>;
13
+ spawnTeammate: SpawnTeammateFn;
30
14
  }): Promise<void> {
31
15
  const { ctx, rest, teammates, style, spawnTeammate } = opts;
32
16
  const strings = getTeamsStrings(style);
@@ -3,7 +3,7 @@ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
3
3
  import { writeToMailbox } from "./mailbox.js";
4
4
  import { sanitizeName } from "./names.js";
5
5
  import { getTeamDir } from "./paths.js";
6
- import { TEAM_MAILBOX_NS } from "./protocol.js";
6
+ import { taskAssignmentPayload } from "./protocol.js";
7
7
  import {
8
8
  addTaskDependency,
9
9
  clearTasks,
@@ -17,7 +17,16 @@ import {
17
17
  } from "./task-store.js";
18
18
  import { ensureTeamConfig } from "./team-config.js";
19
19
  import type { TeamsStyle } from "./teams-style.js";
20
- import { formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
20
+ import { formatMemberDisplayName } from "./teams-style.js";
21
+
22
+ function parseAssigneePrefix(text: string): { assignee?: string; text: string } {
23
+ const m = text.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/);
24
+ if (!m) return { text };
25
+ const assignee = m[1];
26
+ const rest = m[2];
27
+ if (!assignee || !rest) return { text };
28
+ return { assignee, text: rest };
29
+ }
21
30
 
22
31
  export async function handleTeamTaskCommand(opts: {
23
32
  ctx: ExtensionCommandContext;
@@ -29,8 +38,6 @@ export async function handleTeamTaskCommand(opts: {
29
38
  getTasks: () => TeamTask[];
30
39
  refreshTasks: () => Promise<void>;
31
40
  renderWidget: () => void;
32
- parseAssigneePrefix: (text: string) => { assignee?: string; text: string };
33
- taskAssignmentPayload: (task: TeamTask, assignedBy: string) => unknown;
34
41
  }): Promise<void> {
35
42
  const {
36
43
  ctx,
@@ -42,10 +49,7 @@ export async function handleTeamTaskCommand(opts: {
42
49
  getTasks,
43
50
  refreshTasks,
44
51
  renderWidget,
45
- parseAssigneePrefix,
46
- taskAssignmentPayload,
47
52
  } = opts;
48
- const strings = getTeamsStrings(style);
49
53
 
50
54
  const [taskSub, ...taskRest] = rest;
51
55
  const teamId = ctx.sessionManager.getSessionId();