@fradser/pi-agent-teams 0.10.0 → 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.
- package/README.md +132 -64
- package/package.json +13 -5
- package/references/agent-roles.md +23 -13
- package/src/activity.ts +20 -11
- package/src/agent-actions.ts +61 -36
- package/src/agent-host.ts +64 -0
- package/src/capability-tools.ts +34 -0
- package/src/guidance.ts +44 -38
- package/src/index.ts +70 -36
- package/src/recipient.ts +4 -4
- package/src/state.ts +163 -618
- package/src/statefile.ts +28 -244
- package/src/team-machine.ts +825 -534
- package/src/tool-copy.ts +26 -34
- package/src/tools.ts +57 -210
- package/src/types.ts +51 -159
- package/src/ui.ts +85 -59
- package/src/worker.ts +88 -66
- package/src/agents.ts +0 -296
- package/src/leader-reports.ts +0 -92
- package/src/spawner.ts +0 -751
- package/src/work-context.ts +0 -88
- package/src/worker-tools.ts +0 -4
- package/src/worktree.ts +0 -164
package/src/work-context.ts
DELETED
|
@@ -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
|
-
}
|
package/src/worker-tools.ts
DELETED
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
|
-
}
|