herdr-plugin-amq 0.1.5 → 0.1.7
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 +2 -0
- package/src/bridge.mjs +1 -1
- package/src/fleet.mjs +319 -0
- package/src/index.mjs +5 -0
- package/src/migration.mjs +154 -0
- package/src/store.mjs +68 -30
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.7"
|
|
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
|
@@ -242,6 +242,8 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
|
|
|
242
242
|
}).trim();
|
|
243
243
|
headCommitCache.set(repoRoot, { sha: commitSha, at: now });
|
|
244
244
|
}
|
|
245
|
+
} else if (/^[a-f0-9]{40}$/i.test(commit)) {
|
|
246
|
+
commitSha = commit;
|
|
245
247
|
} else {
|
|
246
248
|
commitSha = execFileSync("git", ["rev-parse", commit], {
|
|
247
249
|
cwd: repoRoot,
|
package/src/bridge.mjs
CHANGED
|
@@ -120,7 +120,7 @@ function healAgentName(handle, dryRun = false) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function promptAgent(handle, text, dryRun = false) {
|
|
123
|
-
if (dryRun) {
|
|
123
|
+
if (dryRun || process.env.HERDR_DISABLE_PROMPT === "1" || process.env.NODE_ENV === "test") {
|
|
124
124
|
console.log(`[bridge] DRY: would prompt ${handle}: ${text.slice(0, 60)}...`);
|
|
125
125
|
return true;
|
|
126
126
|
}
|
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
|
@@ -164,6 +164,20 @@ export function resolveAttachmentPath(ref, amqRoot) {
|
|
|
164
164
|
* verifying presence on disk and providing existence flags.
|
|
165
165
|
*/
|
|
166
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,49 +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) with timestamp
|
|
188
|
-
if (amqRoot) {
|
|
189
|
-
try {
|
|
190
|
-
const ingested = ingestAttachment(rawRef, amqRoot, repoRoot, {
|
|
191
|
-
timestamp: meta?.created || null,
|
|
192
|
-
text: body,
|
|
193
|
-
});
|
|
194
|
-
if (ingested && ingested.exists) {
|
|
195
|
-
attachments.push({
|
|
196
|
-
...ingested,
|
|
197
|
-
originalRef: typeof rawRef === "string" ? clean : (rawRef.name || clean),
|
|
198
|
-
sizeDisplay: formatFileSize(ingested.sizeBytes || 0),
|
|
199
|
-
});
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
} catch {}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// 2. Fallback to disk resolution
|
|
206
205
|
const ext = path.extname(clean).toLowerCase();
|
|
207
206
|
const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
|
|
208
207
|
const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
|
|
209
208
|
|
|
209
|
+
// 1. Check disk resolution first (sub-millisecond)
|
|
210
210
|
const resolved = resolveAttachmentPath(clean, amqRoot);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (exists) {
|
|
211
|
+
if (resolved) {
|
|
212
|
+
let sizeBytes = 0;
|
|
214
213
|
try {
|
|
215
214
|
sizeBytes = fs.statSync(resolved).size;
|
|
216
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
|
+
}
|
|
217
254
|
}
|
|
218
255
|
|
|
256
|
+
// 3. Fallback: missing on disk (unmigrated legacy reference)
|
|
219
257
|
attachments.push({
|
|
220
|
-
path:
|
|
258
|
+
path: clean,
|
|
221
259
|
originalRef: clean,
|
|
222
260
|
name: base,
|
|
223
261
|
ext,
|
|
224
262
|
isImage,
|
|
225
263
|
isLog,
|
|
226
|
-
exists,
|
|
227
|
-
sizeBytes,
|
|
228
|
-
sizeDisplay:
|
|
229
|
-
url:
|
|
264
|
+
exists: false,
|
|
265
|
+
sizeBytes: 0,
|
|
266
|
+
sizeDisplay: "Missing on disk",
|
|
267
|
+
url: null,
|
|
230
268
|
});
|
|
231
269
|
}
|
|
232
270
|
|
|
@@ -236,8 +274,8 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
236
274
|
}
|
|
237
275
|
}
|
|
238
276
|
|
|
239
|
-
// Auto-scan body for referenced
|
|
240
|
-
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;
|
|
241
279
|
const matches = body.match(regex) || [];
|
|
242
280
|
|
|
243
281
|
for (const m of matches) {
|