@fradser/pi-agent-teams 0.5.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/.memory/MEMORY.md +3 -0
- package/.memory/openai_teammate_api_reference.md +118 -0
- package/.memory/run-centric-design.md +22 -0
- package/README.md +111 -0
- package/agents/observer.md +10 -0
- package/agents/reviewer.md +11 -0
- package/agents/specialist.md +10 -0
- package/agents/worker.md +11 -0
- package/package.json +40 -0
- package/src/agents.ts +138 -0
- package/src/console-viewport.ts +30 -0
- package/src/guidance.ts +77 -0
- package/src/index.ts +79 -0
- package/src/run-machine.ts +580 -0
- package/src/spawner.ts +549 -0
- package/src/state.ts +684 -0
- package/src/statefile.ts +140 -0
- package/src/terminal.ts +34 -0
- package/src/tools.ts +154 -0
- package/src/types.ts +231 -0
- package/src/ui.ts +411 -0
- package/src/worker.ts +56 -0
- package/src/worktree.ts +115 -0
package/src/statefile.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared session files for the leader-owned debug snapshot and worker outboxes.
|
|
3
|
+
*
|
|
4
|
+
* Layout: `~/.pi/agent/teammate/<sessionKey>/state.json`, sessionKey derived
|
|
5
|
+
* from the session file (or cwd for ephemeral sessions) so different sessions
|
|
6
|
+
* never share a board file.
|
|
7
|
+
*
|
|
8
|
+
* Concurrency: the parent is the sole writer of state.json (atomic tmp+rename).
|
|
9
|
+
* Workers append requests only to their own JSONL outbox; the state snapshot is
|
|
10
|
+
* not a worker inbox or a communication channel.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as crypto from "node:crypto";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { TeammateState, WorkerEvent } from "./types";
|
|
19
|
+
|
|
20
|
+
const MAX_WORKER_EVENT_BYTES = 64 * 1024;
|
|
21
|
+
const MAX_OUTBOX_READ_BYTES = 256 * 1024;
|
|
22
|
+
|
|
23
|
+
export function sessionStateDir(sessionFile: string | undefined, cwd: string): string {
|
|
24
|
+
const key = crypto
|
|
25
|
+
.createHash("sha256")
|
|
26
|
+
.update(sessionFile ?? cwd)
|
|
27
|
+
.digest("hex")
|
|
28
|
+
.slice(0, 16);
|
|
29
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME, "agent", "teammate", key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function stateFilePath(sessionFile: string | undefined, cwd: string): string {
|
|
33
|
+
return path.join(sessionStateDir(sessionFile, cwd), "state.json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Write the leader-owned debug snapshot atomically (tmp + rename). */
|
|
37
|
+
export function writeStateFile(file: string, state: TeammateState): void {
|
|
38
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
39
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
40
|
+
fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
41
|
+
fs.renameSync(tmp, file);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Per-run append-only event log. Its filename cannot escape the session dir. */
|
|
45
|
+
export function workerOutboxPath(stateFile: string, workerName: string, runId: string): string {
|
|
46
|
+
return path.join(path.dirname(stateFile), "events", `${encodeURIComponent(workerName)}.${encodeURIComponent(runId)}.jsonl`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Delete a drained per-run outbox after its final snapshot is safely published. */
|
|
50
|
+
export function removeWorkerOutbox(stateFile: string, workerName: string, runId: string): void {
|
|
51
|
+
fs.rmSync(workerOutboxPath(stateFile, workerName, runId), { force: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Append one worker event. Workers never replace the leader-owned state snapshot. */
|
|
55
|
+
export function appendWorkerEvent(file: string, event: WorkerEvent): void {
|
|
56
|
+
const record = `${JSON.stringify(event)}\n`;
|
|
57
|
+
if (Buffer.byteLength(record, "utf-8") > MAX_WORKER_EVENT_BYTES) {
|
|
58
|
+
throw new Error(`Worker event exceeds ${MAX_WORKER_EVENT_BYTES} bytes.`);
|
|
59
|
+
}
|
|
60
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
61
|
+
fs.appendFileSync(file, record, { encoding: "utf-8", mode: 0o600 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read a bounded batch of complete JSONL records after byteOffset. */
|
|
65
|
+
export function readWorkerEvents(file: string, byteOffset: number): { events: unknown[]; nextOffset: number } {
|
|
66
|
+
let fd: number | undefined;
|
|
67
|
+
try {
|
|
68
|
+
fd = fs.openSync(file, "r");
|
|
69
|
+
const size = fs.fstatSync(fd).size;
|
|
70
|
+
// A truncated/recreated outbox starts at zero; event IDs make replay safe.
|
|
71
|
+
const offset = byteOffset > size ? 0 : Math.max(0, byteOffset);
|
|
72
|
+
const toRead = Math.min(MAX_OUTBOX_READ_BYTES, size - offset);
|
|
73
|
+
if (toRead === 0) return { events: [], nextOffset: offset };
|
|
74
|
+
const raw = Buffer.allocUnsafe(toRead);
|
|
75
|
+
const bytesRead = fs.readSync(fd, raw, 0, toRead, offset);
|
|
76
|
+
const unread = raw.subarray(0, bytesRead);
|
|
77
|
+
const lastNewline = unread.lastIndexOf(0x0a);
|
|
78
|
+
if (lastNewline < 0) {
|
|
79
|
+
// A line larger than the batch cap is malformed for this protocol; skip
|
|
80
|
+
// this chunk so one worker cannot block event draining indefinitely.
|
|
81
|
+
return { events: [], nextOffset: bytesRead === MAX_OUTBOX_READ_BYTES ? offset + bytesRead : offset };
|
|
82
|
+
}
|
|
83
|
+
const complete = unread.subarray(0, lastNewline).toString("utf-8");
|
|
84
|
+
const events: unknown[] = [];
|
|
85
|
+
for (const line of complete.split("\n")) {
|
|
86
|
+
if (!line.trim()) continue;
|
|
87
|
+
try {
|
|
88
|
+
events.push(JSON.parse(line));
|
|
89
|
+
} catch {
|
|
90
|
+
// Malformed records are consumed, not retried forever.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { events, nextOffset: offset + lastNewline + 1 };
|
|
94
|
+
} catch {
|
|
95
|
+
return { events: [], nextOffset: 0 };
|
|
96
|
+
} finally {
|
|
97
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Expired state-dir cleanup ───────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** Root of all per-session teammate state dirs (`~/.pi/agent/teammate/`). */
|
|
104
|
+
export function stateDirsRoot(): string {
|
|
105
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME, "agent", "teammate");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Remove the current session's state dir (called on session_shutdown). */
|
|
109
|
+
export function removeSessionStateDir(sessionFile: string | undefined, cwd: string): void {
|
|
110
|
+
fs.rmSync(sessionStateDir(sessionFile, cwd), { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Sweep state dirs whose last write is older than maxAgeMs. Returns how many
|
|
115
|
+
* were removed. Called on session_start so abandoned sessions never pile up.
|
|
116
|
+
*/
|
|
117
|
+
export function cleanupExpiredStateDirs(maxAgeMs: number): number {
|
|
118
|
+
const root = stateDirsRoot();
|
|
119
|
+
let entries: string[] = [];
|
|
120
|
+
try {
|
|
121
|
+
entries = fs.readdirSync(root);
|
|
122
|
+
} catch {
|
|
123
|
+
return 0; // root missing — nothing to clean
|
|
124
|
+
}
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
let removed = 0;
|
|
127
|
+
for (const name of entries) {
|
|
128
|
+
const full = path.join(root, name);
|
|
129
|
+
try {
|
|
130
|
+
const st = fs.statSync(full);
|
|
131
|
+
if (st.isDirectory() && now - st.mtimeMs > maxAgeMs) {
|
|
132
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
133
|
+
removed++;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Unreadable entries are skipped, not fatal.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return removed;
|
|
140
|
+
}
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface NodeTerminalResultInput {
|
|
2
|
+
runId: string;
|
|
3
|
+
nodeId: string;
|
|
4
|
+
agent: string;
|
|
5
|
+
result: {
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
signal: NodeJS.Signals | null;
|
|
9
|
+
timedOut: boolean;
|
|
10
|
+
};
|
|
11
|
+
nodeResult?: string;
|
|
12
|
+
nodeError?: string;
|
|
13
|
+
cancelled: boolean;
|
|
14
|
+
completedAfterShutdown?: boolean;
|
|
15
|
+
patchText: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildNodeTerminalResult(input: NodeTerminalResultInput): string {
|
|
19
|
+
const status = input.cancelled
|
|
20
|
+
? "cancelled"
|
|
21
|
+
: input.completedAfterShutdown
|
|
22
|
+
? "completed"
|
|
23
|
+
: input.result.timedOut
|
|
24
|
+
? "timed out"
|
|
25
|
+
: input.result.signal
|
|
26
|
+
? `terminated by ${input.result.signal}`
|
|
27
|
+
: input.nodeError
|
|
28
|
+
? "failed"
|
|
29
|
+
: "completed";
|
|
30
|
+
const summary = [input.nodeResult, input.nodeError, input.result.stdout, input.result.stderr]
|
|
31
|
+
.find((value) => value?.trim()) ?? "No worker summary was produced.";
|
|
32
|
+
const worktree = input.patchText ? "\nWorktree diff captured; inspect the node result before integration." : "";
|
|
33
|
+
return `Node [${input.runId}/${input.nodeId}] ${status} — agent ${input.agent}.\n${summary}${worktree}`;
|
|
34
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { discoverAgents, resolveAgent } from "./agents";
|
|
3
|
+
import {
|
|
4
|
+
applyWorkerEvents,
|
|
5
|
+
buildRunSummary,
|
|
6
|
+
cancelNodeAndTerminate,
|
|
7
|
+
cancelRunAndTerminate,
|
|
8
|
+
ensureRunContext,
|
|
9
|
+
publishStateSnapshot,
|
|
10
|
+
scheduleRun,
|
|
11
|
+
type DispatchCtx,
|
|
12
|
+
} from "./run-machine";
|
|
13
|
+
import { createRun, getRun, getSummary, markRunCompletionDelivered, retryRun } from "./state";
|
|
14
|
+
import { TeammateCancelParams, TeammateRetryParams, TeammateRunParams } from "./types";
|
|
15
|
+
import { ensureTeamWidget, openTeamConsole, refreshTeamUI } from "./ui";
|
|
16
|
+
|
|
17
|
+
function dispatchContext(ctx: ExtensionContext): DispatchCtx {
|
|
18
|
+
return { ui: ctx.ui, sessionManager: ctx.sessionManager, cwd: ctx.cwd };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sleep(ms: number): Promise<void> {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function gatherForeground(runId: string, signal: AbortSignal | undefined): Promise<string> {
|
|
26
|
+
const foregroundTimeoutMs = 5 * 60 * 1000;
|
|
27
|
+
const deadline = Date.now() + foregroundTimeoutMs;
|
|
28
|
+
while (true) {
|
|
29
|
+
applyWorkerEvents();
|
|
30
|
+
const run = getRun(runId);
|
|
31
|
+
if (!run || run.status !== "running") break;
|
|
32
|
+
if (signal?.aborted) {
|
|
33
|
+
run.background = true;
|
|
34
|
+
run.completionNotified = false;
|
|
35
|
+
publishStateSnapshot();
|
|
36
|
+
throw new Error(`Run [${runId}] continues in the background — workers will message team-leader upon completion.`);
|
|
37
|
+
}
|
|
38
|
+
if (Date.now() >= deadline) {
|
|
39
|
+
run.background = true;
|
|
40
|
+
run.completionNotified = false;
|
|
41
|
+
publishStateSnapshot();
|
|
42
|
+
return `Run [${runId}] is still running after ${Math.round(foregroundTimeoutMs / 1000)}s — detached to background. Workers will message team-leader upon completion.`;
|
|
43
|
+
}
|
|
44
|
+
await sleep(150);
|
|
45
|
+
}
|
|
46
|
+
markRunCompletionDelivered(runId);
|
|
47
|
+
publishStateSnapshot();
|
|
48
|
+
return buildRunSummary(runId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
52
|
+
pi.registerTool({
|
|
53
|
+
name: "teammate_run",
|
|
54
|
+
promptSnippet: "Dispatch a dependency-aware task graph in one call",
|
|
55
|
+
label: "Run Tasks",
|
|
56
|
+
description: "Dispatch a dependency-aware task graph in one call. Root nodes start immediately; downstream nodes auto-start after dependencies complete. Concurrency is bounded per run and across the session. Teammates run in the background by default and deliver completion through a follow-up. Do not sleep to wait.",
|
|
57
|
+
parameters: TeammateRunParams,
|
|
58
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
59
|
+
const cwd = params.cwd ?? ctx.cwd ?? process.cwd();
|
|
60
|
+
const dctx = dispatchContext(ctx);
|
|
61
|
+
ensureRunContext(dctx);
|
|
62
|
+
ensureTeamWidget(ctx);
|
|
63
|
+
for (const task of params.tasks) {
|
|
64
|
+
if (!resolveAgent(task.agent, cwd)) {
|
|
65
|
+
const available = [...discoverAgents(cwd).keys()].join(", ");
|
|
66
|
+
throw new Error(`Agent "${task.agent}" not found in any scope. Available agents: ${available || "(none)"}.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const created = createRun({
|
|
70
|
+
cwd,
|
|
71
|
+
concurrency: params.concurrency ?? 4,
|
|
72
|
+
worktree: params.worktree ?? false,
|
|
73
|
+
background: params.background ?? true,
|
|
74
|
+
timeoutMs: params.timeoutMs,
|
|
75
|
+
summarize: params.summarize,
|
|
76
|
+
summaryAgent: params.summaryAgent,
|
|
77
|
+
nodes: params.tasks.map((task) => ({
|
|
78
|
+
id: task.id,
|
|
79
|
+
agent: task.agent,
|
|
80
|
+
prompt: task.prompt,
|
|
81
|
+
paths: task.paths,
|
|
82
|
+
access: task.access ?? "read",
|
|
83
|
+
model: task.model,
|
|
84
|
+
timeoutMs: task.timeoutMs,
|
|
85
|
+
dependsOn: task.dependsOn ?? [],
|
|
86
|
+
})),
|
|
87
|
+
});
|
|
88
|
+
if (!created.ok) throw new Error(created.error ?? "Failed to create run.");
|
|
89
|
+
const run = created.run;
|
|
90
|
+
publishStateSnapshot();
|
|
91
|
+
refreshTeamUI(ctx);
|
|
92
|
+
scheduleRun(run.id, dctx);
|
|
93
|
+
|
|
94
|
+
if (run.background) {
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: `Started run [${run.id}] "${Object.keys(run.nodes).length} node(s)" — background.\nConcurrency: ${run.concurrency} | Worktree: ${run.worktree ? "yes" : "no"}\n\nWorkers will report their deliverables through the automatic completion follow-up. Do not sleep to wait.` }],
|
|
97
|
+
details: {},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { content: [{ type: "text", text: await gatherForeground(run.id, signal) }], details: {} };
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
pi.registerTool({
|
|
105
|
+
name: "teammate_cancel",
|
|
106
|
+
promptSnippet: "Cancel a run and stop its workers",
|
|
107
|
+
label: "Cancel Run",
|
|
108
|
+
description: "Cancel a run or one node. Running workers receive SIGTERM with bounded SIGKILL escalation; pending nodes are cancelled.",
|
|
109
|
+
parameters: TeammateCancelParams,
|
|
110
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
111
|
+
const dctx = dispatchContext(ctx);
|
|
112
|
+
ensureRunContext(dctx);
|
|
113
|
+
const result = params.nodeId
|
|
114
|
+
? await cancelNodeAndTerminate(params.runId, params.nodeId, dctx)
|
|
115
|
+
: await cancelRunAndTerminate(params.runId, dctx);
|
|
116
|
+
if (!result.ok) throw new Error(result.error);
|
|
117
|
+
return { content: [{ type: "text", text: params.nodeId
|
|
118
|
+
? `Node [${params.runId}/${params.nodeId}] cancelled — the rest of the run continues.`
|
|
119
|
+
: `Run [${params.runId}] cancelled — pending nodes were cancelled and running workers were stopped.` }], details: {} };
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
pi.registerTool({
|
|
124
|
+
name: "teammate_retry",
|
|
125
|
+
promptSnippet: "Retry the failed or cancelled nodes of a settled run",
|
|
126
|
+
label: "Retry Run Nodes",
|
|
127
|
+
description: "Retry failed and cancelled nodes of a settled run without re-running completed nodes.",
|
|
128
|
+
parameters: TeammateRetryParams,
|
|
129
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
130
|
+
const dctx = dispatchContext(ctx);
|
|
131
|
+
ensureRunContext(dctx);
|
|
132
|
+
const retried = retryRun(params.runId, params.nodeIds);
|
|
133
|
+
if (!retried.ok) throw new Error(retried.error ?? "Failed to retry run.");
|
|
134
|
+
publishStateSnapshot();
|
|
135
|
+
scheduleRun(params.runId, dctx);
|
|
136
|
+
refreshTeamUI(ctx);
|
|
137
|
+
return { content: [{ type: "text", text: `Run [${params.runId}] retried — reset ${retried.reset.length} node(s): ${retried.reset.join(", ")}. Completed nodes are retained.` }], details: {} };
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function registerTeamCommand(pi: ExtensionAPI): void {
|
|
143
|
+
pi.registerCommand("teammate", {
|
|
144
|
+
description: "Open the full-screen teammate team console: run/node status, node details, interrupt/stop",
|
|
145
|
+
handler: async (_args, ctx) => {
|
|
146
|
+
if (ctx.mode !== "tui") {
|
|
147
|
+
ctx.ui.notify(getSummary() ?? "No runs yet — dispatch work with teammate_run.", "info");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await openTeamConsole(ctx);
|
|
151
|
+
refreshTeamUI(ctx);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { Static } from "typebox";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
// ── Run / Node ────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export const NodeStatus = Type.Union(
|
|
7
|
+
[
|
|
8
|
+
Type.Literal("pending"),
|
|
9
|
+
Type.Literal("running"),
|
|
10
|
+
Type.Literal("completed"),
|
|
11
|
+
Type.Literal("failed"),
|
|
12
|
+
Type.Literal("cancelled"),
|
|
13
|
+
],
|
|
14
|
+
{ description: "Current status of a run node" },
|
|
15
|
+
);
|
|
16
|
+
export type NodeStatus = Static<typeof NodeStatus>;
|
|
17
|
+
|
|
18
|
+
export const RunStatus = Type.Union(
|
|
19
|
+
[
|
|
20
|
+
Type.Literal("running"),
|
|
21
|
+
Type.Literal("completed"),
|
|
22
|
+
Type.Literal("failed"),
|
|
23
|
+
Type.Literal("cancelled"),
|
|
24
|
+
],
|
|
25
|
+
{ description: "Current status of a run" },
|
|
26
|
+
);
|
|
27
|
+
export type RunStatus = Static<typeof RunStatus>;
|
|
28
|
+
|
|
29
|
+
/** One task in a dispatched run: a bounded child-process unit of work. */
|
|
30
|
+
export interface Node {
|
|
31
|
+
/** User-chosen id, unique within the run. */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Stable node identity: `${runId}:${nodeId}`. */
|
|
34
|
+
workerKey: string;
|
|
35
|
+
/** Resolved agent definition name. */
|
|
36
|
+
agent: string;
|
|
37
|
+
/** The specific task text handed to the worker. */
|
|
38
|
+
prompt: string;
|
|
39
|
+
/** Repository-relative paths used to coordinate this node. */
|
|
40
|
+
paths: string[];
|
|
41
|
+
/** Read nodes may overlap; write nodes need shared-workspace conflict protection. */
|
|
42
|
+
access: "read" | "write";
|
|
43
|
+
/** Optional per-node model pin (provider/model). */
|
|
44
|
+
model?: string;
|
|
45
|
+
/** Optional per-node hard wall-clock cap before the worker is killed. */
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
/** Node ids that must complete before this node may start. */
|
|
48
|
+
dependsOn: string[];
|
|
49
|
+
status: NodeStatus;
|
|
50
|
+
result?: string;
|
|
51
|
+
errorMessage?: string;
|
|
52
|
+
/** Real child-process execution info when this node was spawned. */
|
|
53
|
+
spawn?: SpawnInfo;
|
|
54
|
+
createdAt: number;
|
|
55
|
+
updatedAt: number;
|
|
56
|
+
completedAt?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A dispatched dependency-aware task graph. */
|
|
60
|
+
export interface Run {
|
|
61
|
+
id: string;
|
|
62
|
+
cwd: string;
|
|
63
|
+
status: RunStatus;
|
|
64
|
+
concurrency: number;
|
|
65
|
+
/** Every node runs in its own git worktree when true. */
|
|
66
|
+
worktree: boolean;
|
|
67
|
+
/** background=true returns immediately; false gathers in the tool call. */
|
|
68
|
+
background: boolean;
|
|
69
|
+
/** Optional run-level hard wall-clock cap before the run fails. */
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
/** Absolute deadline for timeoutMs, when set. */
|
|
72
|
+
deadlineAt?: number;
|
|
73
|
+
/** Run-level failure detail (e.g. run timeout). */
|
|
74
|
+
errorMessage?: string;
|
|
75
|
+
nodes: Record<string, Node>;
|
|
76
|
+
createdAt: number;
|
|
77
|
+
updatedAt: number;
|
|
78
|
+
finishedAt?: number;
|
|
79
|
+
/** True once the run-completion follow-up was delivered, or claimed by wait/foreground gather. */
|
|
80
|
+
completionNotified?: boolean;
|
|
81
|
+
/** True once the run-settled leader summary was sent (onRunSettled is idempotent). */
|
|
82
|
+
settledMessageSent?: boolean;
|
|
83
|
+
/** Synthesized final summary produced by the optional __summary node. */
|
|
84
|
+
summary?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Spawn ─────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/** Lifecycle of a spawned child Pi process running a node. */
|
|
90
|
+
export type SpawnStatus = "running" | "completed" | "failed";
|
|
91
|
+
|
|
92
|
+
/** Token/cost usage reported by a finished worker (from JSON-mode output). */
|
|
93
|
+
export interface WorkerUsage {
|
|
94
|
+
input: number;
|
|
95
|
+
output: number;
|
|
96
|
+
cacheRead: number;
|
|
97
|
+
cacheWrite: number;
|
|
98
|
+
totalTokens: number;
|
|
99
|
+
cost: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SpawnInfo {
|
|
103
|
+
/** Per-spawn capability identity, regenerated for every worker process. */
|
|
104
|
+
spawnId: string;
|
|
105
|
+
pid: number;
|
|
106
|
+
status: SpawnStatus;
|
|
107
|
+
startedAt: number;
|
|
108
|
+
/** Live assistant text assembled from JSON-mode stream events. */
|
|
109
|
+
liveText?: string;
|
|
110
|
+
/** Current child tool name, if a tool is executing. */
|
|
111
|
+
activeTool?: string;
|
|
112
|
+
/** Live assistant reasoning streamed from the child (shown while no tool runs). */
|
|
113
|
+
liveThinking?: string;
|
|
114
|
+
/** Number of assistant turns observed from the child JSON stream. */
|
|
115
|
+
turns?: number;
|
|
116
|
+
/** The child emitted a final successful assistant response and should close promptly. */
|
|
117
|
+
finalResponse?: boolean;
|
|
118
|
+
finishedAt?: number;
|
|
119
|
+
exitCode?: number;
|
|
120
|
+
/** Truncated stdout of the child process. */
|
|
121
|
+
stdout?: string;
|
|
122
|
+
/** Truncated stderr of the child process. */
|
|
123
|
+
stderr?: string;
|
|
124
|
+
/** Spawn-level error (e.g. CLI resolution failure). */
|
|
125
|
+
error?: string;
|
|
126
|
+
/** Token/cost usage reported by the worker, when available. */
|
|
127
|
+
usage?: WorkerUsage;
|
|
128
|
+
/** True when the worker was killed by the spawn timeout. */
|
|
129
|
+
timedOut?: boolean;
|
|
130
|
+
/** Whether this spawn owns a dedicated Git worktree. */
|
|
131
|
+
isolation?: "worktree" | "none";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Mailbox ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
export interface MailboxMessage {
|
|
137
|
+
id: string;
|
|
138
|
+
from: string;
|
|
139
|
+
subject: string;
|
|
140
|
+
body: string;
|
|
141
|
+
runId?: string;
|
|
142
|
+
timestamp: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Append-only event emitted by a worker and applied by the team leader. */
|
|
146
|
+
export interface WorkerMessageEvent {
|
|
147
|
+
id: string;
|
|
148
|
+
type: "message";
|
|
149
|
+
worker: string;
|
|
150
|
+
spawnId: string;
|
|
151
|
+
subject: string;
|
|
152
|
+
body: string;
|
|
153
|
+
status?: "in_progress" | "completed" | "failed";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type WorkerEvent = WorkerMessageEvent;
|
|
157
|
+
|
|
158
|
+
// ── Tool parameter schemas (typebox) ──────────────────────────────
|
|
159
|
+
|
|
160
|
+
const NodeAccess = Type.Union(
|
|
161
|
+
[Type.Literal("read"), Type.Literal("write")],
|
|
162
|
+
{ description: "read permits overlapping analysis; write protects overlapping shared-workspace changes. Default: read." },
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
/** One task inside teammate_run. */
|
|
166
|
+
export const RunTaskSpec = Type.Object({
|
|
167
|
+
id: Type.String({ description: "Node id, unique within this run" }),
|
|
168
|
+
agent: Type.String({ description: "Agent definition name (bundled, user, or project scope)" }),
|
|
169
|
+
prompt: Type.String({ minLength: 1, description: "The specific task text handed to this worker" }),
|
|
170
|
+
dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Node ids that must complete before this node starts" })),
|
|
171
|
+
paths: Type.Array(Type.String({ description: "Repository-relative path this node may inspect or modify" }), {
|
|
172
|
+
minItems: 1,
|
|
173
|
+
description: "Repository-relative paths used to coordinate this node",
|
|
174
|
+
}),
|
|
175
|
+
access: Type.Optional(NodeAccess),
|
|
176
|
+
model: Type.Optional(Type.String({ description: "Optional per-node provider/model pin" })),
|
|
177
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Optional per-node hard wall-clock cap before the worker is killed (default: 30 minutes; do not set overly short timeouts for multi-file or reasoning tasks)" })),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/** Dispatch a dependency-aware task graph in one call. */
|
|
181
|
+
export const TeammateRunParams = Type.Object({
|
|
182
|
+
tasks: Type.Array(RunTaskSpec, {
|
|
183
|
+
minItems: 1,
|
|
184
|
+
maxItems: 32,
|
|
185
|
+
description: "Task graph to dispatch; dependsOn edges must form a DAG",
|
|
186
|
+
}),
|
|
187
|
+
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 32, description: "Max nodes running at once (default: 4)" })),
|
|
188
|
+
worktree: Type.Optional(Type.Boolean({ description: "Run every node in its own git worktree (default: false)" })),
|
|
189
|
+
background: Type.Optional(Type.Boolean({ default: true, description: "Return immediately and deliver one completion follow-up. Default: true — teammates always run in the background; workers message team-leader with deliverables upon completion." })),
|
|
190
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Run-level hard wall-clock cap; the run fails when exceeded (default: none, nodes have their own caps)" })),
|
|
191
|
+
summarize: Type.Optional(Type.Boolean({ description: "Append a __summary node after all leaf nodes. Default: true when the run has more than one user task, false for a single task." })),
|
|
192
|
+
summaryAgent: Type.Optional(Type.String({ description: "Agent used for the summary node when summarize is on (default: observer)" })),
|
|
193
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for this run (default: the session cwd)" })),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** Explicit cancel of a run or single node. */
|
|
197
|
+
export const TeammateCancelParams = Type.Object({
|
|
198
|
+
runId: Type.String({ description: "Run id to cancel" }),
|
|
199
|
+
nodeId: Type.Optional(Type.String({ description: "Cancel one node (and its not-yet-started dependents) instead of the whole run; the run continues" })),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/** Retry the failed/cancelled nodes of a settled run. */
|
|
203
|
+
export const TeammateRetryParams = Type.Object({
|
|
204
|
+
runId: Type.String({ description: "Run id of a settled (failed/cancelled/completed) run" }),
|
|
205
|
+
nodeIds: Type.Optional(Type.Array(Type.String(), { description: "Node ids to reset and re-run; defaults to all failed and cancelled nodes" })),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/** Worker progress message or final report to the team leader. */
|
|
209
|
+
export const TeammateMessageParams = Type.Object({
|
|
210
|
+
subject: Type.String({ description: "Concise report subject" }),
|
|
211
|
+
body: Type.String({ description: "Progress note or full final deliverable for the team leader" }),
|
|
212
|
+
status: Type.Optional(Type.Union([
|
|
213
|
+
Type.Literal("in_progress"),
|
|
214
|
+
Type.Literal("completed"),
|
|
215
|
+
Type.Literal("failed"),
|
|
216
|
+
], { description: "Optional worker status; use completed or failed for the final deliverable" })),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ── State snapshot for persistence ────────────────────────────────
|
|
220
|
+
|
|
221
|
+
export interface TeammateState {
|
|
222
|
+
runs: Record<string, Run>;
|
|
223
|
+
/** Single leader inbox for worker terminal reports and leader-bound messages. */
|
|
224
|
+
leaderMailbox: MailboxMessage[];
|
|
225
|
+
messageCounter: number;
|
|
226
|
+
runCounter: number;
|
|
227
|
+
/** Byte offsets consumed by the parent from each worker's append-only outbox. */
|
|
228
|
+
workerEventOffsets: Record<string, number>;
|
|
229
|
+
/** Event IDs already applied by the parent, keyed to their worker spawn for compaction. */
|
|
230
|
+
workerEventIds: Record<string, string>;
|
|
231
|
+
}
|