@fradser/pi-agent-teams 0.5.3 → 0.6.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/src/worker.ts CHANGED
@@ -1,59 +1,197 @@
1
+ /**
2
+ * Teammate capabilities. A single addressed send_message primitive covers
3
+ * leader reports and peer mail. Task-list registration is shared by leader
4
+ * and worker processes; claiming and submitting remain worker-only.
5
+ */
6
+
1
7
  import { randomUUID } from "node:crypto";
8
+ import * as path from "node:path";
2
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { appendWorkerEvent } from "./statefile";
4
- import { TeammateMessageParams, type WorkerEvent } from "./types";
10
+ import { livingTeammates, listTasks } from "./state.ts";
11
+ import { appendInboxMessage, appendWorkerEvent, createTaskIntent, readBoardFile, readRoster } from "./statefile.ts";
12
+ import {
13
+ LEADER_RECIPIENT,
14
+ messageTitle,
15
+ SendMessageParams,
16
+ TaskClaimParams,
17
+ TaskListParams,
18
+ TaskSubmitParams,
19
+ type BoardTask,
20
+ } from "./types.ts";
5
21
 
6
22
  export interface WorkerBinding {
7
23
  worker: string;
8
- taskId: string;
9
24
  spawnId: string;
10
25
  outbox: string;
26
+ inbox: string;
27
+ rosterFile: string;
28
+ boardFile: string;
29
+ claimsDir: string;
30
+ submissionsDir: string;
31
+ }
32
+
33
+ const REQUIRED_ENV = [
34
+ "PI_TEAMMATE_WORKER_NAME",
35
+ "PI_TEAMMATE_SPAWN_ID",
36
+ "PI_TEAMMATE_OUTBOX_FILE",
37
+ "PI_TEAMMATE_INBOX_FILE",
38
+ "PI_TEAMMATE_ROSTER_FILE",
39
+ "PI_TEAMMATE_BOARD_FILE",
40
+ "PI_TEAMMATE_CLAIMS_DIR",
41
+ "PI_TEAMMATE_SUBMISSIONS_DIR",
42
+ ] as const;
43
+
44
+ export function workerBinding(): WorkerBinding | undefined {
45
+ const values = REQUIRED_ENV.map((name) => process.env[name]);
46
+ if (values.some((value) => !value)) return undefined;
47
+ const [worker, spawnId, outbox, inbox, rosterFile, boardFile, claimsDir, submissionsDir] = values as string[];
48
+ return { worker, spawnId, outbox, inbox, rosterFile, boardFile, claimsDir, submissionsDir };
49
+ }
50
+
51
+ function livingRecipients(binding: WorkerBinding): Set<string> {
52
+ return new Set(readRoster(binding.rosterFile)
53
+ .filter((entry) => entry.status !== "stopped")
54
+ .map((entry) => entry.name));
55
+ }
56
+
57
+ function loadBoardTasks(binding: WorkerBinding): BoardTask[] {
58
+ const board = readBoardFile(binding.boardFile);
59
+ return Object.values(board?.tasks ?? {}).sort((a, b) => a.id.localeCompare(b.id));
11
60
  }
12
61
 
13
- export function workerOutboxBinding(): WorkerBinding | undefined {
14
- const worker = process.env.PI_TEAMMATE_WORKER_NAME;
15
- const taskId = process.env.PI_TEAMMATE_TASK_ID;
16
- const spawnId = process.env.PI_TEAMMATE_SPAWN_ID;
17
- const outbox = process.env.PI_TEAMMATE_OUTBOX_FILE;
18
- return worker && taskId && spawnId && outbox ? { worker, taskId, spawnId, outbox } : undefined;
62
+ function dependenciesMet(task: BoardTask, tasks: Map<string, BoardTask>): boolean {
63
+ return task.dependsOn.every((dep) => tasks.get(dep)?.status === "completed");
19
64
  }
20
65
 
21
- export function isWorkerEvent(value: unknown): value is WorkerEvent {
22
- if (!value || typeof value !== "object") return false;
23
- const event = value as Partial<WorkerEvent>;
24
- return typeof event.id === "string"
25
- && event.type === "message"
26
- && typeof event.worker === "string"
27
- && typeof event.spawnId === "string"
28
- && typeof event.subject === "string"
29
- && typeof event.body === "string"
30
- && (event.status === undefined || ["in_progress", "completed", "failed"].includes(event.status))
31
- && (event.data === undefined || typeof event.data === "object")
32
- ;
66
+ export function renderTaskBoard(tasks: BoardTask[]): string {
67
+ if (tasks.length === 0) return "(the task board is empty)";
68
+ const byId = new Map(tasks.map((task) => [task.id, task]));
69
+ return tasks.map((task) => {
70
+ const deps = task.dependsOn.length > 0
71
+ ? ` | depends on ${task.dependsOn.join(", ")} (${dependenciesMet(task, byId) ? "met" : "unmet"})`
72
+ : "";
73
+ const holder = task.claimedBy ? ` by @${task.claimedBy}` : "";
74
+ const verify = task.verify ? ` | verify: ${task.verify}` : "";
75
+ const description = task.description ? `\n ${task.description.slice(0, 2000)}` : "";
76
+ return `- [${task.id}] ${task.status}${holder}: ${task.subject}${deps}${verify}${description}`;
77
+ }).join("\n");
78
+ }
79
+
80
+ /** One registration shared by leader and worker; binding chooses the data source. */
81
+ export function registerTaskListTool(pi: ExtensionAPI): void {
82
+ pi.registerTool({
83
+ name: "task_list",
84
+ promptSnippet: "Read the shared task board",
85
+ label: "Task Board",
86
+ description: "Read board tasks with status, dependencies, and verify gates. Pending tasks with met dependencies are claimable.",
87
+ parameters: TaskListParams,
88
+ async execute() {
89
+ const binding = workerBinding();
90
+ const tasks = binding ? loadBoardTasks(binding) : listTasks();
91
+ // Both sides get a roster tail: peer discovery is zero-cost for workers.
92
+ const roster = binding
93
+ ? readRoster(binding.rosterFile)
94
+ .filter((entry) => entry.status !== "stopped")
95
+ .map((entry) => `@${entry.name} (${entry.agent}, ${entry.status})`)
96
+ .join("\n")
97
+ : livingTeammates().map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
98
+ return { content: [{ type: "text", text: `${renderTaskBoard(tasks)}\n\nRoster:\n${roster || "(none)"}` }], details: {} };
99
+ },
100
+ });
33
101
  }
34
102
 
35
103
  export function registerWorkerCapabilities(pi: ExtensionAPI): void {
36
104
  pi.registerTool({
37
- name: "teammate_message",
38
- promptSnippet: "Report progress or a final deliverable to the team leader",
39
- label: "Teammate Report",
40
- description: "Worker-only report channel. Send progress, blockers, or the final deliverable to the team leader. Set status=\"completed\" or \"failed\" for the terminal report.",
41
- parameters: TeammateMessageParams,
105
+ name: "send_message",
106
+ promptSnippet: "Send a message to the leader or a teammate",
107
+ label: "Send Message",
108
+ description: "The only messaging primitive. Use to=\"leader\" for reports; use a teammate name for direct peer mail. status is valid only for leader reports.",
109
+ parameters: SendMessageParams,
42
110
  async execute(_toolCallId, params) {
43
- const binding = workerOutboxBinding();
111
+ const binding = workerBinding();
44
112
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
45
- appendWorkerEvent(binding.outbox, {
113
+ if (params.to === LEADER_RECIPIENT) {
114
+ appendWorkerEvent(binding.outbox, {
115
+ id: randomUUID(),
116
+ type: "message",
117
+ worker: binding.worker,
118
+ spawnId: binding.spawnId,
119
+ body: params.message,
120
+ status: params.status,
121
+ });
122
+ return { content: [{ type: "text", text: "Queued message to leader." }], details: {} };
123
+ }
124
+ if (params.status) throw new Error('status is valid only when to="leader".');
125
+ if (params.to === binding.worker) throw new Error("You are already the recipient — no need to message yourself.");
126
+ if (!livingRecipients(binding).has(params.to)) {
127
+ throw new Error(`No living teammate named "${params.to}". Check the roster or ask the leader.`);
128
+ }
129
+ const recipientInbox = path.join(path.dirname(binding.inbox), `inbox-${encodeURIComponent(params.to)}.jsonl`);
130
+ appendInboxMessage(recipientInbox, {
46
131
  id: randomUUID(),
47
- type: "message",
132
+ from: binding.worker,
133
+ subject: messageTitle(params.message),
134
+ body: params.message,
135
+ });
136
+ return { content: [{ type: "text", text: `Delivered to @${params.to}'s inbox.` }], details: {} };
137
+ },
138
+ });
139
+
140
+ registerTaskListTool(pi);
141
+
142
+ pi.registerTool({
143
+ name: "task_claim",
144
+ promptSnippet: "Self-claim a pending board task",
145
+ label: "Claim Task",
146
+ description: "Atomically claim a pending task whose dependencies are met. Omit taskId to claim the first claimable task.",
147
+ parameters: TaskClaimParams,
148
+ async execute(_toolCallId, params) {
149
+ const binding = workerBinding();
150
+ if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
151
+ const tasks = loadBoardTasks(binding);
152
+ const byId = new Map(tasks.map((task) => [task.id, task]));
153
+ const candidates = params.taskId
154
+ ? tasks.filter((task) => task.id === params.taskId)
155
+ : tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId));
156
+ if (candidates.length === 0) {
157
+ throw new Error(params.taskId ? `Task "${params.taskId}" was not found on the board.` : "No claimable task right now.");
158
+ }
159
+ for (const task of candidates) {
160
+ if (task.status !== "pending" || !dependenciesMet(task, byId)) continue;
161
+ const won = createTaskIntent(binding.claimsDir, task.id, {
162
+ taskId: task.id,
163
+ worker: binding.worker,
164
+ spawnId: binding.spawnId,
165
+ timestamp: Date.now(),
166
+ });
167
+ if (won) {
168
+ return { content: [{ type: "text", text: `You won [${task.id}] "${task.subject}". Submit with task_submit.` }], details: {} };
169
+ }
170
+ if (params.taskId) throw new Error(`Task "${task.id}" was claimed by someone else first.`);
171
+ }
172
+ throw new Error("All candidate tasks were claimed in the race. Check the board again.");
173
+ },
174
+ });
175
+
176
+ pi.registerTool({
177
+ name: "task_submit",
178
+ promptSnippet: "Submit a claimed task outcome",
179
+ label: "Submit Task",
180
+ description: "Submit a task you claimed. completed runs its verify gate; failed releases the task back to the board.",
181
+ parameters: TaskSubmitParams,
182
+ async execute(_toolCallId, params) {
183
+ const binding = workerBinding();
184
+ if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
185
+ const won = createTaskIntent(binding.submissionsDir, params.taskId, {
186
+ taskId: params.taskId,
48
187
  worker: binding.worker,
49
188
  spawnId: binding.spawnId,
50
- subject: params.subject,
51
- body: params.body,
52
189
  status: params.status,
53
- data: params.data,
190
+ result: params.result,
191
+ timestamp: Date.now(),
54
192
  });
55
- const statusNote = params.status ? ` with status "${params.status}"` : "";
56
- return { content: [{ type: "text", text: `Queued report to team-leader${statusNote}.` }], details: {} };
193
+ if (!won) throw new Error(`A submission for "${params.taskId}" is already pending.`);
194
+ return { content: [{ type: "text", text: `Submitted ${params.status} for [${params.taskId}].` }], details: {} };
57
195
  },
58
196
  });
59
197
  }
package/src/worktree.ts CHANGED
@@ -27,6 +27,8 @@ export interface WorktreeDiff {
27
27
  diffStat: string;
28
28
  }
29
29
 
30
+ export type WorktreeDiffResult = { ok: true; diff: WorktreeDiff } | { ok: false; error: string };
31
+
30
32
  function runGit(cwd: string, args: string[]): { status: number | null; stdout: string; stderr: string } {
31
33
  const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" });
32
34
  return {
@@ -81,14 +83,24 @@ export function createWorktree(
81
83
  * Capture the worker's changes in the worktree as a patch against the base
82
84
  * commit. The worktree is left untouched; call cleanupWorktree afterwards.
83
85
  */
84
- export function captureWorktreeDiff(setup: WorktreeSetup): WorktreeDiff {
85
- execFileSync("git", ["-C", setup.path, "add", "-A"], { stdio: "ignore" });
86
- const stat = runGit(setup.path, ["diff", "--cached", "--stat", setup.baseCommit]);
87
- const patch = runGit(setup.path, ["diff", "--cached", setup.baseCommit]);
88
- return {
89
- patch: patch.stdout ?? "",
90
- diffStat: stat.stdout?.trim() ?? "",
91
- };
86
+ export function captureWorktreeDiff(setup: WorktreeSetup): WorktreeDiffResult {
87
+ try {
88
+ execFileSync("git", ["-C", setup.path, "add", "-A"], { stdio: "ignore" });
89
+ const stat = runGit(setup.path, ["diff", "--cached", "--stat", setup.baseCommit]);
90
+ const patch = runGit(setup.path, ["diff", "--cached", setup.baseCommit]);
91
+ if (stat.status !== 0 || patch.status !== 0) {
92
+ return { ok: false, error: patch.stderr.trim() || stat.stderr.trim() || "git diff failed" };
93
+ }
94
+ return {
95
+ ok: true,
96
+ diff: {
97
+ patch: patch.stdout ?? "",
98
+ diffStat: stat.stdout?.trim() ?? "",
99
+ },
100
+ };
101
+ } catch (error) {
102
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
103
+ }
92
104
  }
93
105
 
94
106
  /**
@@ -1,22 +0,0 @@
1
- ---
2
- name: teammate-autonomous-and-tui
3
- description: Agent Teams (packages/agent-teams) — run-centric: declarative agent files, single-call DAG dispatch, bounded child-process nodes, one-way leader inbox reports, session-wide worker cap, per-spawn identity validation
4
- type: project
5
- ---
6
-
7
- `packages/agent-teams` (0.3.0) is a run-centric multi-agent system: the Pi session is the leader; agents are declarative Markdown files; each run is a dependency-aware task graph dispatched in one call, where each node is a bounded child Pi process and a session-wide cap limits total workers to 8.
8
-
9
- **Why:**
10
- The teammate-registry model (runtime `teammate_register`/`create_task`/`start_task` ceremony, session-scoped identities, read receipts) was replaced after a design review. The ceremony critique: a one-off delegation cost 4 tool calls (list/register/create/start), identities were session-ephemeral so registration cost was paid per session, and both competitor packages (pi-maestro-teammate, @gcoder1991/pi-mesh) proved single-call DAG dispatch + declarative markdown agents. The later simplification removed the remaining half-channels: workers report only to the leader, upstream DAG results replace peer delivery, and live run status is not injected into every system prompt.
11
-
12
- **How to apply:**
13
- 1. **Agents are declarative files** (`src/agents.ts`): frontmatter `name`/`description`/`tools`/`model` (inline `#` comments stripped), body = role prompt. Discovery precedence per name: project `.pi/agents/` > user `~/.pi/agent/agents/` > bundled `agents/`. Bundled: worker/reviewer/specialist/observer.
14
- 2. **Single-call DAG dispatch** (`teammate_run`): `tasks` with `id`/`agent`/`prompt`/`paths`/`access` (default read)/`dependsOn`/`model`/`timeoutMs`, plus `concurrency` (default 4), `worktree`, `background`, `timeoutMs`, `summarize`, and `summaryAgent`. Validates dup ids, unknown dependsOn, cycles, bad paths before any spawn. Root nodes start immediately; `scheduleRun` auto-starts dependents on completion; write nodes with overlapping paths are deferred across all runs in the session unless worktree-isolated (`findSharedWorkspaceWriteConflict`).
15
- 3. **Leader tools (3)**: `teammate_run`, `teammate_cancel` (run or `nodeId` node-level cancel; marks cancelled BEFORE terminating workers; SIGTERM→SIGKILL; console `x` reuses `cancelNodeAndTerminate`), and `teammate_retry` (retries failed/cancelled nodes without re-running completed ones). No leader message tool exists.
16
- 4. **Worker protocol (1 capability tool)**: worker-only `teammate_message` has only `subject`/`body`/optional `status` and always reports to the leader. Per-spawn identity: `PI_TEAMMATE_WORKER_NAME` = `runId:nodeId`, `PI_TEAMMATE_SPAWN_ID` = fresh UUID; `applyWorkerEvents` rejects stale-spawn events. Reports enter one leader inbox; there are no peer mailboxes, worker inboxes, or leader broadcasts. Upstream DAG results are injected into downstream prompts (`=== UPSTREAM HANDOFF ===`).
17
-
18
- 5. **Outcomes authoritative & non-polling leader**: workers message `team-leader` (`teammate_message`) with their deliverables upon completion, delivered automatically as a follow-up turn. The team leader does not sleep while tasks execute. One canonical terminal result per node built by the harness after child close; reported completion + SIGTERM close stays completed; failed node cancels its transitive pending dependents (`cancelBlockedDependents`) and fails the run. Worktree per node (`createWorktree(run.cwd, "${runId}-${nodeId}")`), diff captured for integration review. State snapshots are dirty-gated and worker task temp files are removed after close. Background runs deliver one run-completion follow-up; foreground gather (`background: false`) blocks the tool call (detaches after 5 min so turn is never hung).
19
- 6. **UI**: passive widget (display-only) + `/teammate` full-screen console (owns input via `ctx.ui.custom`, no global interception) — rows are nodes, detail = node lifecycle + leader reports, live activity streams tool calls and thinking deltas, SGR mouse-wheel scrolling. Follows `@packages/btw` style language.
20
- 7. **Testing**: BDD in `features/agent-teams.feature`, package tests in `tests/test_teammate_package.py` including node-eval runtime tests of state.ts/spawner.ts; typecheck via `npx tsc --noEmit --strict ... src/*.ts`.
21
-
22
- **Related:** [[pi-cli-print-json-usage]] [[no-global-input-interception]] [[pi-kitty-csi-u-keys]] [[pi-custom-component-rendering]] [[pi-package-conventions]]