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 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.5"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-plugin-amq",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Herdr plugin for AMQ (Agent Message Queue) autonomous bridge, status monitoring, and AGmail dashboard",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
@@ -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
- const exists = Boolean(resolved);
212
- let sizeBytes = 0;
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: resolved || clean,
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: exists ? formatFileSize(sizeBytes) : "Missing on disk",
229
- url: exists ? `/api/file?path=${encodeURIComponent(resolved || clean)}` : null,
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 files (/tmp/..., paths with slashes, or image/log basenames)
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|gd|tres|tscn|sh|md))|(?:\b[a-zA-Z0-9_.-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|diff|patch|out)\b))/gi;
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) {