pi-extended-teams 2.1.16
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 +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- package/src/utils/write-queue.ts +202 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Project: pi-extended-teams
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { TaskFile } from "./models";
|
|
5
|
+
import { taskDir, sanitizeName } from "./paths";
|
|
6
|
+
|
|
7
|
+
export interface FileClaimConflict {
|
|
8
|
+
path: string;
|
|
9
|
+
heldBy: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FILE_CLAIM_BLOCK_PREFIX = "file-claim:";
|
|
13
|
+
|
|
14
|
+
function fileClaimBlockId(conflict: FileClaimConflict): string {
|
|
15
|
+
return `${FILE_CLAIM_BLOCK_PREFIX}${encodeURIComponent(conflict.path)}:${encodeURIComponent(conflict.heldBy)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseFileClaimBlockPath(blocker: string): string | null {
|
|
19
|
+
if (!blocker.startsWith(FILE_CLAIM_BLOCK_PREFIX)) return null;
|
|
20
|
+
const encodedPath = blocker.slice(FILE_CLAIM_BLOCK_PREFIX.length).split(":", 1)[0];
|
|
21
|
+
try {
|
|
22
|
+
return decodeURIComponent(encodedPath);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isOpenTask(task: TaskFile): boolean {
|
|
29
|
+
return task.status !== "completed" && task.status !== "deleted";
|
|
30
|
+
}
|
|
31
|
+
import { teamExists } from "./teams";
|
|
32
|
+
import { withLock } from "./lock";
|
|
33
|
+
import { runHook } from "./hooks";
|
|
34
|
+
|
|
35
|
+
export interface GuardedTaskUpdateOptions {
|
|
36
|
+
expectedStatus?: TaskFile["status"];
|
|
37
|
+
expectedOwner?: string | null;
|
|
38
|
+
expectedVersion?: number;
|
|
39
|
+
expectedUpdatedAt?: string;
|
|
40
|
+
operationId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GuardedTaskUpdateResult {
|
|
44
|
+
task: TaskFile;
|
|
45
|
+
updated: boolean;
|
|
46
|
+
idempotent: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nowIso(): string {
|
|
50
|
+
return new Date().toISOString();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function nextTaskState(task: TaskFile, updates: Partial<TaskFile>, operationId?: string): TaskFile {
|
|
54
|
+
const now = nowIso();
|
|
55
|
+
const metadata = { ...(task.metadata || {}), ...(updates.metadata || {}) };
|
|
56
|
+
if (operationId) {
|
|
57
|
+
metadata.lastOperationId = operationId;
|
|
58
|
+
metadata.appliedOperationIds = {
|
|
59
|
+
...(task.metadata?.appliedOperationIds || {}),
|
|
60
|
+
...(updates.metadata?.appliedOperationIds || {}),
|
|
61
|
+
[operationId]: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
...task,
|
|
67
|
+
...updates,
|
|
68
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
69
|
+
version: (task.version || 0) + 1,
|
|
70
|
+
updatedAt: now,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function taskOperationMatches(task: TaskFile, operationId?: string): boolean {
|
|
75
|
+
if (!operationId) return false;
|
|
76
|
+
return task.metadata?.lastOperationId === operationId || task.metadata?.appliedOperationIds?.[operationId] === true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertGuardMatches(task: TaskFile, guard: GuardedTaskUpdateOptions): void {
|
|
80
|
+
if (guard.expectedStatus !== undefined && task.status !== guard.expectedStatus) {
|
|
81
|
+
throw new Error(`Task ${task.id} status guard failed: expected ${guard.expectedStatus}, got ${task.status}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (guard.expectedOwner !== undefined && (task.owner ?? null) !== guard.expectedOwner) {
|
|
85
|
+
throw new Error(`Task ${task.id} owner guard failed: expected ${guard.expectedOwner ?? "<none>"}, got ${task.owner ?? "<none>"}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (guard.expectedVersion !== undefined && (task.version || 0) !== guard.expectedVersion) {
|
|
89
|
+
throw new Error(`Task ${task.id} version guard failed: expected ${guard.expectedVersion}, got ${task.version || 0}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (guard.expectedUpdatedAt !== undefined && task.updatedAt !== guard.expectedUpdatedAt) {
|
|
93
|
+
throw new Error(`Task ${task.id} updatedAt guard failed: expected ${guard.expectedUpdatedAt}, got ${task.updatedAt ?? "<none>"}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getTaskId(teamName: string): string {
|
|
98
|
+
const dir = taskDir(teamName);
|
|
99
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
100
|
+
const ids = files.map(f => parseInt(path.parse(f).name, 10)).filter(id => !isNaN(id));
|
|
101
|
+
return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getTaskPath(teamName: string, taskId: string): string {
|
|
105
|
+
const dir = taskDir(teamName);
|
|
106
|
+
const safeTaskId = sanitizeName(taskId);
|
|
107
|
+
return path.join(dir, `${safeTaskId}.json`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function createTask(
|
|
111
|
+
teamName: string,
|
|
112
|
+
subject: string,
|
|
113
|
+
description: string,
|
|
114
|
+
activeForm = "",
|
|
115
|
+
metadata?: Record<string, any>
|
|
116
|
+
): Promise<TaskFile> {
|
|
117
|
+
if (!subject || !subject.trim()) throw new Error("Task subject must not be empty");
|
|
118
|
+
if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`);
|
|
119
|
+
|
|
120
|
+
const dir = taskDir(teamName);
|
|
121
|
+
const lockPath = dir;
|
|
122
|
+
|
|
123
|
+
return await withLock(lockPath, async () => {
|
|
124
|
+
const id = getTaskId(teamName);
|
|
125
|
+
const createdAt = nowIso();
|
|
126
|
+
const task: TaskFile = {
|
|
127
|
+
id,
|
|
128
|
+
subject,
|
|
129
|
+
description,
|
|
130
|
+
activeForm,
|
|
131
|
+
status: "pending",
|
|
132
|
+
blocks: [],
|
|
133
|
+
blockedBy: [],
|
|
134
|
+
version: 1,
|
|
135
|
+
createdAt,
|
|
136
|
+
updatedAt: createdAt,
|
|
137
|
+
metadata,
|
|
138
|
+
};
|
|
139
|
+
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(task, null, 2));
|
|
140
|
+
return task;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function updateTask(
|
|
145
|
+
teamName: string,
|
|
146
|
+
taskId: string,
|
|
147
|
+
updates: Partial<TaskFile>,
|
|
148
|
+
retries?: number
|
|
149
|
+
): Promise<TaskFile> {
|
|
150
|
+
const p = getTaskPath(teamName, taskId);
|
|
151
|
+
|
|
152
|
+
return await withLock(p, async () => {
|
|
153
|
+
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
154
|
+
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
155
|
+
const updated = nextTaskState(task, updates);
|
|
156
|
+
|
|
157
|
+
if (updates.status === "deleted") {
|
|
158
|
+
fs.unlinkSync(p);
|
|
159
|
+
return updated;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
|
163
|
+
|
|
164
|
+
if (updates.status === "completed") {
|
|
165
|
+
await runHook(teamName, "task_completed", updated);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return updated;
|
|
169
|
+
}, retries);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Submits a plan for a task, updating its status to "planning".
|
|
174
|
+
* @param teamName The name of the team
|
|
175
|
+
* @param taskId The ID of the task
|
|
176
|
+
* @param plan The content of the plan
|
|
177
|
+
* @returns The updated task
|
|
178
|
+
*/
|
|
179
|
+
export async function updateTaskGuarded(
|
|
180
|
+
teamName: string,
|
|
181
|
+
taskId: string,
|
|
182
|
+
updates: Partial<TaskFile>,
|
|
183
|
+
guard: GuardedTaskUpdateOptions,
|
|
184
|
+
retries?: number
|
|
185
|
+
): Promise<GuardedTaskUpdateResult> {
|
|
186
|
+
const p = getTaskPath(teamName, taskId);
|
|
187
|
+
|
|
188
|
+
return await withLock(p, async () => {
|
|
189
|
+
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
190
|
+
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
191
|
+
|
|
192
|
+
if (taskOperationMatches(task, guard.operationId)) {
|
|
193
|
+
return { task, updated: false, idempotent: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
assertGuardMatches(task, guard);
|
|
197
|
+
const updated = nextTaskState(task, updates, guard.operationId);
|
|
198
|
+
|
|
199
|
+
if (updates.status === "deleted") {
|
|
200
|
+
fs.unlinkSync(p);
|
|
201
|
+
return { task: updated, updated: true, idempotent: false };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
|
205
|
+
|
|
206
|
+
if (updates.status === "completed") {
|
|
207
|
+
await runHook(teamName, "task_completed", updated);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { task: updated, updated: true, idempotent: false };
|
|
211
|
+
}, retries);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function submitPlan(teamName: string, taskId: string, plan: string): Promise<TaskFile> {
|
|
215
|
+
if (!plan || !plan.trim()) throw new Error("Plan must not be empty");
|
|
216
|
+
return await updateTask(teamName, taskId, { status: "planning", plan });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Evaluates a submitted plan for a task.
|
|
221
|
+
* @param teamName The name of the team
|
|
222
|
+
* @param taskId The ID of the task
|
|
223
|
+
* @param action The evaluation action: "approve" or "reject"
|
|
224
|
+
* @param feedback Optional feedback for the evaluation (required for rejection)
|
|
225
|
+
* @param retries Number of times to retry acquiring the lock
|
|
226
|
+
* @returns The updated task
|
|
227
|
+
*/
|
|
228
|
+
export async function evaluatePlan(
|
|
229
|
+
teamName: string,
|
|
230
|
+
taskId: string,
|
|
231
|
+
action: "approve" | "reject",
|
|
232
|
+
feedback?: string,
|
|
233
|
+
retries?: number
|
|
234
|
+
): Promise<TaskFile> {
|
|
235
|
+
const p = getTaskPath(teamName, taskId);
|
|
236
|
+
|
|
237
|
+
return await withLock(p, async () => {
|
|
238
|
+
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
239
|
+
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
240
|
+
|
|
241
|
+
// 1. Validate state: Only "planning" tasks can be evaluated
|
|
242
|
+
if (task.status !== "planning") {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` +
|
|
245
|
+
`Tasks must be in 'planning' status to be evaluated.`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 2. Validate plan presence
|
|
250
|
+
if (!task.plan || !task.plan.trim()) {
|
|
251
|
+
throw new Error(`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 3. Require feedback for rejections
|
|
255
|
+
if (action === "reject" && (!feedback || !feedback.trim())) {
|
|
256
|
+
throw new Error("Feedback is required when rejecting a plan.");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 4. Perform update
|
|
260
|
+
const updates: Partial<TaskFile> = action === "approve"
|
|
261
|
+
? { status: "in_progress", planFeedback: "" }
|
|
262
|
+
: { status: "planning", planFeedback: feedback };
|
|
263
|
+
|
|
264
|
+
const updated = nextTaskState(task, updates);
|
|
265
|
+
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
|
|
266
|
+
return updated;
|
|
267
|
+
}, retries);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function readTask(teamName: string, taskId: string, retries?: number): Promise<TaskFile> {
|
|
271
|
+
const p = getTaskPath(teamName, taskId);
|
|
272
|
+
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
|
|
273
|
+
return await withLock(p, async () => {
|
|
274
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
275
|
+
}, retries);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function listTasks(teamName: string): Promise<TaskFile[]> {
|
|
279
|
+
const dir = taskDir(teamName);
|
|
280
|
+
return await withLock(dir, async () => {
|
|
281
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
282
|
+
const tasks: TaskFile[] = files
|
|
283
|
+
.map(f => {
|
|
284
|
+
const id = parseInt(path.parse(f).name, 10);
|
|
285
|
+
if (isNaN(id)) return null;
|
|
286
|
+
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
|
287
|
+
})
|
|
288
|
+
.filter(t => t !== null);
|
|
289
|
+
return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function markOwnerTasksBlockedByFileClaims(
|
|
294
|
+
teamName: string,
|
|
295
|
+
agentName: string,
|
|
296
|
+
conflicts: FileClaimConflict[],
|
|
297
|
+
blockedAt: string = new Date().toISOString()
|
|
298
|
+
): Promise<TaskFile[]> {
|
|
299
|
+
if (conflicts.length === 0) return [];
|
|
300
|
+
|
|
301
|
+
const dir = taskDir(teamName);
|
|
302
|
+
if (!fs.existsSync(dir)) return [];
|
|
303
|
+
|
|
304
|
+
return await withLock(dir, async () => {
|
|
305
|
+
const blockerIds = conflicts.map(fileClaimBlockId);
|
|
306
|
+
const updatedTasks: TaskFile[] = [];
|
|
307
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
308
|
+
|
|
309
|
+
for (const f of files) {
|
|
310
|
+
const p = path.join(dir, f);
|
|
311
|
+
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
312
|
+
if (task.owner !== agentName || !isOpenTask(task)) continue;
|
|
313
|
+
|
|
314
|
+
task.blockedBy = Array.from(new Set([...(task.blockedBy || []), ...blockerIds]));
|
|
315
|
+
task.metadata = {
|
|
316
|
+
...(task.metadata || {}),
|
|
317
|
+
fileClaimBlock: { blockedAt, conflicts },
|
|
318
|
+
};
|
|
319
|
+
fs.writeFileSync(p, JSON.stringify(task, null, 2));
|
|
320
|
+
updatedTasks.push(task);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return updatedTasks;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function clearOwnerFileClaimBlocks(
|
|
328
|
+
teamName: string,
|
|
329
|
+
agentName: string,
|
|
330
|
+
paths?: string[]
|
|
331
|
+
): Promise<TaskFile[]> {
|
|
332
|
+
const dir = taskDir(teamName);
|
|
333
|
+
if (!fs.existsSync(dir)) return [];
|
|
334
|
+
const pathSet = paths ? new Set(paths) : undefined;
|
|
335
|
+
|
|
336
|
+
return await withLock(dir, async () => {
|
|
337
|
+
const updatedTasks: TaskFile[] = [];
|
|
338
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
339
|
+
|
|
340
|
+
for (const f of files) {
|
|
341
|
+
const p = path.join(dir, f);
|
|
342
|
+
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
343
|
+
if (task.owner !== agentName) continue;
|
|
344
|
+
|
|
345
|
+
const originalBlockedBy = task.blockedBy || [];
|
|
346
|
+
const nextBlockedBy = originalBlockedBy.filter(blocker => {
|
|
347
|
+
const blockedPath = parseFileClaimBlockPath(blocker);
|
|
348
|
+
if (!blockedPath) return true;
|
|
349
|
+
return pathSet ? !pathSet.has(blockedPath) : false;
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const fileClaimBlock = task.metadata?.fileClaimBlock as { conflicts?: FileClaimConflict[] } | undefined;
|
|
353
|
+
const remainingConflicts = fileClaimBlock?.conflicts
|
|
354
|
+
? fileClaimBlock.conflicts.filter(conflict => pathSet ? !pathSet.has(conflict.path) : false)
|
|
355
|
+
: [];
|
|
356
|
+
|
|
357
|
+
const metadata = { ...(task.metadata || {}) };
|
|
358
|
+
if (remainingConflicts.length > 0) {
|
|
359
|
+
metadata.fileClaimBlock = { ...fileClaimBlock, conflicts: remainingConflicts };
|
|
360
|
+
} else {
|
|
361
|
+
delete metadata.fileClaimBlock;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const metadataChanged = JSON.stringify(metadata) !== JSON.stringify(task.metadata || {});
|
|
365
|
+
const blockedByChanged = nextBlockedBy.length !== originalBlockedBy.length;
|
|
366
|
+
if (!metadataChanged && !blockedByChanged) continue;
|
|
367
|
+
|
|
368
|
+
task.blockedBy = nextBlockedBy;
|
|
369
|
+
task.metadata = Object.keys(metadata).length > 0 ? metadata : undefined;
|
|
370
|
+
fs.writeFileSync(p, JSON.stringify(task, null, 2));
|
|
371
|
+
updatedTasks.push(task);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return updatedTasks;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function resetOwnerTasks(teamName: string, agentName: string) {
|
|
379
|
+
const dir = taskDir(teamName);
|
|
380
|
+
const lockPath = dir;
|
|
381
|
+
|
|
382
|
+
await withLock(lockPath, async () => {
|
|
383
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
384
|
+
for (const f of files) {
|
|
385
|
+
const p = path.join(dir, f);
|
|
386
|
+
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
387
|
+
if (task.owner === agentName) {
|
|
388
|
+
task.owner = undefined;
|
|
389
|
+
if (task.status !== "completed") {
|
|
390
|
+
task.status = "pending";
|
|
391
|
+
}
|
|
392
|
+
fs.writeFileSync(p, JSON.stringify(task, null, 2));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { TeamConfig, Member } from "./models";
|
|
4
|
+
import { configPath, teamDir, taskDir } from "./paths";
|
|
5
|
+
import { withLock } from "./lock";
|
|
6
|
+
import {
|
|
7
|
+
assertLifecycleTombstoneAbsent,
|
|
8
|
+
generateLifecycleRunId,
|
|
9
|
+
withLifecycleTombstoneLock,
|
|
10
|
+
} from "./lifecycle-tombstone";
|
|
11
|
+
|
|
12
|
+
export function teamExists(teamName: string) {
|
|
13
|
+
return fs.existsSync(configPath(teamName));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class TeamAlreadyExistsError extends Error {
|
|
17
|
+
constructor(readonly teamName: string) {
|
|
18
|
+
super(`Team "${teamName}" already exists.`);
|
|
19
|
+
this.name = "TeamAlreadyExistsError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TeamCreationInput {
|
|
24
|
+
name: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
leadAgentId: string;
|
|
27
|
+
description: string;
|
|
28
|
+
defaultModel?: string;
|
|
29
|
+
separateWindows?: boolean;
|
|
30
|
+
metadata?: Record<string, any>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createTeamWithMode(input: TeamCreationInput, exclusive: boolean): TeamConfig {
|
|
34
|
+
const dir = teamDir(input.name);
|
|
35
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
|
|
37
|
+
const tasksDir = taskDir(input.name);
|
|
38
|
+
if (!fs.existsSync(tasksDir)) fs.mkdirSync(tasksDir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
const leadMember: Member = {
|
|
41
|
+
agentId: input.leadAgentId,
|
|
42
|
+
name: "team-lead",
|
|
43
|
+
agentType: "lead",
|
|
44
|
+
joinedAt: Date.now(),
|
|
45
|
+
tmuxPaneId: process.env.TMUX_PANE || "",
|
|
46
|
+
cwd: process.cwd(),
|
|
47
|
+
subscriptions: [],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const config: TeamConfig = {
|
|
51
|
+
name: input.name,
|
|
52
|
+
description: input.description,
|
|
53
|
+
createdAt: Date.now(),
|
|
54
|
+
leadAgentId: input.leadAgentId,
|
|
55
|
+
leadSessionId: input.sessionId,
|
|
56
|
+
members: [leadMember],
|
|
57
|
+
defaultModel: input.defaultModel,
|
|
58
|
+
separateWindows: input.separateWindows,
|
|
59
|
+
metadata: input.metadata,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const serialized = JSON.stringify(config, null, 2);
|
|
63
|
+
if (exclusive) {
|
|
64
|
+
try {
|
|
65
|
+
fs.writeFileSync(configPath(input.name), serialized, { flag: "wx" });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") throw error;
|
|
68
|
+
throw new TeamAlreadyExistsError(input.name);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
fs.writeFileSync(configPath(input.name), serialized);
|
|
72
|
+
}
|
|
73
|
+
return config;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createTeam(
|
|
77
|
+
name: string,
|
|
78
|
+
sessionId: string,
|
|
79
|
+
leadAgentId: string,
|
|
80
|
+
description = "",
|
|
81
|
+
defaultModel?: string,
|
|
82
|
+
separateWindows?: boolean,
|
|
83
|
+
metadata?: Record<string, any>
|
|
84
|
+
): TeamConfig {
|
|
85
|
+
return createTeamWithMode({ name, sessionId, leadAgentId, description, defaultModel, separateWindows, metadata }, false);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createTeamIfAbsent(
|
|
89
|
+
name: string,
|
|
90
|
+
sessionId: string,
|
|
91
|
+
leadAgentId: string,
|
|
92
|
+
description = "",
|
|
93
|
+
defaultModel?: string,
|
|
94
|
+
separateWindows?: boolean,
|
|
95
|
+
metadata?: Record<string, any>
|
|
96
|
+
): TeamConfig {
|
|
97
|
+
return createTeamWithMode({ name, sessionId, leadAgentId, description, defaultModel, separateWindows, metadata }, true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readConfigRaw(p: string): TeamConfig {
|
|
101
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function readConfig(teamName: string): Promise<TeamConfig> {
|
|
105
|
+
const p = configPath(teamName);
|
|
106
|
+
if (!fs.existsSync(p)) throw new Error(`Team ${teamName} not found`);
|
|
107
|
+
return await withLock(p, async () => {
|
|
108
|
+
return readConfigRaw(p);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface EnsureTeamOptions {
|
|
113
|
+
name: string;
|
|
114
|
+
sessionId?: string;
|
|
115
|
+
leadAgentId?: string;
|
|
116
|
+
description?: string;
|
|
117
|
+
defaultModel?: string;
|
|
118
|
+
separateWindows?: boolean;
|
|
119
|
+
metadata?: Record<string, any>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface EnsureTeamResult {
|
|
123
|
+
config: TeamConfig;
|
|
124
|
+
created: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function ensureTeam(options: EnsureTeamOptions): Promise<EnsureTeamResult> {
|
|
128
|
+
if (teamExists(options.name)) {
|
|
129
|
+
return { config: await readConfig(options.name), created: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const config = createTeam(
|
|
133
|
+
options.name,
|
|
134
|
+
options.sessionId || "local-session",
|
|
135
|
+
options.leadAgentId || "lead-agent",
|
|
136
|
+
options.description || "",
|
|
137
|
+
options.defaultModel,
|
|
138
|
+
options.separateWindows,
|
|
139
|
+
options.metadata
|
|
140
|
+
);
|
|
141
|
+
return { config, created: true };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function addMember(teamName: string, member: Member) {
|
|
145
|
+
const p = configPath(teamName);
|
|
146
|
+
if (member.name === "team-lead") {
|
|
147
|
+
await withLock(p, async () => {
|
|
148
|
+
const config = readConfigRaw(p);
|
|
149
|
+
config.members.push(member);
|
|
150
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Every successful admission is a distinct lifecycle run, even when a caller
|
|
156
|
+
// accidentally reuses a previously admitted Member object.
|
|
157
|
+
member.lifecycleRunId = generateLifecycleRunId();
|
|
158
|
+
await withLifecycleTombstoneLock(teamName, member.name, async lifecycleLock => {
|
|
159
|
+
assertLifecycleTombstoneAbsent(teamName, member.name, lifecycleLock.read());
|
|
160
|
+
await withLock(p, async () => {
|
|
161
|
+
const config = readConfigRaw(p);
|
|
162
|
+
if (config.members.some(existing => existing.name === member.name)) {
|
|
163
|
+
throw new Error(`Teammate ${member.name} already exists in team ${teamName}.`);
|
|
164
|
+
}
|
|
165
|
+
config.members.push(member);
|
|
166
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function ensureMemberLifecycleRunId(teamName: string, agentName: string, preferredCompatibilityRunId?: string): Promise<string> {
|
|
172
|
+
const p = configPath(teamName);
|
|
173
|
+
return withLifecycleTombstoneLock(teamName, agentName, async lifecycleLock => {
|
|
174
|
+
const tombstone = lifecycleLock.read();
|
|
175
|
+
if (tombstone.status === "corrupt") {
|
|
176
|
+
assertLifecycleTombstoneAbsent(teamName, agentName, tombstone);
|
|
177
|
+
}
|
|
178
|
+
return withLock(p, async () => {
|
|
179
|
+
const config = readConfigRaw(p);
|
|
180
|
+
const member = config.members.find(item => item.name === agentName);
|
|
181
|
+
if (!member) throw new Error(`Agent ${agentName} is not a member of team ${teamName}.`);
|
|
182
|
+
if (member.lifecycleRunId) return member.lifecycleRunId;
|
|
183
|
+
|
|
184
|
+
// Compatibility identity for persisted pre-v1 members. If closure already
|
|
185
|
+
// fenced the member, adopt that authoritative run instead of inventing a
|
|
186
|
+
// second identity. This field is lifecycle state, never user metadata.
|
|
187
|
+
member.lifecycleRunId = tombstone.status === "occupied"
|
|
188
|
+
? tombstone.tombstone.runId
|
|
189
|
+
: preferredCompatibilityRunId || `compat-${generateLifecycleRunId()}`;
|
|
190
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
191
|
+
return member.lifecycleRunId;
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function removeMember(teamName: string, agentName: string) {
|
|
197
|
+
const p = configPath(teamName);
|
|
198
|
+
await withLock(p, async () => {
|
|
199
|
+
const config = readConfigRaw(p);
|
|
200
|
+
config.members = config.members.filter(m => m.name !== agentName);
|
|
201
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function removeMemberMatchingRun(
|
|
206
|
+
teamName: string,
|
|
207
|
+
agentName: string,
|
|
208
|
+
expectedRunId: string
|
|
209
|
+
): Promise<boolean> {
|
|
210
|
+
const p = configPath(teamName);
|
|
211
|
+
return withLock(p, async () => {
|
|
212
|
+
const config = readConfigRaw(p);
|
|
213
|
+
const index = config.members.findIndex(member => member.name === agentName);
|
|
214
|
+
if (index < 0 || config.members[index].lifecycleRunId !== expectedRunId) return false;
|
|
215
|
+
config.members.splice(index, 1);
|
|
216
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
217
|
+
return true;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function updateMember(teamName: string, agentName: string, updates: Partial<Member>) {
|
|
222
|
+
const p = configPath(teamName);
|
|
223
|
+
await withLock(p, async () => {
|
|
224
|
+
const config = readConfigRaw(p);
|
|
225
|
+
const m = config.members.find(m => m.name === agentName);
|
|
226
|
+
if (m) {
|
|
227
|
+
Object.assign(m, updates);
|
|
228
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|