@tmustier/pi-agent-teams 0.2.0 → 0.3.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.
@@ -17,7 +17,7 @@
17
17
 
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
- import { spawn, spawnSync, type ChildProcess } from "node:child_process";
20
+ import { spawnSync, type ChildProcess } from "node:child_process";
21
21
  import { randomUUID } from "node:crypto";
22
22
  import { fileURLToPath } from "node:url";
23
23
 
@@ -31,9 +31,7 @@ import {
31
31
  type TeamTask,
32
32
  } from "../extensions/teams/task-store.js";
33
33
 
34
- function sleep(ms: number): Promise<void> {
35
- return new Promise((r) => setTimeout(r, ms));
36
- }
34
+ import { sleep, spawnTeamsWorkerRpc, terminateAll } from "./lib/pi-workers.js";
37
35
 
38
36
  function parseArgs(argv: readonly string[]): { timeoutSec: number; pollMs: number } {
39
37
  let timeoutSec = 15 * 60;
@@ -60,30 +58,6 @@ function parseArgs(argv: readonly string[]): { timeoutSec: number; pollMs: numbe
60
58
  return { timeoutSec, pollMs };
61
59
  }
62
60
 
63
- async function terminateAll(children: ChildProcess[]): Promise<void> {
64
- for (const c of children) {
65
- try {
66
- c.kill("SIGTERM");
67
- } catch {
68
- // ignore
69
- }
70
- }
71
-
72
- const deadline = Date.now() + 10_000;
73
- for (const c of children) {
74
- while (c.exitCode === null && Date.now() < deadline) {
75
- await sleep(100);
76
- }
77
- if (c.exitCode === null) {
78
- try {
79
- c.kill("SIGKILL");
80
- } catch {
81
- // ignore
82
- }
83
- }
84
- }
85
- }
86
-
87
61
  function spawnWorker(opts: {
88
62
  cwd: string;
89
63
  repoRoot: string;
@@ -93,17 +67,7 @@ function spawnWorker(opts: {
93
67
  agentName: string;
94
68
  logDir: string;
95
69
  }): ChildProcess {
96
- const { cwd, repoRoot, entryPath, sessionsDir, teamId, agentName, logDir } = opts;
97
-
98
- fs.mkdirSync(logDir, { recursive: true });
99
- fs.mkdirSync(sessionsDir, { recursive: true });
100
-
101
- const sessionFile = path.join(sessionsDir, `${agentName}.jsonl`);
102
- fs.closeSync(fs.openSync(sessionFile, "a"));
103
-
104
- const logPath = path.join(logDir, `${agentName}.log`);
105
- const out = fs.openSync(logPath, "a");
106
- const err = fs.openSync(logPath, "a");
70
+ const { cwd, entryPath, sessionsDir, teamId, agentName, logDir } = opts;
107
71
 
108
72
  const systemAppend = [
109
73
  "You are a teammate in an automated integration test.",
@@ -112,34 +76,19 @@ function spawnWorker(opts: {
112
76
  "Always end with: ACCEPTED: <one-line acceptance confirmation>.",
113
77
  ].join(" ");
114
78
 
115
- const args = [
116
- "--mode",
117
- "rpc",
118
- "--session",
119
- sessionFile,
120
- "--session-dir",
121
- sessionsDir,
122
- "--no-extensions",
123
- "-e",
79
+ return spawnTeamsWorkerRpc({
80
+ cwd,
124
81
  entryPath,
125
- "--append-system-prompt",
82
+ sessionsDir,
83
+ teamId,
84
+ taskListId: teamId,
85
+ agentName,
86
+ leadName: "team-lead",
87
+ style: "normal",
88
+ autoClaim: true,
89
+ planRequired: false,
126
90
  systemAppend,
127
- ];
128
-
129
- return spawn("pi", args, {
130
- cwd,
131
- env: {
132
- ...process.env,
133
- PI_TEAMS_WORKER: "1",
134
- PI_TEAMS_TEAM_ID: teamId,
135
- PI_TEAMS_TASK_LIST_ID: teamId,
136
- PI_TEAMS_AGENT_NAME: agentName,
137
- PI_TEAMS_LEAD_NAME: "team-lead",
138
- PI_TEAMS_STYLE: "normal",
139
- PI_TEAMS_AUTO_CLAIM: "1",
140
- PI_TEAMS_PLAN_REQUIRED: "0",
141
- },
142
- stdio: ["ignore", out, err],
91
+ logDir,
143
92
  });
144
93
  }
145
94
 
@@ -0,0 +1,105 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { spawn, type ChildProcess } from "node:child_process";
4
+
5
+ export function sleep(ms: number): Promise<void> {
6
+ return new Promise((r) => setTimeout(r, ms));
7
+ }
8
+
9
+ export async function terminateAll(children: readonly ChildProcess[]): Promise<void> {
10
+ for (const c of children) {
11
+ try {
12
+ c.kill("SIGTERM");
13
+ } catch {
14
+ // ignore
15
+ }
16
+ }
17
+
18
+ // Give them a moment to flush + exit.
19
+ const deadline = Date.now() + 10_000;
20
+ for (const c of children) {
21
+ while (c.exitCode === null && Date.now() < deadline) {
22
+ await sleep(100);
23
+ }
24
+ if (c.exitCode === null) {
25
+ try {
26
+ c.kill("SIGKILL");
27
+ } catch {
28
+ // ignore
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ export function spawnTeamsWorkerRpc(opts: {
35
+ cwd: string;
36
+ entryPath: string;
37
+ sessionsDir: string;
38
+ teamId: string;
39
+ taskListId: string;
40
+ agentName: string;
41
+ leadName: string;
42
+ style: "normal" | "soviet";
43
+ autoClaim: boolean;
44
+ planRequired: boolean;
45
+ systemAppend: string;
46
+ logDir: string;
47
+ extraEnv?: Record<string, string>;
48
+ }): ChildProcess {
49
+ const {
50
+ cwd,
51
+ entryPath,
52
+ sessionsDir,
53
+ teamId,
54
+ taskListId,
55
+ agentName,
56
+ leadName,
57
+ style,
58
+ autoClaim,
59
+ planRequired,
60
+ systemAppend,
61
+ logDir,
62
+ extraEnv,
63
+ } = opts;
64
+
65
+ fs.mkdirSync(logDir, { recursive: true });
66
+ fs.mkdirSync(sessionsDir, { recursive: true });
67
+
68
+ const sessionFile = path.join(sessionsDir, `${agentName}.jsonl`);
69
+ fs.closeSync(fs.openSync(sessionFile, "a"));
70
+
71
+ const logPath = path.join(logDir, `${agentName}.log`);
72
+ const out = fs.openSync(logPath, "a");
73
+ const err = fs.openSync(logPath, "a");
74
+
75
+ const args = [
76
+ "--mode",
77
+ "rpc",
78
+ "--session",
79
+ sessionFile,
80
+ "--session-dir",
81
+ sessionsDir,
82
+ "--no-extensions",
83
+ "-e",
84
+ entryPath,
85
+ "--append-system-prompt",
86
+ systemAppend,
87
+ ];
88
+
89
+ return spawn("pi", args, {
90
+ cwd,
91
+ env: {
92
+ ...process.env,
93
+ PI_TEAMS_WORKER: "1",
94
+ PI_TEAMS_TEAM_ID: teamId,
95
+ PI_TEAMS_TASK_LIST_ID: taskListId,
96
+ PI_TEAMS_AGENT_NAME: agentName,
97
+ PI_TEAMS_LEAD_NAME: leadName,
98
+ PI_TEAMS_STYLE: style,
99
+ PI_TEAMS_AUTO_CLAIM: autoClaim ? "1" : "0",
100
+ PI_TEAMS_PLAN_REQUIRED: planRequired ? "1" : "0",
101
+ ...(extraEnv ?? {}),
102
+ },
103
+ stdio: ["ignore", out, err],
104
+ });
105
+ }
@@ -95,12 +95,14 @@ Spawning with `plan` restricts the teammate to read-only tools. After producing
95
95
  ```
96
96
  /team panel # interactive overlay with teammate details
97
97
  /team list # show teammates and their state
98
+ /team shutdown # stop all teammates (RPC + best-effort manual) (leader session remains active)
98
99
  /team shutdown <name> # graceful shutdown (teammate can reject if busy)
99
- /team kill # force kill all RPC teammates
100
+ /team prune [--all] # hide stale manual teammates (mark offline in config)
101
+ /team kill <name> # force-terminate one RPC teammate
100
102
  /team cleanup [--force] # delete team directory after all teammates stopped
101
103
  ```
102
104
 
103
- Teammates reject shutdown requests when they have an active task. Use `/team kill` to force.
105
+ Teammates reject shutdown requests when they have an active task. Use `/team kill <name>` to force.
104
106
 
105
107
  ## Other commands
106
108
 
@@ -111,13 +113,15 @@ Teammates reject shutdown requests when they have an active task. Use `/team kil
111
113
 
112
114
  ## Shared task list across sessions
113
115
 
114
- Set `PI_TEAMS_TASK_LIST_ID` env to reuse tasks across team sessions. Or switch mid-session:
116
+ `PI_TEAMS_TASK_LIST_ID` is primarily **worker-side** (use it when you start a teammate manually).
117
+
118
+ The leader switches task lists via:
115
119
 
116
120
  ```
117
121
  /team task use my-persistent-list
118
122
  ```
119
123
 
120
- Teammates spawned after the switch inherit the new task list ID.
124
+ The chosen task list ID is persisted in `config.json`. Teammates spawned after the switch inherit the new task list ID; existing teammates need a restart to pick up changes.
121
125
 
122
126
  ## Message protocol
123
127
 
@@ -1,32 +0,0 @@
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
@@ -1,199 +0,0 @@
1
- import * as assert from "node:assert/strict";
2
- import * as fs from "node:fs";
3
- import * as os from "node:os";
4
- import * as path from "node:path";
5
- import { createRequire } from "node:module";
6
-
7
- const require = createRequire(import.meta.url);
8
- const { createJiti } = require("/opt/homebrew/lib/node_modules/@mariozechner/pi-coding-agent/node_modules/@mariozechner/jiti");
9
-
10
- const jiti = createJiti(import.meta.url, {
11
- interopDefault: true,
12
- });
13
-
14
- const root = process.cwd();
15
- const teamConfig = jiti(path.join(root, "extensions/teams/team-config.ts"));
16
- const mailbox = jiti(path.join(root, "extensions/teams/mailbox.ts"));
17
- const taskStore = jiti(path.join(root, "extensions/teams/task-store.ts"));
18
- const cleanup = jiti(path.join(root, "extensions/teams/cleanup.ts"));
19
-
20
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-teams-smoke-"));
21
- const teamId = "TEAM123";
22
- const taskListId = teamId;
23
- const teamDir = path.join(tmpRoot, "teams", teamId);
24
-
25
- console.log("tmpRoot:", tmpRoot);
26
-
27
- // -----------------------------------------------------------------------------
28
- // Team config
29
- // -----------------------------------------------------------------------------
30
- {
31
- const cfg = await teamConfig.ensureTeamConfig(teamDir, { teamId, taskListId, leadName: "team-lead", style: "normal" });
32
- assert.equal(cfg.teamId, teamId);
33
- assert.equal(cfg.taskListId, taskListId);
34
- assert.equal(cfg.leadName, "team-lead");
35
- assert.ok(cfg.members.find((m) => m.name === "team-lead" && m.role === "lead"));
36
-
37
- await teamConfig.upsertMember(teamDir, { name: "alice", role: "worker", status: "online" });
38
- const cfg2 = await teamConfig.loadTeamConfig(teamDir);
39
- assert.ok(cfg2);
40
- assert.ok(cfg2.members.find((m) => m.name === "alice" && m.status === "online"));
41
-
42
- await teamConfig.setMemberStatus(teamDir, "alice", "offline", { meta: { reason: "test" } });
43
- const cfg3 = await teamConfig.loadTeamConfig(teamDir);
44
- assert.ok(cfg3);
45
- assert.ok(cfg3.members.find((m) => m.name === "alice" && m.status === "offline"));
46
- }
47
-
48
- // -----------------------------------------------------------------------------
49
- // Mailbox
50
- // -----------------------------------------------------------------------------
51
- {
52
- await mailbox.writeToMailbox(teamDir, "team", "team-lead", {
53
- from: "alice",
54
- text: JSON.stringify({ type: "idle_notification", from: "alice", timestamp: new Date().toISOString() }),
55
- timestamp: new Date().toISOString(),
56
- });
57
-
58
- const msgs = await mailbox.popUnreadMessages(teamDir, "team", "team-lead");
59
- assert.equal(msgs.length, 1);
60
- assert.equal(msgs[0].from, "alice");
61
-
62
- const msgs2 = await mailbox.popUnreadMessages(teamDir, "team", "team-lead");
63
- assert.equal(msgs2.length, 0);
64
- }
65
-
66
- // -----------------------------------------------------------------------------
67
- // Task store
68
- // -----------------------------------------------------------------------------
69
- {
70
- const t1 = await taskStore.createTask(teamDir, taskListId, { subject: "Smoke task 1", description: "Do thing" });
71
- assert.equal(t1.id, "1");
72
-
73
- const t2 = await taskStore.createTask(teamDir, taskListId, { subject: "Smoke task 2", description: "Do other" });
74
- assert.equal(t2.id, "2");
75
-
76
- const t3 = await taskStore.createTask(teamDir, taskListId, { subject: "Smoke task 3", description: "Do third" });
77
- assert.equal(t3.id, "3");
78
-
79
- let tasks = await taskStore.listTasks(teamDir, taskListId);
80
- assert.equal(tasks.length, 3);
81
-
82
- // Dependencies: #2 depends on #1 (so #2 is blocked until #1 is completed)
83
- const depRes = await taskStore.addTaskDependency(teamDir, taskListId, "2", "1");
84
- assert.ok(depRes.ok);
85
-
86
- const t1AfterDep = await taskStore.getTask(teamDir, taskListId, "1");
87
- const t2AfterDep = await taskStore.getTask(teamDir, taskListId, "2");
88
- assert.ok(t1AfterDep);
89
- assert.ok(t2AfterDep);
90
- assert.ok(t1AfterDep.blocks.includes("2"));
91
- assert.ok(t2AfterDep.blockedBy.includes("1"));
92
- assert.equal(await taskStore.isTaskBlocked(teamDir, taskListId, t2AfterDep), true);
93
-
94
- const claimed = await taskStore.claimNextAvailableTask(teamDir, taskListId, "alice", { checkAgentBusy: true });
95
- assert.ok(claimed);
96
- assert.equal(claimed.id, "1");
97
- assert.equal(claimed.owner, "alice");
98
- assert.equal(claimed.status, "in_progress");
99
-
100
- // Blocked self-claim: bob should skip #2 (blocked) and claim #3
101
- const claimedBob = await taskStore.claimNextAvailableTask(teamDir, taskListId, "bob", { checkAgentBusy: true });
102
- assert.ok(claimedBob);
103
- assert.equal(claimedBob.id, "3");
104
- assert.equal(claimedBob.owner, "bob");
105
-
106
- // Busy check: should refuse a second claim while alice has in_progress
107
- const claimed2 = await taskStore.claimNextAvailableTask(teamDir, taskListId, "alice", { checkAgentBusy: true });
108
- assert.equal(claimed2, null);
109
-
110
- await taskStore.completeTask(teamDir, taskListId, claimed.id, "alice", "done");
111
-
112
- const t2AfterDepDone = await taskStore.getTask(teamDir, taskListId, "2");
113
- assert.ok(t2AfterDepDone);
114
- assert.equal(await taskStore.isTaskBlocked(teamDir, taskListId, t2AfterDepDone), false);
115
-
116
- // Now alice can claim #2
117
- const claimed3 = await taskStore.claimNextAvailableTask(teamDir, taskListId, "alice", { checkAgentBusy: true });
118
- assert.ok(claimed3);
119
- assert.equal(claimed3.id, "2");
120
-
121
- // Create an additional pending task owned by alice (simulates assigned-but-not-started)
122
- const t4 = await taskStore.createTask(teamDir, taskListId, {
123
- subject: "Smoke task 4",
124
- description: "Do fourth",
125
- owner: "alice",
126
- });
127
- assert.equal(t4.id, "4");
128
- assert.equal(t4.owner, "alice");
129
- assert.equal(t4.status, "pending");
130
-
131
- // Unassign a single task (should only unassign task 2, leaving task 4 assigned)
132
- await taskStore.unassignTask(teamDir, taskListId, claimed3.id, "alice", "test abort", { abortedBy: "alice" });
133
-
134
- tasks = await taskStore.listTasks(teamDir, taskListId);
135
- const t2After = tasks.find((t) => t.id === "2");
136
- assert.ok(t2After);
137
- assert.equal(t2After.owner, undefined);
138
- assert.equal(t2After.status, "pending");
139
-
140
- const t4After = tasks.find((t) => t.id === "4");
141
- assert.ok(t4After);
142
- assert.equal(t4After.owner, "alice");
143
- assert.equal(t4After.status, "pending");
144
-
145
- // Unassign remaining non-completed tasks for alice (should unassign task 4)
146
- const unassignedCount = await taskStore.unassignTasksForAgent(teamDir, taskListId, "alice", "test unassign");
147
- assert.equal(unassignedCount, 1);
148
-
149
- tasks = await taskStore.listTasks(teamDir, taskListId);
150
- const t4After2 = tasks.find((t) => t.id === "4");
151
- assert.ok(t4After2);
152
- assert.equal(t4After2.owner, undefined);
153
- assert.equal(t4After2.status, "pending");
154
-
155
- // Complete bob's task so clear(completed) deletes both #1 and #3
156
- await taskStore.completeTask(teamDir, taskListId, claimedBob.id, "bob", "done");
157
-
158
- // Clear completed tasks (should delete tasks 1 + 3)
159
- const clearedCompleted = await taskStore.clearTasks(teamDir, taskListId, "completed");
160
- assert.equal(clearedCompleted.errors.length, 0);
161
- assert.deepEqual([...clearedCompleted.deletedTaskIds].sort(), ["1", "3"]);
162
-
163
- tasks = await taskStore.listTasks(teamDir, taskListId);
164
- assert.equal(tasks.length, 2);
165
- assert.deepEqual(
166
- tasks.map((t) => t.id).sort(),
167
- ["2", "4"],
168
- );
169
-
170
- // Clear all remaining tasks
171
- const clearedAll = await taskStore.clearTasks(teamDir, taskListId, "all");
172
- assert.equal(clearedAll.errors.length, 0);
173
-
174
- tasks = await taskStore.listTasks(teamDir, taskListId);
175
- assert.equal(tasks.length, 0);
176
- }
177
-
178
- // -----------------------------------------------------------------------------
179
- // Cleanup
180
- // -----------------------------------------------------------------------------
181
- {
182
- const teamsRootDir = path.join(tmpRoot, "teams");
183
-
184
- // Put a sentinel file in the team dir, then delete it.
185
- fs.mkdirSync(teamDir, { recursive: true });
186
- fs.writeFileSync(path.join(teamDir, "sentinel.txt"), "sentinel");
187
-
188
- await cleanup.cleanupTeamDir(teamsRootDir, teamDir);
189
- assert.equal(fs.existsSync(teamDir), false);
190
-
191
- // Refuse unsafe paths
192
- await assert.rejects(
193
- () => cleanup.cleanupTeamDir(teamsRootDir, path.join(tmpRoot, "outside", "TEAM123")),
194
- /Refusing to operate on path outside teams root/,
195
- );
196
- await assert.rejects(() => cleanup.cleanupTeamDir(teamsRootDir, teamsRootDir), /Refusing to operate on path outside teams root/);
197
- }
198
-
199
- console.log("OK: smoke test passed");