diff --git a/apps/server/src/cross-repo-messaging-settings.ts b/apps/server/src/cross-repo-messaging-settings.ts new file mode 100644 index 00000000..d72aa57f --- /dev/null +++ b/apps/server/src/cross-repo-messaging-settings.ts @@ -0,0 +1,28 @@ +import type { Pool } from "pg"; + +import { getSetting, setSetting } from "./db/settings.js"; + +/** + * Whether agent-to-agent messaging (dispatch_send_message / list_agents) may + * address agents in *other* repositories. By default the addressable peer set + * is scoped to the sender's git repo root; enabling this lifts that scoping for + * local multi-repo workflows. This is a single server-wide setting — the gate + * applies to every agent on this Dispatch server, not per device. + * + * The key and the "true"/"false" <-> boolean encoding live only here so the + * route and the MCP handler share one definition (mirrors agent-type-settings). + */ +const CROSS_REPO_MESSAGING_KEY = "cross_repo_messaging_enabled"; + +export async function isCrossRepoMessagingEnabled( + pool: Pool +): Promise { + return (await getSetting(pool, CROSS_REPO_MESSAGING_KEY)) === "true"; +} + +export async function setCrossRepoMessagingEnabled( + pool: Pool, + enabled: boolean +): Promise { + await setSetting(pool, CROSS_REPO_MESSAGING_KEY, enabled ? "true" : "false"); +} diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 7ec9b584..9d95be0a 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -6,6 +6,10 @@ import type { FastifyBaseLogger, FastifyInstance } from "fastify"; import type { Pool } from "pg"; import { deleteSetting, getSetting, setSetting } from "../db/settings.js"; +import { + isCrossRepoMessagingEnabled, + setCrossRepoMessagingEnabled, +} from "../cross-repo-messaging-settings.js"; import { JobService } from "../jobs/service.js"; import { AGENT_TYPES, @@ -416,6 +420,22 @@ export async function registerSystemRoutes( return { enabledIdes: await setEnabledIdes(deps.pool, uniqueIdes) }; }); + app.get("/api/v1/app/settings/cross-repo-messaging", async () => { + return { enabled: await isCrossRepoMessagingEnabled(deps.pool) }; + }); + + app.post( + "/api/v1/app/settings/cross-repo-messaging", + async (request, reply) => { + const body = request.body as { enabled?: unknown } | null; + if (typeof body?.enabled !== "boolean") { + return reply.code(400).send({ error: "enabled must be a boolean." }); + } + await setCrossRepoMessagingEnabled(deps.pool, body.enabled); + return { enabled: body.enabled }; + } + ); + app.post("/api/v1/energy-report", async (request, reply) => { try { deps.appLog.info( diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 41dbbdb2..8331e628 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -18,6 +18,7 @@ import { getEnabledAgentTypes, isCliAgentType, } from "../agent-type-settings.js"; +import { isCrossRepoMessagingEnabled } from "../cross-repo-messaging-settings.js"; import type { JobService } from "../jobs/service.js"; import type { NotifyInput, @@ -110,6 +111,36 @@ export function mcpMethodNotAllowed(): { }; } +/** + * The set of agents a sender may address via dispatch_send_message and + * list_agents: every other agent (self excluded), scoped to the sender's git + * repo root unless cross-repo messaging is enabled. This is the single + * definition of that visibility boundary — both message delivery and agent + * listing consult it, so they can never disagree about who is reachable. + */ +async function addressableAgents( + all: T[], + agentId: string, + senderRepoRoot: string | null, + crossRepo: boolean +): Promise { + const result: T[] = []; + for (const a of all) { + if (a.id === agentId) continue; + if (crossRepo) { + result.push(a); + continue; + } + try { + const aRoot = await resolveRepoRoot(a.cwd); + if (aRoot === senderRepoRoot) result.push(a); + } catch { + // agent cwd not in a git repo — skip + } + } + return result; +} + export function createMcpHandlers(deps: CreateMcpHandlersDeps) { const { pool, @@ -790,23 +821,19 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { if (!sender) throw new Error("Sender agent not found."); const senderRepoRoot = input.senderRepoRoot; - if (!senderRepoRoot) { + const crossRepo = await isCrossRepoMessagingEnabled(pool); + if (!crossRepo && !senderRepoRoot) { throw new Error( "Cannot send messages: unable to determine your project's repository root." ); } - const allAgentsRaw = await agentManager.listAgents(); - const allAgents: typeof allAgentsRaw = []; - for (const a of allAgentsRaw) { - if (a.id === agentId) continue; - try { - const aRoot = await resolveRepoRoot(a.cwd); - if (aRoot === senderRepoRoot) allAgents.push(a); - } catch { - // agent cwd not in a git repo — skip - } - } + const allAgents = await addressableAgents( + await agentManager.listAgents(), + agentId, + senderRepoRoot, + crossRepo + ); const isAgentId = input.target.startsWith("agt_"); @@ -817,9 +844,7 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { const lowerTarget = input.target.toLowerCase(); const matches = allAgents.filter( (a) => - a.id !== agentId && - a.status === "running" && - a.name.toLowerCase().includes(lowerTarget) + a.status === "running" && a.name.toLowerCase().includes(lowerTarget) ); if (matches.length === 1) { target = matches[0]; @@ -833,7 +858,7 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { if (!target) { const running = allAgents - .filter((a) => a.id !== agentId && a.status === "running") + .filter((a) => a.status === "running") .map((a) => ` ${a.id} "${a.name}"`) .join("\n"); throw new Error( @@ -841,10 +866,6 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { ); } - if (target.id === agentId) { - throw new Error("Cannot send a message to yourself."); - } - if (target.status !== "running") { throw new Error( `Agent "${target.name}" (${target.id}) is ${target.status}, not running.` @@ -891,13 +912,19 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { latestEvent: { type: string; message: string } | null; }> > { - if (!senderRepoRoot) { + const crossRepo = await isCrossRepoMessagingEnabled(pool); + if (!crossRepo && !senderRepoRoot) { throw new Error( "Cannot list agents: unable to determine your project's repository root." ); } - const agents = await agentManager.listAgents(); + const agents = await addressableAgents( + await agentManager.listAgents(), + agentId, + senderRepoRoot, + crossRepo + ); const result: Array<{ id: string; name: string; @@ -905,13 +932,6 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { latestEvent: { type: string; message: string } | null; }> = []; for (const a of agents) { - if (a.id === agentId) continue; - try { - const aRoot = await resolveRepoRoot(a.cwd); - if (aRoot !== senderRepoRoot) continue; - } catch { - continue; - } result.push({ id: a.id, name: a.name, diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index 8555f806..e9e46f57 100644 --- a/apps/server/test/mcp-handlers.test.ts +++ b/apps/server/test/mcp-handlers.test.ts @@ -1122,6 +1122,31 @@ describe("createMcpHandlers", () => { }) ).rejects.toThrow('No agent found matching "other"'); }); + + it("delivers cross-repo when the cross-repo messaging setting is enabled", async () => { + // getSetting(cross_repo_messaging_enabled) -> "true" + deps.pool.query.mockResolvedValue({ rows: [{ value: "true" }] }); + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_test1", + name: "sender", + cwd: "/repo-a", + status: "running", + }, + { id: "agt_other", name: "other", cwd: "/repo-b", status: "running" }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + // senderRepoRoot null is tolerated once cross-repo messaging is on. + const result = await handlers.sendMessage("agt_test1", { + target: "other", + message: "hi", + senderRepoRoot: null, + }); + expect(result.delivered).toBe(true); + expect(result.targetAgentId).toBe("agt_other"); + }); }); describe("listAgentsForAgent", () => { @@ -1168,6 +1193,33 @@ describe("createMcpHandlers", () => { handlers.listAgentsForAgent("agt_self", null) ).rejects.toThrow("Cannot list agents"); }); + + it("lists agents across repos when the cross-repo messaging setting is enabled", async () => { + // getSetting(cross_repo_messaging_enabled) -> "true" + deps.pool.query.mockResolvedValue({ rows: [{ value: "true" }] }); + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_self", + name: "self", + cwd: "/repo", + status: "running", + latestEvent: null, + }, + { + id: "agt_other", + name: "other", + cwd: "/other-repo", + status: "running", + latestEvent: null, + }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + // senderRepoRoot null is tolerated once cross-repo messaging is on. + const result = await handlers.listAgentsForAgent("agt_self", null); + expect(result.map((a) => a.id)).toEqual(["agt_other"]); + }); }); describe("shareMedia", () => { diff --git a/apps/web/src/components/app/cross-repo-messaging-settings.tsx b/apps/web/src/components/app/cross-repo-messaging-settings.tsx new file mode 100644 index 00000000..43a4d648 --- /dev/null +++ b/apps/web/src/components/app/cross-repo-messaging-settings.tsx @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAtom } from "jotai"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { api } from "@/lib/api"; +import { crossRepoMessagingEnabledAtom } from "@/lib/store"; + +type CrossRepoMessagingResponse = { enabled: boolean }; + +const ENDPOINT = "/api/v1/app/settings/cross-repo-messaging"; + +/** + * Toggle for the server-wide cross-repo messaging gate. The gate is enforced + * server-side (a single settings row read by the MCP handler for every agent), + * so the server is the source of truth: on mount we GET the current value and + * hydrate the jotai atom from it, and we only ever POST in response to an + * explicit user toggle. The atom is a cached view that keeps the checkbox + * reactive and gives an instant first paint; it is never re-asserted to the + * server on its own. + */ +export function CrossRepoMessagingSettings(): JSX.Element { + const [enabled, setEnabled] = useAtom(crossRepoMessagingEnabledAtom); + const [error, setError] = useState(""); + const latestReq = useRef(0); + const confirmedValue = useRef(enabled); + + useEffect(() => { + const seq = (latestReq.current += 1); + void api(ENDPOINT) + .then((data) => { + if (seq === latestReq.current) { + confirmedValue.current = data.enabled; + setEnabled(data.enabled); + } + }) + .catch(() => { + if (seq === latestReq.current) { + setError("Failed to load cross-repo messaging setting."); + } + }); + }, [setEnabled]); + + const handleToggle = useCallback( + (next: boolean) => { + const seq = (latestReq.current += 1); + setError(""); + setEnabled(next); + void api(ENDPOINT, { + method: "POST", + body: JSON.stringify({ enabled: next }), + }) + .then(() => { + if (seq === latestReq.current) confirmedValue.current = next; + }) + .catch((err) => { + if (seq !== latestReq.current) return; + setEnabled(confirmedValue.current); + setError( + err instanceof Error + ? err.message + : "Failed to save cross-repo messaging setting." + ); + }); + }, + [setEnabled] + ); + + return ( +
+
+ Cross-repo messaging +
+

+ By default agents can only message and list other agents in the same git + repository. Enable this to let agents coordinate across repositories for + local multi-repo workflows. Applies to all agents on this Dispatch + server. +

+
+ +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/app/settings-pane.tsx b/apps/web/src/components/app/settings-pane.tsx index 2d45ccc6..5b1fa280 100644 --- a/apps/web/src/components/app/settings-pane.tsx +++ b/apps/web/src/components/app/settings-pane.tsx @@ -2,6 +2,7 @@ import { Database, Server, Settings } from "lucide-react"; import { AgentTypeSettings } from "@/components/app/agent-type-settings"; import { AppearanceSettings } from "@/components/app/appearance-settings"; +import { CrossRepoMessagingSettings } from "@/components/app/cross-repo-messaging-settings"; import { IdeSettings } from "@/components/app/ide-settings"; import { InstanceNameSettings } from "@/components/app/instance-name-settings"; import { DocsContent, DOCS_SECTION_NAV } from "@/components/app/docs-pane"; @@ -217,6 +218,9 @@ export function SettingsContent({ onChange={onEnabledIdesChange} /> +
+ +
diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index 33e00265..6b4255a8 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -69,6 +69,16 @@ export const preferredIdeAtom = atomWithLocalStorage( "vscode" ); +// Cached view of the server-wide cross-repo messaging gate (lets agents +// message/list agents in OTHER repositories). The server enforces and owns the +// value; CrossRepoMessagingSettings hydrates this atom from the GET endpoint on +// mount and writes back only on an explicit toggle. localStorage just gives an +// instant first paint before the GET resolves. Default off. +export const crossRepoMessagingEnabledAtom = atomWithLocalStorage( + "dispatch:crossRepoMessaging", + false +); + // Per-cwd preferences for the Create Agent dialog. Each cwd gets its own // atom backed by localStorage; the family caches them by trimmed cwd. export const createNewBranchPrefAtom = atomFamily((cwd: string) => diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 17eedb91..dd38dc53 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -17,6 +17,12 @@ test.describe("Settings pane", () => { webNotifyEvents: ["done", "waiting_user", "blocked"], }, }); + await request.post("/api/v1/app/settings/cross-repo-messaging", { + headers: { + Authorization: `Bearer ${process.env.AUTH_TOKEN ?? "dev-token"}`, + }, + data: { enabled: false }, + }); }); test("opens and closes the settings pane", async ({ page }) => { @@ -91,6 +97,41 @@ test.describe("Settings pane", () => { ).not.toBeVisible(); }); + test("cross-repo messaging toggle defaults off and persists to the server", async ({ + page, + request, + }) => { + await loadApp(page); + + await page.getByTestId("settings-button").click(); + await page + .getByTestId("sidebar-shell") + .getByText("Agents", { exact: true }) + .click(); + + const toggle = page.getByTestId("cross-repo-messaging-toggle"); + await toggle.scrollIntoViewIfNeeded(); + await expect(toggle).not.toBeChecked(); + + await toggle.check(); + await expect(toggle).toBeChecked(); + + // The local toggle is mirrored to the server, which enforces the boundary. + await expect + .poll(async () => { + const res = await request.get( + "/api/v1/app/settings/cross-repo-messaging", + { + headers: { + Authorization: `Bearer ${process.env.AUTH_TOKEN ?? "dev-token"}`, + }, + } + ); + return (await res.json()).enabled; + }) + .toBe(true); + }); + test("single enabled agent type removes split buttons", async ({ page, request,