herdr-plugin-amq 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ The **Bridge Daemon** continuously inspects agent inboxes. When an agent is `idl
33
33
  flowchart TD
34
34
  subgraph Storage ["Persistent Transport Layer"]
35
35
  AMQ[".agent-mail/ (Maildir + RFC 5322)<br/>Decoupled Markdown Transmissions"]
36
- BUS[".opencode/bus/ (Decentralized Task Cards)<br/>backlog/ → doing/ → blocked/ → done/"]
36
+ BUS[".agent-mail/bus/ (Decentralized Task Cards)<br/>backlog/ → doing/ → blocked/ → done/"]
37
37
  CAS[".agent-mail/blobs/ (CAS Blobstore)<br/>SHA-256 Render Strips & Proofs"]
38
38
  end
39
39
 
@@ -72,7 +72,8 @@ flowchart TD
72
72
 
73
73
  ### 1. The Autonomous Doorbell Bridge
74
74
  - **Lifecycle-Aware Wakeups**: Rings doorbells (`herdr agent prompt`) only when agents are `idle` or `done`, preventing command interleaving during active turns.
75
- - **De-duplication**: Tracks delivered message IDs in persistent state (`bridge-state.json`) so agents are never doorbelled twice for the same mail.
75
+ - **Dual-Queue Wakeups (Mail & Tasks)**: Evaluates both unread Maildir messages and pending backlog tasks assigned to idle agents, prompting agents with specific drainage and claim actions.
76
+ - **De-duplication**: Tracks delivered message and task IDs in persistent state (`bridge-state.json`) so agents are never doorbelled twice for the same event.
76
77
  - **Self-Healing Panes**: Automatically detects and renames desynced terminal titles back to their canonical agent handles (`herdr agent rename`).
77
78
  - **Blocked State Alerts**: When an agent with unread mail is blocked on external input, logs actionable alert directives for human intervention.
78
79
 
@@ -90,7 +91,7 @@ flowchart TD
90
91
 
91
92
  ### 4. Git Worktree Isolation & Task Bus
92
93
  - **Multi-Lane Isolation**: Automatically provisions and manages dedicated Git worktrees (`.worktrees/<agent>`) so parallel agents never step on each other's unstaged files.
93
- - **Decentralized File-Based Task Cards**: Directory-based task bus (`.opencode/bus/`) immune to concurrent merge conflicts.
94
+ - **Decentralized File-Based Task Cards**: Directory-based task bus (`.agent-mail/bus/`) immune to concurrent merge conflicts.
94
95
 
95
96
  ---
96
97
 
@@ -195,6 +196,8 @@ herdr-amq drain --me coordinator
195
196
 
196
197
  # Decentralized Kanban Task Bus
197
198
  herdr-amq task list
199
+ herdr-amq task drain --me range
200
+ herdr-amq task next --me range
198
201
  herdr-amq task claim TSK-402 --me worker-alpha
199
202
  herdr-amq task done TSK-402 --proof "Proof of Sabotage: INV-29 passed with non-zero exit on mutation"
200
203
  herdr-amq task block TSK-402 --reason "Waiting on asset import lock"
@@ -238,7 +241,7 @@ Under the hood, this pipeline automatically:
238
241
  ### 2. Context Resilience (Do agents lose context on cold start?)
239
242
  **No.** Context is completely decoupled from the terminal scrollback:
240
243
  * **Persistent Transmissions**: All messages, decisions, reviews, and CAS/Git attachments live as RFC 5322 markdown files in `.agent-mail/`.
241
- * **Decentralized Task Bus**: Tasks live in `.opencode/bus/{backlog,doing,blocked,done}/`.
244
+ * **Decentralized Task Bus**: Tasks live in `.agent-mail/bus/{backlog,doing,blocked,done}/`.
242
245
  * **Code Branch Isolation**: Staged and uncommitted edits remain intact in `.worktrees/<handle>` on the agent's branch.
243
246
  * **Turn-Based Epistolary Execution**: When an agent wakes up, it drains its inbox (`herdr-amq mail drain --me <handle>`), reads its assigned task card, inspects `git status`, and resumes work without relying on monolithic LLM chat memory.
244
247
 
package/bin/herdr-amq.mjs CHANGED
@@ -53,6 +53,12 @@ switch (cmd) {
53
53
  case "board":
54
54
  handleTaskCommand(process.argv[3], process.argv.slice(4));
55
55
  break;
56
+ case "next":
57
+ handleTaskCommand("next", process.argv.slice(3));
58
+ break;
59
+ case "task-drain":
60
+ handleTaskCommand("drain", process.argv.slice(3));
61
+ break;
56
62
  case "mail":
57
63
  handleMailCommand(process.argv[3], process.argv.slice(4));
58
64
  break;
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "cabra.amq"
2
2
  name = "Herdr AMQ"
3
- version = "0.1.6"
3
+ version = "0.1.8"
4
4
  min_herdr_version = "0.7.0"
5
5
  description = "Agent Message Queue (AMQ) bridge daemon, mailbox monitor, and dashboard for Herdr"
6
6
  platforms = ["linux", "macos"]
@@ -62,6 +62,12 @@ title = "Open AGmail Webmail Dashboard"
62
62
  contexts = ["workspace"]
63
63
  command = ["node", "bin/herdr-amq.mjs", "dashboard"]
64
64
 
65
+ [[actions]]
66
+ id = "task-drain"
67
+ title = "Drain Assigned Backlog Tasks"
68
+ contexts = ["workspace", "pane"]
69
+ command = ["node", "bin/herdr-amq.mjs", "task", "drain"]
70
+
65
71
  [[panes]]
66
72
  id = "dashboard"
67
73
  title = "AGmail Dashboard"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-plugin-amq",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Herdr plugin for AMQ (Agent Message Queue) autonomous bridge, status monitoring, and AGmail dashboard",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
@@ -12,9 +12,10 @@ metadata:
12
12
 
13
13
  ## Golden Rules for Agents
14
14
 
15
- 1. **Drain First**: Whenever awakened by a doorbell notification or starting a turn, drain your inbox before taking action:
15
+ 1. **Drain First**: Whenever awakened by a doorbell notification or starting a turn, drain your inbox and check assigned backlog tasks:
16
16
  ```bash
17
17
  herdr-amq mail drain --me <handle> --include-body
18
+ herdr-amq task drain --me <handle>
18
19
  ```
19
20
  2. **Reply to the Sender**: Always reply to the sender on the same thread/ref chain. Never drop thread context:
20
21
  ```bash
@@ -22,8 +23,10 @@ metadata:
22
23
  ```
23
24
  3. **Atomic Task Claiming**: Claim cards from the directory bus before modifying shared code:
24
25
  ```bash
25
- herdr-amq task list
26
+ herdr-amq task drain --me <handle>
26
27
  herdr-amq task claim <task-id> --me <handle>
28
+ # Or auto-claim next in a single command:
29
+ herdr-amq task next --me <handle>
27
30
  ```
28
31
  When finished, complete with proof:
29
32
  ```bash
@@ -50,11 +53,13 @@ metadata:
50
53
 
51
54
  ### Task Board & Bus (`herdr-amq task`)
52
55
 
53
- Operates against decentralized card files in `.opencode/bus/{backlog,doing,blocked,done}/`:
56
+ Operates against decentralized card files in `.agent-mail/bus/{backlog,doing,blocked,done}/`:
54
57
 
55
58
  | Command | Usage | Description |
56
59
  |---|---|---|
57
60
  | `list` | `herdr-amq task list [--json]` | List cards grouped by column |
61
+ | `drain` | `herdr-amq task drain --me <handle> [--claim]` | Drain assigned backlog cards with full description bodies, optionally claiming |
62
+ | `next` | `herdr-amq task next --me <handle>` | Shortcut to auto-claim and start next assigned backlog card |
58
63
  | `claim` | `herdr-amq task claim <task-id> --me <handle>` | Atomically moves card to `doing/<task-id>.md` and updates assignee |
59
64
  | `done` | `herdr-amq task done <task-id> --proof "<proof>"` | Moves card to `done/` with timestamp, proof, and duration |
60
65
  | `block` | `herdr-amq task block <task-id> --reason "<reason>"` | Moves card to `blocked/` with blocker reason |
package/src/actions.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  getStateDir,
8
8
  getConfigDir,
9
9
  getEventContext,
10
+ getPluginVersion,
10
11
  } from "./config.mjs";
11
12
  import {
12
13
  isDaemonRunning,
@@ -20,6 +21,7 @@ import {
20
21
  addBoardTask,
21
22
  updateBoardTask,
22
23
  deleteBoardTask,
24
+ drainTasks,
23
25
  } from "./board.mjs";
24
26
  import {
25
27
  sendMaildirMessage,
@@ -31,6 +33,7 @@ import {
31
33
  discoverFleetPersonas,
32
34
  prepopulateFleet,
33
35
  launchFleet,
36
+ stopFleet,
34
37
  } from "./fleet.mjs";
35
38
  import { getHerdrAgents } from "./herdr.mjs";
36
39
 
@@ -38,9 +41,11 @@ export function handleStatus() {
38
41
  const amqRoot = findAmqRoot();
39
42
  const pid = isDaemonRunning();
40
43
  const handles = amqRoot ? getAgentHandles(amqRoot) : [];
44
+ const version = getPluginVersion();
41
45
 
42
- console.log("\n📦 \x1b[1mHerdr AMQ Bridge Status\x1b[0m");
46
+ console.log(`\n📦 \x1b[1mHerdr AMQ Bridge Status\x1b[0m \x1b[2m(v${version})\x1b[0m`);
43
47
  console.log("──────────────────────────────────────────────");
48
+ console.log(`Version: v${version}`);
44
49
  console.log(`Daemon: ${pid ? `\x1b[32m● Running\x1b[0m (PID ${pid})` : "\x1b[33m○ Stopped\x1b[0m"}`);
45
50
  console.log(`AMQ Root: ${amqRoot ? `\x1b[36m${amqRoot}\x1b[0m` : "\x1b[31mNot found\x1b[0m"}`);
46
51
  console.log(`State Dir: ${getStateDir()}`);
@@ -109,17 +114,19 @@ export function handleDoorbell() {
109
114
  process.exit(1);
110
115
  }
111
116
 
112
- console.log(`🔔 Checking AMQ inboxes at ${amqRoot}...`);
113
- const res = runDoorbellPass({ amqRoot });
117
+ const force = process.argv.includes("--force") || process.argv.includes("-f");
118
+ console.log(`🔔 Checking AMQ inboxes at ${amqRoot}${force ? " (force=true)" : ""}...`);
119
+ const res = runDoorbellPass({ amqRoot, force });
114
120
 
115
121
  if (!res.ok) {
116
122
  console.error(`❌ Doorbell check failed: ${res.error}`);
117
123
  return;
118
124
  }
119
125
 
120
- console.log(`Checked ${res.agentsChecked} agents. Doorbelled: ${res.doorbelled} message(s).`);
126
+ console.log(`Checked ${res.agentsChecked} agents. Doorbelled: ${res.doorbelled} message(s), ${res.doorbelledTasks || 0} task(s).`);
121
127
  for (const r of res.results || []) {
122
- console.log(` - ${r.handle} (${r.status}): ${r.count} msg(s) -> ${r.action}`);
128
+ const taskInfo = r.tasksCount ? `, ${r.tasksCount} task(s)` : "";
129
+ console.log(` - ${r.handle} (${r.status}): ${r.count} msg(s)${taskInfo} -> ${r.action}`);
123
130
  }
124
131
  }
125
132
 
@@ -380,12 +387,75 @@ export function handleTaskCommand(subcommand = "list", rawArgs = []) {
380
387
  break;
381
388
  }
382
389
 
390
+ case "drain": {
391
+ const claim = Boolean(flags.claim || flags.autoClaim);
392
+ const res = drainTasks(repoRoot, amqRoot, {
393
+ me,
394
+ claim,
395
+ notify: flags.notify !== "false",
396
+ });
397
+
398
+ if (flags.json) {
399
+ console.log(JSON.stringify(res, null, 2));
400
+ return;
401
+ }
402
+
403
+ console.log(`\n📋 \x1b[1mTask Drain for ${me}\x1b[0m (${res.count} pending backlog task(s))`);
404
+ console.log("────────────────────────────────────────────────────────────────────────────");
405
+
406
+ if (res.activeTasks && res.activeTasks.length > 0) {
407
+ for (const at of res.activeTasks) {
408
+ console.log(`⚡ \x1b[33mActive task in progress (doing):\x1b[0m \x1b[1m${at.title}\x1b[0m (ID: ${at.id})`);
409
+ }
410
+ console.log("────────────────────────────────────────────────────────────────────────────");
411
+ }
412
+
413
+ if (res.tasks.length === 0) {
414
+ console.log(` (no pending backlog tasks assigned to ${me})`);
415
+ } else {
416
+ for (const t of res.tasks) {
417
+ const isClaimed = res.claimedTask && res.claimedTask.id === t.id;
418
+ const statusStr = isClaimed
419
+ ? `\x1b[32m[CLAIMED -> in_progress]\x1b[0m`
420
+ : `\x1b[34m[backlog]\x1b[0m`;
421
+
422
+ console.log(`\n${statusStr} \x1b[1m${t.title}\x1b[0m (ID: \x1b[36m${t.id}\x1b[0m)`);
423
+ if (t.created) console.log(` Created: ${t.created}`);
424
+ if (t.description) {
425
+ console.log(` Details:`);
426
+ for (const line of t.description.split("\n")) {
427
+ console.log(` ${line}`);
428
+ }
429
+ }
430
+ }
431
+
432
+ console.log("\n────────────────────────────────────────────────────────────────────────────");
433
+ if (res.claimedTask) {
434
+ console.log(`🚀 \x1b[32mAuto-claimed task ${res.claimedTask.id} into doing/\x1b[0m (status: in_progress)`);
435
+ console.log(`✉️ Notification dispatched to coordinator via AMQ.`);
436
+ } else {
437
+ console.log(`👉 \x1b[1mTo claim a task:\x1b[0m`);
438
+ console.log(` herdr-amq task claim ${res.tasks[0].id} --me ${me}`);
439
+ console.log(` or auto-claim next: herdr-amq task next --me ${me}`);
440
+ }
441
+ }
442
+ console.log("────────────────────────────────────────────────────────────────────────────\n");
443
+ break;
444
+ }
445
+
446
+ case "next": {
447
+ handleTaskCommand("drain", [...rawArgs, "--claim"]);
448
+ break;
449
+ }
450
+
383
451
  default:
384
452
  console.log(`\n📋 \x1b[1mAGboard Task Coordination CLI\x1b[0m`);
385
453
  console.log("────────────────────────────────────────────────────────────────────────────");
386
454
  console.log("Usage: herdr-amq task <subcommand> [options]");
387
455
  console.log("\nCommands:");
388
456
  console.log(" list [--owner <h>] [--status <s>] [--json] List all tasks");
457
+ console.log(" drain [--me <h>] [--claim] [--json] Drain backlog tasks with full descriptions");
458
+ console.log(" next [--me <h>] Auto-claim and start next backlog task");
389
459
  console.log(" assign --to <h> --title <t> [--desc <d>] Assign a new task to an agent");
390
460
  console.log(" claim <id> [--me <h>] Claim an existing task");
391
461
  console.log(" done <id> [--me <h>] [--proof <evidence>] Complete a task with proof");
@@ -567,8 +637,15 @@ export function handleSkillCommand(args = []) {
567
637
  destDir = path.resolve(process.cwd(), destDir);
568
638
  }
569
639
 
640
+ let targetFile;
641
+ if (destDir.endsWith(".md")) {
642
+ targetFile = destDir;
643
+ destDir = path.dirname(destDir);
644
+ } else {
645
+ targetFile = path.join(destDir, "SKILL.md");
646
+ }
647
+
570
648
  fs.mkdirSync(destDir, { recursive: true });
571
- const targetFile = path.join(destDir, "SKILL.md");
572
649
  fs.writeFileSync(targetFile, content, "utf-8");
573
650
  console.log(`✅ Successfully installed herdr-amq skill to ${targetFile}`);
574
651
  return targetFile;
@@ -654,12 +731,14 @@ Usage: herdr-amq fleet <command> [options]
654
731
  Commands:
655
732
  status, list Show discovered fleet personas, worktrees, and Herdr status
656
733
  prepopulate Create AMQ maildirs and worktrees for all fleet personas
657
- up Launch missing fleet agents into Herdr terminal tabs
734
+ up Launch missing agents and replace mismatched kinds
735
+ down Close fleet agent panes without removing worktrees
658
736
 
659
737
  Options:
660
- --kind <kind> Agent kind to launch (default: agy, options: agy, opencode, pi)
738
+ --kind <kind> Agent kind (default: agy for up, required for down)
661
739
  --agents <list> Comma-separated handles to target (default: all)
662
- --dry-run Preview actions without creating tabs or starting agents
740
+ --no-replace Refuse to replace agents running as another kind
741
+ --dry-run Preview actions without changing panes
663
742
  --help, -h Show this help message
664
743
  `);
665
744
  return;
@@ -675,7 +754,7 @@ Options:
675
754
  for (const [handle, p] of personas.entries()) {
676
755
  const live = liveMap.get(handle);
677
756
  const liveBadge = live
678
- ? `\x1b[32m● ${live.agent_status} (${live.pane_id})\x1b[0m`
757
+ ? `\x1b[32m● ${live.agent || "unknown"} ${live.agent_status} (${live.pane_id})\x1b[0m`
679
758
  : `\x1b[90m○ offline\x1b[0m`;
680
759
  const wtExists = fs.existsSync(path.join(repoRoot, ".worktrees", handle));
681
760
  const wtBadge = wtExists ? "worktree: ok" : "\x1b[33mno worktree\x1b[0m";
@@ -696,24 +775,82 @@ Options:
696
775
  return;
697
776
  }
698
777
 
778
+ if (subcommand === "down") {
779
+ const kindIdx = rawArgs.indexOf("--kind");
780
+ const kind = kindIdx !== -1 && rawArgs[kindIdx + 1] ? rawArgs[kindIdx + 1] : null;
781
+ const agentsIdx = rawArgs.indexOf("--agents");
782
+ const agents = agentsIdx !== -1 && rawArgs[agentsIdx + 1] ? rawArgs[agentsIdx + 1] : null;
783
+ const dryRun = rawArgs.includes("--dry-run");
784
+ if (!kind) {
785
+ console.error("❌ fleet down requires --kind <agy|opencode|pi>");
786
+ process.exitCode = 1;
787
+ return;
788
+ }
789
+
790
+ console.log(`\n🛑 \x1b[1mStopping Fleet via Herdr (kind: ${kind})\x1b[0m`);
791
+ console.log("──────────────────────────────────────────────");
792
+ if (dryRun) console.log("Mode: \x1b[33mDry Run (preview only)\x1b[0m\n");
793
+
794
+ const res = await stopFleet(amqRoot, repoRoot, { kind, agents, dryRun });
795
+ if (dryRun && res.wouldStop.length > 0) {
796
+ console.log(`\x1b[33m⚡ Would stop (${res.wouldStop.length}):\x1b[0m ${[...new Set(res.wouldStop.map((entry) => entry.handle))].join(", ")}`);
797
+ }
798
+ if (res.stopped.length > 0) {
799
+ console.log(`\x1b[32m✔ Stopped agents (${res.stopped.length}):\x1b[0m`);
800
+ for (const entry of res.stopped) {
801
+ console.log(` • ${entry.handle} -> pane ${entry.paneId} (${entry.kind})`);
802
+ }
803
+ }
804
+ if (res.skipped.length > 0) {
805
+ console.log(`\x1b[33m↷ Skipped mismatched agents (${res.skipped.length}):\x1b[0m`);
806
+ for (const entry of res.skipped) {
807
+ console.log(` • ${entry.handle}: ${entry.reason}`);
808
+ }
809
+ }
810
+ if (res.failed.length > 0) {
811
+ console.log(`\x1b[31m✖ Failed to stop:\x1b[0m`);
812
+ for (const entry of res.failed) {
813
+ console.log(` • ${entry.handle} (${entry.paneId}): ${entry.error}`);
814
+ }
815
+ }
816
+ console.log("\n✅ Fleet stop pass complete. Worktrees and maildirs were preserved.\n");
817
+ return res;
818
+ }
819
+
699
820
  if (subcommand === "up") {
700
821
  const kindIdx = rawArgs.indexOf("--kind");
701
822
  const kind = kindIdx !== -1 && rawArgs[kindIdx + 1] ? rawArgs[kindIdx + 1] : "agy";
702
823
  const agentsIdx = rawArgs.indexOf("--agents");
703
824
  const agents = agentsIdx !== -1 && rawArgs[agentsIdx + 1] ? rawArgs[agentsIdx + 1] : null;
704
825
  const dryRun = rawArgs.includes("--dry-run");
826
+ const replace = !rawArgs.includes("--no-replace");
705
827
 
706
828
  console.log(`\n🚀 \x1b[1mLaunching Fleet via Herdr (kind: ${kind})\x1b[0m`);
707
829
  console.log("──────────────────────────────────────────────");
708
830
  if (dryRun) console.log("Mode: \x1b[33mDry Run (preview only)\x1b[0m\n");
709
831
 
710
- const res = await launchFleet(amqRoot, repoRoot, { kind, agents, dryRun });
832
+ const res = await launchFleet(amqRoot, repoRoot, { kind, agents, dryRun, replace });
711
833
  if (res.alreadyRunning.length > 0) {
712
834
  console.log(`\x1b[36m● Already running (${res.alreadyRunning.length}):\x1b[0m ${res.alreadyRunning.join(", ")}`);
713
835
  }
836
+ if (res.replaced.length > 0) {
837
+ console.log(`\x1b[33m↻ Replaced mismatched agents (${res.replaced.length}):\x1b[0m`);
838
+ for (const entry of res.replaced) {
839
+ console.log(` • ${entry.handle}: ${entry.fromKinds.join(", ")} -> ${kind}`);
840
+ }
841
+ }
842
+ if (dryRun && res.wouldReplace.length > 0) {
843
+ console.log(`\x1b[33m⚡ Would replace (${res.wouldReplace.length}):\x1b[0m ${res.wouldReplace.join(", ")}`);
844
+ }
714
845
  if (dryRun && res.wouldLaunch.length > 0) {
715
846
  console.log(`\x1b[33m⚡ Would launch into Herdr (${res.wouldLaunch.length}):\x1b[0m ${res.wouldLaunch.join(", ")}`);
716
847
  }
848
+ if (res.blocked.length > 0) {
849
+ console.log(`\x1b[33m↷ Blocked mismatched agents (${res.blocked.length}):\x1b[0m`);
850
+ for (const entry of res.blocked) {
851
+ console.log(` • ${entry.handle}: ${entry.reason}`);
852
+ }
853
+ }
717
854
  if (res.launched.length > 0) {
718
855
  console.log(`\x1b[32m✔ Launched agents (${res.launched.length}):\x1b[0m`);
719
856
  for (const l of res.launched) {
package/src/board.mjs CHANGED
@@ -29,22 +29,34 @@ export function findStatusFile(repoRoot) {
29
29
 
30
30
  /**
31
31
  * Locate the global bus directory for tasks.
32
- * Defaults to .opencode/bus (or .agent-mail/bus).
32
+ * Defaults to .agent-mail/bus (lives alongside agent mailboxes and blobs).
33
+ * Falls back to legacy .opencode/bus if present.
33
34
  */
34
35
  export function getBusDirectory(repoRoot, amqRoot) {
36
+ if (process.env.AMQ_BUS_DIR) {
37
+ return path.resolve(process.env.AMQ_BUS_DIR);
38
+ }
39
+ // 1. Primary: .agent-mail/bus if amqRoot is provided
40
+ if (amqRoot) {
41
+ const amqBus = path.join(amqRoot, "bus");
42
+ if (fs.existsSync(amqBus)) return amqBus;
43
+ }
44
+ // 2. Check repoRoot/.agent-mail/bus
35
45
  if (repoRoot) {
46
+ const repoAmqBus = path.join(repoRoot, ".agent-mail", "bus");
47
+ if (fs.existsSync(repoAmqBus)) return repoAmqBus;
48
+ // 3. Fallback to legacy .opencode/bus if it exists
36
49
  const opencodeBus = path.join(repoRoot, ".opencode", "bus");
37
- if (fs.existsSync(opencodeBus)) {
38
- return opencodeBus;
39
- }
50
+ if (fs.existsSync(opencodeBus)) return opencodeBus;
40
51
  }
52
+ // Default new creation target: .agent-mail/bus
41
53
  if (amqRoot) {
42
54
  return path.join(amqRoot, "bus");
43
55
  }
44
56
  if (repoRoot) {
45
- return path.join(repoRoot, ".opencode", "bus");
57
+ return path.join(repoRoot, ".agent-mail", "bus");
46
58
  }
47
- return path.join(process.cwd(), ".opencode", "bus");
59
+ return path.join(process.cwd(), ".agent-mail", "bus");
48
60
  }
49
61
 
50
62
  export const STAGE_DIRS = {
@@ -758,3 +770,129 @@ export function deleteBoardTask(repoRoot, amqRoot, taskId) {
758
770
 
759
771
  return { ok: true, taskId, deleted };
760
772
  }
773
+
774
+ /**
775
+ * List pending backlog tasks assigned to a specific handle (or all backlog tasks if null/all).
776
+ */
777
+ export function listBacklogTasks(repoRoot, amqRoot, handle = null) {
778
+ const busDir = getBusDirectory(repoRoot, amqRoot);
779
+ const backlogDir = path.join(busDir, "backlog");
780
+ if (!fs.existsSync(backlogDir)) return [];
781
+
782
+ try {
783
+ const files = fs.readdirSync(backlogDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
784
+ const tasks = [];
785
+ const target = handle && handle !== "all" ? canonicalizeOwner(handle) : null;
786
+
787
+ for (const f of files) {
788
+ const fullPath = path.join(backlogDir, f);
789
+ const parsed = parseTaskFile(fullPath, "backlog");
790
+ if (parsed) {
791
+ if (!target || parsed.owner === target) {
792
+ tasks.push(parsed);
793
+ }
794
+ }
795
+ }
796
+ tasks.sort((a, b) => (a.created || "").localeCompare(b.created || ""));
797
+ return tasks;
798
+ } catch {
799
+ return [];
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Drain tasks for an agent: returns all pending backlog tasks with full descriptions,
805
+ * optionally auto-claiming the first available task if claim: true.
806
+ */
807
+ export function drainTasks(repoRoot, amqRoot, { me, claim = false, notify = true } = {}) {
808
+ const target = me ? canonicalizeOwner(me) : "coordinator";
809
+ const tasks = listBacklogTasks(repoRoot, amqRoot, target);
810
+
811
+ let claimedTask = null;
812
+ if (claim && tasks.length > 0) {
813
+ const toClaim = tasks[0];
814
+ const res = updateBoardTask(
815
+ repoRoot,
816
+ amqRoot,
817
+ toClaim.id,
818
+ { status: "in_progress", owner: target },
819
+ { from: target, notify }
820
+ );
821
+ if (res.ok) {
822
+ claimedTask = res.task;
823
+ }
824
+ }
825
+
826
+ // Also discover any active tasks currently in doing/in_progress
827
+ const busDir = getBusDirectory(repoRoot, amqRoot);
828
+ const doingDir = path.join(busDir, resolveStageDir(busDir, "doing"));
829
+ const activeTasks = [];
830
+ if (fs.existsSync(doingDir)) {
831
+ try {
832
+ const files = fs.readdirSync(doingDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
833
+ for (const f of files) {
834
+ const fullPath = path.join(doingDir, f);
835
+ const parsed = parseTaskFile(fullPath, "in_progress");
836
+ if (parsed && parsed.owner === target) {
837
+ activeTasks.push(parsed);
838
+ }
839
+ }
840
+ } catch {}
841
+ }
842
+
843
+ return {
844
+ ok: true,
845
+ owner: target,
846
+ count: tasks.length,
847
+ tasks,
848
+ activeTasks,
849
+ claimedTask,
850
+ };
851
+ }
852
+
853
+ /**
854
+ * Fast query of task numbers/statistics for an agent across stages:
855
+ * { backlog, doing, blocked, done, total }
856
+ */
857
+ export function getAgentTaskStats(repoRoot, amqRoot, handle) {
858
+ const target = handle ? canonicalizeOwner(handle) : null;
859
+ const busDir = getBusDirectory(repoRoot, amqRoot);
860
+ const stages = [
861
+ { dir: "backlog", key: "backlog" },
862
+ { dir: "doing", key: "doing" },
863
+ { dir: "in_progress", key: "doing" },
864
+ { dir: "blocked", key: "blocked" },
865
+ { dir: "done", key: "done" },
866
+ ];
867
+
868
+ const stats = {
869
+ backlog: 0,
870
+ doing: 0,
871
+ blocked: 0,
872
+ done: 0,
873
+ total: 0,
874
+ };
875
+
876
+ const seenIds = new Set();
877
+
878
+ for (const { dir, key } of stages) {
879
+ const fullDir = path.join(busDir, dir);
880
+ if (!fs.existsSync(fullDir)) continue;
881
+ try {
882
+ const files = fs.readdirSync(fullDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
883
+ for (const f of files) {
884
+ const fullPath = path.join(fullDir, f);
885
+ const parsed = parseTaskFile(fullPath, key);
886
+ if (parsed && (!target || parsed.owner === target)) {
887
+ if (!seenIds.has(parsed.id)) {
888
+ seenIds.add(parsed.id);
889
+ stats[key]++;
890
+ stats.total++;
891
+ }
892
+ }
893
+ }
894
+ } catch {}
895
+ }
896
+
897
+ return stats;
898
+ }
package/src/bridge.mjs CHANGED
@@ -6,9 +6,11 @@ import {
6
6
  getStateDir,
7
7
  getConfigDir,
8
8
  findAmqRoot,
9
+ getRepoRootFromAmq,
9
10
  getAgentHandles,
10
11
  execCmd,
11
12
  } from "./config.mjs";
13
+ import { listBacklogTasks, getAgentTaskStats } from "./board.mjs";
12
14
 
13
15
  function getPidFile() {
14
16
  return path.join(getStateDir(), "bridge.pid");
@@ -120,7 +122,7 @@ function healAgentName(handle, dryRun = false) {
120
122
  }
121
123
 
122
124
  function promptAgent(handle, text, dryRun = false) {
123
- if (dryRun) {
125
+ if (dryRun || process.env.HERDR_DISABLE_PROMPT === "1" || process.env.NODE_ENV === "test") {
124
126
  console.log(`[bridge] DRY: would prompt ${handle}: ${text.slice(0, 60)}...`);
125
127
  return true;
126
128
  }
@@ -152,9 +154,12 @@ function recordAlert(handle, count, from, dryRun = false) {
152
154
  function loadDeliveredState() {
153
155
  const stateFile = getStateFile();
154
156
  try {
155
- return JSON.parse(fs.readFileSync(stateFile, "utf8"));
157
+ const s = JSON.parse(fs.readFileSync(stateFile, "utf8"));
158
+ s.delivered = s.delivered || {};
159
+ s.deliveredTasks = s.deliveredTasks || {};
160
+ return s;
156
161
  } catch {
157
- return { delivered: {} };
162
+ return { delivered: {}, deliveredTasks: {} };
158
163
  }
159
164
  }
160
165
 
@@ -167,6 +172,13 @@ function saveDeliveredState(state) {
167
172
  delete state.delivered[id];
168
173
  }
169
174
  }
175
+ const taskIds = Object.keys(state.deliveredTasks || {});
176
+ if (taskIds.length > 2000) {
177
+ taskIds.sort();
178
+ for (const id of taskIds.slice(0, taskIds.length - 1500)) {
179
+ delete state.deliveredTasks[id];
180
+ }
181
+ }
170
182
  fs.writeFileSync(stateFile, JSON.stringify(state, null, 1), "utf8");
171
183
  }
172
184
 
@@ -239,16 +251,64 @@ export function listInbox(amqRoot, handle) {
239
251
  }
240
252
  }
241
253
 
242
- const buildDoorbellPrompt = (handle, msgs) => {
254
+ export const buildDoorbellPrompt = (handle, msgs = [], taskStatsOrBacklog = null) => {
243
255
  const senders = [...new Set(msgs.map((m) => m.from))].join(", ");
244
- const n = msgs.length;
256
+ const mCount = msgs.length;
257
+
258
+ let stats;
259
+ if (Array.isArray(taskStatsOrBacklog)) {
260
+ stats = { backlog: taskStatsOrBacklog.length, blocked: 0, doing: 0, done: 0 };
261
+ } else if (taskStatsOrBacklog && typeof taskStatsOrBacklog === "object") {
262
+ stats = taskStatsOrBacklog;
263
+ } else {
264
+ stats = { backlog: 0, blocked: 0, doing: 0, done: 0 };
265
+ }
266
+
267
+ const bCount = stats.backlog || 0;
268
+
269
+ // Build task numbers breakdown string: e.g. " (1 blocked, 2 in progress, 3 done)"
270
+ const taskDetails = [];
271
+ if (stats.blocked > 0) taskDetails.push(`${stats.blocked} blocked`);
272
+ if (stats.doing > 0) taskDetails.push(`${stats.doing} in progress`);
273
+ if (stats.done > 0) taskDetails.push(`${stats.done} done`);
274
+ const detailsStr = taskDetails.length > 0 ? ` (${taskDetails.join(", ")})` : "";
275
+
276
+ if (mCount > 0 && bCount > 0) {
277
+ return (
278
+ `AMQ & Task doorbell: ${mCount} new message(s) from ${senders}. You have ${bCount} task(s) in backlog${detailsStr}. ` +
279
+ `Run: herdr-amq mail drain --me ${handle} --include-body && herdr-amq task drain --me ${handle}. ` +
280
+ `Claim next task via: herdr-amq task next --me ${handle}, then reply on-thread with herdr-amq mail reply --id <msg_id>.`
281
+ );
282
+ }
283
+
284
+ if (bCount > 0) {
285
+ return (
286
+ `Task doorbell: You have ${bCount} task(s) in backlog${detailsStr}. ` +
287
+ `Run: herdr-amq task drain --me ${handle} and claim via: herdr-amq task next --me ${handle}.`
288
+ );
289
+ }
290
+
245
291
  return (
246
- `AMQ doorbell: ${n} new message(s) in your inbox (from ${senders}). ` +
247
- `Run: amq drain --me ${handle} --include-body, then reply to the sender on the same ` +
248
- `thread with amq reply --id <msg_id>. After replying, resume your work.`
292
+ `AMQ doorbell: ${mCount} new message(s) in your inbox from ${senders}${detailsStr}. ` +
293
+ `Run: herdr-amq mail drain --me ${handle} --include-body, then reply on-thread with herdr-amq mail reply --id <msg_id>. After replying, resume your work.`
249
294
  );
250
295
  };
251
296
 
297
+ export const DEFAULT_DOORBELL_COOLDOWN_MS = 45000; // 45s cooldown window
298
+
299
+ /**
300
+ * Determines whether an item was recently doorbelled and is still in-flight.
301
+ * If the item has not been drained after the cooldown window, it no longer
302
+ * counts as delivered and will be re-doorbelled.
303
+ */
304
+ export function isItemPendingDrain(deliveryEntry, cooldownMs = DEFAULT_DOORBELL_COOLDOWN_MS, force = false) {
305
+ if (force) return false;
306
+ if (!deliveryEntry || !deliveryEntry.at) return false;
307
+ const deliveredAt = new Date(deliveryEntry.at).getTime();
308
+ if (isNaN(deliveredAt)) return false;
309
+ return Date.now() - deliveredAt < cooldownMs;
310
+ }
311
+
252
312
  // ─── Doorbell Pass ────────────────────────────────────────────────────────────
253
313
 
254
314
  export function runDoorbellPass({
@@ -256,6 +316,8 @@ export function runDoorbellPass({
256
316
  handles = null,
257
317
  targetHandle = null,
258
318
  dryRun = false,
319
+ force = false,
320
+ cooldownMs = parseInt(process.env.HERDR_DOORBELL_COOLDOWN_MS || String(DEFAULT_DOORBELL_COOLDOWN_MS), 10),
259
321
  } = {}) {
260
322
  if (!amqRoot) {
261
323
  return { ok: false, error: "No .agent-mail queue found." };
@@ -266,25 +328,30 @@ export function runDoorbellPass({
266
328
  : (handles && handles.length > 0 ? handles : getAgentHandles(amqRoot));
267
329
 
268
330
  if (!agentList.length) {
269
- return { ok: true, checked: 0, doorbelled: 0, message: "No registered agents found." };
331
+ return { ok: true, checked: 0, doorbelled: 0, doorbelledTasks: 0, message: "No registered agents found." };
270
332
  }
271
333
 
334
+ const repoRoot = getRepoRootFromAmq(amqRoot);
272
335
  const state = loadDeliveredState();
273
336
  state.delivered = state.delivered || {};
337
+ state.deliveredTasks = state.deliveredTasks || {};
274
338
 
275
339
  let doorbelledCount = 0;
340
+ let doorbelledTasksCount = 0;
276
341
  const results = [];
277
342
 
278
343
  for (const handle of agentList) {
279
344
  const rawMsgs = listInbox(amqRoot, handle);
280
345
  // Ignore self-messages
281
346
  const msgs = rawMsgs.filter((m) => m.from !== handle);
347
+ // If message is still in inbox/new after cooldown, the agent did NOT drain it!
348
+ const undeliveredMsgs = msgs.filter((m) => !isItemPendingDrain(state.delivered[m.id], cooldownMs, force));
282
349
 
283
- if (!msgs.length) continue;
350
+ const backlogTasks = listBacklogTasks(repoRoot, amqRoot, handle);
351
+ // If task is still in backlog after cooldown, the agent did NOT claim/drain it!
352
+ const undeliveredTasks = backlogTasks.filter((t) => !isItemPendingDrain(state.deliveredTasks?.[t.id], cooldownMs, force));
284
353
 
285
- // Filter messages not yet delivered
286
- const undelivered = msgs.filter((m) => !state.delivered[m.id]);
287
- if (!undelivered.length) continue;
354
+ if (!undeliveredMsgs.length && !undeliveredTasks.length) continue;
288
355
 
289
356
  let status = getAgentStatus(handle);
290
357
  if (status === "missing") {
@@ -292,33 +359,74 @@ export function runDoorbellPass({
292
359
  if (healed) status = getAgentStatus(handle);
293
360
  }
294
361
 
362
+ const taskStats = getAgentTaskStats(repoRoot, amqRoot, handle);
363
+ taskStats.backlog = undeliveredTasks.length > 0 ? undeliveredTasks.length : backlogTasks.length;
364
+
295
365
  if (status === "idle" || status === "done") {
296
- const text = buildDoorbellPrompt(handle, undelivered);
366
+ const text = buildDoorbellPrompt(handle, undeliveredMsgs, taskStats);
297
367
  const ok = promptAgent(handle, text, dryRun);
298
368
  if (ok) {
299
- doorbelledCount += undelivered.length;
369
+ doorbelledCount += undeliveredMsgs.length;
370
+ doorbelledTasksCount += undeliveredTasks.length;
300
371
  if (!dryRun) {
301
- for (const m of undelivered) {
372
+ for (const m of undeliveredMsgs) {
373
+ const prev = state.delivered[m.id];
302
374
  state.delivered[m.id] = {
303
375
  at: new Date().toISOString(),
304
376
  to: handle,
305
377
  from: m.from,
378
+ attempts: (prev?.attempts || 0) + 1,
379
+ };
380
+ }
381
+ for (const t of undeliveredTasks) {
382
+ const prev = state.deliveredTasks[t.id];
383
+ state.deliveredTasks[t.id] = {
384
+ at: new Date().toISOString(),
385
+ to: handle,
386
+ title: t.title,
387
+ attempts: (prev?.attempts || 0) + 1,
306
388
  };
307
389
  }
308
390
  }
309
- results.push({ handle, status, count: undelivered.length, action: "prompted" });
391
+ results.push({
392
+ handle,
393
+ status,
394
+ count: undeliveredMsgs.length,
395
+ tasksCount: undeliveredTasks.length,
396
+ action: "prompted",
397
+ });
310
398
  }
311
399
  } else if (status === "working") {
312
- results.push({ handle, status, count: undelivered.length, action: "working_wait" });
400
+ results.push({
401
+ handle,
402
+ status,
403
+ count: undeliveredMsgs.length,
404
+ tasksCount: undeliveredTasks.length,
405
+ action: "working_wait",
406
+ });
313
407
  } else if (status === "blocked") {
314
- recordAlert(handle, undelivered.length, undelivered[0]?.from, dryRun);
315
- results.push({ handle, status, count: undelivered.length, action: "alert_blocked" });
408
+ if (undeliveredMsgs.length) {
409
+ recordAlert(handle, undeliveredMsgs.length, undeliveredMsgs[0]?.from, dryRun);
410
+ }
411
+ results.push({
412
+ handle,
413
+ status,
414
+ count: undeliveredMsgs.length,
415
+ tasksCount: undeliveredTasks.length,
416
+ action: "alert_blocked",
417
+ });
316
418
  } else {
317
- results.push({ handle, status, count: undelivered.length, action: "unknown_state" });
419
+ results.push({
420
+ handle,
421
+ status,
422
+ count: undeliveredMsgs.length,
423
+ tasksCount: undeliveredTasks.length,
424
+ action: "unknown_state",
425
+ });
318
426
  }
319
427
  }
320
428
 
321
- if (!dryRun && doorbelledCount > 0) {
429
+ if (!dryRun && (doorbelledCount > 0 || doorbelledTasksCount > 0)) {
322
430
  saveDeliveredState(state);
323
431
  }
324
432
 
@@ -327,6 +435,7 @@ export function runDoorbellPass({
327
435
  amqRoot,
328
436
  agentsChecked: agentList.length,
329
437
  doorbelled: doorbelledCount,
438
+ doorbelledTasks: doorbelledTasksCount,
330
439
  results,
331
440
  };
332
441
  }
package/src/config.mjs CHANGED
@@ -1,13 +1,37 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { execFileSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export function getPluginVersion() {
9
+ try {
10
+ const pkgPath = path.resolve(__dirname, "../package.json");
11
+ if (fs.existsSync(pkgPath)) {
12
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
13
+ return pkg.version || "unknown";
14
+ }
15
+ } catch {}
16
+ return "unknown";
17
+ }
4
18
 
5
19
  export function getHerdrBin() {
6
20
  return process.env.HERDR_BIN_PATH || "herdr";
7
21
  }
8
22
 
9
23
  export function getStateDir() {
10
- const dir = process.env.HERDR_PLUGIN_STATE_DIR || path.join(process.env.HOME || "/tmp", ".herdr-amq-state");
24
+ if (process.env.HERDR_PLUGIN_STATE_DIR) {
25
+ const dir = process.env.HERDR_PLUGIN_STATE_DIR;
26
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
27
+ return dir;
28
+ }
29
+ const legacyDir = path.join(process.env.HOME || "/tmp", ".herdr-amq-state");
30
+ if (fs.existsSync(legacyDir)) {
31
+ return legacyDir;
32
+ }
33
+ const base = process.env.XDG_STATE_HOME || path.join(process.env.HOME || "/tmp", ".local", "state");
34
+ const dir = path.join(base, "herdr-amq");
11
35
  if (!fs.existsSync(dir)) {
12
36
  fs.mkdirSync(dir, { recursive: true });
13
37
  }
@@ -22,7 +46,17 @@ export function getRepoRootFromAmq(amqRoot) {
22
46
  }
23
47
 
24
48
  export function getConfigDir() {
25
- const dir = process.env.HERDR_PLUGIN_CONFIG_DIR || path.join(process.env.HOME || "/tmp", ".herdr-amq-config");
49
+ if (process.env.HERDR_PLUGIN_CONFIG_DIR) {
50
+ const dir = process.env.HERDR_PLUGIN_CONFIG_DIR;
51
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
52
+ return dir;
53
+ }
54
+ const legacyDir = path.join(process.env.HOME || "/tmp", ".herdr-amq-config");
55
+ if (fs.existsSync(legacyDir)) {
56
+ return legacyDir;
57
+ }
58
+ const base = process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || "/tmp", ".config");
59
+ const dir = path.join(base, "herdr-amq");
26
60
  if (!fs.existsSync(dir)) {
27
61
  fs.mkdirSync(dir, { recursive: true });
28
62
  }
package/src/fleet.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
- import { execFileSync, execSync } from "node:child_process";
4
+ import { execFileSync } from "node:child_process";
5
5
  import { scanAgentBriefs } from "./briefs.mjs";
6
6
  import { ensureAgentWorktree } from "./worktrees.mjs";
7
7
  import { registerAgent, formatAgentTitle } from "./store.mjs";
@@ -173,7 +173,143 @@ export function buildFleetEnvPath() {
173
173
  process.env.PATH || "",
174
174
  ];
175
175
 
176
- return Array.from(new Set(paths.filter(Boolean))).join(":");
176
+ return Array.from(new Set(paths.filter(Boolean))).join(path.delimiter);
177
+ }
178
+
179
+ function filterPersonas(fleet, filter) {
180
+ if (!filter) return fleet;
181
+ const selected = new Set(
182
+ (Array.isArray(filter) ? filter : filter.split(","))
183
+ .map((value) => value.trim().toLowerCase())
184
+ .filter(Boolean),
185
+ );
186
+ return fleet.filter((agent) => selected.has(agent.handle.toLowerCase()));
187
+ }
188
+
189
+ function pathsMatch(left, right) {
190
+ if (!left || !right) return false;
191
+ try {
192
+ return fs.realpathSync(left) === fs.realpathSync(right);
193
+ } catch {
194
+ return path.resolve(left) === path.resolve(right);
195
+ }
196
+ }
197
+
198
+ function matchingFleetPanes(agent, liveAgents) {
199
+ return liveAgents.filter((live) => (
200
+ live.name === agent.handle && pathsMatch(live.cwd, agent.worktree)
201
+ ));
202
+ }
203
+
204
+ function runHerdr(args, execHerdr) {
205
+ if (execHerdr) return execHerdr(args);
206
+ return execFileSync("herdr", args, { encoding: "utf8" });
207
+ }
208
+
209
+ function waitFor(ms) {
210
+ return new Promise((resolve) => setTimeout(resolve, ms));
211
+ }
212
+
213
+ export function defaultLaunchArgs(kind, handle) {
214
+ if (kind === "agy") return ["--dangerously-skip-permissions"];
215
+ if (kind === "opencode") return ["--agent", handle, "--auto"];
216
+ return [];
217
+ }
218
+
219
+ export function resolveExecutable(name, envPath = buildFleetEnvPath()) {
220
+ for (const dir of envPath.split(path.delimiter)) {
221
+ if (!dir) continue;
222
+ const candidate = path.join(dir, name);
223
+ try {
224
+ fs.accessSync(candidate, fs.constants.X_OK);
225
+ if (fs.statSync(candidate).isFile()) return fs.realpathSync(candidate);
226
+ } catch {}
227
+ }
228
+ return null;
229
+ }
230
+
231
+ function validModel(model) {
232
+ return typeof model === "string" && /^[^\s/]+\/[^\s/]+/.test(model) ? model : null;
233
+ }
234
+
235
+ export function readOpencodeModel(worktree) {
236
+ for (const relativePath of ["opencode.json", path.join(".opencode", "opencode.json")]) {
237
+ const configPath = path.join(worktree, relativePath);
238
+ try {
239
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
240
+ const model = validModel(config.model);
241
+ if (model) return model;
242
+ } catch {}
243
+ }
244
+ return null;
245
+ }
246
+
247
+ function shellQuote(value) {
248
+ return `'${String(value).replaceAll("'", "'\"'\"'")}'`;
249
+ }
250
+
251
+ export function createOpencodeLauncher(handle, model, executable) {
252
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-amq-opencode-"));
253
+ const binDir = path.join(root, handle.replace(/[^a-zA-Z0-9._-]/g, "_"));
254
+ fs.mkdirSync(binDir, { recursive: true });
255
+ const agentConfig = { mode: "all" };
256
+ const selectedModel = validModel(model);
257
+ if (selectedModel) agentConfig.model = selectedModel;
258
+ const config = JSON.stringify({ agent: { [handle]: agentConfig } });
259
+ const launcher = path.join(binDir, "opencode");
260
+ fs.writeFileSync(
261
+ launcher,
262
+ `#!/bin/sh\nexport OPENCODE_CONFIG_CONTENT=${shellQuote(config)}\nexec ${shellQuote(executable)} "$@"\n`,
263
+ { mode: 0o700 },
264
+ );
265
+ return { root, binDir, launcher, config };
266
+ }
267
+
268
+ export async function stopFleet(amqRoot, repoRoot, options = {}) {
269
+ const personas = discoverFleetPersonas(repoRoot);
270
+ const fleet = [...personas.values()].map((agent) => ({
271
+ ...agent,
272
+ worktree: path.join(repoRoot, ".worktrees", agent.handle),
273
+ }));
274
+ const targetFleet = filterPersonas(fleet, options.agents);
275
+ const kind = options.kind || null;
276
+ const dryRun = Boolean(options.dryRun);
277
+ const getLiveAgents = options.getLiveAgents || getHerdrAgents;
278
+ const execHerdr = options.execHerdr || null;
279
+ const liveAgents = await getLiveAgents();
280
+ const result = {
281
+ total: targetFleet.length,
282
+ stopped: [],
283
+ wouldStop: [],
284
+ skipped: [],
285
+ failed: [],
286
+ dryRun,
287
+ };
288
+
289
+ for (const agent of targetFleet) {
290
+ const panes = matchingFleetPanes(agent, liveAgents);
291
+ const selected = kind ? panes.filter((pane) => pane.agent === kind) : panes;
292
+ if (selected.length === 0) {
293
+ if (panes.length > 0) {
294
+ result.skipped.push({ handle: agent.handle, reason: `kind is ${panes.map((pane) => pane.agent).join(", ")}` });
295
+ }
296
+ continue;
297
+ }
298
+ if (dryRun) {
299
+ result.wouldStop.push(...selected.map((pane) => ({ handle: agent.handle, paneId: pane.pane_id, kind: pane.agent })));
300
+ continue;
301
+ }
302
+ for (const pane of selected) {
303
+ try {
304
+ runHerdr(["pane", "close", pane.pane_id], execHerdr);
305
+ result.stopped.push({ handle: agent.handle, paneId: pane.pane_id, kind: pane.agent });
306
+ } catch (error) {
307
+ result.failed.push({ handle: agent.handle, paneId: pane.pane_id, error: error.message });
308
+ }
309
+ }
310
+ }
311
+
312
+ return result;
177
313
  }
178
314
 
179
315
  /**
@@ -183,135 +319,168 @@ export async function launchFleet(amqRoot, repoRoot, options = {}) {
183
319
  const kind = options.kind || "agy";
184
320
  const dryRun = Boolean(options.dryRun);
185
321
  const timeoutMs = options.timeout || 25000;
186
- const customArgs = options.args || (kind === "agy" ? ["--dangerously-skip-permissions"] : []);
187
- const filterList = options.agents
188
- ? (Array.isArray(options.agents) ? options.agents : options.agents.split(",")).map((s) => s.trim().toLowerCase())
189
- : null;
190
-
191
- // 1. Prepopulate maildirs and worktrees
192
- const fleet = prepopulateFleet(amqRoot, repoRoot);
193
- const targetFleet = filterList ? fleet.filter((f) => filterList.includes(f.handle)) : fleet;
194
-
322
+ const replace = options.replace !== false;
323
+ const prepopulate = options.prepopulate || prepopulateFleet;
324
+ const getLiveAgents = options.getLiveAgents || getHerdrAgents;
325
+ const execHerdr = options.execHerdr || null;
326
+ const sleep = options.sleep || waitFor;
327
+ const safePath = options.envPath || buildFleetEnvPath();
328
+ const configuredArgs = options.args == null
329
+ ? defaultLaunchArgs(kind, "")
330
+ : Array.isArray(options.args) ? options.args : [String(options.args)];
331
+ const fleet = prepopulate(amqRoot, repoRoot);
332
+ const targetFleet = filterPersonas(fleet, options.agents);
195
333
  const result = {
196
334
  total: targetFleet.length,
197
- prepopulated: targetFleet.map((t) => t.handle),
335
+ prepopulated: targetFleet.map((agent) => agent.handle),
198
336
  alreadyRunning: [],
337
+ replaced: [],
338
+ wouldReplace: [],
199
339
  wouldLaunch: [],
340
+ blocked: [],
200
341
  launched: [],
201
342
  failed: [],
202
343
  dryRun,
203
344
  };
204
-
205
- // 2. Check Herdr connectivity and live agents
206
- let activeHandles = new Set();
207
- try {
208
- const liveAgents = await getHerdrAgents();
209
- activeHandles = new Set(liveAgents.map((a) => a.name).filter(Boolean));
210
- } catch {}
211
-
212
- for (const agent of targetFleet) {
213
- if (activeHandles.has(agent.handle)) {
214
- result.alreadyRunning.push(agent.handle);
215
- } else {
216
- result.wouldLaunch.push(agent.handle);
217
- }
218
- }
219
-
220
- if (dryRun) {
221
- return result;
222
- }
223
-
224
- // Determine Herdr workspace
345
+ const liveAgents = await getLiveAgents();
346
+ const launcherRoots = new Set();
225
347
  let workspaceId = process.env.HERDR_WORKSPACE_ID || null;
226
- if (!workspaceId) {
227
- try {
228
- const wsRaw = execFileSync("herdr", ["workspace", "list"], { encoding: "utf8" });
229
- const wsJson = JSON.parse(wsRaw);
230
- const workspaces = wsJson?.result?.workspaces || [];
231
- const matched = workspaces.find((w) => w.cwd === repoRoot || w.label === path.basename(repoRoot));
232
- workspaceId = matched ? matched.workspace_id : (workspaces[0]?.workspace_id || null);
233
- } catch {}
234
- }
235
-
236
- const safePath = buildFleetEnvPath();
237
348
 
238
- // 4. Launch each non-active agent into a tab
239
- for (const agent of targetFleet) {
240
- const handle = agent.handle;
241
- if (activeHandles.has(handle)) {
242
- result.alreadyRunning.push(handle);
243
- continue;
349
+ try {
350
+ if (!dryRun && !workspaceId) {
351
+ try {
352
+ const workspaceOutput = runHerdr(["workspace", "list"], execHerdr);
353
+ const workspaceJson = JSON.parse(workspaceOutput);
354
+ const workspaces = workspaceJson?.result?.workspaces || [];
355
+ const matched = workspaces.find((workspace) => workspace.cwd === repoRoot || workspace.label === path.basename(repoRoot));
356
+ workspaceId = matched ? matched.workspace_id : (workspaces[0]?.workspace_id || null);
357
+ } catch {}
244
358
  }
245
359
 
246
- try {
247
- // Create tab in Herdr targeting worktree
248
- const tabArgs = [
249
- "tab",
250
- "create",
251
- "--cwd",
252
- agent.worktree,
253
- "--label",
254
- handle,
255
- "--env",
256
- `PATH=${safePath}`,
257
- "--no-focus",
258
- ];
259
- if (workspaceId) {
260
- tabArgs.push("--workspace", workspaceId);
360
+ for (const agent of targetFleet) {
361
+ const handle = agent.handle;
362
+ const panes = matchingFleetPanes(agent, liveAgents);
363
+ const matchingKind = panes.filter((pane) => pane.agent === kind);
364
+
365
+ if (matchingKind.length > 0) {
366
+ result.alreadyRunning.push(handle);
367
+ for (const duplicate of matchingKind.slice(1)) {
368
+ try {
369
+ runHerdr(["pane", "close", duplicate.pane_id], execHerdr);
370
+ } catch (error) {
371
+ result.failed.push({ handle, error: error.message });
372
+ }
373
+ }
374
+ continue;
261
375
  }
262
376
 
263
- const tabOut = execFileSync("herdr", tabArgs, { encoding: "utf8" });
264
- const tabJson = JSON.parse(tabOut);
265
- const paneId = tabJson?.result?.root_pane?.pane_id;
266
-
267
- if (!paneId) {
268
- throw new Error(`Failed to acquire pane_id from herdr tab create: ${tabOut}`);
377
+ if (panes.length > 0) {
378
+ if (dryRun) {
379
+ result.wouldReplace.push(handle);
380
+ continue;
381
+ }
382
+ if (!replace) {
383
+ result.blocked.push({ handle, reason: `already running as ${panes.map((pane) => pane.agent).join(", ")}` });
384
+ continue;
385
+ }
386
+ try {
387
+ for (const pane of panes) {
388
+ runHerdr(["pane", "close", pane.pane_id], execHerdr);
389
+ }
390
+ await sleep(300);
391
+ result.replaced.push({
392
+ handle,
393
+ fromKinds: panes.map((pane) => pane.agent),
394
+ paneIds: panes.map((pane) => pane.pane_id),
395
+ });
396
+ } catch (error) {
397
+ result.failed.push({ handle, error: error.message });
398
+ continue;
399
+ }
269
400
  }
270
401
 
271
- // Start the agent in the new pane with retry if the shell is still booting
272
- const startArgs = [
273
- "agent",
274
- "start",
275
- handle,
276
- "--kind",
277
- kind,
278
- "--pane",
279
- paneId,
280
- "--timeout",
281
- String(timeoutMs),
282
- ];
283
-
284
- if (customArgs.length > 0) {
285
- startArgs.push("--", ...customArgs);
402
+ if (dryRun) {
403
+ result.wouldLaunch.push(handle);
404
+ continue;
286
405
  }
287
406
 
288
- let started = false;
289
- let lastErr = null;
290
- for (let attempt = 0; attempt < 5; attempt++) {
291
- try {
292
- if (attempt === 0) {
293
- await new Promise((r) => setTimeout(r, 800));
294
- } else {
295
- await new Promise((r) => setTimeout(r, 1200));
296
- }
297
- execFileSync("herdr", startArgs, { encoding: "utf8" });
298
- started = true;
299
- result.launched.push({ handle, paneId, kind });
300
- break;
301
- } catch (err) {
302
- lastErr = err;
303
- if (err.message && err.message.includes("agent_pane_busy")) {
304
- continue;
305
- }
306
- break;
407
+ let paneId;
408
+ try {
409
+ let launchPath = safePath;
410
+ if (kind === "opencode") {
411
+ const executable = options.opencodeExecutable || resolveExecutable("opencode", safePath);
412
+ if (!executable) throw new Error("OpenCode executable not found in fleet PATH");
413
+ const createLauncher = options.createOpencodeLauncher || createOpencodeLauncher;
414
+ const launcher = createLauncher(handle, readOpencodeModel(agent.worktree), executable);
415
+ launcherRoots.add(launcher.root);
416
+ launchPath = `${launcher.binDir}${path.delimiter}${safePath}`;
307
417
  }
308
- }
309
418
 
310
- if (!started) {
311
- result.failed.push({ handle, error: lastErr?.message || "Failed to start agent" });
419
+ const tabArgs = [
420
+ "tab",
421
+ "create",
422
+ "--cwd",
423
+ agent.worktree,
424
+ "--label",
425
+ handle,
426
+ "--env",
427
+ `PATH=${launchPath}`,
428
+ "--no-focus",
429
+ ];
430
+ if (workspaceId) tabArgs.push("--workspace", workspaceId);
431
+ const tabOutput = runHerdr(tabArgs, execHerdr);
432
+ const tabJson = JSON.parse(tabOutput);
433
+ paneId = tabJson?.result?.root_pane?.pane_id;
434
+ if (!paneId) throw new Error(`Failed to acquire pane_id from herdr tab create: ${tabOutput}`);
435
+
436
+ const startArgs = [
437
+ "agent",
438
+ "start",
439
+ handle,
440
+ "--kind",
441
+ kind,
442
+ "--pane",
443
+ paneId,
444
+ "--timeout",
445
+ String(timeoutMs),
446
+ ];
447
+ const launchArgs = kind === "opencode" && options.args == null
448
+ ? defaultLaunchArgs(kind, handle)
449
+ : configuredArgs;
450
+ if (launchArgs.length > 0) startArgs.push("--", ...launchArgs);
451
+
452
+ let started = false;
453
+ let lastError = null;
454
+ for (let attempt = 0; attempt < 5; attempt++) {
455
+ try {
456
+ await sleep(attempt === 0 ? 800 : 1200);
457
+ runHerdr(startArgs, execHerdr);
458
+ started = true;
459
+ result.launched.push({ handle, paneId, kind });
460
+ break;
461
+ } catch (error) {
462
+ lastError = error;
463
+ if (error.message && error.message.includes("agent_pane_busy")) continue;
464
+ break;
465
+ }
466
+ }
467
+ if (!started) {
468
+ throw lastError || new Error("Failed to start agent");
469
+ }
470
+ } catch (error) {
471
+ result.failed.push({ handle, error: error.message || String(error) });
472
+ if (paneId) {
473
+ try {
474
+ runHerdr(["pane", "close", paneId], execHerdr);
475
+ } catch {}
476
+ }
312
477
  }
313
- } catch (err) {
314
- result.failed.push({ handle, error: err.message });
478
+ }
479
+ } finally {
480
+ for (const root of launcherRoots) {
481
+ try {
482
+ fs.rmSync(root, { recursive: true, force: true });
483
+ } catch {}
315
484
  }
316
485
  }
317
486