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
13 changes: 10 additions & 3 deletions src/loop/bridge-guidance.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { BRIDGE_SERVER, CLAUDE_CHANNEL_USER } from "./bridge-constants";
import type { Agent } from "./types";

export type BridgeTool = "bridge_status" | "receive_messages" | "send_message";

const bridgeTargetLiteral = (agent: Agent): string => `target: "${agent}"`;
const codexBridgeToolName = (tool: BridgeTool): string =>
`mcp__${BRIDGE_SERVER.replaceAll("-", "_")}__${tool}`;

export const bridgeToolName = (agent: Agent, tool: BridgeTool): string =>
agent === "claude" ? tool : codexBridgeToolName(tool);
Comment on lines +10 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The quotedBridgeTool helper is currently duplicated in paired-loop.ts and tmux.ts. It should be defined once here and exported to improve maintainability. Ensure that the resulting strings are used within newline-delimited JSON for agent communication.

Suggested change
export const bridgeToolName = (agent: Agent, tool: BridgeTool): string =>
agent === "claude" ? tool : codexBridgeToolName(tool);
export const bridgeToolName = (agent: Agent, tool: BridgeTool): string =>
agent === "claude" ? tool : codexBridgeToolName(tool);
export const quotedBridgeTool = (agent: Agent, tool: BridgeTool): string =>
'"' + bridgeToolName(agent, tool) + '"';
References
  1. When communicating between agents in a paired tmux runtime, use newline-delimited JSON on stdout for agent CLIs, as Content-Length framing can break registration/connection.


export const quotedBridgeTool = (agent: Agent, tool: BridgeTool): string =>
`"${bridgeToolName(agent, tool)}"`;

export const bridgeStatusStuckGuidance =
'Use "bridge_status" only when direct delivery appears stuck.';

export const receiveMessagesStuckGuidance =
'Use "bridge_status" or "receive_messages" only if delivery looks stuck.';

export const sendToClaudeGuidance = (): string =>
`Use "send_message" with ${bridgeTargetLiteral("claude")} for Claude-facing messages, not a human-facing message.`;

export const sendProactiveCodexGuidance = (): string =>
`Use "send_message" with ${bridgeTargetLiteral("codex")} for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.`;

Expand Down
56 changes: 31 additions & 25 deletions src/loop/paired-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
acknowledgeBridgeDelivery,
readNextPendingBridgeMessage,
} from "./bridge-dispatch";
import { quotedBridgeTool } from "./bridge-guidance";
import { formatCodexBridgeMessage } from "./bridge-message-format";
import { getLastClaudeSessionId } from "./claude-sdk-server";
import { getLastCodexThreadId } from "./codex-app-server";
Expand Down Expand Up @@ -100,30 +101,31 @@ const bridgeGuidance = (agent: Agent): string => {
const target = agent === "claude" ? "codex" : "claude";
return [
"Paired mode:",
`You are in a persistent Claude/Codex pair. Use the MCP tool "send_message" with ${bridgeTargetLiteral(target)} when you want ${peer} to act, review, or answer.`,
'Do not ask the human to relay messages between agents or answer the human on the other agent\'s behalf. Use "bridge_status" only if delivery looks stuck.',
'Use "receive_messages" only if "bridge_status" shows pending messages addressed to you and direct delivery looks stuck.',
`You are in a persistent Claude/Codex pair. Use the MCP tool ${quotedBridgeTool(agent, "send_message")} with ${bridgeTargetLiteral(target)} when you want ${peer} to act, review, or answer.`,
`Do not ask the human to relay messages between agents or answer the human on the other agent's behalf. Use ${quotedBridgeTool(agent, "bridge_status")} only if delivery looks stuck.`,
`Use ${quotedBridgeTool(agent, "receive_messages")} only if ${quotedBridgeTool(agent, "bridge_status")} shows pending messages addressed to you and direct delivery looks stuck.`,
].join("\n");
};

const bridgeToolGuidance = [
'You can use the MCP tools "send_message", "bridge_status", and "receive_messages" for direct Claude/Codex coordination.',
'Only use "bridge_status" or "receive_messages" when delivery looks stuck.',
"Do not ask the human to relay messages between agents.",
].join("\n");
const bridgeToolGuidance = (agent: Agent): string =>
[
`You can use the MCP tools ${quotedBridgeTool(agent, "send_message")}, ${quotedBridgeTool(agent, "bridge_status")}, and ${quotedBridgeTool(agent, "receive_messages")} for direct Claude/Codex coordination.`,
`Only use ${quotedBridgeTool(agent, "bridge_status")} or ${quotedBridgeTool(agent, "receive_messages")} when delivery looks stuck.`,
"Do not ask the human to relay messages between agents.",
].join("\n");

const reviewDeliveryGuidance = (reviewer: Agent, opts: Options): string => {
if (reviewer === opts.agent) {
return "If review is needed, keep the actionable notes in your review body before the final review signal.";
}

return `If review is needed, send the actionable notes to ${capitalize(opts.agent)} with "send_message" using ${bridgeTargetLiteral(opts.agent)} before returning your final review signal.`;
return `If review is needed, send the actionable notes to ${capitalize(opts.agent)} with ${quotedBridgeTool(reviewer, "send_message")} using ${bridgeTargetLiteral(opts.agent)} before returning your final review signal.`;
};

const reviewToolGuidance = (reviewer: Agent, opts: Options): string =>
reviewer === opts.agent
? "Use the review body itself for follow-up notes. No bridge message is needed for a self-review."
: bridgeToolGuidance;
: bridgeToolGuidance(reviewer);

const formatSelfReviewNotes = (
failures: ReviewFailure[],
Expand Down Expand Up @@ -158,22 +160,26 @@ const forwardBridgePrompt = ({
}: {
message: string;
source: Agent;
}): string =>
(source === "claude"
? [
formatCodexBridgeMessage(source, message),
"Treat this as direct agent-to-agent coordination. Do not reply to the human.",
'Send a message to the other agent with "send_message" only when you have something useful for them to act on.',
"Do not acknowledge receipt without new information.",
]
: [
`Message from ${capitalize(source)} via the loop bridge:`,
message.trim(),
"Treat this as direct agent-to-agent coordination. Do not reply to the human.",
'Send a message to the other agent with "send_message" only when you have something useful for them to act on.',
"Do not acknowledge receipt without new information.",
]
}): string => {
const agent = source === "claude" ? "codex" : "claude";
const replyGuidance = `Send a message to the other agent with ${quotedBridgeTool(agent, "send_message")} only when you have something useful for them to act on.`;
return (
source === "claude"
? [
formatCodexBridgeMessage(source, message),
"Treat this as direct agent-to-agent coordination. Do not reply to the human.",
replyGuidance,
"Do not acknowledge receipt without new information.",
]
: [
`Message from ${capitalize(source)} via the loop bridge:`,
message.trim(),
"Treat this as direct agent-to-agent coordination. Do not reply to the human.",
replyGuidance,
"Do not acknowledge receipt without new information.",
]
).join("\n\n");
};

const updateIds = (state: PairedState): void => {
const next = touchRunManifest(
Expand Down
25 changes: 14 additions & 11 deletions src/loop/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ import {
legacyClaudeChannelServerName,
resolveClaudeChannelServerName,
} from "./bridge-config";
import {
receiveMessagesStuckGuidance,
sendProactiveCodexGuidance,
sendToClaudeGuidance,
} from "./bridge-guidance";
import { type BridgeTool, quotedBridgeTool } from "./bridge-guidance";
import { getCodexAppServerUrl, getLastCodexThreadId } from "./codex-app-server";
import {
CODEX_TMUX_PROXY_SUBCOMMAND,
Expand Down Expand Up @@ -155,20 +151,27 @@ const appendProofPrompt = (parts: string[], proof: string): void => {
parts.push(`Proof requirements:\n${trimmed}`);
};

const quotedClaudeTmuxBridgeTool = (
serverName: string,
tool: BridgeTool
): string => `"mcp__${serverName}__${tool}"`;

const pairedBridgeGuidance = (
agent: Agent,
_runId: string,
serverName: string
): string => {
if (agent === "claude") {
return [
`Your bridge MCP server is "${serverName}". All bridge tool calls must use the mcp__${serverName}__ prefix.`,
sendProactiveCodexGuidance(),
receiveMessagesStuckGuidance,
`Your bridge MCP server is "${serverName}". Use ${quotedClaudeTmuxBridgeTool(serverName, "send_message")} with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.`,
`Use ${quotedClaudeTmuxBridgeTool(serverName, "bridge_status")} or ${quotedClaudeTmuxBridgeTool(serverName, "receive_messages")} only if delivery looks stuck.`,
].join("\n");
}

return [sendToClaudeGuidance(), receiveMessagesStuckGuidance].join("\n");
return [
`Use the MCP tool ${quotedBridgeTool(agent, "send_message")} with target: "claude" for Claude-facing messages, not a human-facing message.`,
`Use ${quotedBridgeTool(agent, "bridge_status")} or ${quotedBridgeTool(agent, "receive_messages")} only if delivery looks stuck.`,
].join("\n");
};

const pairedWorkflowGuidance = (opts: Options, agent: Agent): string => {
Expand Down Expand Up @@ -202,7 +205,7 @@ const buildPrimaryPrompt = (
const parts = [
`Agent-to-agent pair programming: you are the primary ${capitalize(opts.agent)} agent for this run.`,
`Task:\n${task.trim()}`,
`Your peer is ${peer}. Do the initial pass yourself, then use "send_message" when you want review or targeted help from ${peer}.`,
`Your peer is ${peer}. Do the initial pass yourself, then use ${quotedBridgeTool(opts.agent, "send_message")} when you want review or targeted help from ${peer}.`,
];
appendProofPrompt(parts, opts.proof);
parts.push(SPAWN_TEAM_WITH_WORKTREE_ISOLATION);
Expand Down Expand Up @@ -245,7 +248,7 @@ const buildInteractivePrimaryPrompt = (
const parts = [
`Agent-to-agent pair programming: you are the primary ${capitalize(opts.agent)} agent for this run.`,
"No task has been assigned yet.",
`Your peer is ${peer}. Use "send_message" for review or help once the human gives you a task.`,
`Your peer is ${peer}. Use ${quotedBridgeTool(opts.agent, "send_message")} for review or help once the human gives you a task.`,
];
appendProofPrompt(parts, opts.proof);
parts.push(
Expand Down
24 changes: 24 additions & 0 deletions tests/loop/bridge-guidance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
import {
bridgeToolName,
quotedBridgeTool,
} from "../../src/loop/bridge-guidance";

test("bridgeToolName namespaces Codex bridge tools only", () => {
expect(bridgeToolName("codex", "send_message")).toBe(
"mcp__loop_bridge__send_message"
);
expect(bridgeToolName("codex", "bridge_status")).toBe(
"mcp__loop_bridge__bridge_status"
);
expect(bridgeToolName("claude", "send_message")).toBe("send_message");
});

test("quotedBridgeTool wraps the resolved bridge tool name", () => {
expect(quotedBridgeTool("codex", "send_message")).toBe(
'"mcp__loop_bridge__send_message"'
);
expect(quotedBridgeTool("claude", "receive_messages")).toBe(
'"receive_messages"'
);
});
3 changes: 2 additions & 1 deletion tests/loop/paired-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,7 @@ test("runPairedLoop delivers peer messages back to the primary agent", async ()
"Please verify the implementation details."
);
expect(calls[1]?.prompt).toContain("Do not reply to the human.");
expect(calls[1]?.prompt).toContain('"mcp__loop_bridge__send_message"');
expect(calls[2]?.agent).toBe("claude");
expect(calls[2]?.prompt).toContain(
"Message from Codex via the loop bridge:"
Expand Down Expand Up @@ -862,7 +863,7 @@ test("runPairedLoop preserves claudex reviewers in paired mode", async () => {
"concrete file paths, commands, and code locations that must change"
);
expect(reviewPrompts[1]?.prompt).toContain(
'send the actionable notes to Claude with "send_message" using target: "claude"'
'send the actionable notes to Claude with "mcp__loop_bridge__send_message" using target: "claude"'
);
});
});
Expand Down
25 changes: 15 additions & 10 deletions tests/loop/tmux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,19 +792,21 @@ test("tmux prompts keep the paired review workflow explicit", () => {
"create a draft PR or send a follow-up commit to the existing PR"
);
expect(primaryPrompt).not.toContain("Wait briefly if it arrives");
expect(primaryPrompt).toContain(
'Use "send_message" with target: "claude" for Claude-facing messages'
);
expect(primaryPrompt).toContain('"mcp__loop_bridge__send_message"');
expect(primaryPrompt).toContain("worktree isolation");
expect(peerPrompt).toContain("You are the reviewer/support agent.");
expect(peerPrompt).toContain("Do not take over the task or create the PR");
expect(peerPrompt).toContain("Wait for Codex to send you a targeted request");
expect(peerPrompt).not.toContain('"reply"');
expect(peerPrompt).toContain(
'Use "send_message" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.'
'Use "mcp__loop-bridge-repo-123-1__send_message" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.'
);
expect(primaryPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix");
expect(peerPrompt).toContain("mcp__loop-bridge-repo-123-1__ prefix");
expect(peerPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix");
expect(peerPrompt).toContain('"mcp__loop-bridge-repo-123-1__bridge_status"');
expect(peerPrompt).toContain(
'"mcp__loop-bridge-repo-123-1__receive_messages"'
);
});

test("interactive tmux prompts tell both agents to wait for the human", () => {
Expand All @@ -831,9 +833,7 @@ test("interactive tmux prompts tell both agents to wait for the human", () => {
expect(primaryPrompt).toContain("If the human asks for plan mode");
expect(primaryPrompt).toContain("ask Claude for a plan review");
expect(primaryPrompt).toContain("ask the human to review the plan");
expect(primaryPrompt).toContain(
'Use "send_message" with target: "claude" for Claude-facing messages'
);
expect(primaryPrompt).toContain('"mcp__loop_bridge__send_message"');
expect(primaryPrompt).toContain("worktree isolation");
expect(peerPrompt).toContain("No task has been assigned yet.");
expect(peerPrompt).toContain(
Expand All @@ -843,13 +843,18 @@ test("interactive tmux prompts tell both agents to wait for the human", () => {
expect(peerPrompt).toContain("human clearly assigns you separate work");
expect(peerPrompt).not.toContain('"reply"');
expect(peerPrompt).toContain(
'Use "send_message" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.'
'Use "mcp__loop-bridge-repo-123-1__send_message" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.'
);
expect(peerPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix");
expect(peerPrompt).toContain('"mcp__loop-bridge-repo-123-1__bridge_status"');
expect(peerPrompt).toContain(
'"mcp__loop-bridge-repo-123-1__receive_messages"'
);
expect(peerPrompt).toContain(
"If you are answering Codex, use the bridge tools instead of a human-facing reply."
);
expect(primaryPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix");
expect(peerPrompt).toContain("mcp__loop-bridge-repo-123-1__ prefix");
expect(peerPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix");
});

test("runInTmux auto-confirms Claude startup prompts in paired mode", async () => {
Expand Down
Loading