@hdwebsoft/hdcode-agent-team 0.1.3 → 0.2.1
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 +545 -74
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { relative, isAbsolute } from "node:path";
|
|
3
|
+
|
|
1
4
|
// src/teams.ts
|
|
2
5
|
import { readdir as readdir2 } from "node:fs/promises";
|
|
3
6
|
import { join as join3 } from "node:path";
|
|
4
7
|
|
|
5
8
|
// src/utils.ts
|
|
6
|
-
import { readdir, mkdir, rename,
|
|
9
|
+
import { readdir, mkdir, rename, rm, stat } from "node:fs/promises";
|
|
7
10
|
import { join } from "node:path";
|
|
8
11
|
var TEAM_ROOT = ".team";
|
|
9
12
|
var SAFE_NAME = /^[a-z0-9-]+$/;
|
|
10
|
-
var
|
|
11
|
-
var
|
|
12
|
-
var
|
|
13
|
+
var LOCK_STALE_MS = 30000;
|
|
14
|
+
var LOCK_RETRY_MIN_MS = 25;
|
|
15
|
+
var LOCK_RETRY_MAX_MS = 100;
|
|
16
|
+
var LOCK_ACQUIRE_TIMEOUT_MS = 1e4;
|
|
13
17
|
function validateName(name, label) {
|
|
14
18
|
if (!SAFE_NAME.test(name)) {
|
|
15
19
|
throw new Error(`Invalid ${label}: "${name}" (must be lowercase alphanumeric + hyphens)`);
|
|
@@ -28,7 +32,7 @@ async function exists(path) {
|
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
async function writeJsonAtomic(path, data) {
|
|
31
|
-
const tmp = path
|
|
35
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
32
36
|
await Bun.write(tmp, JSON.stringify(data, null, 2));
|
|
33
37
|
await rename(tmp, path);
|
|
34
38
|
}
|
|
@@ -48,32 +52,43 @@ async function listJsonFiles(dir) {
|
|
|
48
52
|
async function ensureDir(path) {
|
|
49
53
|
await mkdir(path, { recursive: true });
|
|
50
54
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
function jitter(min, max) {
|
|
56
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
57
|
+
}
|
|
58
|
+
async function isLockStale(lockDir, staleMs) {
|
|
59
|
+
try {
|
|
60
|
+
const s = await stat(lockDir);
|
|
61
|
+
return Date.now() - s.mtimeMs > staleMs;
|
|
62
|
+
} catch {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function withTeamLock(base, teamName, fn) {
|
|
67
|
+
const locksRoot = join(base, TEAM_ROOT, ".locks");
|
|
68
|
+
const lockDir = join(locksRoot, `${teamName}.lock`);
|
|
69
|
+
await mkdir(locksRoot, { recursive: true });
|
|
70
|
+
const startedAt = Date.now();
|
|
71
|
+
while (true) {
|
|
72
|
+
try {
|
|
73
|
+
await mkdir(lockDir);
|
|
74
|
+
try {
|
|
75
|
+
return await fn();
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {});
|
|
61
78
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err?.code !== "EEXIST")
|
|
81
|
+
throw err;
|
|
82
|
+
if (await isLockStale(lockDir, LOCK_STALE_MS)) {
|
|
83
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => {});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (Date.now() - startedAt > LOCK_ACQUIRE_TIMEOUT_MS) {
|
|
87
|
+
throw new Error(`Timed out acquiring team lock for "${teamName}"`);
|
|
88
|
+
}
|
|
89
|
+
await Bun.sleep(jitter(LOCK_RETRY_MIN_MS, LOCK_RETRY_MAX_MS));
|
|
67
90
|
}
|
|
68
91
|
}
|
|
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
92
|
}
|
|
78
93
|
|
|
79
94
|
// src/tasks.ts
|
|
@@ -195,7 +210,7 @@ async function getActiveTeamsSummary(base) {
|
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
// src/tools/team-tools.ts
|
|
198
|
-
import { readdir as readdir3, rm } from "node:fs/promises";
|
|
213
|
+
import { readdir as readdir3, rm as rm2 } from "node:fs/promises";
|
|
199
214
|
import { join as join4 } from "node:path";
|
|
200
215
|
import { tool } from "@opencode-ai/plugin";
|
|
201
216
|
|
|
@@ -263,7 +278,7 @@ function createTeamTools(directory) {
|
|
|
263
278
|
if (!await exists(join4(dir, "config.json"))) {
|
|
264
279
|
return `Error: Team "${args.teamName}" not found`;
|
|
265
280
|
}
|
|
266
|
-
await
|
|
281
|
+
await rm2(dir, { recursive: true, force: true });
|
|
267
282
|
return `Team "${args.teamName}" deleted successfully`;
|
|
268
283
|
}
|
|
269
284
|
}),
|
|
@@ -342,9 +357,309 @@ function createTeamTools(directory) {
|
|
|
342
357
|
}
|
|
343
358
|
|
|
344
359
|
// src/tools/task-tools.ts
|
|
345
|
-
import { join as
|
|
360
|
+
import { join as join6 } from "node:path";
|
|
346
361
|
import { unlink as unlink2 } from "node:fs/promises";
|
|
347
362
|
import { tool as tool2 } from "@opencode-ai/plugin";
|
|
363
|
+
|
|
364
|
+
// src/reservations.ts
|
|
365
|
+
import { join as join5 } from "node:path";
|
|
366
|
+
import { readdir as readdir4 } from "node:fs/promises";
|
|
367
|
+
import { randomUUID } from "node:crypto";
|
|
368
|
+
var DEFAULT_TTL_SECONDS = 1800;
|
|
369
|
+
var MAX_TTL_SECONDS = 14400;
|
|
370
|
+
function reservationStorePath(base, teamName) {
|
|
371
|
+
return join5(teamDir(base, teamName), "reservations.json");
|
|
372
|
+
}
|
|
373
|
+
function emptyStore() {
|
|
374
|
+
return { version: 1, updatedAt: new Date().toISOString(), reservations: [] };
|
|
375
|
+
}
|
|
376
|
+
async function loadStore(base, teamName) {
|
|
377
|
+
const path = reservationStorePath(base, teamName);
|
|
378
|
+
if (!await exists(path))
|
|
379
|
+
return emptyStore();
|
|
380
|
+
try {
|
|
381
|
+
return await readJson(path);
|
|
382
|
+
} catch {
|
|
383
|
+
return emptyStore();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function saveStore(base, teamName, store) {
|
|
387
|
+
store.updatedAt = new Date().toISOString();
|
|
388
|
+
await writeJsonAtomic(reservationStorePath(base, teamName), store);
|
|
389
|
+
}
|
|
390
|
+
function normalizeRepoPath(input) {
|
|
391
|
+
let p = input.replace(/\\/g, "/");
|
|
392
|
+
if (p.startsWith("./"))
|
|
393
|
+
p = p.slice(2);
|
|
394
|
+
if (p.startsWith("/"))
|
|
395
|
+
throw new Error(`Absolute paths not allowed: ${input}`);
|
|
396
|
+
if (p.includes(".."))
|
|
397
|
+
throw new Error(`Path traversal not allowed: ${input}`);
|
|
398
|
+
return p;
|
|
399
|
+
}
|
|
400
|
+
function globToPrefix(glob) {
|
|
401
|
+
const normalized = glob.replace(/\\/g, "/");
|
|
402
|
+
if (!normalized.endsWith("/**")) {
|
|
403
|
+
throw new Error(`Only dir/** globs are supported, got: "${glob}"`);
|
|
404
|
+
}
|
|
405
|
+
const prefix = normalized.slice(0, -3);
|
|
406
|
+
if (!prefix || prefix.includes("*")) {
|
|
407
|
+
throw new Error(`Invalid glob pattern: "${glob}"`);
|
|
408
|
+
}
|
|
409
|
+
return normalizeRepoPath(prefix);
|
|
410
|
+
}
|
|
411
|
+
function isPathInsidePrefix(path, prefix) {
|
|
412
|
+
return path === prefix || path.startsWith(prefix + "/");
|
|
413
|
+
}
|
|
414
|
+
function pruneExpired(store, now = Date.now()) {
|
|
415
|
+
const before = store.reservations.length;
|
|
416
|
+
store.reservations = store.reservations.filter((r) => new Date(r.expiresAt).getTime() > now);
|
|
417
|
+
return store.reservations.length !== before;
|
|
418
|
+
}
|
|
419
|
+
function findConflicts(req, active) {
|
|
420
|
+
const conflicts = [];
|
|
421
|
+
for (const r of active) {
|
|
422
|
+
if (r.agentId === req.agentId)
|
|
423
|
+
continue;
|
|
424
|
+
for (const f of req.files) {
|
|
425
|
+
if (r.files.includes(f)) {
|
|
426
|
+
conflicts.push({
|
|
427
|
+
reservationId: r.id,
|
|
428
|
+
agentId: r.agentId,
|
|
429
|
+
taskId: r.taskId,
|
|
430
|
+
reason: r.reason,
|
|
431
|
+
matchedFile: f,
|
|
432
|
+
expiresAt: r.expiresAt
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
for (const p of r.prefixes) {
|
|
436
|
+
if (isPathInsidePrefix(f, p)) {
|
|
437
|
+
conflicts.push({
|
|
438
|
+
reservationId: r.id,
|
|
439
|
+
agentId: r.agentId,
|
|
440
|
+
taskId: r.taskId,
|
|
441
|
+
reason: r.reason,
|
|
442
|
+
matchedPrefix: p,
|
|
443
|
+
expiresAt: r.expiresAt
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const newP of req.prefixes) {
|
|
449
|
+
for (const existFile of r.files) {
|
|
450
|
+
if (isPathInsidePrefix(existFile, newP)) {
|
|
451
|
+
conflicts.push({
|
|
452
|
+
reservationId: r.id,
|
|
453
|
+
agentId: r.agentId,
|
|
454
|
+
taskId: r.taskId,
|
|
455
|
+
reason: r.reason,
|
|
456
|
+
matchedFile: existFile,
|
|
457
|
+
expiresAt: r.expiresAt
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const existP of r.prefixes) {
|
|
462
|
+
if (isPathInsidePrefix(newP, existP) || isPathInsidePrefix(existP, newP)) {
|
|
463
|
+
conflicts.push({
|
|
464
|
+
reservationId: r.id,
|
|
465
|
+
agentId: r.agentId,
|
|
466
|
+
taskId: r.taskId,
|
|
467
|
+
reason: r.reason,
|
|
468
|
+
matchedPrefix: existP,
|
|
469
|
+
expiresAt: r.expiresAt
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return conflicts;
|
|
476
|
+
}
|
|
477
|
+
async function reserveFiles(base, teamName, opts) {
|
|
478
|
+
const normalizedFiles = (opts.files || []).map(normalizeRepoPath);
|
|
479
|
+
const prefixes = (opts.globs || []).map(globToPrefix);
|
|
480
|
+
if (normalizedFiles.length === 0 && prefixes.length === 0) {
|
|
481
|
+
return { ok: true };
|
|
482
|
+
}
|
|
483
|
+
const ttl = Math.min(opts.ttlSeconds || DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS);
|
|
484
|
+
const store = await loadStore(base, teamName);
|
|
485
|
+
pruneExpired(store);
|
|
486
|
+
const conflicts = findConflicts({ files: normalizedFiles, prefixes, agentId: opts.agentId }, store.reservations);
|
|
487
|
+
if (conflicts.length > 0) {
|
|
488
|
+
return { ok: false, conflicts };
|
|
489
|
+
}
|
|
490
|
+
const existingIdx = opts.taskId ? store.reservations.findIndex((r) => r.agentId === opts.agentId && r.taskId === opts.taskId) : -1;
|
|
491
|
+
const now = new Date;
|
|
492
|
+
const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
|
|
493
|
+
if (existingIdx >= 0) {
|
|
494
|
+
const existing = store.reservations[existingIdx];
|
|
495
|
+
for (const f of normalizedFiles) {
|
|
496
|
+
if (!existing.files.includes(f))
|
|
497
|
+
existing.files.push(f);
|
|
498
|
+
}
|
|
499
|
+
for (const p of prefixes) {
|
|
500
|
+
if (!existing.prefixes.includes(p))
|
|
501
|
+
existing.prefixes.push(p);
|
|
502
|
+
}
|
|
503
|
+
existing.expiresAt = expiresAt;
|
|
504
|
+
await saveStore(base, teamName, store);
|
|
505
|
+
return { ok: true, reservationId: existing.id, expiresAt };
|
|
506
|
+
}
|
|
507
|
+
const reservation = {
|
|
508
|
+
id: `resv_${randomUUID().slice(0, 8)}`,
|
|
509
|
+
agentId: opts.agentId,
|
|
510
|
+
...opts.taskId && { taskId: opts.taskId },
|
|
511
|
+
...opts.reason && { reason: opts.reason },
|
|
512
|
+
files: normalizedFiles,
|
|
513
|
+
prefixes,
|
|
514
|
+
createdAt: now.toISOString(),
|
|
515
|
+
expiresAt
|
|
516
|
+
};
|
|
517
|
+
store.reservations.push(reservation);
|
|
518
|
+
await saveStore(base, teamName, store);
|
|
519
|
+
return { ok: true, reservationId: reservation.id, expiresAt };
|
|
520
|
+
}
|
|
521
|
+
async function releaseByTask(base, teamName, agentId, taskId) {
|
|
522
|
+
const store = await loadStore(base, teamName);
|
|
523
|
+
const released = [];
|
|
524
|
+
store.reservations = store.reservations.filter((r) => {
|
|
525
|
+
if (r.agentId === agentId && r.taskId === taskId) {
|
|
526
|
+
released.push(r.id);
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
return true;
|
|
530
|
+
});
|
|
531
|
+
if (released.length > 0) {
|
|
532
|
+
await saveStore(base, teamName, store);
|
|
533
|
+
}
|
|
534
|
+
return released;
|
|
535
|
+
}
|
|
536
|
+
async function releaseByAgent(base, teamName, agentId) {
|
|
537
|
+
const store = await loadStore(base, teamName);
|
|
538
|
+
const released = [];
|
|
539
|
+
store.reservations = store.reservations.filter((r) => {
|
|
540
|
+
if (r.agentId === agentId) {
|
|
541
|
+
released.push(r.id);
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
return true;
|
|
545
|
+
});
|
|
546
|
+
if (released.length > 0) {
|
|
547
|
+
await saveStore(base, teamName, store);
|
|
548
|
+
}
|
|
549
|
+
return released;
|
|
550
|
+
}
|
|
551
|
+
async function releaseById(base, teamName, agentId, reservationId) {
|
|
552
|
+
const store = await loadStore(base, teamName);
|
|
553
|
+
const idx = store.reservations.findIndex((r) => r.id === reservationId && r.agentId === agentId);
|
|
554
|
+
if (idx < 0)
|
|
555
|
+
return false;
|
|
556
|
+
store.reservations.splice(idx, 1);
|
|
557
|
+
await saveStore(base, teamName, store);
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
async function checkFile(base, teamName, path, agentId) {
|
|
561
|
+
const normalized = normalizeRepoPath(path);
|
|
562
|
+
const store = await loadStore(base, teamName);
|
|
563
|
+
pruneExpired(store);
|
|
564
|
+
const holders = [];
|
|
565
|
+
for (const r of store.reservations) {
|
|
566
|
+
if (r.files.includes(normalized)) {
|
|
567
|
+
holders.push({
|
|
568
|
+
reservationId: r.id,
|
|
569
|
+
agentId: r.agentId,
|
|
570
|
+
taskId: r.taskId,
|
|
571
|
+
reason: r.reason,
|
|
572
|
+
expiresAt: r.expiresAt,
|
|
573
|
+
matchedBy: "file",
|
|
574
|
+
value: normalized
|
|
575
|
+
});
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
for (const p of r.prefixes) {
|
|
579
|
+
if (isPathInsidePrefix(normalized, p)) {
|
|
580
|
+
holders.push({
|
|
581
|
+
reservationId: r.id,
|
|
582
|
+
agentId: r.agentId,
|
|
583
|
+
taskId: r.taskId,
|
|
584
|
+
reason: r.reason,
|
|
585
|
+
expiresAt: r.expiresAt,
|
|
586
|
+
matchedBy: "prefix",
|
|
587
|
+
value: p
|
|
588
|
+
});
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const reserved = holders.length > 0;
|
|
594
|
+
const canEdit = !reserved || !!agentId && holders.every((h) => h.agentId === agentId);
|
|
595
|
+
return { reserved, canEdit, holders };
|
|
596
|
+
}
|
|
597
|
+
async function checkFileAcrossTeams(base, filePath, sessionId) {
|
|
598
|
+
const root = join5(base, TEAM_ROOT);
|
|
599
|
+
if (!await exists(root))
|
|
600
|
+
return null;
|
|
601
|
+
let normalized;
|
|
602
|
+
try {
|
|
603
|
+
normalized = normalizeRepoPath(filePath);
|
|
604
|
+
} catch {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
let entries;
|
|
608
|
+
try {
|
|
609
|
+
entries = await readdir4(root, { withFileTypes: true });
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
for (const entry of entries) {
|
|
614
|
+
if (!entry.isDirectory() || entry.name.startsWith("."))
|
|
615
|
+
continue;
|
|
616
|
+
const storePath = reservationStorePath(base, entry.name);
|
|
617
|
+
if (!await exists(storePath))
|
|
618
|
+
continue;
|
|
619
|
+
let store;
|
|
620
|
+
try {
|
|
621
|
+
store = await readJson(storePath);
|
|
622
|
+
} catch {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
pruneExpired(store);
|
|
626
|
+
for (const r of store.reservations) {
|
|
627
|
+
if (r.files.includes(normalized)) {
|
|
628
|
+
return {
|
|
629
|
+
teamName: entry.name,
|
|
630
|
+
holder: {
|
|
631
|
+
reservationId: r.id,
|
|
632
|
+
agentId: r.agentId,
|
|
633
|
+
taskId: r.taskId,
|
|
634
|
+
reason: r.reason,
|
|
635
|
+
expiresAt: r.expiresAt,
|
|
636
|
+
matchedBy: "file",
|
|
637
|
+
value: normalized
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
for (const p of r.prefixes) {
|
|
642
|
+
if (isPathInsidePrefix(normalized, p)) {
|
|
643
|
+
return {
|
|
644
|
+
teamName: entry.name,
|
|
645
|
+
holder: {
|
|
646
|
+
reservationId: r.id,
|
|
647
|
+
agentId: r.agentId,
|
|
648
|
+
taskId: r.taskId,
|
|
649
|
+
reason: r.reason,
|
|
650
|
+
expiresAt: r.expiresAt,
|
|
651
|
+
matchedBy: "prefix",
|
|
652
|
+
value: p
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/tools/task-tools.ts
|
|
348
663
|
function parseStringArray(raw, label) {
|
|
349
664
|
if (!raw.trimStart().startsWith("[")) {
|
|
350
665
|
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
@@ -371,17 +686,16 @@ function createTaskTools(directory) {
|
|
|
371
686
|
activeForm: tool2.schema.string().optional().describe("Spinner text when in_progress"),
|
|
372
687
|
addBlocks: tool2.schema.string().optional().describe('JSON array of task IDs this task blocks, e.g. ["3","4"]'),
|
|
373
688
|
addBlockedBy: tool2.schema.string().optional().describe('JSON array of task IDs that block this task, e.g. ["1"]'),
|
|
374
|
-
metadata: tool2.schema.string().optional().describe(
|
|
689
|
+
metadata: tool2.schema.string().optional().describe('JSON object of arbitrary metadata (use fileScope key for file reservation: {"fileScope": {"files": [...], "globs": [...]}})')
|
|
375
690
|
},
|
|
376
691
|
async execute(args) {
|
|
377
692
|
const dir = teamDir(directory, args.teamName);
|
|
378
|
-
if (!await exists(
|
|
693
|
+
if (!await exists(join6(dir, "config.json"))) {
|
|
379
694
|
return `Error: Team "${args.teamName}" not found`;
|
|
380
695
|
}
|
|
381
|
-
const tasksDir =
|
|
696
|
+
const tasksDir = join6(dir, "tasks");
|
|
382
697
|
await ensureDir(tasksDir);
|
|
383
|
-
|
|
384
|
-
return withLock(tasksLockPath, async () => {
|
|
698
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
385
699
|
let addBlocks = [];
|
|
386
700
|
let addBlockedBy = [];
|
|
387
701
|
let metadata;
|
|
@@ -431,7 +745,7 @@ function createTaskTools(directory) {
|
|
|
431
745
|
blockedBy: addBlockedBy,
|
|
432
746
|
...metadata && { metadata }
|
|
433
747
|
};
|
|
434
|
-
await writeJsonAtomic(
|
|
748
|
+
await writeJsonAtomic(join6(tasksDir, `${id}.json`), task);
|
|
435
749
|
for (const targetId of addBlockedBy) {
|
|
436
750
|
const targetPath = taskFilePath(directory, args.teamName, targetId);
|
|
437
751
|
const target = await readJson(targetPath);
|
|
@@ -453,7 +767,7 @@ function createTaskTools(directory) {
|
|
|
453
767
|
}
|
|
454
768
|
}),
|
|
455
769
|
task_update: tool2({
|
|
456
|
-
description: "Update task fields, manage dependencies, auto-unblock. Status transitions: pending→in_progress, in_progress→completed. On
|
|
770
|
+
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[]}.",
|
|
457
771
|
args: {
|
|
458
772
|
teamName: tool2.schema.string().describe("Team name"),
|
|
459
773
|
taskId: tool2.schema.string().describe("Task ID (integer string)"),
|
|
@@ -468,12 +782,10 @@ function createTaskTools(directory) {
|
|
|
468
782
|
},
|
|
469
783
|
async execute(args) {
|
|
470
784
|
const dir = teamDir(directory, args.teamName);
|
|
471
|
-
if (!await exists(
|
|
785
|
+
if (!await exists(join6(dir, "config.json"))) {
|
|
472
786
|
return `Error: Team "${args.teamName}" not found`;
|
|
473
787
|
}
|
|
474
|
-
|
|
475
|
-
const tasksLockPath = join5(tasksDir, ".lock");
|
|
476
|
-
return withLock(tasksLockPath, async () => {
|
|
788
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
477
789
|
const tPath = taskFilePath(directory, args.teamName, args.taskId);
|
|
478
790
|
if (!await exists(tPath)) {
|
|
479
791
|
return `Error: Task "${args.taskId}" not found in team "${args.teamName}"`;
|
|
@@ -504,6 +816,27 @@ function createTaskTools(directory) {
|
|
|
504
816
|
return `Error: Cannot start task with unresolved blockers: ${activeBlockers.join(", ")}`;
|
|
505
817
|
}
|
|
506
818
|
}
|
|
819
|
+
if (args.status === "in_progress") {
|
|
820
|
+
const fileScope = task.metadata?.fileScope;
|
|
821
|
+
if (fileScope) {
|
|
822
|
+
const agentId = args.owner || task.owner || "unknown";
|
|
823
|
+
const reserveResult = await reserveFiles(directory, args.teamName, {
|
|
824
|
+
agentId,
|
|
825
|
+
taskId: task.id,
|
|
826
|
+
files: fileScope.files,
|
|
827
|
+
globs: fileScope.globs
|
|
828
|
+
});
|
|
829
|
+
if (!reserveResult.ok) {
|
|
830
|
+
return `Error: Cannot start task — file reservation conflict: ${JSON.stringify(reserveResult.conflicts)}`;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (args.status === "completed") {
|
|
835
|
+
const agentId = args.owner || task.owner;
|
|
836
|
+
if (agentId) {
|
|
837
|
+
await releaseByTask(directory, args.teamName, agentId, task.id);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
507
840
|
task.status = args.status;
|
|
508
841
|
}
|
|
509
842
|
if (args.subject !== undefined)
|
|
@@ -579,24 +912,25 @@ function createTaskTools(directory) {
|
|
|
579
912
|
}
|
|
580
913
|
}),
|
|
581
914
|
task_delete: tool2({
|
|
582
|
-
description: "Delete a task and clean up all bidirectional dependency links. Removes this task from blocks[] and blockedBy[] of all related tasks.",
|
|
915
|
+
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.",
|
|
583
916
|
args: {
|
|
584
917
|
teamName: tool2.schema.string().describe("Team name"),
|
|
585
918
|
taskId: tool2.schema.string().describe("Task ID to delete")
|
|
586
919
|
},
|
|
587
920
|
async execute(args) {
|
|
588
921
|
const dir = teamDir(directory, args.teamName);
|
|
589
|
-
if (!await exists(
|
|
922
|
+
if (!await exists(join6(dir, "config.json"))) {
|
|
590
923
|
return `Error: Team "${args.teamName}" not found`;
|
|
591
924
|
}
|
|
592
|
-
|
|
593
|
-
const tasksLockPath = join5(tasksDir, ".lock");
|
|
594
|
-
return withLock(tasksLockPath, async () => {
|
|
925
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
595
926
|
const tPath = taskFilePath(directory, args.teamName, args.taskId);
|
|
596
927
|
if (!await exists(tPath)) {
|
|
597
928
|
return `Error: Task "${args.taskId}" not found in team "${args.teamName}"`;
|
|
598
929
|
}
|
|
599
930
|
const task = await readJson(tPath);
|
|
931
|
+
if (task.owner) {
|
|
932
|
+
await releaseByTask(directory, args.teamName, task.owner, task.id);
|
|
933
|
+
}
|
|
600
934
|
for (const blockerId of task.blockedBy) {
|
|
601
935
|
const blockerPath = taskFilePath(directory, args.teamName, blockerId);
|
|
602
936
|
if (await exists(blockerPath)) {
|
|
@@ -639,7 +973,7 @@ function createTaskTools(directory) {
|
|
|
639
973
|
},
|
|
640
974
|
async execute(args) {
|
|
641
975
|
const dir = teamDir(directory, args.teamName);
|
|
642
|
-
if (!await exists(
|
|
976
|
+
if (!await exists(join6(dir, "config.json"))) {
|
|
643
977
|
return `Error: Team "${args.teamName}" not found`;
|
|
644
978
|
}
|
|
645
979
|
const tasks = await loadAllTasks(directory, args.teamName);
|
|
@@ -651,7 +985,7 @@ function createTaskTools(directory) {
|
|
|
651
985
|
}
|
|
652
986
|
|
|
653
987
|
// src/tools/message-tools.ts
|
|
654
|
-
import { join as
|
|
988
|
+
import { join as join7 } from "node:path";
|
|
655
989
|
import { tool as tool3 } from "@opencode-ai/plugin";
|
|
656
990
|
function createMessageTools(directory) {
|
|
657
991
|
return {
|
|
@@ -673,14 +1007,13 @@ function createMessageTools(directory) {
|
|
|
673
1007
|
},
|
|
674
1008
|
async execute(args) {
|
|
675
1009
|
const dir = teamDir(directory, args.teamName);
|
|
676
|
-
const configPath =
|
|
1010
|
+
const configPath = join7(dir, "config.json");
|
|
677
1011
|
if (!await exists(configPath)) {
|
|
678
1012
|
return `Error: Team "${args.teamName}" not found`;
|
|
679
1013
|
}
|
|
680
1014
|
const sender = args.from || "team-lead";
|
|
681
|
-
const inboxesDir =
|
|
1015
|
+
const inboxesDir = join7(dir, "inboxes");
|
|
682
1016
|
await ensureDir(inboxesDir);
|
|
683
|
-
const inboxesLockPath = join6(inboxesDir, ".lock");
|
|
684
1017
|
const message = {
|
|
685
1018
|
from: sender,
|
|
686
1019
|
text: args.content,
|
|
@@ -691,11 +1024,11 @@ function createMessageTools(directory) {
|
|
|
691
1024
|
const config = await readJson(configPath);
|
|
692
1025
|
if (args.type === "broadcast") {
|
|
693
1026
|
const recipients = [];
|
|
694
|
-
return
|
|
1027
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
695
1028
|
for (const member of config.members) {
|
|
696
1029
|
if (member.name === sender)
|
|
697
1030
|
continue;
|
|
698
|
-
const inboxPath =
|
|
1031
|
+
const inboxPath = join7(inboxesDir, `${member.name}.json`);
|
|
699
1032
|
await appendToInbox(inboxPath, message);
|
|
700
1033
|
recipients.push(member.name);
|
|
701
1034
|
}
|
|
@@ -710,8 +1043,8 @@ function createMessageTools(directory) {
|
|
|
710
1043
|
if (!member) {
|
|
711
1044
|
return `Error: Recipient "${args.recipient}" not found in team "${args.teamName}"`;
|
|
712
1045
|
}
|
|
713
|
-
return
|
|
714
|
-
const inboxPath =
|
|
1046
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
1047
|
+
const inboxPath = join7(inboxesDir, `${member.name}.json`);
|
|
715
1048
|
await appendToInbox(inboxPath, message);
|
|
716
1049
|
return JSON.stringify({ sent: true, recipients: [member.name] }, null, 2);
|
|
717
1050
|
});
|
|
@@ -728,40 +1061,157 @@ function createMessageTools(directory) {
|
|
|
728
1061
|
},
|
|
729
1062
|
async execute(args) {
|
|
730
1063
|
const dir = teamDir(directory, args.teamName);
|
|
731
|
-
if (!await exists(
|
|
1064
|
+
if (!await exists(join7(dir, "config.json"))) {
|
|
732
1065
|
return `Error: Team "${args.teamName}" not found`;
|
|
733
1066
|
}
|
|
734
1067
|
const agentName = args.agent.includes("@") ? args.agent.split("@")[0] : args.agent;
|
|
735
|
-
const inboxPath =
|
|
1068
|
+
const inboxPath = join7(dir, "inboxes", `${agentName}.json`);
|
|
1069
|
+
if (args.markRead) {
|
|
1070
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
1071
|
+
const allMessages = await readInbox(inboxPath);
|
|
1072
|
+
const sinceDate = args.since ? new Date(args.since) : null;
|
|
1073
|
+
let changed = false;
|
|
1074
|
+
for (const m of allMessages) {
|
|
1075
|
+
if (sinceDate && new Date(m.timestamp) <= sinceDate)
|
|
1076
|
+
continue;
|
|
1077
|
+
if (!m.read) {
|
|
1078
|
+
m.read = true;
|
|
1079
|
+
changed = true;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (changed) {
|
|
1083
|
+
await writeJsonAtomic(inboxPath, allMessages);
|
|
1084
|
+
}
|
|
1085
|
+
let filtered = allMessages;
|
|
1086
|
+
if (sinceDate) {
|
|
1087
|
+
filtered = allMessages.filter((m) => new Date(m.timestamp) > sinceDate);
|
|
1088
|
+
}
|
|
1089
|
+
filtered.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
1090
|
+
return filtered.length > 0 ? JSON.stringify(filtered, null, 2) : "No messages found";
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
736
1093
|
let messages = await readInbox(inboxPath);
|
|
737
1094
|
if (args.since) {
|
|
738
1095
|
const sinceDate = new Date(args.since);
|
|
739
1096
|
messages = messages.filter((m) => new Date(m.timestamp) > sinceDate);
|
|
740
1097
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1098
|
+
messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
1099
|
+
return messages.length > 0 ? JSON.stringify(messages, null, 2) : "No messages found";
|
|
1100
|
+
}
|
|
1101
|
+
})
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// src/tools/reservation-tools.ts
|
|
1106
|
+
import { join as join8 } from "node:path";
|
|
1107
|
+
import { tool as tool4 } from "@opencode-ai/plugin";
|
|
1108
|
+
function createReservationTools(directory) {
|
|
1109
|
+
return {
|
|
1110
|
+
file_reserve: tool4({
|
|
1111
|
+
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.",
|
|
1112
|
+
args: {
|
|
1113
|
+
teamName: tool4.schema.string().describe("Team name"),
|
|
1114
|
+
agentId: tool4.schema.string().describe("Agent name reserving the files"),
|
|
1115
|
+
taskId: tool4.schema.string().optional().describe("Task ID (for auto-release on task completion)"),
|
|
1116
|
+
reason: tool4.schema.string().optional().describe("Why these files are being reserved"),
|
|
1117
|
+
files: tool4.schema.string().optional().describe('JSON array of exact file paths, e.g. ["src/auth.ts","src/api/users.ts"]'),
|
|
1118
|
+
globs: tool4.schema.string().optional().describe('JSON array of dir/** globs, e.g. ["src/api/**","src/lib/**"]'),
|
|
1119
|
+
ttlSeconds: tool4.schema.number().optional().describe("TTL in seconds (default 1800, max 14400)")
|
|
1120
|
+
},
|
|
1121
|
+
async execute(args) {
|
|
1122
|
+
const dir = teamDir(directory, args.teamName);
|
|
1123
|
+
if (!await exists(join8(dir, "config.json"))) {
|
|
1124
|
+
return `Error: Team "${args.teamName}" not found`;
|
|
1125
|
+
}
|
|
1126
|
+
let files;
|
|
1127
|
+
let globs;
|
|
1128
|
+
if (args.files) {
|
|
1129
|
+
try {
|
|
1130
|
+
files = JSON.parse(args.files);
|
|
1131
|
+
if (!Array.isArray(files))
|
|
1132
|
+
return "Error: files must be a JSON array";
|
|
1133
|
+
} catch {
|
|
1134
|
+
return "Error: Invalid files JSON";
|
|
752
1135
|
}
|
|
753
|
-
|
|
754
|
-
|
|
1136
|
+
}
|
|
1137
|
+
if (args.globs) {
|
|
1138
|
+
try {
|
|
1139
|
+
globs = JSON.parse(args.globs);
|
|
1140
|
+
if (!Array.isArray(globs))
|
|
1141
|
+
return "Error: globs must be a JSON array";
|
|
1142
|
+
} catch {
|
|
1143
|
+
return "Error: Invalid globs JSON";
|
|
755
1144
|
}
|
|
756
1145
|
}
|
|
757
|
-
|
|
758
|
-
|
|
1146
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
1147
|
+
try {
|
|
1148
|
+
const result = await reserveFiles(directory, args.teamName, {
|
|
1149
|
+
agentId: args.agentId,
|
|
1150
|
+
taskId: args.taskId,
|
|
1151
|
+
reason: args.reason,
|
|
1152
|
+
files,
|
|
1153
|
+
globs,
|
|
1154
|
+
ttlSeconds: args.ttlSeconds
|
|
1155
|
+
});
|
|
1156
|
+
return JSON.stringify(result, null, 2);
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
return `Error: ${err.message}`;
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
}),
|
|
1163
|
+
file_release: tool4({
|
|
1164
|
+
description: "Release file reservations. Can release by reservation ID, by task ID, or release all reservations for an agent.",
|
|
1165
|
+
args: {
|
|
1166
|
+
teamName: tool4.schema.string().describe("Team name"),
|
|
1167
|
+
agentId: tool4.schema.string().describe("Agent name releasing the reservation"),
|
|
1168
|
+
reservationId: tool4.schema.string().optional().describe("Specific reservation ID to release"),
|
|
1169
|
+
taskId: tool4.schema.string().optional().describe("Release all reservations for this task")
|
|
1170
|
+
},
|
|
1171
|
+
async execute(args) {
|
|
1172
|
+
const dir = teamDir(directory, args.teamName);
|
|
1173
|
+
if (!await exists(join8(dir, "config.json"))) {
|
|
1174
|
+
return `Error: Team "${args.teamName}" not found`;
|
|
1175
|
+
}
|
|
1176
|
+
return withTeamLock(directory, args.teamName, async () => {
|
|
1177
|
+
if (args.reservationId) {
|
|
1178
|
+
const ok = await releaseById(directory, args.teamName, args.agentId, args.reservationId);
|
|
1179
|
+
return ok ? JSON.stringify({ ok: true, releasedReservationIds: [args.reservationId] }, null, 2) : `Error: Reservation "${args.reservationId}" not found or not owned by "${args.agentId}"`;
|
|
1180
|
+
}
|
|
1181
|
+
if (args.taskId) {
|
|
1182
|
+
const released2 = await releaseByTask(directory, args.teamName, args.agentId, args.taskId);
|
|
1183
|
+
return JSON.stringify({ ok: true, releasedReservationIds: released2 }, null, 2);
|
|
1184
|
+
}
|
|
1185
|
+
const released = await releaseByAgent(directory, args.teamName, args.agentId);
|
|
1186
|
+
return JSON.stringify({ ok: true, releasedReservationIds: released }, null, 2);
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}),
|
|
1190
|
+
file_check: tool4({
|
|
1191
|
+
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.",
|
|
1192
|
+
args: {
|
|
1193
|
+
teamName: tool4.schema.string().describe("Team name"),
|
|
1194
|
+
path: tool4.schema.string().describe("File path to check (repo-relative, e.g. src/auth.ts)"),
|
|
1195
|
+
agentId: tool4.schema.string().optional().describe("Caller's agent name (to determine canEdit)")
|
|
1196
|
+
},
|
|
1197
|
+
async execute(args) {
|
|
1198
|
+
const dir = teamDir(directory, args.teamName);
|
|
1199
|
+
if (!await exists(join8(dir, "config.json"))) {
|
|
1200
|
+
return `Error: Team "${args.teamName}" not found`;
|
|
1201
|
+
}
|
|
1202
|
+
try {
|
|
1203
|
+
const result = await checkFile(directory, args.teamName, args.path, args.agentId);
|
|
1204
|
+
return JSON.stringify(result, null, 2);
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
return `Error: ${err.message}`;
|
|
1207
|
+
}
|
|
759
1208
|
}
|
|
760
1209
|
})
|
|
761
1210
|
};
|
|
762
1211
|
}
|
|
763
1212
|
|
|
764
1213
|
// src/index.ts
|
|
1214
|
+
var WRITE_TOOLS = new Set(["edit", "write", "create"]);
|
|
765
1215
|
var HDTeamPlugin = async ({ directory }) => {
|
|
766
1216
|
return {
|
|
767
1217
|
"experimental.session.compacting": async (_input, output) => {
|
|
@@ -778,10 +1228,31 @@ Use team_status(teamName) to get full details. Continue any in-progress tasks.
|
|
|
778
1228
|
`);
|
|
779
1229
|
}
|
|
780
1230
|
},
|
|
1231
|
+
"tool.execute.before": async (input, output) => {
|
|
1232
|
+
if (!WRITE_TOOLS.has(input.tool))
|
|
1233
|
+
return;
|
|
1234
|
+
const filePath = output.args.filePath;
|
|
1235
|
+
if (!filePath)
|
|
1236
|
+
return;
|
|
1237
|
+
let repoPath = filePath;
|
|
1238
|
+
if (isAbsolute(filePath)) {
|
|
1239
|
+
repoPath = relative(directory, filePath);
|
|
1240
|
+
if (repoPath.startsWith(".."))
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
const match = await checkFileAcrossTeams(directory, repoPath);
|
|
1244
|
+
if (match) {
|
|
1245
|
+
const h = match.holder;
|
|
1246
|
+
const who = h.taskId ? `${h.agentId} (task ${h.taskId})` : h.agentId;
|
|
1247
|
+
const reason = h.reason ? ` — ${h.reason}` : "";
|
|
1248
|
+
throw new Error(`\uD83D\uDD12 File "${repoPath}" is reserved by ${who} in team "${match.teamName}"${reason}. ` + `Use file_check to see details or wait for the reservation to expire (${h.expiresAt}).`);
|
|
1249
|
+
}
|
|
1250
|
+
},
|
|
781
1251
|
tool: {
|
|
782
1252
|
...createTeamTools(directory),
|
|
783
1253
|
...createTaskTools(directory),
|
|
784
|
-
...createMessageTools(directory)
|
|
1254
|
+
...createMessageTools(directory),
|
|
1255
|
+
...createReservationTools(directory)
|
|
785
1256
|
}
|
|
786
1257
|
};
|
|
787
1258
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hdwebsoft/hdcode-agent-team",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "OpenCode plugin for multi-agent team coordination — per-agent inboxes, task management with dependency tracking, and
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|