diff --git a/packages/cli/src/utils/relay.ts b/packages/cli/src/utils/relay.ts index 0c0b544..abe2c0b 100644 --- a/packages/cli/src/utils/relay.ts +++ b/packages/cli/src/utils/relay.ts @@ -74,6 +74,31 @@ function branchRoot(agentId: string): string { return resolveDeliveryPath(agentId); } +/** True if `recipient` is a local branch agent or team member (has a branch-office presence). */ +function isBranchRecipient(recipient: string): boolean { + const branchDir = join(process.env.HOME || homedir(), ".tps", "branch-office"); + if (!existsSync(branchDir)) return false; + if (existsSync(join(branchDir, recipient))) return true; // registered branch / team root + try { + for (const d of readdirSync(branchDir)) { + const sidecar = join(branchDir, d, "team.json"); + if (!existsSync(sidecar)) continue; + const s = JSON.parse(readFileSync(sidecar, "utf-8")); + if (Array.isArray(s.members) && s.members.includes(recipient)) return true; + } + } catch {} + return false; +} + +/** + * Delivery hook for FileSystemTransport (ops-16): a local branch/team recipient + * reads its workspace inbox, not the flat host mail path, so route relayed mail + * there. Returns null for host agents so they keep the host path unchanged. + */ +function resolveBranchRecipientDir(recipient: string): string | null { + return isBranchRecipient(recipient) ? resolveDeliveryPath(recipient) : null; +} + function assertAgent(agentId: string): void { const safe = sanitizeIdentifier(agentId); if (!agentId || safe !== agentId) { @@ -92,7 +117,7 @@ function atomicWriteJson(targetPath: string, data: unknown): void { } const detectors = new Map(); -const transportRegistry = new TransportRegistry(new FileSystemTransport()); +const transportRegistry = new TransportRegistry(new FileSystemTransport(resolveBranchRecipientDir)); function getDetector(agentId: string): LoopDetector { if (!detectors.has(agentId)) { diff --git a/packages/cli/src/utils/transport.ts b/packages/cli/src/utils/transport.ts index f4e4ae4..8380614 100644 --- a/packages/cli/src/utils/transport.ts +++ b/packages/cli/src/utils/transport.ts @@ -107,13 +107,25 @@ function hostMailRoot(): string { } export class FileSystemTransport implements DeliveryTransport { + /** + * @param resolveRecipientDir Optional hook: given a recipient id, return the mail + * root to deliver into (its `/new` is used), or null to use the flat host + * mail path. relay.ts injects a branch-aware resolver so a message to a local + * branch agent lands in that branch's workspace inbox (where it actually reads), + * not the host path (ops-16). With no hook, host delivery is unchanged. + */ + constructor(private readonly resolveRecipientDir?: (recipient: string) => string | null) {} + name(): string { return "filesystem"; } async deliver(envelope: MailEnvelope): Promise { try { - const recipientNew = join(hostMailRoot(), envelope.to, "new"); + const branchRoot = this.resolveRecipientDir?.(envelope.to) ?? null; + const recipientNew = branchRoot + ? join(branchRoot, "new") + : join(hostMailRoot(), envelope.to, "new"); ensureDir(recipientNew); const payload = { diff --git a/packages/cli/test/relay.test.ts b/packages/cli/test/relay.test.ts index ef6ce30..a536e64 100644 --- a/packages/cli/test/relay.test.ts +++ b/packages/cli/test/relay.test.ts @@ -1,5 +1,5 @@ import { beforeEach, afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { deliverToSandbox, processOutboxOnce } from "../src/utils/relay.js"; @@ -170,6 +170,65 @@ describe("relay utils", () => { expect(msg.body).toBe("hello team member"); }); + test("relays branch→branch mail to the recipient's workspace inbox, not the host path (ops-16)", async () => { + const out = outboxNew("brancha"); + // branchb is a registered branch (has a branch-office dir) → reads its workspace inbox. + const recipBranch = join(root, ".tps", "branch-office", "branchb"); + mkdirSync(recipBranch, { recursive: true }); + writeJson(join(out, "m1.json"), { + id: "msg-bb", from: "brancha", to: "branchb", + body: "hello branch b", timestamp: new Date().toISOString(), + }); + + const res = await processOutboxOnce("brancha"); + expect(res.processed).toBe(1); + + // Lands in branchb's workspace inbox (branch-office/branchb/mail/new)... + const wsInbox = join(recipBranch, "mail", "new"); + expect(readdirSync(wsInbox).filter((f) => f.endsWith(".json")).length).toBe(1); + // ...not the flat host path (~/.tps/mail/branchb/new). + const hostPath = join(root, ".tps", "mail", "branchb", "new"); + const hostCount = existsSync(hostPath) ? readdirSync(hostPath).filter((f) => f.endsWith(".json")).length : 0; + expect(hostCount).toBe(0); + }); + + test("relays to a team member's workspace inbox, not the host path (ops-16)", async () => { + const out = outboxNew("brancha"); + const teamDir = join(root, ".tps", "branch-office", "team1"); + const workspaceMail = join(teamDir, "workspace", "mail"); + mkdirSync(teamDir, { recursive: true }); + writeJson(join(teamDir, "team.json"), { + teamId: "team1", members: ["member1"], workspaceMail, createdAt: new Date().toISOString(), + }); + writeJson(join(out, "m1.json"), { + id: "msg-m1", from: "brancha", to: "member1", + body: "hello member", timestamp: new Date().toISOString(), + }); + + const res = await processOutboxOnce("brancha"); + expect(res.processed).toBe(1); + + expect(readdirSync(join(workspaceMail, "new")).filter((f) => f.endsWith(".json")).length).toBe(1); + const hostPath = join(root, ".tps", "mail", "member1", "new"); + const hostCount = existsSync(hostPath) ? readdirSync(hostPath).filter((f) => f.endsWith(".json")).length : 0; + expect(hostCount).toBe(0); + }); + + test("relays to a non-branch (host) recipient via the host mail path (ops-16 regression guard)", async () => { + const out = outboxNew("brancha"); + writeJson(join(out, "m1.json"), { + id: "msg-host", from: "brancha", to: "kern", + body: "hello host agent", timestamp: new Date().toISOString(), + }); + + const res = await processOutboxOnce("brancha"); + expect(res.processed).toBe(1); + + // kern has no branch-office presence → host path, unchanged. + const hostInbox = join(root, ".tps", "mail", "kern", "new"); + expect(readdirSync(hostInbox).filter((f) => f.endsWith(".json")).length).toBe(1); + }); + test("relay pauses duplicate messages (loop detection)", async () => { const agentId = "brancha"; const out = outboxNew(agentId);