@hdwebsoft/hdcode-agent-team 0.1.2 → 0.2.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/dist/index.js +536 -69
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -3,13 +3,14 @@ import { readdir as readdir2 } from "node:fs/promises";
|
|
|
3
3
|
import { join as join3 } from "node:path";
|
|
4
4
|
|
|
5
5
|
// src/utils.ts
|
|
6
|
-
import { readdir, mkdir, rename,
|
|
6
|
+
import { readdir, mkdir, rename, rm, stat } from "node:fs/promises";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
var TEAM_ROOT = ".team";
|
|
9
9
|
var SAFE_NAME = /^[a-z0-9-]+$/;
|
|
10
|
-
var
|
|
11
|
-
var
|
|
12
|
-
var
|
|
10
|
+
var LOCK_STALE_MS = 30000;
|
|
11
|
+
var LOCK_RETRY_MIN_MS = 25;
|
|
12
|
+
var LOCK_RETRY_MAX_MS = 100;
|
|
13
|
+
var LOCK_ACQUIRE_TIMEOUT_MS = 1e4;
|
|
13
14
|
function validateName(name, label) {
|
|
14
15
|
if (!SAFE_NAME.test(name)) {
|
|
15
16
|
throw new Error(`Invalid ${label}: "${name}" (must be lowercase alphanumeric + hyphens)`);
|
|
@@ -28,7 +29,7 @@ async function exists(path) {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
async function writeJsonAtomic(path, data) {
|
|
31
|
-
const tmp = path
|
|
32
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
32
33
|
await Bun.write(tmp, JSON.stringify(data, null, 2));
|
|
33
34
|
await rename(tmp, path);
|
|
34
35
|
}
|
|
@@ -48,32 +49,43 @@ async function listJsonFiles(dir) {
|
|
|
48
49
|
async function ensureDir(path) {
|
|
49
50
|
await mkdir(path, { recursive: true });
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
function jitter(min, max) {
|
|
53
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
54
|
+
}
|
|
55
|
+
async function isLockStale(lockDir, staleMs) {
|
|
56
|
+
try {
|
|
57
|
+
const s = await stat(lockDir);
|
|
58
|
+
return Date.now() - s.mtimeMs > staleMs;
|
|
59
|
+
} catch {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function withTeamLock(base, teamName, fn) {
|
|
64
|
+
const locksRoot = join(base, TEAM_ROOT, ".locks");
|
|
65
|
+
const lockDir = join(locksRoot, `${teamName}.lock`);
|
|
66
|
+
await mkdir(locksRoot, { recursive: true });
|
|
67
|
+
const startedAt = Date.now();
|
|
68
|
+
while (true) {
|
|
69
|
+
try {
|
|
70
|
+
await mkdir(lockDir);
|
|
71
|
+
try {
|
|
72
|
+
return await fn();
|
|
73
|
+
} finally {
|
|
74
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err?.code !== "EEXIST")
|
|
78
|
+
throw err;
|
|
79
|
+
if (await isLockStale(lockDir, LOCK_STALE_MS)) {
|
|
80
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {});
|
|
81
|
+
continue;
|
|
61
82
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
break;
|
|
83
|
+
if (Date.now() - startedAt > LOCK_ACQUIRE_TIMEOUT_MS) {
|
|
84
|
+
throw new Error(`Timed out acquiring team lock for "${teamName}"`);
|
|
85
|
+
}
|
|
86
|
+
await Bun.sleep(jitter(LOCK_RETRY_MIN_MS, LOCK_RETRY_MAX_MS));
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
|
-
if (!acquired) {
|
|
70
|
-
throw new Error(`Failed to acquire lock: ${lockPath} (timed out after ${LOCK_MAX_RETRIES * LOCK_RETRY_MS}ms)`);
|
|
71
|
-
}
|
|
72
|
-
try {
|
|
73
|
-
return await fn();
|
|
74
|
-
} finally {
|
|
75
|
-
await unlink(lockPath).catch(() => {});
|
|
76
|
-
}
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
// src/tasks.ts
|
|
@@ -117,6 +129,37 @@ function hasCycle(taskId, blockedBy, allTasks) {
|
|
|
117
129
|
}
|
|
118
130
|
return false;
|
|
119
131
|
}
|
|
132
|
+
function hasCycleViaBlocks(taskId, blockTargets, blockedBySources, allTasks) {
|
|
133
|
+
const upstream = new Set;
|
|
134
|
+
const queue = [...blockedBySources];
|
|
135
|
+
while (queue.length > 0) {
|
|
136
|
+
const current = queue.shift();
|
|
137
|
+
if (upstream.has(current))
|
|
138
|
+
continue;
|
|
139
|
+
upstream.add(current);
|
|
140
|
+
const task = allTasks.get(current);
|
|
141
|
+
if (task)
|
|
142
|
+
queue.push(...task.blockedBy);
|
|
143
|
+
}
|
|
144
|
+
for (const targetId of blockTargets) {
|
|
145
|
+
if (upstream.has(targetId))
|
|
146
|
+
return true;
|
|
147
|
+
const targetQueue = [targetId];
|
|
148
|
+
const visited = new Set;
|
|
149
|
+
while (targetQueue.length > 0) {
|
|
150
|
+
const cur = targetQueue.shift();
|
|
151
|
+
if (visited.has(cur))
|
|
152
|
+
continue;
|
|
153
|
+
visited.add(cur);
|
|
154
|
+
if (upstream.has(cur))
|
|
155
|
+
return true;
|
|
156
|
+
const t = allTasks.get(cur);
|
|
157
|
+
if (t)
|
|
158
|
+
targetQueue.push(...t.blockedBy);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
120
163
|
async function unblockDependents(base, teamName, completedTaskId) {
|
|
121
164
|
const unblocked = [];
|
|
122
165
|
const allTasks = await loadAllTasks(base, teamName);
|
|
@@ -164,7 +207,7 @@ async function getActiveTeamsSummary(base) {
|
|
|
164
207
|
}
|
|
165
208
|
|
|
166
209
|
// src/tools/team-tools.ts
|
|
167
|
-
import { readdir as readdir3, rm } from "node:fs/promises";
|
|
210
|
+
import { readdir as readdir3, rm as rm2 } from "node:fs/promises";
|
|
168
211
|
import { join as join4 } from "node:path";
|
|
169
212
|
import { tool } from "@opencode-ai/plugin";
|
|
170
213
|
|
|
@@ -232,7 +275,7 @@ function createTeamTools(directory) {
|
|
|
232
275
|
if (!await exists(join4(dir, "config.json"))) {
|
|
233
276
|
return `Error: Team "${args.teamName}" not found`;
|
|
234
277
|
}
|
|
235
|
-
await
|
|
278
|
+
await rm2(dir, { recursive: true, force: true });
|
|
236
279
|
return `Team "${args.teamName}" deleted successfully`;
|
|
237
280
|
}
|
|
238
281
|
}),
|
|
@@ -311,8 +354,244 @@ function createTeamTools(directory) {
|
|
|
311
354
|
}
|
|
312
355
|
|
|
313
356
|
// src/tools/task-tools.ts
|
|
314
|
-
import { join as
|
|
357
|
+
import { join as join6 } from "node:path";
|
|
358
|
+
import { unlink as unlink2 } from "node:fs/promises";
|
|
315
359
|
import { tool as tool2 } from "@opencode-ai/plugin";
|
|
360
|
+
|
|
361
|
+
// src/reservations.ts
|
|
362
|
+
import { join as join5 } from "node:path";
|
|
363
|
+
import { randomUUID } from "node:crypto";
|
|
364
|
+
var DEFAULT_TTL_SECONDS = 1800;
|
|
365
|
+
var MAX_TTL_SECONDS = 14400;
|
|
366
|
+
function reservationStorePath(base, teamName) {
|
|
367
|
+
return join5(teamDir(base, teamName), "reservations.json");
|
|
368
|
+
}
|
|
369
|
+
function emptyStore() {
|
|
370
|
+
return { version: 1, updatedAt: new Date().toISOString(), reservations: [] };
|
|
371
|
+
}
|
|
372
|
+
async function loadStore(base, teamName) {
|
|
373
|
+
const path = reservationStorePath(base, teamName);
|
|
374
|
+
if (!await exists(path))
|
|
375
|
+
return emptyStore();
|
|
376
|
+
try {
|
|
377
|
+
return await readJson(path);
|
|
378
|
+
} catch {
|
|
379
|
+
return emptyStore();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function saveStore(base, teamName, store) {
|
|
383
|
+
store.updatedAt = new Date().toISOString();
|
|
384
|
+
await writeJsonAtomic(reservationStorePath(base, teamName), store);
|
|
385
|
+
}
|
|
386
|
+
function normalizeRepoPath(input) {
|
|
387
|
+
let p = input.replace(/\\/g, "/");
|
|
388
|
+
if (p.startsWith("./"))
|
|
389
|
+
p = p.slice(2);
|
|
390
|
+
if (p.startsWith("/"))
|
|
391
|
+
throw new Error(`Absolute paths not allowed: ${input}`);
|
|
392
|
+
if (p.includes(".."))
|
|
393
|
+
throw new Error(`Path traversal not allowed: ${input}`);
|
|
394
|
+
return p;
|
|
395
|
+
}
|
|
396
|
+
function globToPrefix(glob) {
|
|
397
|
+
const normalized = glob.replace(/\\/g, "/");
|
|
398
|
+
if (!normalized.endsWith("/**")) {
|
|
399
|
+
throw new Error(`Only dir/** globs are supported, got: "${glob}"`);
|
|
400
|
+
}
|
|
401
|
+
const prefix = normalized.slice(0, -3);
|
|
402
|
+
if (!prefix || prefix.includes("*")) {
|
|
403
|
+
throw new Error(`Invalid glob pattern: "${glob}"`);
|
|
404
|
+
}
|
|
405
|
+
return normalizeRepoPath(prefix);
|
|
406
|
+
}
|
|
407
|
+
function isPathInsidePrefix(path, prefix) {
|
|
408
|
+
return path === prefix || path.startsWith(prefix + "/");
|
|
409
|
+
}
|
|
410
|
+
function pruneExpired(store, now = Date.now()) {
|
|
411
|
+
const before = store.reservations.length;
|
|
412
|
+
store.reservations = store.reservations.filter((r) => new Date(r.expiresAt).getTime() > now);
|
|
413
|
+
return store.reservations.length !== before;
|
|
414
|
+
}
|
|
415
|
+
function findConflicts(req, active) {
|
|
416
|
+
const conflicts = [];
|
|
417
|
+
for (const r of active) {
|
|
418
|
+
if (r.agentId === req.agentId)
|
|
419
|
+
continue;
|
|
420
|
+
for (const f of req.files) {
|
|
421
|
+
if (r.files.includes(f)) {
|
|
422
|
+
conflicts.push({
|
|
423
|
+
reservationId: r.id,
|
|
424
|
+
agentId: r.agentId,
|
|
425
|
+
taskId: r.taskId,
|
|
426
|
+
reason: r.reason,
|
|
427
|
+
matchedFile: f,
|
|
428
|
+
expiresAt: r.expiresAt
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
for (const p of r.prefixes) {
|
|
432
|
+
if (isPathInsidePrefix(f, p)) {
|
|
433
|
+
conflicts.push({
|
|
434
|
+
reservationId: r.id,
|
|
435
|
+
agentId: r.agentId,
|
|
436
|
+
taskId: r.taskId,
|
|
437
|
+
reason: r.reason,
|
|
438
|
+
matchedPrefix: p,
|
|
439
|
+
expiresAt: r.expiresAt
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
for (const newP of req.prefixes) {
|
|
445
|
+
for (const existFile of r.files) {
|
|
446
|
+
if (isPathInsidePrefix(existFile, newP)) {
|
|
447
|
+
conflicts.push({
|
|
448
|
+
reservationId: r.id,
|
|
449
|
+
agentId: r.agentId,
|
|
450
|
+
taskId: r.taskId,
|
|
451
|
+
reason: r.reason,
|
|
452
|
+
matchedFile: existFile,
|
|
453
|
+
expiresAt: r.expiresAt
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const existP of r.prefixes) {
|
|
458
|
+
if (isPathInsidePrefix(newP, existP) || isPathInsidePrefix(existP, newP)) {
|
|
459
|
+
conflicts.push({
|
|
460
|
+
reservationId: r.id,
|
|
461
|
+
agentId: r.agentId,
|
|
462
|
+
taskId: r.taskId,
|
|
463
|
+
reason: r.reason,
|
|
464
|
+
matchedPrefix: existP,
|
|
465
|
+
expiresAt: r.expiresAt
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return conflicts;
|
|
472
|
+
}
|
|
473
|
+
async function reserveFiles(base, teamName, opts) {
|
|
474
|
+
const normalizedFiles = (opts.files || []).map(normalizeRepoPath);
|
|
475
|
+
const prefixes = (opts.globs || []).map(globToPrefix);
|
|
476
|
+
if (normalizedFiles.length === 0 && prefixes.length === 0) {
|
|
477
|
+
return { ok: true };
|
|
478
|
+
}
|
|
479
|
+
const ttl = Math.min(opts.ttlSeconds || DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS);
|
|
480
|
+
const store = await loadStore(base, teamName);
|
|
481
|
+
pruneExpired(store);
|
|
482
|
+
const conflicts = findConflicts({ files: normalizedFiles, prefixes, agentId: opts.agentId }, store.reservations);
|
|
483
|
+
if (conflicts.length > 0) {
|
|
484
|
+
return { ok: false, conflicts };
|
|
485
|
+
}
|
|
486
|
+
const existingIdx = opts.taskId ? store.reservations.findIndex((r) => r.agentId === opts.agentId && r.taskId === opts.taskId) : -1;
|
|
487
|
+
const now = new Date;
|
|
488
|
+
const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
|
|
489
|
+
if (existingIdx >= 0) {
|
|
490
|
+
const existing = store.reservations[existingIdx];
|
|
491
|
+
for (const f of normalizedFiles) {
|
|
492
|
+
if (!existing.files.includes(f))
|
|
493
|
+
existing.files.push(f);
|
|
494
|
+
}
|
|
495
|
+
for (const p of prefixes) {
|
|
496
|
+
if (!existing.prefixes.includes(p))
|
|
497
|
+
existing.prefixes.push(p);
|
|
498
|
+
}
|
|
499
|
+
existing.expiresAt = expiresAt;
|
|
500
|
+
await saveStore(base, teamName, store);
|
|
501
|
+
return { ok: true, reservationId: existing.id, expiresAt };
|
|
502
|
+
}
|
|
503
|
+
const reservation = {
|
|
504
|
+
id: `resv_${randomUUID().slice(0, 8)}`,
|
|
505
|
+
agentId: opts.agentId,
|
|
506
|
+
...opts.taskId && { taskId: opts.taskId },
|
|
507
|
+
...opts.reason && { reason: opts.reason },
|
|
508
|
+
files: normalizedFiles,
|
|
509
|
+
prefixes,
|
|
510
|
+
createdAt: now.toISOString(),
|
|
511
|
+
expiresAt
|
|
512
|
+
};
|
|
513
|
+
store.reservations.push(reservation);
|
|
514
|
+
await saveStore(base, teamName, store);
|
|
515
|
+
return { ok: true, reservationId: reservation.id, expiresAt };
|
|
516
|
+
}
|
|
517
|
+
async function releaseByTask(base, teamName, agentId, taskId) {
|
|
518
|
+
const store = await loadStore(base, teamName);
|
|
519
|
+
const released = [];
|
|
520
|
+
store.reservations = store.reservations.filter((r) => {
|
|
521
|
+
if (r.agentId === agentId && r.taskId === taskId) {
|
|
522
|
+
released.push(r.id);
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
return true;
|
|
526
|
+
});
|
|
527
|
+
if (released.length > 0) {
|
|
528
|
+
await saveStore(base, teamName, store);
|
|
529
|
+
}
|
|
530
|
+
return released;
|
|
531
|
+
}
|
|
532
|
+
async function releaseByAgent(base, teamName, agentId) {
|
|
533
|
+
const store = await loadStore(base, teamName);
|
|
534
|
+
const released = [];
|
|
535
|
+
store.reservations = store.reservations.filter((r) => {
|
|
536
|
+
if (r.agentId === agentId) {
|
|
537
|
+
released.push(r.id);
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
return true;
|
|
541
|
+
});
|
|
542
|
+
if (released.length > 0) {
|
|
543
|
+
await saveStore(base, teamName, store);
|
|
544
|
+
}
|
|
545
|
+
return released;
|
|
546
|
+
}
|
|
547
|
+
async function releaseById(base, teamName, agentId, reservationId) {
|
|
548
|
+
const store = await loadStore(base, teamName);
|
|
549
|
+
const idx = store.reservations.findIndex((r) => r.id === reservationId && r.agentId === agentId);
|
|
550
|
+
if (idx < 0)
|
|
551
|
+
return false;
|
|
552
|
+
store.reservations.splice(idx, 1);
|
|
553
|
+
await saveStore(base, teamName, store);
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
async function checkFile(base, teamName, path, agentId) {
|
|
557
|
+
const normalized = normalizeRepoPath(path);
|
|
558
|
+
const store = await loadStore(base, teamName);
|
|
559
|
+
pruneExpired(store);
|
|
560
|
+
const holders = [];
|
|
561
|
+
for (const r of store.reservations) {
|
|
562
|
+
if (r.files.includes(normalized)) {
|
|
563
|
+
holders.push({
|
|
564
|
+
reservationId: r.id,
|
|
565
|
+
agentId: r.agentId,
|
|
566
|
+
taskId: r.taskId,
|
|
567
|
+
reason: r.reason,
|
|
568
|
+
expiresAt: r.expiresAt,
|
|
569
|
+
matchedBy: "file",
|
|
570
|
+
value: normalized
|
|
571
|
+
});
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
for (const p of r.prefixes) {
|
|
575
|
+
if (isPathInsidePrefix(normalized, p)) {
|
|
576
|
+
holders.push({
|
|
577
|
+
reservationId: r.id,
|
|
578
|
+
agentId: r.agentId,
|
|
579
|
+
taskId: r.taskId,
|
|
580
|
+
reason: r.reason,
|
|
581
|
+
expiresAt: r.expiresAt,
|
|
582
|
+
matchedBy: "prefix",
|
|
583
|
+
value: p
|
|
584
|
+
});
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const reserved = holders.length > 0;
|
|
590
|
+
const canEdit = !reserved || !!agentId && holders.every((h) => h.agentId === agentId);
|
|
591
|
+
return { reserved, canEdit, holders };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/tools/task-tools.ts
|
|
316
595
|
function parseStringArray(raw, label) {
|
|
317
596
|
if (!raw.trimStart().startsWith("[")) {
|
|
318
597
|
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
@@ -339,17 +618,16 @@ function createTaskTools(directory) {
|
|
|
339
618
|
activeForm: tool2.schema.string().optional().describe("Spinner text when in_progress"),
|
|
340
619
|
addBlocks: tool2.schema.string().optional().describe('JSON array of task IDs this task blocks, e.g. ["3","4"]'),
|
|
341
620
|
addBlockedBy: tool2.schema.string().optional().describe('JSON array of task IDs that block this task, e.g. ["1"]'),
|
|
342
|
-
metadata: tool2.schema.string().optional().describe(
|
|
621
|
+
metadata: tool2.schema.string().optional().describe('JSON object of arbitrary metadata (use fileScope key for file reservation: {"fileScope": {"files": [...], "globs": [...]}})')
|
|
343
622
|
},
|
|
344
623
|
async execute(args) {
|
|
345
624
|
const dir = teamDir(directory, args.teamName);
|
|
346
|
-
if (!await exists(
|
|
625
|
+
if (!await exists(join6(dir, "config.json"))) {
|
|
347
626
|
return `Error: Team "${args.teamName}" not found`;
|
|
348
627
|
}
|
|
349
|
-
const tasksDir =
|
|
628
|
+
const tasksDir = join6(dir, "tasks");
|
|
350
629
|
await ensureDir(tasksDir);
|
|
351
|
-
|
|
352
|
-
return withLock(tasksLockPath, async () => {
|
|
630
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
353
631
|
let addBlocks = [];
|
|
354
632
|
let addBlockedBy = [];
|
|
355
633
|
let metadata;
|
|
@@ -386,6 +664,9 @@ function createTaskTools(directory) {
|
|
|
386
664
|
if (addBlockedBy.length > 0 && hasCycle(id, addBlockedBy, taskMap)) {
|
|
387
665
|
return `Error: Circular dependency detected for task ${id}`;
|
|
388
666
|
}
|
|
667
|
+
if (addBlocks.length > 0 && hasCycleViaBlocks(id, addBlocks, addBlockedBy, taskMap)) {
|
|
668
|
+
return `Error: Circular dependency detected for task ${id}`;
|
|
669
|
+
}
|
|
389
670
|
const task = {
|
|
390
671
|
id,
|
|
391
672
|
subject: args.subject,
|
|
@@ -396,7 +677,7 @@ function createTaskTools(directory) {
|
|
|
396
677
|
blockedBy: addBlockedBy,
|
|
397
678
|
...metadata && { metadata }
|
|
398
679
|
};
|
|
399
|
-
await writeJsonAtomic(
|
|
680
|
+
await writeJsonAtomic(join6(tasksDir, `${id}.json`), task);
|
|
400
681
|
for (const targetId of addBlockedBy) {
|
|
401
682
|
const targetPath = taskFilePath(directory, args.teamName, targetId);
|
|
402
683
|
const target = await readJson(targetPath);
|
|
@@ -418,7 +699,7 @@ function createTaskTools(directory) {
|
|
|
418
699
|
}
|
|
419
700
|
}),
|
|
420
701
|
task_update: tool2({
|
|
421
|
-
description: "Update task fields, manage dependencies, auto-unblock. Status transitions: pending→in_progress, in_progress→completed. On
|
|
702
|
+
description: "Update task fields, manage dependencies, auto-unblock. Status transitions: pending→in_progress, in_progress→completed. On in_progress with fileScope metadata: auto-reserves files. On completed: auto-releases. Returns {task, unblocked[]}.",
|
|
422
703
|
args: {
|
|
423
704
|
teamName: tool2.schema.string().describe("Team name"),
|
|
424
705
|
taskId: tool2.schema.string().describe("Task ID (integer string)"),
|
|
@@ -433,12 +714,10 @@ function createTaskTools(directory) {
|
|
|
433
714
|
},
|
|
434
715
|
async execute(args) {
|
|
435
716
|
const dir = teamDir(directory, args.teamName);
|
|
436
|
-
if (!await exists(
|
|
717
|
+
if (!await exists(join6(dir, "config.json"))) {
|
|
437
718
|
return `Error: Team "${args.teamName}" not found`;
|
|
438
719
|
}
|
|
439
|
-
|
|
440
|
-
const tasksLockPath = join5(tasksDir, ".lock");
|
|
441
|
-
return withLock(tasksLockPath, async () => {
|
|
720
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
442
721
|
const tPath = taskFilePath(directory, args.teamName, args.taskId);
|
|
443
722
|
if (!await exists(tPath)) {
|
|
444
723
|
return `Error: Task "${args.taskId}" not found in team "${args.teamName}"`;
|
|
@@ -469,6 +748,27 @@ function createTaskTools(directory) {
|
|
|
469
748
|
return `Error: Cannot start task with unresolved blockers: ${activeBlockers.join(", ")}`;
|
|
470
749
|
}
|
|
471
750
|
}
|
|
751
|
+
if (args.status === "in_progress") {
|
|
752
|
+
const fileScope = task.metadata?.fileScope;
|
|
753
|
+
if (fileScope) {
|
|
754
|
+
const agentId = args.owner || task.owner || "unknown";
|
|
755
|
+
const reserveResult = await reserveFiles(directory, args.teamName, {
|
|
756
|
+
agentId,
|
|
757
|
+
taskId: task.id,
|
|
758
|
+
files: fileScope.files,
|
|
759
|
+
globs: fileScope.globs
|
|
760
|
+
});
|
|
761
|
+
if (!reserveResult.ok) {
|
|
762
|
+
return `Error: Cannot start task — file reservation conflict: ${JSON.stringify(reserveResult.conflicts)}`;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
if (args.status === "completed") {
|
|
767
|
+
const agentId = args.owner || task.owner;
|
|
768
|
+
if (agentId) {
|
|
769
|
+
await releaseByTask(directory, args.teamName, agentId, task.id);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
472
772
|
task.status = args.status;
|
|
473
773
|
}
|
|
474
774
|
if (args.subject !== undefined)
|
|
@@ -492,6 +792,11 @@ function createTaskTools(directory) {
|
|
|
492
792
|
const result = parseStringArray(args.addBlocks, "addBlocks");
|
|
493
793
|
if (typeof result === "string")
|
|
494
794
|
return result;
|
|
795
|
+
const allTasks = await loadAllTasks(directory, args.teamName);
|
|
796
|
+
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
|
797
|
+
if (hasCycleViaBlocks(task.id, result, task.blockedBy, taskMap)) {
|
|
798
|
+
return `Error: Circular dependency detected — cannot add blocks`;
|
|
799
|
+
}
|
|
495
800
|
for (const targetId of result) {
|
|
496
801
|
if (!task.blocks.includes(targetId)) {
|
|
497
802
|
task.blocks.push(targetId);
|
|
@@ -510,6 +815,11 @@ function createTaskTools(directory) {
|
|
|
510
815
|
const result = parseStringArray(args.addBlockedBy, "addBlockedBy");
|
|
511
816
|
if (typeof result === "string")
|
|
512
817
|
return result;
|
|
818
|
+
const allTasks = await loadAllTasks(directory, args.teamName);
|
|
819
|
+
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
|
820
|
+
if (hasCycle(task.id, result, taskMap)) {
|
|
821
|
+
return `Error: Circular dependency detected — cannot add blockedBy`;
|
|
822
|
+
}
|
|
513
823
|
for (const targetId of result) {
|
|
514
824
|
if (!task.blockedBy.includes(targetId)) {
|
|
515
825
|
task.blockedBy.push(targetId);
|
|
@@ -533,6 +843,47 @@ function createTaskTools(directory) {
|
|
|
533
843
|
});
|
|
534
844
|
}
|
|
535
845
|
}),
|
|
846
|
+
task_delete: tool2({
|
|
847
|
+
description: "Delete a task and clean up all bidirectional dependency links. Removes this task from blocks[] and blockedBy[] of all related tasks. Auto-releases file reservations.",
|
|
848
|
+
args: {
|
|
849
|
+
teamName: tool2.schema.string().describe("Team name"),
|
|
850
|
+
taskId: tool2.schema.string().describe("Task ID to delete")
|
|
851
|
+
},
|
|
852
|
+
async execute(args) {
|
|
853
|
+
const dir = teamDir(directory, args.teamName);
|
|
854
|
+
if (!await exists(join6(dir, "config.json"))) {
|
|
855
|
+
return `Error: Team "${args.teamName}" not found`;
|
|
856
|
+
}
|
|
857
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
858
|
+
const tPath = taskFilePath(directory, args.teamName, args.taskId);
|
|
859
|
+
if (!await exists(tPath)) {
|
|
860
|
+
return `Error: Task "${args.taskId}" not found in team "${args.teamName}"`;
|
|
861
|
+
}
|
|
862
|
+
const task = await readJson(tPath);
|
|
863
|
+
if (task.owner) {
|
|
864
|
+
await releaseByTask(directory, args.teamName, task.owner, task.id);
|
|
865
|
+
}
|
|
866
|
+
for (const blockerId of task.blockedBy) {
|
|
867
|
+
const blockerPath = taskFilePath(directory, args.teamName, blockerId);
|
|
868
|
+
if (await exists(blockerPath)) {
|
|
869
|
+
const blocker = await readJson(blockerPath);
|
|
870
|
+
blocker.blocks = blocker.blocks.filter((id) => id !== args.taskId);
|
|
871
|
+
await writeJsonAtomic(blockerPath, blocker);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
for (const blockedId of task.blocks) {
|
|
875
|
+
const blockedPath = taskFilePath(directory, args.teamName, blockedId);
|
|
876
|
+
if (await exists(blockedPath)) {
|
|
877
|
+
const blocked = await readJson(blockedPath);
|
|
878
|
+
blocked.blockedBy = blocked.blockedBy.filter((id) => id !== args.taskId);
|
|
879
|
+
await writeJsonAtomic(blockedPath, blocked);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
await unlink2(tPath);
|
|
883
|
+
return `Task "${args.taskId}" deleted and all dependency links cleaned up`;
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}),
|
|
536
887
|
task_get: tool2({
|
|
537
888
|
description: "Get a single task by ID.",
|
|
538
889
|
args: {
|
|
@@ -554,7 +905,7 @@ function createTaskTools(directory) {
|
|
|
554
905
|
},
|
|
555
906
|
async execute(args) {
|
|
556
907
|
const dir = teamDir(directory, args.teamName);
|
|
557
|
-
if (!await exists(
|
|
908
|
+
if (!await exists(join6(dir, "config.json"))) {
|
|
558
909
|
return `Error: Team "${args.teamName}" not found`;
|
|
559
910
|
}
|
|
560
911
|
const tasks = await loadAllTasks(directory, args.teamName);
|
|
@@ -566,7 +917,7 @@ function createTaskTools(directory) {
|
|
|
566
917
|
}
|
|
567
918
|
|
|
568
919
|
// src/tools/message-tools.ts
|
|
569
|
-
import { join as
|
|
920
|
+
import { join as join7 } from "node:path";
|
|
570
921
|
import { tool as tool3 } from "@opencode-ai/plugin";
|
|
571
922
|
function createMessageTools(directory) {
|
|
572
923
|
return {
|
|
@@ -588,14 +939,13 @@ function createMessageTools(directory) {
|
|
|
588
939
|
},
|
|
589
940
|
async execute(args) {
|
|
590
941
|
const dir = teamDir(directory, args.teamName);
|
|
591
|
-
const configPath =
|
|
942
|
+
const configPath = join7(dir, "config.json");
|
|
592
943
|
if (!await exists(configPath)) {
|
|
593
944
|
return `Error: Team "${args.teamName}" not found`;
|
|
594
945
|
}
|
|
595
946
|
const sender = args.from || "team-lead";
|
|
596
|
-
const inboxesDir =
|
|
947
|
+
const inboxesDir = join7(dir, "inboxes");
|
|
597
948
|
await ensureDir(inboxesDir);
|
|
598
|
-
const inboxesLockPath = join6(inboxesDir, ".lock");
|
|
599
949
|
const message = {
|
|
600
950
|
from: sender,
|
|
601
951
|
text: args.content,
|
|
@@ -606,11 +956,11 @@ function createMessageTools(directory) {
|
|
|
606
956
|
const config = await readJson(configPath);
|
|
607
957
|
if (args.type === "broadcast") {
|
|
608
958
|
const recipients = [];
|
|
609
|
-
return
|
|
959
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
610
960
|
for (const member of config.members) {
|
|
611
961
|
if (member.name === sender)
|
|
612
962
|
continue;
|
|
613
|
-
const inboxPath =
|
|
963
|
+
const inboxPath = join7(inboxesDir, `${member.name}.json`);
|
|
614
964
|
await appendToInbox(inboxPath, message);
|
|
615
965
|
recipients.push(member.name);
|
|
616
966
|
}
|
|
@@ -625,8 +975,8 @@ function createMessageTools(directory) {
|
|
|
625
975
|
if (!member) {
|
|
626
976
|
return `Error: Recipient "${args.recipient}" not found in team "${args.teamName}"`;
|
|
627
977
|
}
|
|
628
|
-
return
|
|
629
|
-
const inboxPath =
|
|
978
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
979
|
+
const inboxPath = join7(inboxesDir, `${member.name}.json`);
|
|
630
980
|
await appendToInbox(inboxPath, message);
|
|
631
981
|
return JSON.stringify({ sent: true, recipients: [member.name] }, null, 2);
|
|
632
982
|
});
|
|
@@ -643,34 +993,150 @@ function createMessageTools(directory) {
|
|
|
643
993
|
},
|
|
644
994
|
async execute(args) {
|
|
645
995
|
const dir = teamDir(directory, args.teamName);
|
|
646
|
-
if (!await exists(
|
|
996
|
+
if (!await exists(join7(dir, "config.json"))) {
|
|
647
997
|
return `Error: Team "${args.teamName}" not found`;
|
|
648
998
|
}
|
|
649
999
|
const agentName = args.agent.includes("@") ? args.agent.split("@")[0] : args.agent;
|
|
650
|
-
const inboxPath =
|
|
1000
|
+
const inboxPath = join7(dir, "inboxes", `${agentName}.json`);
|
|
1001
|
+
if (args.markRead) {
|
|
1002
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
1003
|
+
const allMessages = await readInbox(inboxPath);
|
|
1004
|
+
const sinceDate = args.since ? new Date(args.since) : null;
|
|
1005
|
+
let changed = false;
|
|
1006
|
+
for (const m of allMessages) {
|
|
1007
|
+
if (sinceDate && new Date(m.timestamp) <= sinceDate)
|
|
1008
|
+
continue;
|
|
1009
|
+
if (!m.read) {
|
|
1010
|
+
m.read = true;
|
|
1011
|
+
changed = true;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if (changed) {
|
|
1015
|
+
await writeJsonAtomic(inboxPath, allMessages);
|
|
1016
|
+
}
|
|
1017
|
+
let filtered = allMessages;
|
|
1018
|
+
if (sinceDate) {
|
|
1019
|
+
filtered = allMessages.filter((m) => new Date(m.timestamp) > sinceDate);
|
|
1020
|
+
}
|
|
1021
|
+
filtered.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
1022
|
+
return filtered.length > 0 ? JSON.stringify(filtered, null, 2) : "No messages found";
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
651
1025
|
let messages = await readInbox(inboxPath);
|
|
652
1026
|
if (args.since) {
|
|
653
1027
|
const sinceDate = new Date(args.since);
|
|
654
1028
|
messages = messages.filter((m) => new Date(m.timestamp) > sinceDate);
|
|
655
1029
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
1030
|
+
messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
1031
|
+
return messages.length > 0 ? JSON.stringify(messages, null, 2) : "No messages found";
|
|
1032
|
+
}
|
|
1033
|
+
})
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// src/tools/reservation-tools.ts
|
|
1038
|
+
import { join as join8 } from "node:path";
|
|
1039
|
+
import { tool as tool4 } from "@opencode-ai/plugin";
|
|
1040
|
+
function createReservationTools(directory) {
|
|
1041
|
+
return {
|
|
1042
|
+
file_reserve: tool4({
|
|
1043
|
+
description: "Reserve file paths or directory globs (dir/**) before editing code. Prevents other agents from editing the same files. Returns reservation ID or conflict details. Reservations auto-expire after TTL.",
|
|
1044
|
+
args: {
|
|
1045
|
+
teamName: tool4.schema.string().describe("Team name"),
|
|
1046
|
+
agentId: tool4.schema.string().describe("Agent name reserving the files"),
|
|
1047
|
+
taskId: tool4.schema.string().optional().describe("Task ID (for auto-release on task completion)"),
|
|
1048
|
+
reason: tool4.schema.string().optional().describe("Why these files are being reserved"),
|
|
1049
|
+
files: tool4.schema.string().optional().describe('JSON array of exact file paths, e.g. ["src/auth.ts","src/api/users.ts"]'),
|
|
1050
|
+
globs: tool4.schema.string().optional().describe('JSON array of dir/** globs, e.g. ["src/api/**","src/lib/**"]'),
|
|
1051
|
+
ttlSeconds: tool4.schema.number().optional().describe("TTL in seconds (default 1800, max 14400)")
|
|
1052
|
+
},
|
|
1053
|
+
async execute(args) {
|
|
1054
|
+
const dir = teamDir(directory, args.teamName);
|
|
1055
|
+
if (!await exists(join8(dir, "config.json"))) {
|
|
1056
|
+
return `Error: Team "${args.teamName}" not found`;
|
|
1057
|
+
}
|
|
1058
|
+
let files;
|
|
1059
|
+
let globs;
|
|
1060
|
+
if (args.files) {
|
|
1061
|
+
try {
|
|
1062
|
+
files = JSON.parse(args.files);
|
|
1063
|
+
if (!Array.isArray(files))
|
|
1064
|
+
return "Error: files must be a JSON array";
|
|
1065
|
+
} catch {
|
|
1066
|
+
return "Error: Invalid files JSON";
|
|
667
1067
|
}
|
|
668
|
-
|
|
669
|
-
|
|
1068
|
+
}
|
|
1069
|
+
if (args.globs) {
|
|
1070
|
+
try {
|
|
1071
|
+
globs = JSON.parse(args.globs);
|
|
1072
|
+
if (!Array.isArray(globs))
|
|
1073
|
+
return "Error: globs must be a JSON array";
|
|
1074
|
+
} catch {
|
|
1075
|
+
return "Error: Invalid globs JSON";
|
|
670
1076
|
}
|
|
671
1077
|
}
|
|
672
|
-
|
|
673
|
-
|
|
1078
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
1079
|
+
try {
|
|
1080
|
+
const result = await reserveFiles(directory, args.teamName, {
|
|
1081
|
+
agentId: args.agentId,
|
|
1082
|
+
taskId: args.taskId,
|
|
1083
|
+
reason: args.reason,
|
|
1084
|
+
files,
|
|
1085
|
+
globs,
|
|
1086
|
+
ttlSeconds: args.ttlSeconds
|
|
1087
|
+
});
|
|
1088
|
+
return JSON.stringify(result, null, 2);
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
return `Error: ${err.message}`;
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
}),
|
|
1095
|
+
file_release: tool4({
|
|
1096
|
+
description: "Release file reservations. Can release by reservation ID, by task ID, or release all reservations for an agent.",
|
|
1097
|
+
args: {
|
|
1098
|
+
teamName: tool4.schema.string().describe("Team name"),
|
|
1099
|
+
agentId: tool4.schema.string().describe("Agent name releasing the reservation"),
|
|
1100
|
+
reservationId: tool4.schema.string().optional().describe("Specific reservation ID to release"),
|
|
1101
|
+
taskId: tool4.schema.string().optional().describe("Release all reservations for this task")
|
|
1102
|
+
},
|
|
1103
|
+
async execute(args) {
|
|
1104
|
+
const dir = teamDir(directory, args.teamName);
|
|
1105
|
+
if (!await exists(join8(dir, "config.json"))) {
|
|
1106
|
+
return `Error: Team "${args.teamName}" not found`;
|
|
1107
|
+
}
|
|
1108
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
1109
|
+
if (args.reservationId) {
|
|
1110
|
+
const ok = await releaseById(directory, args.teamName, args.agentId, args.reservationId);
|
|
1111
|
+
return ok ? JSON.stringify({ ok: true, releasedReservationIds: [args.reservationId] }, null, 2) : `Error: Reservation "${args.reservationId}" not found or not owned by "${args.agentId}"`;
|
|
1112
|
+
}
|
|
1113
|
+
if (args.taskId) {
|
|
1114
|
+
const released2 = await releaseByTask(directory, args.teamName, args.agentId, args.taskId);
|
|
1115
|
+
return JSON.stringify({ ok: true, releasedReservationIds: released2 }, null, 2);
|
|
1116
|
+
}
|
|
1117
|
+
const released = await releaseByAgent(directory, args.teamName, args.agentId);
|
|
1118
|
+
return JSON.stringify({ ok: true, releasedReservationIds: released }, null, 2);
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}),
|
|
1122
|
+
file_check: tool4({
|
|
1123
|
+
description: "Check if a file is reserved by another agent before editing. Returns reservation status and whether the caller can edit. Lock-free read — safe to call frequently.",
|
|
1124
|
+
args: {
|
|
1125
|
+
teamName: tool4.schema.string().describe("Team name"),
|
|
1126
|
+
path: tool4.schema.string().describe("File path to check (repo-relative, e.g. src/auth.ts)"),
|
|
1127
|
+
agentId: tool4.schema.string().optional().describe("Caller's agent name (to determine canEdit)")
|
|
1128
|
+
},
|
|
1129
|
+
async execute(args) {
|
|
1130
|
+
const dir = teamDir(directory, args.teamName);
|
|
1131
|
+
if (!await exists(join8(dir, "config.json"))) {
|
|
1132
|
+
return `Error: Team "${args.teamName}" not found`;
|
|
1133
|
+
}
|
|
1134
|
+
try {
|
|
1135
|
+
const result = await checkFile(directory, args.teamName, args.path, args.agentId);
|
|
1136
|
+
return JSON.stringify(result, null, 2);
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
return `Error: ${err.message}`;
|
|
1139
|
+
}
|
|
674
1140
|
}
|
|
675
1141
|
})
|
|
676
1142
|
};
|
|
@@ -696,7 +1162,8 @@ Use team_status(teamName) to get full details. Continue any in-progress tasks.
|
|
|
696
1162
|
tool: {
|
|
697
1163
|
...createTeamTools(directory),
|
|
698
1164
|
...createTaskTools(directory),
|
|
699
|
-
...createMessageTools(directory)
|
|
1165
|
+
...createMessageTools(directory),
|
|
1166
|
+
...createReservationTools(directory)
|
|
700
1167
|
}
|
|
701
1168
|
};
|
|
702
1169
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hdwebsoft/hdcode-agent-team",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "OpenCode plugin for multi-agent team coordination — per-agent inboxes, task management with dependency tracking, and
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OpenCode plugin for multi-agent team coordination — per-agent inboxes, task management with dependency tracking, file reservation/locking, and atomic mkdir-based concurrency control.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
"multi-agent",
|
|
30
30
|
"task-management",
|
|
31
31
|
"inbox",
|
|
32
|
-
"coordination"
|
|
32
|
+
"coordination",
|
|
33
|
+
"file-reservation",
|
|
34
|
+
"concurrency"
|
|
33
35
|
],
|
|
34
36
|
"author": "HDWebSoft",
|
|
35
37
|
"license": "MIT",
|