Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/server/src/cross-repo-messaging-settings.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return (await getSetting(pool, CROSS_REPO_MESSAGING_KEY)) === "true";
}

export async function setCrossRepoMessagingEnabled(
pool: Pool,
enabled: boolean
): Promise<void> {
await setSetting(pool, CROSS_REPO_MESSAGING_KEY, enabled ? "true" : "false");
}
20 changes: 20 additions & 0 deletions apps/server/src/routes/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
78 changes: 49 additions & 29 deletions apps/server/src/server/mcp-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T extends { id: string; cwd: string }>(
all: T[],
agentId: string,
senderRepoRoot: string | null,
crossRepo: boolean
): Promise<T[]> {
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,
Expand Down Expand Up @@ -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_");

Expand All @@ -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];
Expand All @@ -833,18 +858,14 @@ 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(
`No agent found matching "${input.target}".${running ? ` Running agents:\n${running}` : " No other agents are running."}`
);
}

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.`
Expand Down Expand Up @@ -891,27 +912,26 @@ 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;
status: string;
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,
Expand Down
52 changes: 52 additions & 0 deletions apps/server/test/mcp-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
104 changes: 104 additions & 0 deletions apps/web/src/components/app/cross-repo-messaging-settings.tsx
Original file line number Diff line number Diff line change
@@ -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<CrossRepoMessagingResponse>(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<CrossRepoMessagingResponse>(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 (
<div className="p-6">
<div className="mb-1.5 text-[10px] uppercase tracking-widest text-muted-foreground">
Cross-repo messaging
</div>
<p className="mb-3 max-w-2xl text-sm text-muted-foreground">
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.
</p>
<div className="max-w-lg">
<label className="flex cursor-pointer items-center gap-3 rounded border border-border px-3 py-2.5 transition-colors hover:bg-muted/50">
<Checkbox
checked={enabled}
onCheckedChange={(checked) => handleToggle(checked === true)}
data-testid="cross-repo-messaging-toggle"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
Allow messaging agents in other repositories
</div>
<div className="text-xs text-muted-foreground">
When on, name-based targeting can match agents across all repos —
use the agent ID (agt_…) to address one unambiguously.
</div>
</div>
</label>
</div>
{error ? (
<p role="alert" className="mt-3 text-sm text-destructive">
{error}
</p>
) : null}
</div>
);
}
4 changes: 4 additions & 0 deletions apps/web/src/components/app/settings-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -217,6 +218,9 @@ export function SettingsContent({
onChange={onEnabledIdesChange}
/>
</div>
<div className="border-t border-border">
<CrossRepoMessagingSettings />
</div>
<div className="px-6 pb-6">
<WorktreeLocationSettings />
</div>
Expand Down
Loading