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.
Files changed (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. 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
+ }