herdr-plugin-amq 0.1.7 → 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 +7 -4
- package/bin/herdr-amq.mjs +6 -0
- package/herdr-plugin.toml +7 -1
- package/package.json +1 -1
- package/skills/herdr-amq/SKILL.md +8 -3
- package/src/actions.mjs +148 -11
- package/src/board.mjs +144 -6
- package/src/bridge.mjs +130 -21
- package/src/config.mjs +36 -2
- package/src/fleet.mjs +279 -110
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[".
|
|
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
|
-
- **
|
|
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 (`.
|
|
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 `.
|
|
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.
|
|
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
|
@@ -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
|
|
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
|
|
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 `.
|
|
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(
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
738
|
+
--kind <kind> Agent kind (default: agy for up, required for down)
|
|
661
739
|
--agents <list> Comma-separated handles to target (default: all)
|
|
662
|
-
--
|
|
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 .
|
|
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, ".
|
|
57
|
+
return path.join(repoRoot, ".agent-mail", "bus");
|
|
46
58
|
}
|
|
47
|
-
return path.join(process.cwd(), ".
|
|
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");
|
|
@@ -152,9 +154,12 @@ function recordAlert(handle, count, from, dryRun = false) {
|
|
|
152
154
|
function loadDeliveredState() {
|
|
153
155
|
const stateFile = getStateFile();
|
|
154
156
|
try {
|
|
155
|
-
|
|
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
|
|
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: ${
|
|
247
|
-
`Run: amq drain --me ${handle} --include-body, then reply
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
366
|
+
const text = buildDoorbellPrompt(handle, undeliveredMsgs, taskStats);
|
|
297
367
|
const ok = promptAgent(handle, text, dryRun);
|
|
298
368
|
if (ok) {
|
|
299
|
-
doorbelledCount +=
|
|
369
|
+
doorbelledCount += undeliveredMsgs.length;
|
|
370
|
+
doorbelledTasksCount += undeliveredTasks.length;
|
|
300
371
|
if (!dryRun) {
|
|
301
|
-
for (const m of
|
|
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({
|
|
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({
|
|
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
|
-
|
|
315
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
}
|
|
314
|
-
|
|
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
|
|