@hdwebsoft/hdcode-agent-team 0.1.3 → 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.
Files changed (2) hide show
  1. package/dist/index.js +456 -74
  2. 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, unlink, stat } from "node:fs/promises";
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 LOCK_TIMEOUT_MS = 1e4;
11
- var LOCK_RETRY_MS = 50;
12
- var LOCK_MAX_RETRIES = 100;
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 + ".tmp";
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
- async function withLock(lockPath, fn) {
52
- let acquired = false;
53
- for (let i = 0;i < LOCK_MAX_RETRIES; i++) {
54
- if (await exists(lockPath)) {
55
- const content = await Bun.file(lockPath).text().catch(() => "0");
56
- const lockTime = parseInt(content, 10) || 0;
57
- if (Date.now() - lockTime > LOCK_TIMEOUT_MS) {
58
- await Bun.write(lockPath, Date.now().toString());
59
- acquired = true;
60
- break;
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(() => {});
61
75
  }
62
- await Bun.sleep(LOCK_RETRY_MS);
63
- } else {
64
- await Bun.write(lockPath, Date.now().toString());
65
- acquired = true;
66
- break;
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;
82
+ }
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
@@ -195,7 +207,7 @@ async function getActiveTeamsSummary(base) {
195
207
  }
196
208
 
197
209
  // src/tools/team-tools.ts
198
- import { readdir as readdir3, rm } from "node:fs/promises";
210
+ import { readdir as readdir3, rm as rm2 } from "node:fs/promises";
199
211
  import { join as join4 } from "node:path";
200
212
  import { tool } from "@opencode-ai/plugin";
201
213
 
@@ -263,7 +275,7 @@ function createTeamTools(directory) {
263
275
  if (!await exists(join4(dir, "config.json"))) {
264
276
  return `Error: Team "${args.teamName}" not found`;
265
277
  }
266
- await rm(dir, { recursive: true, force: true });
278
+ await rm2(dir, { recursive: true, force: true });
267
279
  return `Team "${args.teamName}" deleted successfully`;
268
280
  }
269
281
  }),
@@ -342,9 +354,244 @@ function createTeamTools(directory) {
342
354
  }
343
355
 
344
356
  // src/tools/task-tools.ts
345
- import { join as join5 } from "node:path";
357
+ import { join as join6 } from "node:path";
346
358
  import { unlink as unlink2 } from "node:fs/promises";
347
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
348
595
  function parseStringArray(raw, label) {
349
596
  if (!raw.trimStart().startsWith("[")) {
350
597
  return raw.split(",").map((s) => s.trim()).filter(Boolean);
@@ -371,17 +618,16 @@ function createTaskTools(directory) {
371
618
  activeForm: tool2.schema.string().optional().describe("Spinner text when in_progress"),
372
619
  addBlocks: tool2.schema.string().optional().describe('JSON array of task IDs this task blocks, e.g. ["3","4"]'),
373
620
  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("JSON object of arbitrary metadata")
621
+ metadata: tool2.schema.string().optional().describe('JSON object of arbitrary metadata (use fileScope key for file reservation: {"fileScope": {"files": [...], "globs": [...]}})')
375
622
  },
376
623
  async execute(args) {
377
624
  const dir = teamDir(directory, args.teamName);
378
- if (!await exists(join5(dir, "config.json"))) {
625
+ if (!await exists(join6(dir, "config.json"))) {
379
626
  return `Error: Team "${args.teamName}" not found`;
380
627
  }
381
- const tasksDir = join5(dir, "tasks");
628
+ const tasksDir = join6(dir, "tasks");
382
629
  await ensureDir(tasksDir);
383
- const tasksLockPath = join5(tasksDir, ".lock");
384
- return withLock(tasksLockPath, async () => {
630
+ return withTeamLock(directory, args.teamName, async () => {
385
631
  let addBlocks = [];
386
632
  let addBlockedBy = [];
387
633
  let metadata;
@@ -431,7 +677,7 @@ function createTaskTools(directory) {
431
677
  blockedBy: addBlockedBy,
432
678
  ...metadata && { metadata }
433
679
  };
434
- await writeJsonAtomic(join5(tasksDir, `${id}.json`), task);
680
+ await writeJsonAtomic(join6(tasksDir, `${id}.json`), task);
435
681
  for (const targetId of addBlockedBy) {
436
682
  const targetPath = taskFilePath(directory, args.teamName, targetId);
437
683
  const target = await readJson(targetPath);
@@ -453,7 +699,7 @@ function createTaskTools(directory) {
453
699
  }
454
700
  }),
455
701
  task_update: tool2({
456
- description: "Update task fields, manage dependencies, auto-unblock. Status transitions: pending→in_progress, in_progress→completed. On completion: auto-unblocks dependents. Returns {task, unblocked[]}.",
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[]}.",
457
703
  args: {
458
704
  teamName: tool2.schema.string().describe("Team name"),
459
705
  taskId: tool2.schema.string().describe("Task ID (integer string)"),
@@ -468,12 +714,10 @@ function createTaskTools(directory) {
468
714
  },
469
715
  async execute(args) {
470
716
  const dir = teamDir(directory, args.teamName);
471
- if (!await exists(join5(dir, "config.json"))) {
717
+ if (!await exists(join6(dir, "config.json"))) {
472
718
  return `Error: Team "${args.teamName}" not found`;
473
719
  }
474
- const tasksDir = join5(dir, "tasks");
475
- const tasksLockPath = join5(tasksDir, ".lock");
476
- return withLock(tasksLockPath, async () => {
720
+ return withTeamLock(directory, args.teamName, async () => {
477
721
  const tPath = taskFilePath(directory, args.teamName, args.taskId);
478
722
  if (!await exists(tPath)) {
479
723
  return `Error: Task "${args.taskId}" not found in team "${args.teamName}"`;
@@ -504,6 +748,27 @@ function createTaskTools(directory) {
504
748
  return `Error: Cannot start task with unresolved blockers: ${activeBlockers.join(", ")}`;
505
749
  }
506
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
+ }
507
772
  task.status = args.status;
508
773
  }
509
774
  if (args.subject !== undefined)
@@ -579,24 +844,25 @@ function createTaskTools(directory) {
579
844
  }
580
845
  }),
581
846
  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.",
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.",
583
848
  args: {
584
849
  teamName: tool2.schema.string().describe("Team name"),
585
850
  taskId: tool2.schema.string().describe("Task ID to delete")
586
851
  },
587
852
  async execute(args) {
588
853
  const dir = teamDir(directory, args.teamName);
589
- if (!await exists(join5(dir, "config.json"))) {
854
+ if (!await exists(join6(dir, "config.json"))) {
590
855
  return `Error: Team "${args.teamName}" not found`;
591
856
  }
592
- const tasksDir = join5(dir, "tasks");
593
- const tasksLockPath = join5(tasksDir, ".lock");
594
- return withLock(tasksLockPath, async () => {
857
+ return withTeamLock(directory, args.teamName, async () => {
595
858
  const tPath = taskFilePath(directory, args.teamName, args.taskId);
596
859
  if (!await exists(tPath)) {
597
860
  return `Error: Task "${args.taskId}" not found in team "${args.teamName}"`;
598
861
  }
599
862
  const task = await readJson(tPath);
863
+ if (task.owner) {
864
+ await releaseByTask(directory, args.teamName, task.owner, task.id);
865
+ }
600
866
  for (const blockerId of task.blockedBy) {
601
867
  const blockerPath = taskFilePath(directory, args.teamName, blockerId);
602
868
  if (await exists(blockerPath)) {
@@ -639,7 +905,7 @@ function createTaskTools(directory) {
639
905
  },
640
906
  async execute(args) {
641
907
  const dir = teamDir(directory, args.teamName);
642
- if (!await exists(join5(dir, "config.json"))) {
908
+ if (!await exists(join6(dir, "config.json"))) {
643
909
  return `Error: Team "${args.teamName}" not found`;
644
910
  }
645
911
  const tasks = await loadAllTasks(directory, args.teamName);
@@ -651,7 +917,7 @@ function createTaskTools(directory) {
651
917
  }
652
918
 
653
919
  // src/tools/message-tools.ts
654
- import { join as join6 } from "node:path";
920
+ import { join as join7 } from "node:path";
655
921
  import { tool as tool3 } from "@opencode-ai/plugin";
656
922
  function createMessageTools(directory) {
657
923
  return {
@@ -673,14 +939,13 @@ function createMessageTools(directory) {
673
939
  },
674
940
  async execute(args) {
675
941
  const dir = teamDir(directory, args.teamName);
676
- const configPath = join6(dir, "config.json");
942
+ const configPath = join7(dir, "config.json");
677
943
  if (!await exists(configPath)) {
678
944
  return `Error: Team "${args.teamName}" not found`;
679
945
  }
680
946
  const sender = args.from || "team-lead";
681
- const inboxesDir = join6(dir, "inboxes");
947
+ const inboxesDir = join7(dir, "inboxes");
682
948
  await ensureDir(inboxesDir);
683
- const inboxesLockPath = join6(inboxesDir, ".lock");
684
949
  const message = {
685
950
  from: sender,
686
951
  text: args.content,
@@ -691,11 +956,11 @@ function createMessageTools(directory) {
691
956
  const config = await readJson(configPath);
692
957
  if (args.type === "broadcast") {
693
958
  const recipients = [];
694
- return withLock(inboxesLockPath, async () => {
959
+ return withTeamLock(directory, args.teamName, async () => {
695
960
  for (const member of config.members) {
696
961
  if (member.name === sender)
697
962
  continue;
698
- const inboxPath = join6(inboxesDir, `${member.name}.json`);
963
+ const inboxPath = join7(inboxesDir, `${member.name}.json`);
699
964
  await appendToInbox(inboxPath, message);
700
965
  recipients.push(member.name);
701
966
  }
@@ -710,8 +975,8 @@ function createMessageTools(directory) {
710
975
  if (!member) {
711
976
  return `Error: Recipient "${args.recipient}" not found in team "${args.teamName}"`;
712
977
  }
713
- return withLock(inboxesLockPath, async () => {
714
- const inboxPath = join6(inboxesDir, `${member.name}.json`);
978
+ return withTeamLock(directory, args.teamName, async () => {
979
+ const inboxPath = join7(inboxesDir, `${member.name}.json`);
715
980
  await appendToInbox(inboxPath, message);
716
981
  return JSON.stringify({ sent: true, recipients: [member.name] }, null, 2);
717
982
  });
@@ -728,34 +993,150 @@ function createMessageTools(directory) {
728
993
  },
729
994
  async execute(args) {
730
995
  const dir = teamDir(directory, args.teamName);
731
- if (!await exists(join6(dir, "config.json"))) {
996
+ if (!await exists(join7(dir, "config.json"))) {
732
997
  return `Error: Team "${args.teamName}" not found`;
733
998
  }
734
999
  const agentName = args.agent.includes("@") ? args.agent.split("@")[0] : args.agent;
735
- const inboxPath = join6(dir, "inboxes", `${agentName}.json`);
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
+ }
736
1025
  let messages = await readInbox(inboxPath);
737
1026
  if (args.since) {
738
1027
  const sinceDate = new Date(args.since);
739
1028
  messages = messages.filter((m) => new Date(m.timestamp) > sinceDate);
740
1029
  }
741
- if (args.markRead && messages.length > 0) {
742
- const allMessages = await readInbox(inboxPath);
743
- const sinceDate = args.since ? new Date(args.since) : null;
744
- let changed = false;
745
- for (const m of allMessages) {
746
- if (sinceDate && new Date(m.timestamp) <= sinceDate)
747
- continue;
748
- if (!m.read) {
749
- m.read = true;
750
- changed = true;
751
- }
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";
752
1067
  }
753
- if (changed) {
754
- await writeJsonAtomic(inboxPath, allMessages);
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";
755
1076
  }
756
1077
  }
757
- messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
758
- return messages.length > 0 ? JSON.stringify(messages, null, 2) : "No messages found";
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
+ }
759
1140
  }
760
1141
  })
761
1142
  };
@@ -781,7 +1162,8 @@ Use team_status(teamName) to get full details. Continue any in-progress tasks.
781
1162
  tool: {
782
1163
  ...createTeamTools(directory),
783
1164
  ...createTaskTools(directory),
784
- ...createMessageTools(directory)
1165
+ ...createMessageTools(directory),
1166
+ ...createReservationTools(directory)
785
1167
  }
786
1168
  };
787
1169
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hdwebsoft/hdcode-agent-team",
3
- "version": "0.1.3",
4
- "description": "OpenCode plugin for multi-agent team coordination — per-agent inboxes, task management with dependency tracking, and file locking.",
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",