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 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.4"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-plugin-amq",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
@@ -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, try pinning to git
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 gitRef = pinGitRef(repoRoot, rel, "HEAD");
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
- const exists = Boolean(resolved);
209
- let sizeBytes = 0;
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: resolved || clean,
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: exists ? formatFileSize(sizeBytes) : "Missing on disk",
226
- url: exists ? `/api/file?path=${encodeURIComponent(resolved || clean)}` : null,
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 files (/tmp/..., paths with slashes, or image/log basenames)
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|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;
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