herdr-plugin-amq 0.1.4 → 0.1.6
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 +41 -0
- package/bin/herdr-amq.mjs +14 -1
- package/herdr-plugin.toml +25 -1
- package/package.json +1 -1
- package/skills/herdr-amq/SKILL.md +12 -0
- package/src/actions.mjs +189 -0
- package/src/blobs.mjs +84 -4
- package/src/fleet.mjs +319 -0
- package/src/index.mjs +5 -0
- package/src/migration.mjs +154 -0
- package/src/store.mjs +70 -29
package/README.md
CHANGED
|
@@ -158,6 +158,9 @@ herdr plugin action invoke cabra.amq.doorbell-check
|
|
|
158
158
|
|
|
159
159
|
# Launch the AGmail webmail dashboard
|
|
160
160
|
herdr plugin action invoke cabra.amq.open-dashboard
|
|
161
|
+
|
|
162
|
+
# Migrate legacy message attachments into immutable CAS blobs or pinned Git commits
|
|
163
|
+
herdr plugin action invoke cabra.amq.migrate
|
|
161
164
|
```
|
|
162
165
|
|
|
163
166
|
### Herdr Terminal Panes
|
|
@@ -196,6 +199,17 @@ herdr-amq task claim TSK-402 --me worker-alpha
|
|
|
196
199
|
herdr-amq task done TSK-402 --proof "Proof of Sabotage: INV-29 passed with non-zero exit on mutation"
|
|
197
200
|
herdr-amq task block TSK-402 --reason "Waiting on asset import lock"
|
|
198
201
|
|
|
202
|
+
# Attachment Migration (historical CAS blob / Git pinning)
|
|
203
|
+
herdr-amq migrate [--dry-run] [--verbose]
|
|
204
|
+
|
|
205
|
+
# Fleet Discovery & Cold Start (unions .opencode, .agents, .pi, AGENTS.md)
|
|
206
|
+
herdr-amq fleet status
|
|
207
|
+
herdr-amq fleet prepopulate
|
|
208
|
+
herdr-amq fleet up [--kind agy|opencode|pi] [--agents a,b,c] [--dry-run]
|
|
209
|
+
|
|
210
|
+
# Instant One-Shot Swarm Cold-Start (prepopulate + launch + daemon + doorbell)
|
|
211
|
+
herdr-amq bootstrap [--kind agy]
|
|
212
|
+
|
|
199
213
|
# Print or install the agentic skill
|
|
200
214
|
herdr-amq --skill
|
|
201
215
|
herdr-amq --skill --install .opencode/skills/herdr-amq
|
|
@@ -203,6 +217,33 @@ herdr-amq --skill --install .opencode/skills/herdr-amq
|
|
|
203
217
|
|
|
204
218
|
---
|
|
205
219
|
|
|
220
|
+
## Onboarding & Swarm Cold Start
|
|
221
|
+
|
|
222
|
+
When onboarding a new repository or recovering after all Herdr panes were lost (e.g. machine reboot or closed panes):
|
|
223
|
+
|
|
224
|
+
### 1. Instant Automated Bootstrap
|
|
225
|
+
Run a single command to discover external tool personas, provision isolated worktrees, and launch interactive agent sessions:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
herdr-amq bootstrap [--kind agy|opencode|pi]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Under the hood, this pipeline automatically:
|
|
232
|
+
1. **Unifies Personas**: Scans `.opencode/agents/`, `.agents/`, `.pi/agents/`, `.claude/agents/`, rule declarations in `AGENTS.md` (e.g. `Handles: coordinator, ...`), and established `.worktrees/`.
|
|
233
|
+
2. **Prepopulates Storage & Worktrees**: Generates clean Maildir queues (`.agent-mail/agents/<handle>/`) and dedicated Git worktrees (`.worktrees/<handle>`) on `agent/<handle>`.
|
|
234
|
+
3. **Pre-authorizes Workspace Trust**: Injects worktree paths into `trustedWorkspaces` in `~/.gemini/antigravity-cli/settings.json` so `agy` bypasses interactive TUI trust confirmation dialogs.
|
|
235
|
+
4. **Environment Sanitation**: Seeds child PTYs with robust PATH resolution (`~/.local/bin`, Nix profiles) so agent CLIs and local binaries are found unconditionally.
|
|
236
|
+
5. **Supervised Lifecycle**: Launches Herdr terminal tabs with shell-boot backoff, starts the Doorbell Bridge daemon, and executes an initial doorbell pass.
|
|
237
|
+
|
|
238
|
+
### 2. Context Resilience (Do agents lose context on cold start?)
|
|
239
|
+
**No.** Context is completely decoupled from the terminal scrollback:
|
|
240
|
+
* **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}/`.
|
|
242
|
+
* **Code Branch Isolation**: Staged and uncommitted edits remain intact in `.worktrees/<handle>` on the agent's branch.
|
|
243
|
+
* **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
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
206
247
|
## Agentic Skill Integration
|
|
207
248
|
|
|
208
249
|
AI coding agents (Antigravity, Claude Code, OpenCode, Aider) can consume the skill definition directly to learn the protocol without human instruction:
|
package/bin/herdr-amq.mjs
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
handleTaskCommand,
|
|
10
10
|
handleMailCommand,
|
|
11
11
|
handleSkillCommand,
|
|
12
|
+
handleMigrateCommand,
|
|
13
|
+
handleFleetCommand,
|
|
14
|
+
handleBootstrapCommand,
|
|
12
15
|
} from "../src/actions.mjs";
|
|
13
16
|
import { startDaemonLoop } from "../src/bridge.mjs";
|
|
14
17
|
import { launchDashboardPane, launchInboxPeekPane } from "../src/panes.mjs";
|
|
@@ -67,8 +70,18 @@ switch (cmd) {
|
|
|
67
70
|
case "skill":
|
|
68
71
|
handleSkillCommand(process.argv.slice(3));
|
|
69
72
|
break;
|
|
73
|
+
case "migrate":
|
|
74
|
+
handleMigrateCommand(process.argv.slice(3));
|
|
75
|
+
break;
|
|
76
|
+
case "fleet":
|
|
77
|
+
await handleFleetCommand(process.argv[3], process.argv.slice(4));
|
|
78
|
+
break;
|
|
79
|
+
case "bootstrap":
|
|
80
|
+
case "cold-start":
|
|
81
|
+
await handleBootstrapCommand(process.argv.slice(3));
|
|
82
|
+
break;
|
|
70
83
|
default:
|
|
71
84
|
console.error(`Unknown command: ${cmd}`);
|
|
72
|
-
console.log("Available commands: status, start, stop, doorbell, startup, pane-dashboard, pane-inbox, task, mail, send, reply, drain, --skill");
|
|
85
|
+
console.log("Available commands: status, start, stop, doorbell, startup, pane-dashboard, pane-inbox, task, mail, send, reply, drain, migrate, fleet, bootstrap, --skill");
|
|
73
86
|
process.exit(1);
|
|
74
87
|
}
|
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.6"
|
|
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"]
|
|
@@ -32,6 +32,30 @@ title = "Check AMQ Unread Mail and Ring Doorbells"
|
|
|
32
32
|
contexts = ["workspace"]
|
|
33
33
|
command = ["node", "bin/herdr-amq.mjs", "doorbell"]
|
|
34
34
|
|
|
35
|
+
[[actions]]
|
|
36
|
+
id = "migrate"
|
|
37
|
+
title = "Migrate AMQ Attachments to CAS/Git"
|
|
38
|
+
contexts = ["workspace"]
|
|
39
|
+
command = ["node", "bin/herdr-amq.mjs", "migrate"]
|
|
40
|
+
|
|
41
|
+
[[actions]]
|
|
42
|
+
id = "fleet-status"
|
|
43
|
+
title = "Fleet Status & Discovered Personas"
|
|
44
|
+
contexts = ["workspace"]
|
|
45
|
+
command = ["node", "bin/herdr-amq.mjs", "fleet", "status"]
|
|
46
|
+
|
|
47
|
+
[[actions]]
|
|
48
|
+
id = "fleet-up"
|
|
49
|
+
title = "Launch Agent Fleet into Herdr"
|
|
50
|
+
contexts = ["workspace"]
|
|
51
|
+
command = ["node", "bin/herdr-amq.mjs", "fleet", "up"]
|
|
52
|
+
|
|
53
|
+
[[actions]]
|
|
54
|
+
id = "bootstrap"
|
|
55
|
+
title = "Cold Start & Bootstrap Swarm"
|
|
56
|
+
contexts = ["workspace"]
|
|
57
|
+
command = ["node", "bin/herdr-amq.mjs", "bootstrap"]
|
|
58
|
+
|
|
35
59
|
[[actions]]
|
|
36
60
|
id = "open-dashboard"
|
|
37
61
|
title = "Open AGmail Webmail Dashboard"
|
package/package.json
CHANGED
|
@@ -59,6 +59,18 @@ Operates against decentralized card files in `.opencode/bus/{backlog,doing,block
|
|
|
59
59
|
| `done` | `herdr-amq task done <task-id> --proof "<proof>"` | Moves card to `done/` with timestamp, proof, and duration |
|
|
60
60
|
| `block` | `herdr-amq task block <task-id> --reason "<reason>"` | Moves card to `blocked/` with blocker reason |
|
|
61
61
|
|
|
62
|
+
### Fleet Management & Cold-Start (`herdr-amq fleet` / `bootstrap`)
|
|
63
|
+
|
|
64
|
+
Unifies external tool agent briefs (`.opencode/agents`, `.agents`, `.pi/agents`, `.claude/agents`, `AGENTS.md`) and automates swarm provisioning:
|
|
65
|
+
|
|
66
|
+
| Command | Usage | Description |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `fleet status` | `herdr-amq fleet status` | Discover personas across external tools and show live Herdr pane states |
|
|
69
|
+
| `fleet prepopulate` | `herdr-amq fleet prepopulate` | Ensure Maildirs, Git worktrees, and workspace trust exist for all personas |
|
|
70
|
+
| `fleet up` | `herdr-amq fleet up [--kind agy\|opencode\|pi]` | Launch missing fleet agents into isolated Herdr tabs with auto-trust & clean PATH |
|
|
71
|
+
| `bootstrap` | `herdr-amq bootstrap [--kind agy]` | Instant cold-start: prepopulate + launch fleet + start bridge daemon + doorbell pass |
|
|
72
|
+
| `migrate` | `herdr-amq migrate [--dry-run]` | Migrate historical attachments into CAS blobs or pinned Git commits |
|
|
73
|
+
|
|
62
74
|
### Bridge, Dashboard & Status
|
|
63
75
|
|
|
64
76
|
| Command | Usage | Description |
|
package/src/actions.mjs
CHANGED
|
@@ -26,6 +26,13 @@ import {
|
|
|
26
26
|
replyMaildirMessage,
|
|
27
27
|
drainMaildir,
|
|
28
28
|
} from "./protocol.mjs";
|
|
29
|
+
import { migrateMessageAttachments } from "./migration.mjs";
|
|
30
|
+
import {
|
|
31
|
+
discoverFleetPersonas,
|
|
32
|
+
prepopulateFleet,
|
|
33
|
+
launchFleet,
|
|
34
|
+
} from "./fleet.mjs";
|
|
35
|
+
import { getHerdrAgents } from "./herdr.mjs";
|
|
29
36
|
|
|
30
37
|
export function handleStatus() {
|
|
31
38
|
const amqRoot = findAmqRoot();
|
|
@@ -570,4 +577,186 @@ export function handleSkillCommand(args = []) {
|
|
|
570
577
|
process.stdout.write(content + (content.endsWith("\n") ? "" : "\n"));
|
|
571
578
|
}
|
|
572
579
|
|
|
580
|
+
export function handleMigrateCommand(args = []) {
|
|
581
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
582
|
+
console.log(`
|
|
583
|
+
📦 AMQ Attachment Migration
|
|
584
|
+
──────────────────────────────────────────────
|
|
585
|
+
Usage: herdr-amq migrate [options]
|
|
586
|
+
|
|
587
|
+
Scan messages across all agent mailboxes and migrate legacy attachments into
|
|
588
|
+
immutable Content-Addressed Storage (CAS) blobs or pinned Git commits.
|
|
589
|
+
|
|
590
|
+
Options:
|
|
591
|
+
--dry-run Preview changes without modifying message files
|
|
592
|
+
--verbose, -v Log individual errors or details during processing
|
|
593
|
+
--help, -h Show this help message
|
|
594
|
+
`);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const amqRoot = findAmqRoot();
|
|
599
|
+
if (!amqRoot) {
|
|
600
|
+
console.error("❌ No active .agent-mail directory found.");
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const dryRun = args.includes("--dry-run");
|
|
605
|
+
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
606
|
+
|
|
607
|
+
console.log("\n📦 \x1b[1mAMQ Attachment Migration\x1b[0m");
|
|
608
|
+
console.log("──────────────────────────────────────────────");
|
|
609
|
+
console.log(`AMQ Root: \x1b[36m${amqRoot}\x1b[0m`);
|
|
610
|
+
console.log(`Mode: ${dryRun ? "\x1b[33mDry Run (no changes written)\x1b[0m" : "\x1b[32mActive (in-place frontmatter migration)\x1b[0m"}`);
|
|
611
|
+
console.log("──────────────────────────────────────────────\n");
|
|
612
|
+
console.log("🔍 Scanning messages across all agent mailboxes...");
|
|
613
|
+
|
|
614
|
+
const startTime = Date.now();
|
|
615
|
+
const stats = migrateMessageAttachments(amqRoot, {
|
|
616
|
+
dryRun,
|
|
617
|
+
verbose,
|
|
618
|
+
onProgress: (p) => {
|
|
619
|
+
process.stdout.write(`\rProgress: ${p.current}/${p.totalScanned} messages processed...`);
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
624
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
625
|
+
|
|
626
|
+
console.log("✨ \x1b[1mMigration Summary:\x1b[0m");
|
|
627
|
+
console.log(` Total messages scanned: ${stats.totalScanned.toLocaleString()}`);
|
|
628
|
+
console.log(` Already migrated: ${stats.alreadyMigrated.toLocaleString()}`);
|
|
629
|
+
console.log(` Messages updated: \x1b[32m${stats.migrated.toLocaleString()}\x1b[0m`);
|
|
630
|
+
console.log(` Git objects pinned: \x1b[36m${stats.gitPinned.toLocaleString()}\x1b[0m`);
|
|
631
|
+
console.log(` CAS blobs stored: \x1b[35m${stats.blobsStored.toLocaleString()}\x1b[0m`);
|
|
632
|
+
if (stats.errors > 0) {
|
|
633
|
+
console.log(` Errors encountered: \x1b[31m${stats.errors}\x1b[0m`);
|
|
634
|
+
}
|
|
635
|
+
console.log(` Duration: ${durationSec}s`);
|
|
636
|
+
console.log("\n✅ All messages are now self-contained with frozen/pinned attachments.\n");
|
|
637
|
+
return stats;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function handleFleetCommand(subcommand = "status", rawArgs = []) {
|
|
641
|
+
const amqRoot = findAmqRoot();
|
|
642
|
+
if (!amqRoot) {
|
|
643
|
+
console.error("❌ No active .agent-mail directory found.");
|
|
644
|
+
process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
647
|
+
|
|
648
|
+
if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
|
649
|
+
console.log(`
|
|
650
|
+
🚀 Herdr AMQ Fleet Management
|
|
651
|
+
──────────────────────────────────────────────
|
|
652
|
+
Usage: herdr-amq fleet <command> [options]
|
|
653
|
+
|
|
654
|
+
Commands:
|
|
655
|
+
status, list Show discovered fleet personas, worktrees, and Herdr status
|
|
656
|
+
prepopulate Create AMQ maildirs and worktrees for all fleet personas
|
|
657
|
+
up Launch missing fleet agents into Herdr terminal tabs
|
|
658
|
+
|
|
659
|
+
Options:
|
|
660
|
+
--kind <kind> Agent kind to launch (default: agy, options: agy, opencode, pi)
|
|
661
|
+
--agents <list> Comma-separated handles to target (default: all)
|
|
662
|
+
--dry-run Preview actions without creating tabs or starting agents
|
|
663
|
+
--help, -h Show this help message
|
|
664
|
+
`);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (subcommand === "status" || subcommand === "list") {
|
|
669
|
+
const personas = discoverFleetPersonas(repoRoot);
|
|
670
|
+
const herdrAgents = await getHerdrAgents();
|
|
671
|
+
const liveMap = new Map(herdrAgents.map((a) => [a.name, a]));
|
|
672
|
+
|
|
673
|
+
console.log(`\n🚀 \x1b[1mFleet Status & Personas (${personas.size} discovered)\x1b[0m`);
|
|
674
|
+
console.log("────────────────────────────────────────────────────────────────────────────");
|
|
675
|
+
for (const [handle, p] of personas.entries()) {
|
|
676
|
+
const live = liveMap.get(handle);
|
|
677
|
+
const liveBadge = live
|
|
678
|
+
? `\x1b[32m● ${live.agent_status} (${live.pane_id})\x1b[0m`
|
|
679
|
+
: `\x1b[90m○ offline\x1b[0m`;
|
|
680
|
+
const wtExists = fs.existsSync(path.join(repoRoot, ".worktrees", handle));
|
|
681
|
+
const wtBadge = wtExists ? "worktree: ok" : "\x1b[33mno worktree\x1b[0m";
|
|
682
|
+
console.log(` • \x1b[1m${handle.padEnd(16)}\x1b[0m [${p.sourceType}] ${liveBadge.padEnd(30)} ${wtBadge}`);
|
|
683
|
+
if (p.role) console.log(` \x1b[90m↳ ${p.role.slice(0, 70)}\x1b[0m`);
|
|
684
|
+
}
|
|
685
|
+
console.log("────────────────────────────────────────────────────────────────────────────\n");
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (subcommand === "prepopulate") {
|
|
690
|
+
console.log("\n📦 Prepopulating fleet personas and worktrees...");
|
|
691
|
+
const results = prepopulateFleet(amqRoot, repoRoot);
|
|
692
|
+
for (const r of results) {
|
|
693
|
+
console.log(` • \x1b[1m${r.handle.padEnd(16)}\x1b[0m maildir: ${r.maildirOk ? "✓" : "✗"} worktree: ${r.worktreeExisted ? "exists" : "created"}`);
|
|
694
|
+
}
|
|
695
|
+
console.log(`\n✅ Prepopulated ${results.length} fleet agents.\n`);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (subcommand === "up") {
|
|
700
|
+
const kindIdx = rawArgs.indexOf("--kind");
|
|
701
|
+
const kind = kindIdx !== -1 && rawArgs[kindIdx + 1] ? rawArgs[kindIdx + 1] : "agy";
|
|
702
|
+
const agentsIdx = rawArgs.indexOf("--agents");
|
|
703
|
+
const agents = agentsIdx !== -1 && rawArgs[agentsIdx + 1] ? rawArgs[agentsIdx + 1] : null;
|
|
704
|
+
const dryRun = rawArgs.includes("--dry-run");
|
|
705
|
+
|
|
706
|
+
console.log(`\n🚀 \x1b[1mLaunching Fleet via Herdr (kind: ${kind})\x1b[0m`);
|
|
707
|
+
console.log("──────────────────────────────────────────────");
|
|
708
|
+
if (dryRun) console.log("Mode: \x1b[33mDry Run (preview only)\x1b[0m\n");
|
|
709
|
+
|
|
710
|
+
const res = await launchFleet(amqRoot, repoRoot, { kind, agents, dryRun });
|
|
711
|
+
if (res.alreadyRunning.length > 0) {
|
|
712
|
+
console.log(`\x1b[36m● Already running (${res.alreadyRunning.length}):\x1b[0m ${res.alreadyRunning.join(", ")}`);
|
|
713
|
+
}
|
|
714
|
+
if (dryRun && res.wouldLaunch.length > 0) {
|
|
715
|
+
console.log(`\x1b[33m⚡ Would launch into Herdr (${res.wouldLaunch.length}):\x1b[0m ${res.wouldLaunch.join(", ")}`);
|
|
716
|
+
}
|
|
717
|
+
if (res.launched.length > 0) {
|
|
718
|
+
console.log(`\x1b[32m✔ Launched agents (${res.launched.length}):\x1b[0m`);
|
|
719
|
+
for (const l of res.launched) {
|
|
720
|
+
console.log(` • ${l.handle} -> pane ${l.paneId} (${l.kind})`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (res.failed.length > 0) {
|
|
724
|
+
console.log(`\x1b[31m✖ Failed to launch:\x1b[0m`);
|
|
725
|
+
for (const f of res.failed) {
|
|
726
|
+
console.log(` • ${f.handle}: ${f.error}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
console.log("\n✅ Fleet launch pass complete.\n");
|
|
730
|
+
return res;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
console.error(`Unknown fleet command: ${subcommand}`);
|
|
734
|
+
console.log("Run 'herdr-amq fleet --help' for usage.");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export async function handleBootstrapCommand(args = []) {
|
|
738
|
+
const amqRoot = findAmqRoot();
|
|
739
|
+
if (!amqRoot) {
|
|
740
|
+
console.error("❌ No active .agent-mail directory found.");
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
744
|
+
|
|
745
|
+
console.log("\n🌟 \x1b[1mCold Start / Bootstrap Swarm\x1b[0m");
|
|
746
|
+
console.log("──────────────────────────────────────────────");
|
|
747
|
+
console.log("1. Prepopulating agent personas, maildirs & worktrees...");
|
|
748
|
+
prepopulateFleet(amqRoot, repoRoot);
|
|
749
|
+
|
|
750
|
+
console.log("2. Launching fleet into Herdr panes...");
|
|
751
|
+
await handleFleetCommand("up", args);
|
|
752
|
+
|
|
753
|
+
console.log("3. Ensuring AMQ Doorbell Bridge daemon is active...");
|
|
754
|
+
handleStart();
|
|
755
|
+
|
|
756
|
+
console.log("4. Performing initial doorbell sweep...");
|
|
757
|
+
handleDoorbell();
|
|
758
|
+
|
|
759
|
+
console.log("\n🚀 \x1b[32mSwarm is live and operational!\x1b[0m\n");
|
|
760
|
+
}
|
|
761
|
+
|
|
573
762
|
|
package/src/blobs.mjs
CHANGED
|
@@ -161,10 +161,57 @@ export function getBlob(sha256, amqRoot) {
|
|
|
161
161
|
|
|
162
162
|
const gitRefCache = new Map();
|
|
163
163
|
const headCommitCache = new Map();
|
|
164
|
+
const timestampCommitCache = new Map();
|
|
164
165
|
|
|
165
166
|
export function clearGitRefCache() {
|
|
166
167
|
gitRefCache.clear();
|
|
167
168
|
headCommitCache.clear();
|
|
169
|
+
timestampCommitCache.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve the git commit active before or at a given ISO timestamp.
|
|
174
|
+
*/
|
|
175
|
+
export function getCommitAtTimestamp(repoRoot, timestamp) {
|
|
176
|
+
if (!repoRoot || !timestamp) return null;
|
|
177
|
+
const iso = typeof timestamp === "string" ? timestamp.trim() : new Date(timestamp).toISOString();
|
|
178
|
+
const cacheKey = `${repoRoot}:${iso}`;
|
|
179
|
+
if (timestampCommitCache.has(cacheKey)) {
|
|
180
|
+
return timestampCommitCache.get(cacheKey);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const commit = execFileSync("git", ["rev-list", "-n", "1", `--before=${iso}`, "HEAD"], {
|
|
185
|
+
cwd: repoRoot,
|
|
186
|
+
encoding: "utf8",
|
|
187
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
188
|
+
}).trim();
|
|
189
|
+
|
|
190
|
+
const result = commit && /^[a-f0-9]{7,40}$/i.test(commit) ? commit : null;
|
|
191
|
+
timestampCommitCache.set(cacheKey, result);
|
|
192
|
+
return result;
|
|
193
|
+
} catch {
|
|
194
|
+
timestampCommitCache.set(cacheKey, null);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Extract commit hashes explicitly mentioned in message text.
|
|
201
|
+
*/
|
|
202
|
+
export function extractMentionedCommits(text = "") {
|
|
203
|
+
if (!text || typeof text !== "string") return [];
|
|
204
|
+
const commits = [];
|
|
205
|
+
const regex = /\b(?:commit|commitado|commitada|fixado|merge|sha|rev)?[:\s*`"'(]*([0-9a-f]{7,40})\b/gi;
|
|
206
|
+
let match;
|
|
207
|
+
while ((match = regex.exec(text)) !== null) {
|
|
208
|
+
const hex = match[1].toLowerCase();
|
|
209
|
+
if (/^[0-9]+$/.test(hex) && hex.length < 40) continue;
|
|
210
|
+
if (!commits.includes(hex)) {
|
|
211
|
+
commits.push(hex);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return commits;
|
|
168
215
|
}
|
|
169
216
|
|
|
170
217
|
/**
|
|
@@ -195,6 +242,8 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
|
|
|
195
242
|
}).trim();
|
|
196
243
|
headCommitCache.set(repoRoot, { sha: commitSha, at: now });
|
|
197
244
|
}
|
|
245
|
+
} else if (/^[a-f0-9]{40}$/i.test(commit)) {
|
|
246
|
+
commitSha = commit;
|
|
198
247
|
} else {
|
|
199
248
|
commitSha = execFileSync("git", ["rev-parse", commit], {
|
|
200
249
|
cwd: repoRoot,
|
|
@@ -290,7 +339,7 @@ export function readGitRef(repoRoot, commitSha, relativePath) {
|
|
|
290
339
|
* - Committed files in repoRoot can be pinned to git HEAD.
|
|
291
340
|
* - Already pinned/stored items retain their immutable URLs.
|
|
292
341
|
*/
|
|
293
|
-
export function ingestAttachment(candidate, amqRoot, repoRoot) {
|
|
342
|
+
export function ingestAttachment(candidate, amqRoot, repoRoot, { timestamp = null, text = "" } = {}) {
|
|
294
343
|
if (!candidate) return null;
|
|
295
344
|
|
|
296
345
|
// Already a structured descriptor
|
|
@@ -316,7 +365,7 @@ export function ingestAttachment(candidate, amqRoot, repoRoot) {
|
|
|
316
365
|
}
|
|
317
366
|
|
|
318
367
|
if (candidate.path) {
|
|
319
|
-
return ingestAttachment(candidate.path, amqRoot, repoRoot);
|
|
368
|
+
return ingestAttachment(candidate.path, amqRoot, repoRoot, { timestamp, text });
|
|
320
369
|
}
|
|
321
370
|
}
|
|
322
371
|
|
|
@@ -363,16 +412,47 @@ export function ingestAttachment(candidate, amqRoot, repoRoot) {
|
|
|
363
412
|
return storeBlob(diskPath, amqRoot, path.basename(diskPath));
|
|
364
413
|
}
|
|
365
414
|
|
|
366
|
-
// If it's inside repoRoot and git is present,
|
|
415
|
+
// If it's inside repoRoot and git is present, pin to commit at timestamp (or HEAD)
|
|
367
416
|
if (repoRoot && diskPath.startsWith(repoRoot)) {
|
|
368
417
|
const rel = path.relative(repoRoot, diskPath);
|
|
369
|
-
const
|
|
418
|
+
const commit = (timestamp ? getCommitAtTimestamp(repoRoot, timestamp) : null) || "HEAD";
|
|
419
|
+
const gitRef = pinGitRef(repoRoot, rel, commit);
|
|
370
420
|
if (gitRef) return gitRef;
|
|
371
421
|
}
|
|
372
422
|
|
|
373
423
|
// Default fallback for existing files: store into blobstore for guarantee
|
|
374
424
|
return storeBlob(diskPath, amqRoot, path.basename(diskPath));
|
|
375
425
|
}
|
|
426
|
+
|
|
427
|
+
// ─── Historical Git Pinning Fallback ───
|
|
428
|
+
// If the file does not exist on disk right now (e.g. deleted or renamed in subsequent commits),
|
|
429
|
+
// check if it existed in Git at the time the message was created or at a commit mentioned in the message!
|
|
430
|
+
if (!diskPath && repoRoot) {
|
|
431
|
+
const cleanRel = raw.startsWith(repoRoot) ? path.relative(repoRoot, raw) : raw.replace(/^[/\\]+/, "");
|
|
432
|
+
if (!cleanRel.includes("..") && !path.isAbsolute(cleanRel)) {
|
|
433
|
+
const candidateCommits = [];
|
|
434
|
+
if (text) {
|
|
435
|
+
candidateCommits.push(...extractMentionedCommits(text));
|
|
436
|
+
}
|
|
437
|
+
if (timestamp) {
|
|
438
|
+
const atTime = getCommitAtTimestamp(repoRoot, timestamp);
|
|
439
|
+
if (atTime && !candidateCommits.includes(atTime)) {
|
|
440
|
+
candidateCommits.push(atTime);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
candidateCommits.push("HEAD");
|
|
444
|
+
|
|
445
|
+
for (const commitSha of candidateCommits) {
|
|
446
|
+
const gitRef = pinGitRef(repoRoot, cleanRel, commitSha);
|
|
447
|
+
if (gitRef && gitRef.exists) {
|
|
448
|
+
return {
|
|
449
|
+
...gitRef,
|
|
450
|
+
pinnedAt: timestamp || null,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
376
456
|
}
|
|
377
457
|
|
|
378
458
|
return null;
|
package/src/fleet.mjs
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
5
|
+
import { scanAgentBriefs } from "./briefs.mjs";
|
|
6
|
+
import { ensureAgentWorktree } from "./worktrees.mjs";
|
|
7
|
+
import { registerAgent, formatAgentTitle } from "./store.mjs";
|
|
8
|
+
import { getHerdrAgents, isHerdrAvailable } from "./herdr.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse agent handles from AGENTS.md, GEMINI.md, or CLAUDE.md
|
|
12
|
+
*/
|
|
13
|
+
export function parseAgentsMdHandles(repoRoot) {
|
|
14
|
+
if (!repoRoot || !fs.existsSync(repoRoot)) return [];
|
|
15
|
+
const candidateFiles = ["AGENTS.md", "GEMINI.md", "CLAUDE.md"];
|
|
16
|
+
const handles = new Set();
|
|
17
|
+
|
|
18
|
+
for (const f of candidateFiles) {
|
|
19
|
+
const fullPath = path.join(repoRoot, f);
|
|
20
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
24
|
+
// Pattern 1: Handles: `coord`, `worker`, ...
|
|
25
|
+
const handleMatch = content.match(/Handles:\s*([^\n\r]+)/i);
|
|
26
|
+
if (handleMatch) {
|
|
27
|
+
const tokens = handleMatch[1].match(/`([^`]+)`/g) || handleMatch[1].split(/[\s,]+/);
|
|
28
|
+
for (let tok of tokens) {
|
|
29
|
+
tok = tok.replace(/[`'",:]/g, "").trim().toLowerCase();
|
|
30
|
+
if (tok && !["and", "or", "etc", "none"].includes(tok)) {
|
|
31
|
+
handles.add(tok);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return Array.from(handles);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Discover fleet personas across external tool directories (.opencode, .agents, .pi, .claude, AGENTS.md, .worktrees)
|
|
43
|
+
*/
|
|
44
|
+
export function discoverFleetPersonas(repoRoot) {
|
|
45
|
+
const personas = new Map();
|
|
46
|
+
if (!repoRoot || !fs.existsSync(repoRoot)) return personas;
|
|
47
|
+
|
|
48
|
+
// 1. Scan standard briefs (.opencode/agents, .agents, .pi/agents, etc.)
|
|
49
|
+
const briefs = scanAgentBriefs(repoRoot);
|
|
50
|
+
for (const [handle, brief] of briefs.entries()) {
|
|
51
|
+
personas.set(handle, {
|
|
52
|
+
...brief,
|
|
53
|
+
sourceType: "brief",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. Parse handles declared in AGENTS.md
|
|
58
|
+
const declaredHandles = parseAgentsMdHandles(repoRoot);
|
|
59
|
+
for (const handle of declaredHandles) {
|
|
60
|
+
if (!personas.has(handle)) {
|
|
61
|
+
personas.set(handle, {
|
|
62
|
+
handle,
|
|
63
|
+
name: formatAgentTitle(handle),
|
|
64
|
+
role: `Declared agent for ${handle}`,
|
|
65
|
+
description: `Agent defined in AGENTS.md`,
|
|
66
|
+
prompt: `You are the ${handle} agent.`,
|
|
67
|
+
source: "AGENTS.md",
|
|
68
|
+
sourceType: "rule",
|
|
69
|
+
model: null,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Discover established worktrees (.worktrees/<handle>)
|
|
75
|
+
const worktreeDir = path.join(repoRoot, ".worktrees");
|
|
76
|
+
if (fs.existsSync(worktreeDir)) {
|
|
77
|
+
try {
|
|
78
|
+
const entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
|
|
79
|
+
for (const ent of entries) {
|
|
80
|
+
if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
|
|
81
|
+
const handle = ent.name;
|
|
82
|
+
if (!personas.has(handle)) {
|
|
83
|
+
personas.set(handle, {
|
|
84
|
+
handle,
|
|
85
|
+
name: formatAgentTitle(handle),
|
|
86
|
+
role: `Specialist agent for ${handle}`,
|
|
87
|
+
description: `Established worktree agent`,
|
|
88
|
+
prompt: `You are the ${handle} agent.`,
|
|
89
|
+
source: path.join(".worktrees", handle),
|
|
90
|
+
sourceType: "worktree",
|
|
91
|
+
model: null,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return personas;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Prepopulate all fleet agents into AMQ maildirs and ensure isolated Git worktrees exist
|
|
103
|
+
*/
|
|
104
|
+
export function prepopulateFleet(amqRoot, repoRoot) {
|
|
105
|
+
const personas = discoverFleetPersonas(repoRoot);
|
|
106
|
+
const results = [];
|
|
107
|
+
|
|
108
|
+
for (const [handle, persona] of personas.entries()) {
|
|
109
|
+
const worktreeResult = ensureAgentWorktree(repoRoot, handle);
|
|
110
|
+
const regResult = registerAgent(amqRoot, {
|
|
111
|
+
handle,
|
|
112
|
+
name: persona.name,
|
|
113
|
+
role: persona.role || persona.description,
|
|
114
|
+
description: persona.description,
|
|
115
|
+
prompt: persona.prompt,
|
|
116
|
+
model: persona.model || "Gemini 3.8 Flash (High)",
|
|
117
|
+
worktree: worktreeResult.ok ? worktreeResult.path : undefined,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
results.push({
|
|
121
|
+
handle,
|
|
122
|
+
name: persona.name,
|
|
123
|
+
source: persona.source,
|
|
124
|
+
sourceType: persona.sourceType,
|
|
125
|
+
worktree: worktreeResult.path,
|
|
126
|
+
worktreeExisted: worktreeResult.existed || false,
|
|
127
|
+
maildirOk: regResult.ok,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
trustFleetWorkspaces(repoRoot);
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pre-authorize worktree directories in Antigravity CLI's settings.json
|
|
137
|
+
* to bypass the interactive TUI trust dialog
|
|
138
|
+
*/
|
|
139
|
+
export function trustFleetWorkspaces(repoRoot) {
|
|
140
|
+
try {
|
|
141
|
+
const home = os.homedir();
|
|
142
|
+
const settingsPath = path.join(home, ".gemini", "antigravity-cli", "settings.json");
|
|
143
|
+
if (!fs.existsSync(settingsPath)) return;
|
|
144
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
145
|
+
const set = new Set(settings.trustedWorkspaces || []);
|
|
146
|
+
set.add(repoRoot);
|
|
147
|
+
const wtDir = path.join(repoRoot, ".worktrees");
|
|
148
|
+
if (fs.existsSync(wtDir)) {
|
|
149
|
+
for (const f of fs.readdirSync(wtDir)) {
|
|
150
|
+
set.add(path.join(wtDir, f));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
settings.trustedWorkspaces = Array.from(set);
|
|
154
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a robust PATH that guarantees local binaries (~/.local/bin, ~/.gemini/antigravity-cli/bin, nix profiles)
|
|
160
|
+
*/
|
|
161
|
+
export function buildFleetEnvPath() {
|
|
162
|
+
const home = os.homedir();
|
|
163
|
+
const userName = os.userInfo().username;
|
|
164
|
+
const paths = [
|
|
165
|
+
path.join(home, ".local", "bin"),
|
|
166
|
+
path.join(home, ".gemini", "antigravity-cli", "bin"),
|
|
167
|
+
path.join(home, ".nix-profile", "bin"),
|
|
168
|
+
path.join(home, ".cargo", "bin"),
|
|
169
|
+
"/run/wrappers/bin",
|
|
170
|
+
`/etc/profiles/per-user/${userName}/bin`,
|
|
171
|
+
"/nix/profile/bin",
|
|
172
|
+
"/run/current-system/sw/bin",
|
|
173
|
+
process.env.PATH || "",
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
return Array.from(new Set(paths.filter(Boolean))).join(":");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Launch or recover the agent fleet inside Herdr
|
|
181
|
+
*/
|
|
182
|
+
export async function launchFleet(amqRoot, repoRoot, options = {}) {
|
|
183
|
+
const kind = options.kind || "agy";
|
|
184
|
+
const dryRun = Boolean(options.dryRun);
|
|
185
|
+
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
|
+
|
|
195
|
+
const result = {
|
|
196
|
+
total: targetFleet.length,
|
|
197
|
+
prepopulated: targetFleet.map((t) => t.handle),
|
|
198
|
+
alreadyRunning: [],
|
|
199
|
+
wouldLaunch: [],
|
|
200
|
+
launched: [],
|
|
201
|
+
failed: [],
|
|
202
|
+
dryRun,
|
|
203
|
+
};
|
|
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
|
|
225
|
+
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
|
+
|
|
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;
|
|
244
|
+
}
|
|
245
|
+
|
|
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);
|
|
261
|
+
}
|
|
262
|
+
|
|
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}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
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);
|
|
286
|
+
}
|
|
287
|
+
|
|
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;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!started) {
|
|
311
|
+
result.failed.push({ handle, error: lastErr?.message || "Failed to start agent" });
|
|
312
|
+
}
|
|
313
|
+
} catch (err) {
|
|
314
|
+
result.failed.push({ handle, error: err.message });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return result;
|
|
319
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -2,3 +2,8 @@ export * from "./config.mjs";
|
|
|
2
2
|
export * from "./bridge.mjs";
|
|
3
3
|
export * from "./actions.mjs";
|
|
4
4
|
export * from "./panes.mjs";
|
|
5
|
+
export * from "./blobs.mjs";
|
|
6
|
+
export * from "./migration.mjs";
|
|
7
|
+
export * from "./briefs.mjs";
|
|
8
|
+
export * from "./worktrees.mjs";
|
|
9
|
+
export * from "./fleet.mjs";
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseMessage, serializeMessage } from "./protocol.mjs";
|
|
4
|
+
import { ingestAttachment } from "./blobs.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scan all messages across agent mailboxes in amqRoot and migrate attachments once.
|
|
8
|
+
* - Resolves ephemeral files (/tmp/...) into immutable CAS blobs.
|
|
9
|
+
* - Pins deleted or historical repository assets to their git commits.
|
|
10
|
+
* - Writes pinned/frozen attachments directly into the message frontmatter.
|
|
11
|
+
* - Once migrated, parseMessageFile reads attachments in 0ms without git execution.
|
|
12
|
+
*/
|
|
13
|
+
export function migrateMessageAttachments(amqRoot, { dryRun = false, verbose = false, onProgress = null } = {}) {
|
|
14
|
+
if (!amqRoot) throw new Error("amqRoot is required");
|
|
15
|
+
const repoRoot = path.resolve(path.dirname(amqRoot));
|
|
16
|
+
|
|
17
|
+
const stats = {
|
|
18
|
+
totalScanned: 0,
|
|
19
|
+
alreadyMigrated: 0,
|
|
20
|
+
migrated: 0,
|
|
21
|
+
blobsStored: 0,
|
|
22
|
+
gitPinned: 0,
|
|
23
|
+
errors: 0,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const agentsDir = path.join(amqRoot, "agents");
|
|
27
|
+
if (!fs.existsSync(agentsDir)) {
|
|
28
|
+
return stats;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Find all message files across inbox/new, inbox/cur, and outbox/sent
|
|
32
|
+
const messageFiles = [];
|
|
33
|
+
const agentEntries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
|
34
|
+
for (const agentEnt of agentEntries) {
|
|
35
|
+
if (!agentEnt.isDirectory()) continue;
|
|
36
|
+
const agentDir = path.join(agentsDir, agentEnt.name);
|
|
37
|
+
const subdirs = [
|
|
38
|
+
path.join(agentDir, "inbox", "new"),
|
|
39
|
+
path.join(agentDir, "inbox", "cur"),
|
|
40
|
+
path.join(agentDir, "outbox", "sent"),
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
for (const subdir of subdirs) {
|
|
44
|
+
if (!fs.existsSync(subdir)) continue;
|
|
45
|
+
const files = fs.readdirSync(subdir);
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
if (f.endsWith(".md")) {
|
|
48
|
+
messageFiles.push(path.join(subdir, f));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
stats.totalScanned = messageFiles.length;
|
|
55
|
+
|
|
56
|
+
const candidateRegex = /(?:(?:(?:\/|\.\/|[a-zA-Z0-9_.-]+\/)[a-zA-Z0-9_./-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|txt|csv|json|diff|patch|out))|(?:\b[a-zA-Z0-9_.-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|diff|patch|out)\b))/gi;
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < messageFiles.length; i++) {
|
|
59
|
+
const filePath = messageFiles[i];
|
|
60
|
+
try {
|
|
61
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
62
|
+
const { header, body } = parseMessage(raw);
|
|
63
|
+
|
|
64
|
+
if (!header || typeof header !== "object") {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check if already migrated
|
|
69
|
+
const existingAttachments = header.attachments;
|
|
70
|
+
const isAlreadyMigrated =
|
|
71
|
+
Array.isArray(existingAttachments) &&
|
|
72
|
+
existingAttachments.length > 0 &&
|
|
73
|
+
existingAttachments.every(
|
|
74
|
+
(a) => typeof a === "object" && a !== null && (a.type === "blob" || a.type === "git" || a.exists !== undefined)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (isAlreadyMigrated) {
|
|
78
|
+
stats.alreadyMigrated++;
|
|
79
|
+
if (onProgress && i % 100 === 0) onProgress({ ...stats, current: i + 1 });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Collect candidates: existing raw attachments + body regex matches
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const candidates = [];
|
|
86
|
+
|
|
87
|
+
function addCand(ref) {
|
|
88
|
+
if (!ref) return;
|
|
89
|
+
let clean = typeof ref === "string" ? ref.trim().replace(/^["'<(\[]+|[>"')\],;:]+$/g, "") : "";
|
|
90
|
+
if (typeof ref === "object") clean = ref.path || ref.sha256 || ref.name || "";
|
|
91
|
+
if (!clean || seen.has(clean) || clean.startsWith("http://") || clean.startsWith("https://")) return;
|
|
92
|
+
seen.add(clean);
|
|
93
|
+
const base = path.basename(clean);
|
|
94
|
+
if (["config.json", "package.json", "pyproject.toml", "Cargo.toml", "flake.lock"].includes(base)) return;
|
|
95
|
+
candidates.push(ref);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (Array.isArray(existingAttachments)) {
|
|
99
|
+
for (const a of existingAttachments) addCand(a);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const matches = body.match(candidateRegex) || [];
|
|
103
|
+
for (const m of matches) addCand(m);
|
|
104
|
+
|
|
105
|
+
if (candidates.length === 0) {
|
|
106
|
+
if (onProgress && i % 100 === 0) onProgress({ ...stats, current: i + 1 });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Ingest each candidate
|
|
111
|
+
const resolved = [];
|
|
112
|
+
for (const cand of candidates) {
|
|
113
|
+
try {
|
|
114
|
+
const ing = ingestAttachment(cand, amqRoot, repoRoot, {
|
|
115
|
+
timestamp: header.created || null,
|
|
116
|
+
text: body,
|
|
117
|
+
});
|
|
118
|
+
if (ing && ing.exists) {
|
|
119
|
+
resolved.push(ing);
|
|
120
|
+
if (ing.type === "blob") stats.blobsStored++;
|
|
121
|
+
if (ing.type === "git") stats.gitPinned++;
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (resolved.length > 0) {
|
|
127
|
+
header.attachments = resolved;
|
|
128
|
+
const serialized = serializeMessage({
|
|
129
|
+
...header,
|
|
130
|
+
body,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!dryRun) {
|
|
134
|
+
const tmp = `${filePath}.mig.${Date.now()}`;
|
|
135
|
+
fs.writeFileSync(tmp, serialized, "utf8");
|
|
136
|
+
fs.renameSync(tmp, filePath);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
stats.migrated++;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (onProgress && (i % 50 === 0 || i === messageFiles.length - 1)) {
|
|
143
|
+
onProgress({ ...stats, current: i + 1 });
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
stats.errors++;
|
|
147
|
+
if (verbose) {
|
|
148
|
+
console.error(`Error migrating ${filePath}:`, err.message);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return stats;
|
|
154
|
+
}
|
package/src/store.mjs
CHANGED
|
@@ -163,7 +163,21 @@ export function resolveAttachmentPath(ref, amqRoot) {
|
|
|
163
163
|
* Extract attachments and referenced images/logs from body or frontmatter,
|
|
164
164
|
* verifying presence on disk and providing existence flags.
|
|
165
165
|
*/
|
|
166
|
-
export function extractAttachments(body = "", metaAttachments = [], amqRoot = null) {
|
|
166
|
+
export function extractAttachments(body = "", metaAttachments = [], amqRoot = null, meta = {}) {
|
|
167
|
+
// If attachments are already structured and resolved (e.g. via migration or sendMaildirMessage), return directly
|
|
168
|
+
if (Array.isArray(metaAttachments) && metaAttachments.length > 0) {
|
|
169
|
+
const isStructured = metaAttachments.every(
|
|
170
|
+
(a) => typeof a === "object" && a !== null && (a.type === "blob" || a.type === "git" || a.exists !== undefined)
|
|
171
|
+
);
|
|
172
|
+
if (isStructured) {
|
|
173
|
+
return metaAttachments.map((a) => ({
|
|
174
|
+
...a,
|
|
175
|
+
originalRef: a.originalRef || a.path || a.name,
|
|
176
|
+
sizeDisplay: a.sizeDisplay || (a.exists ? formatFileSize(a.sizeBytes || 0) : "Missing on disk"),
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
const attachments = [];
|
|
168
182
|
const seen = new Set();
|
|
169
183
|
const repoRoot = getRepoRootFromAmq(amqRoot);
|
|
@@ -171,7 +185,11 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
171
185
|
function addCandidate(rawRef) {
|
|
172
186
|
if (!rawRef) return;
|
|
173
187
|
let clean = typeof rawRef === "string" ? rawRef.trim().replace(/^["'<(\[]+|[>"')\],;:]+$/g, "") : "";
|
|
174
|
-
if (typeof rawRef === "object") {
|
|
188
|
+
if (typeof rawRef === "object" && rawRef !== null) {
|
|
189
|
+
if (rawRef.type === "git" || rawRef.type === "blob") {
|
|
190
|
+
attachments.push(rawRef);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
175
193
|
clean = rawRef.path || rawRef.sha256 || rawRef.name || "";
|
|
176
194
|
}
|
|
177
195
|
if (!clean || seen.has(clean) || clean.startsWith("http://") || clean.startsWith("https://")) {
|
|
@@ -184,46 +202,69 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
184
202
|
return;
|
|
185
203
|
}
|
|
186
204
|
|
|
187
|
-
// 1. Try hybrid CAS ingestion / Git pinning (Option A + B)
|
|
188
|
-
if (amqRoot) {
|
|
189
|
-
try {
|
|
190
|
-
const ingested = ingestAttachment(rawRef, amqRoot, repoRoot);
|
|
191
|
-
if (ingested && ingested.exists) {
|
|
192
|
-
attachments.push({
|
|
193
|
-
...ingested,
|
|
194
|
-
originalRef: typeof rawRef === "string" ? clean : (rawRef.name || clean),
|
|
195
|
-
sizeDisplay: formatFileSize(ingested.sizeBytes || 0),
|
|
196
|
-
});
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
} catch {}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// 2. Fallback to disk resolution
|
|
203
205
|
const ext = path.extname(clean).toLowerCase();
|
|
204
206
|
const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
|
|
205
207
|
const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
|
|
206
208
|
|
|
209
|
+
// 1. Check disk resolution first (sub-millisecond)
|
|
207
210
|
const resolved = resolveAttachmentPath(clean, amqRoot);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (exists) {
|
|
211
|
+
if (resolved) {
|
|
212
|
+
let sizeBytes = 0;
|
|
211
213
|
try {
|
|
212
214
|
sizeBytes = fs.statSync(resolved).size;
|
|
213
215
|
} catch {}
|
|
216
|
+
|
|
217
|
+
attachments.push({
|
|
218
|
+
path: resolved,
|
|
219
|
+
originalRef: clean,
|
|
220
|
+
name: base,
|
|
221
|
+
ext,
|
|
222
|
+
isImage,
|
|
223
|
+
isLog,
|
|
224
|
+
exists: true,
|
|
225
|
+
sizeBytes,
|
|
226
|
+
sizeDisplay: formatFileSize(sizeBytes),
|
|
227
|
+
url: `/api/file?path=${encodeURIComponent(resolved)}`,
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 2. Check CAS Blobstore if hash
|
|
233
|
+
if (amqRoot) {
|
|
234
|
+
const hashMatch = clean.match(/^(?:blob:)?([a-f0-9]{64})$/i);
|
|
235
|
+
if (hashMatch) {
|
|
236
|
+
const stored = getBlob(hashMatch[1], amqRoot);
|
|
237
|
+
if (stored) {
|
|
238
|
+
attachments.push({
|
|
239
|
+
type: "blob",
|
|
240
|
+
sha256: stored.sha256,
|
|
241
|
+
name: stored.name,
|
|
242
|
+
ext: stored.ext,
|
|
243
|
+
mime: stored.mime,
|
|
244
|
+
sizeBytes: stored.sizeBytes,
|
|
245
|
+
isImage,
|
|
246
|
+
isLog,
|
|
247
|
+
exists: true,
|
|
248
|
+
sizeDisplay: formatFileSize(stored.sizeBytes),
|
|
249
|
+
url: `/api/blob/${stored.sha256}${stored.ext ? `?ext=${encodeURIComponent(stored.ext)}` : ""}`,
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
214
254
|
}
|
|
215
255
|
|
|
256
|
+
// 3. Fallback: missing on disk (unmigrated legacy reference)
|
|
216
257
|
attachments.push({
|
|
217
|
-
path:
|
|
258
|
+
path: clean,
|
|
218
259
|
originalRef: clean,
|
|
219
260
|
name: base,
|
|
220
261
|
ext,
|
|
221
262
|
isImage,
|
|
222
263
|
isLog,
|
|
223
|
-
exists,
|
|
224
|
-
sizeBytes,
|
|
225
|
-
sizeDisplay:
|
|
226
|
-
url:
|
|
264
|
+
exists: false,
|
|
265
|
+
sizeBytes: 0,
|
|
266
|
+
sizeDisplay: "Missing on disk",
|
|
267
|
+
url: null,
|
|
227
268
|
});
|
|
228
269
|
}
|
|
229
270
|
|
|
@@ -233,8 +274,8 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
233
274
|
}
|
|
234
275
|
}
|
|
235
276
|
|
|
236
|
-
// Auto-scan body for referenced
|
|
237
|
-
const regex = /(?:(?:(?:\/|\.\/|[a-zA-Z0-9_.-]+\/)[a-zA-Z0-9_./-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|txt|csv|json|diff|patch|out
|
|
277
|
+
// Auto-scan body for referenced media/logs (strictly excludes code files like .gd/.tscn)
|
|
278
|
+
const regex = /(?:(?:(?:\/|\.\/|[a-zA-Z0-9_.-]+\/)[a-zA-Z0-9_./-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|txt|csv|json|diff|patch|out))|(?:\b[a-zA-Z0-9_.-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|diff|patch|out)\b))/gi;
|
|
238
279
|
const matches = body.match(regex) || [];
|
|
239
280
|
|
|
240
281
|
for (const m of matches) {
|
|
@@ -394,7 +435,7 @@ export function parseMessageFile(filePath, amqRoot = null) {
|
|
|
394
435
|
if (isOutbox) folder = "sent";
|
|
395
436
|
|
|
396
437
|
const root = amqRoot || (filePath.includes("/agents/") ? filePath.split(path.sep + "agents" + path.sep)[0] : null);
|
|
397
|
-
const attachments = extractAttachments(body, meta.attachments, root);
|
|
438
|
+
const attachments = extractAttachments(body, meta.attachments, root, meta);
|
|
398
439
|
const hasImage = attachments.some((a) => a.isImage);
|
|
399
440
|
const hasAttachment = attachments.length > 0;
|
|
400
441
|
|