@fradser/pi-agent-teams 0.9.2 → 1.0.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.
@@ -1,88 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { writeFileSync } from "node:fs";
3
- import {
4
- buildSessionContext,
5
- SessionManager,
6
- type ExtensionContext,
7
- type SessionContext,
8
- type SessionMessageEntry,
9
- } from "@earendil-works/pi-coding-agent";
10
-
11
- type AgentMessage = SessionContext["messages"][number];
12
- type ContextReader = Pick<ExtensionContext["sessionManager"], "getBranch" | "getLeafId">;
13
- type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
14
- type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
15
- type ToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
16
-
17
- function withoutRuntimeState(message: AgentMessage): AgentMessage {
18
- if (message.role === "custom") {
19
- const { details: _details, ...history } = message;
20
- return history;
21
- }
22
- if (message.role === "toolResult") {
23
- const { details: _details, addedToolNames: _tools, ...history } = message;
24
- return history;
25
- }
26
- return message;
27
- }
28
-
29
- function completedExchanges(messages: AgentMessage[]): { calls: Set<ToolCall>; results: Set<ToolResultMessage> } {
30
- const pending = new Map<string, ToolCall>();
31
- const calls = new Set<ToolCall>();
32
- const results = new Set<ToolResultMessage>();
33
- for (const message of messages) {
34
- if (message.role === "user" || message.role === "assistant") pending.clear();
35
- if (message.role === "assistant") {
36
- for (const block of message.content) {
37
- if (block.type === "toolCall" && !pending.has(block.id)) pending.set(block.id, block);
38
- }
39
- } else if (message.role === "toolResult") {
40
- const call = pending.get(message.toolCallId);
41
- if (!call || call.name !== message.toolName) continue;
42
- calls.add(call);
43
- results.add(message);
44
- pending.delete(call.id);
45
- }
46
- }
47
- return { calls, results };
48
- }
49
-
50
- /** Snapshot the active Pi context without moving the parent's branch or replaying pending tools. */
51
- export function snapshotWorkContext(sessionManager: ContextReader | undefined): AgentMessage[] {
52
- if (!sessionManager || typeof sessionManager.getBranch !== "function" || typeof sessionManager.getLeafId !== "function") {
53
- throw new Error("Fork context unavailable: leader session manager is unavailable.");
54
- }
55
- const messages = buildSessionContext(sessionManager.getBranch(), sessionManager.getLeafId()).messages;
56
- const completed = completedExchanges(messages);
57
- const history: AgentMessage[] = [];
58
- for (const message of messages) {
59
- if (message.role === "toolResult" && !completed.results.has(message)) continue;
60
- if (message.role !== "assistant") {
61
- history.push(withoutRuntimeState(message));
62
- continue;
63
- }
64
- const content = message.content.filter((block) => block.type !== "toolCall" || completed.calls.has(block));
65
- if (content.length === 0 && message.content.length > 0) continue;
66
- const hasTools = content.some((block) => block.type === "toolCall");
67
- const stopReason = message.stopReason === "toolUse" && !hasTools ? "stop" : message.stopReason;
68
- history.push({ ...message, content, stopReason });
69
- }
70
- return structuredClone(history);
71
- }
72
-
73
- /** Materialize already-rebuilt context; appendMessage cannot represent Pi summary roles. */
74
- export function writeWorkContext(cwd: string, directory: string, messages: AgentMessage[]): string {
75
- const session = SessionManager.create(cwd, directory);
76
- const file = session.getSessionFile()!;
77
- let parentId: string | null = null;
78
- const entries = messages.map((message): SessionMessageEntry => {
79
- const entry: SessionMessageEntry = {
80
- type: "message", id: randomUUID(), parentId, timestamp: new Date().toISOString(), message,
81
- };
82
- parentId = entry.id;
83
- return entry;
84
- });
85
- const content = [session.getHeader(), ...entries].map((entry) => JSON.stringify(entry)).join("\n");
86
- writeFileSync(file, `${content}\n`, { mode: 0o600, flag: "wx" });
87
- return file;
88
- }
@@ -1,4 +0,0 @@
1
- /** Pi built-ins available to a bare worker; extension aliases are not inherited. */
2
- export const WORKER_BUILTIN_TOOLS: readonly string[] = [
3
- "read", "bash", "edit", "write", "grep", "find", "ls", "powershell",
4
- ];
package/src/worktree.ts DELETED
@@ -1,164 +0,0 @@
1
- /**
2
- * Teammate worktree isolation — run each spawned worker in a fresh git
3
- * worktree so parallel workers never collide on the same working tree.
4
- *
5
- * Layout: `<repoRoot>/.pi/worktrees/teammate-<taskId>/`, branch
6
- * `teammate/<taskId>`. On completion the worker's diff is captured against
7
- * the base commit, then the worktree directory is removed while the branch
8
- * stays so captured work remains retrievable.
9
- */
10
-
11
- import { execFileSync, spawnSync } from "node:child_process";
12
- import * as path from "node:path";
13
-
14
- export interface WorktreeSetup {
15
- repoRoot: string;
16
- /** Absolute path of the worktree. */
17
- path: string;
18
- /** Working directory for the worker (worktree root). */
19
- cwd: string;
20
- branch: string;
21
- baseCommit: string;
22
- }
23
-
24
- export interface WorktreeDiff {
25
- /** Full patch of the worker's changes vs the base commit ("" when clean). */
26
- patch: string;
27
- /** `git diff --stat` summary ("" when clean). */
28
- diffStat: string;
29
- }
30
-
31
- export type WorktreeDiffResult = { ok: true; diff: WorktreeDiff } | { ok: false; error: string };
32
-
33
- function runGit(cwd: string, args: string[]): { status: number | null; stdout: string; stderr: string } {
34
- const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" });
35
- return {
36
- status: result.status,
37
- stdout: result.stdout ?? "",
38
- stderr: result.stderr ?? "",
39
- };
40
- }
41
-
42
- function safeTaskSegment(taskId: string): string {
43
- return taskId.replace(/[^\w.-]/g, "_");
44
- }
45
-
46
- /**
47
- * Create a worktree for a task, branched from the repo's current HEAD.
48
- * Returns an error string when the cwd is not a git repository or the
49
- * worktree could not be created.
50
- */
51
- export function createWorktree(
52
- cwd: string,
53
- taskId: string,
54
- ): WorktreeSetup | { error: string } {
55
- const toplevel = runGit(cwd, ["rev-parse", "--show-toplevel"]);
56
- if (toplevel.status !== 0) {
57
- return { error: "worktree isolation requires a git repository" };
58
- }
59
- const repoRoot = toplevel.stdout.trim();
60
- const baseCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
61
- if (baseCommit.status !== 0 || !baseCommit.stdout.trim()) {
62
- return { error: "worktree isolation requires at least one commit" };
63
- }
64
-
65
- const safeId = safeTaskSegment(taskId);
66
- const worktreePath = path.join(repoRoot, ".pi", "worktrees", `teammate-${safeId}`);
67
- const branch = `teammate/${safeId}`;
68
-
69
- const add = runGit(repoRoot, ["worktree", "add", worktreePath, "-b", branch, "HEAD"]);
70
- if (add.status !== 0) {
71
- return { error: `failed to create worktree: ${add.stderr.trim() || add.stdout.trim()}` };
72
- }
73
-
74
- return {
75
- repoRoot,
76
- path: worktreePath,
77
- cwd: worktreePath,
78
- branch,
79
- baseCommit: baseCommit.stdout.trim(),
80
- };
81
- }
82
-
83
- /**
84
- * Capture the worker's changes in the worktree as a patch against the base
85
- * commit. The worktree is left untouched; call cleanupWorktree afterwards.
86
- */
87
- export function captureWorktreeDiff(setup: WorktreeSetup): WorktreeDiffResult {
88
- try {
89
- execFileSync("git", ["-C", setup.path, "add", "-A"], { stdio: "ignore" });
90
- const stat = runGit(setup.path, ["diff", "--cached", "--stat", setup.baseCommit]);
91
- const patch = runGit(setup.path, ["diff", "--cached", setup.baseCommit]);
92
- if (stat.status !== 0 || patch.status !== 0) {
93
- return { ok: false, error: patch.stderr.trim() || stat.stderr.trim() || "git diff failed" };
94
- }
95
- return {
96
- ok: true,
97
- diff: {
98
- patch: patch.stdout ?? "",
99
- diffStat: stat.stdout?.trim() ?? "",
100
- },
101
- };
102
- } catch (error) {
103
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
104
- }
105
- }
106
-
107
- /**
108
- * Commit any remaining worktree changes onto the branch. Staging alone dies
109
- * with the worktree directory; only a commit makes the kept branch carry the
110
- * captured work. A clean tree succeeds without a commit.
111
- */
112
- function commitRemainingWork(setup: WorktreeSetup): { ok: boolean; error?: string } {
113
- const add = runGit(setup.path, ["add", "-A"]);
114
- if (add.status !== 0) return { ok: false, error: add.stderr.trim() || "git add failed" };
115
- const result = spawnSync("git", ["-C", setup.path, "commit", "-m", "agent-teams: capture teammate work"], { encoding: "utf-8" });
116
- if (result.status === 0) return { ok: true };
117
- const output = `${result.stderr ?? ""}${result.stdout ?? ""}`;
118
- if (/nothing to commit/i.test(output)) return { ok: true };
119
- return { ok: false, error: output.trim() || "git commit failed" };
120
- }
121
-
122
- /**
123
- * Remove the worktree directory but keep the branch: remaining changes are
124
- * committed first so the captured diff stays retrievable with
125
- * `git diff <baseCommit>..<branch>` after cleanup. Pass `{ deleteBranch: true }`
126
- * for teardowns where no work was captured (a failed spawn). When the commit
127
- * fails in keep-branch mode the directory is PRESERVED and returned in the
128
- * error — removing it would destroy the only copy of the work. Errors are
129
- * returned rather than thrown so task completion is never blocked by cleanup
130
- * failure.
131
- */
132
- export function cleanupWorktree(
133
- setup: WorktreeSetup,
134
- options?: { deleteBranch?: boolean },
135
- ): { ok: boolean; error?: string } {
136
- const errors: string[] = [];
137
- if (!options?.deleteBranch) {
138
- const commit = commitRemainingWork(setup);
139
- if (!commit.ok) {
140
- return {
141
- ok: false,
142
- error: `commit: ${commit.error}; worktree left in place at ${setup.path} so the work is not lost`,
143
- };
144
- }
145
- }
146
- const remove = runGit(setup.repoRoot, ["worktree", "remove", "--force", setup.path]);
147
- if (remove.status !== 0) errors.push(`worktree remove: ${remove.stderr.trim()}`);
148
- if (options?.deleteBranch) {
149
- const branch = runGit(setup.repoRoot, ["branch", "-D", setup.branch]);
150
- if (branch.status !== 0) errors.push(`branch delete: ${branch.stderr.trim()}`);
151
- }
152
- try {
153
- runGit(setup.repoRoot, ["worktree", "prune"]);
154
- } catch {
155
- // Prune is best effort.
156
- }
157
- return errors.length > 0 ? { ok: false, error: errors.join("; ") } : { ok: true };
158
- }
159
-
160
- /** Best-effort cleanup used when a spawn fails before the worker starts:
161
- * nothing was captured, so the empty branch goes too. */
162
- export function discardWorktree(setup: WorktreeSetup): void {
163
- cleanupWorktree(setup, { deleteBranch: true });
164
- }