Skip to content

Commit 4b470f5

Browse files
Clean up paired bridge messaging (#56)
1 parent 349bc3a commit 4b470f5

10 files changed

Lines changed: 290 additions & 81 deletions

src/loop/bridge-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { buildLaunchArgv } from "./launch";
66
import type { Agent } from "./types";
77

88
const CODEX_AUTO_APPROVED_BRIDGE_TOOLS = [
9-
"send_to_agent",
9+
"send_message",
1010
"bridge_status",
1111
"receive_messages",
1212
] as const;

src/loop/bridge-guidance.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ export const receiveMessagesStuckGuidance =
1010
'Use "bridge_status" or "receive_messages" only if delivery looks stuck.';
1111

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

1515
export const sendProactiveCodexGuidance = (): string =>
16-
`Use "send_to_agent" 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.`;
16+
`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.`;
1717

1818
export const claudeChannelInstructions = (): string =>
1919
[

src/loop/bridge-message-format.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ export const formatCodexBridgeMessage = (
1515
};
1616

1717
export const normalizeBridgeMessage = (message: string): string =>
18-
message.trim().replace(BRIDGE_PREFIX_RE, "").replace(/\s+/g, " ");
18+
message.trim().replace(BRIDGE_PREFIX_RE, "").replace(/\s+/g, " ").trim();

src/loop/bridge.ts

Lines changed: 93 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { mkdirSync, watch } from "node:fs";
2+
import { basename } from "node:path";
13
import { claudeChannelServerName } from "./bridge-config";
24
import { BRIDGE_SERVER as BRIDGE_SERVER_VALUE } from "./bridge-constants";
35
import {
@@ -28,8 +30,8 @@ import {
2830
import { LOOP_VERSION } from "./constants";
2931
import type { Agent } from "./types";
3032

31-
const CHANNEL_POLL_DELAY_MS = 500;
3233
const CLAUDE_CHANNEL_CAPABILITY = "claude/channel";
34+
const CLAUDE_CHANNEL_FALLBACK_SWEEP_MS = 2000;
3335
const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i;
3436
const CONTENT_LENGTH_PREFIX = "content-length:";
3537
const DEFAULT_PROTOCOL_VERSION = "2024-11-05";
@@ -142,7 +144,7 @@ const handleReceiveMessagesTool = (
142144
});
143145
};
144146

145-
const handleSendToAgentTool = async (
147+
const handleSendMessageTool = async (
146148
id: JsonRpcRequest["id"],
147149
runDir: string,
148150
source: Agent,
@@ -154,7 +156,7 @@ const handleSendToAgentTool = async (
154156
writeError(
155157
id,
156158
MCP_INVALID_PARAMS,
157-
"send_to_agent requires a non-empty target"
159+
"send_message requires a non-empty target"
158160
);
159161
return;
160162
}
@@ -171,15 +173,15 @@ const handleSendToAgentTool = async (
171173
writeError(
172174
id,
173175
MCP_INVALID_PARAMS,
174-
"send_to_agent requires a non-empty message"
176+
"send_message requires a non-empty message"
175177
);
176178
return;
177179
}
178180
if (target === source) {
179181
writeError(
180182
id,
181183
MCP_INVALID_PARAMS,
182-
"send_to_agent cannot target the current agent"
184+
"send_message cannot target the current agent"
183185
);
184186
return;
185187
}
@@ -244,12 +246,21 @@ const handleToolCall = async (
244246
return;
245247
}
246248

247-
if (name !== "send_to_agent") {
249+
if (name === "send_to_agent") {
250+
writeError(
251+
id,
252+
MCP_INVALID_PARAMS,
253+
'Unknown tool: send_to_agent. Use "send_message" instead.'
254+
);
255+
return;
256+
}
257+
258+
if (name !== "send_message") {
248259
writeError(id, MCP_INVALID_PARAMS, `Unknown tool: ${name}`);
249260
return;
250261
}
251262

252-
await handleSendToAgentTool(id, runDir, source, args);
263+
await handleSendMessageTool(id, runDir, source, args);
253264
};
254265

255266
const requestedProtocolVersion = (request: JsonRpcRequest): string =>
@@ -312,7 +323,7 @@ const handleBridgeRequest = async (
312323
tools: [
313324
{
314325
annotations: MUTATING_TOOL_ANNOTATIONS,
315-
description: "Send an explicit message to the paired agent.",
326+
description: "Send a direct message to the paired agent.",
316327
inputSchema: {
317328
additionalProperties: false,
318329
properties: {
@@ -325,12 +336,12 @@ const handleBridgeRequest = async (
325336
required: ["target", "message"],
326337
type: "object",
327338
},
328-
name: "send_to_agent",
339+
name: "send_message",
329340
},
330341
{
331342
annotations: READ_ONLY_TOOL_ANNOTATIONS,
332343
description:
333-
"Inspect the current paired run and pending bridge messages.",
344+
"Inspect the current paired run and pending bridge state when delivery looks stuck.",
334345
inputSchema: {
335346
additionalProperties: false,
336347
properties: {},
@@ -341,7 +352,7 @@ const handleBridgeRequest = async (
341352
{
342353
annotations: RECEIVE_MESSAGES_TOOL_ANNOTATIONS,
343354
description:
344-
"Read and clear pending bridge messages addressed to you.",
355+
"Read and clear pending bridge messages addressed to you when delivery looks stuck.",
345356
inputSchema: {
346357
additionalProperties: false,
347358
properties: {},
@@ -481,12 +492,24 @@ const consumeFrames = (
481492
process.stdin.on("error", reject);
482493
});
483494

495+
const isBridgeWatchEvent = (
496+
runDir: string,
497+
filename: string | Buffer | null
498+
): boolean => {
499+
if (!filename) {
500+
return true;
501+
}
502+
return filename.toString() === basename(bridgePath(runDir));
503+
};
504+
484505
export const runBridgeMcpServer = async (
485506
runDir: string,
486507
source: Agent
487508
): Promise<void> => {
488509
let channelReady = false;
510+
let bridgeWatcher: { close: () => void } | undefined;
489511
let closed = false;
512+
let fallbackSweep: ReturnType<typeof setTimeout> | undefined;
490513
let flushQueue: Promise<void> = Promise.resolve();
491514
let requestQueue: Promise<void> = Promise.resolve();
492515
const queueClaudeFlush = (): Promise<void> => {
@@ -499,39 +522,74 @@ export const runBridgeMcpServer = async (
499522
flushQueue = flushQueue.then(next, next);
500523
return flushQueue;
501524
};
502-
const pollClaudeChannel = async (): Promise<void> => {
503-
while (!closed) {
504-
await queueClaudeFlush();
525+
526+
const triggerClaudeFlush = (): void => {
527+
queueClaudeFlush().catch(() => undefined);
528+
};
529+
530+
const clearClaudeSweep = (): void => {
531+
if (!fallbackSweep) {
532+
return;
533+
}
534+
clearTimeout(fallbackSweep);
535+
fallbackSweep = undefined;
536+
};
537+
538+
const scheduleClaudeSweep = (): void => {
539+
if (!(source === "claude" && channelReady) || closed || fallbackSweep) {
540+
return;
541+
}
542+
fallbackSweep = setTimeout(() => {
543+
fallbackSweep = undefined;
505544
if (closed) {
506545
return;
507546
}
508-
await new Promise((resolve) => {
509-
setTimeout(resolve, CHANNEL_POLL_DELAY_MS);
510-
});
511-
}
547+
triggerClaudeFlush();
548+
scheduleClaudeSweep();
549+
}, CLAUDE_CHANNEL_FALLBACK_SWEEP_MS);
550+
fallbackSweep.unref?.();
512551
};
513552

514-
process.stdin.resume();
515-
const poller = source === "claude" ? pollClaudeChannel() : Promise.resolve();
516-
await consumeFrames(
517-
(request) => {
518-
const handleRequest = async (): Promise<void> => {
519-
if (request.method === "notifications/initialized") {
520-
channelReady = true;
553+
if (source === "claude") {
554+
mkdirSync(runDir, { recursive: true });
555+
try {
556+
bridgeWatcher = watch(runDir, (_eventType, filename) => {
557+
if (!isBridgeWatchEvent(runDir, filename)) {
558+
return;
521559
}
522-
await handleBridgeRequest(runDir, source, request);
523-
await queueClaudeFlush();
524-
};
525-
requestQueue = requestQueue.then(handleRequest, handleRequest);
526-
},
527-
() => {
528-
closed = true;
560+
triggerClaudeFlush();
561+
});
562+
} catch {
563+
bridgeWatcher = undefined;
529564
}
530-
);
531-
closed = true;
565+
}
566+
567+
process.stdin.resume();
568+
try {
569+
await consumeFrames(
570+
(request) => {
571+
const handleRequest = async (): Promise<void> => {
572+
if (request.method === "notifications/initialized") {
573+
channelReady = true;
574+
scheduleClaudeSweep();
575+
}
576+
await handleBridgeRequest(runDir, source, request);
577+
await queueClaudeFlush();
578+
};
579+
requestQueue = requestQueue.then(handleRequest, handleRequest);
580+
},
581+
() => {
582+
closed = true;
583+
}
584+
);
585+
} finally {
586+
closed = true;
587+
bridgeWatcher?.close();
588+
clearClaudeSweep();
589+
}
590+
532591
await requestQueue;
533592
await queueClaudeFlush();
534-
await poller;
535593
};
536594

537595
export const bridgeInternals = {

src/loop/paired-loop.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,15 @@ const bridgeGuidance = (agent: Agent): string => {
100100
const target = agent === "claude" ? "codex" : "claude";
101101
return [
102102
"Paired mode:",
103-
`You are in a persistent Claude/Codex pair. Use the MCP tool "send_to_agent" with ${bridgeTargetLiteral(target)} when you want ${peer} to act, review, or answer.`,
104-
'Do not ask the human to relay messages between agents or answer the human on the other agent\'s behalf. Use "bridge_status" if you need the current bridge state.',
105-
'If "bridge_status" shows pending messages addressed to you, call "receive_messages" to read them.',
103+
`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.`,
104+
'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.',
105+
'Use "receive_messages" only if "bridge_status" shows pending messages addressed to you and direct delivery looks stuck.',
106106
].join("\n");
107107
};
108108

109109
const bridgeToolGuidance = [
110-
'You can use the MCP tools "send_to_agent", "bridge_status", and "receive_messages" for direct Claude/Codex coordination.',
110+
'You can use the MCP tools "send_message", "bridge_status", and "receive_messages" for direct Claude/Codex coordination.',
111+
'Only use "bridge_status" or "receive_messages" when delivery looks stuck.',
111112
"Do not ask the human to relay messages between agents.",
112113
].join("\n");
113114

@@ -116,7 +117,7 @@ const reviewDeliveryGuidance = (reviewer: Agent, opts: Options): string => {
116117
return "If review is needed, keep the actionable notes in your review body before the final review signal.";
117118
}
118119

119-
return `If review is needed, send the actionable notes to ${capitalize(opts.agent)} with "send_to_agent" using ${bridgeTargetLiteral(opts.agent)} before returning your final review signal.`;
120+
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.`;
120121
};
121122

122123
const reviewToolGuidance = (reviewer: Agent, opts: Options): string =>
@@ -151,19 +152,25 @@ const reviewBridgePrompt = (
151152
.filter(Boolean)
152153
.join("\n\n");
153154

154-
const forwardBridgePrompt = (source: Agent, message: string): string =>
155+
const forwardBridgePrompt = ({
156+
message,
157+
source,
158+
}: {
159+
message: string;
160+
source: Agent;
161+
}): string =>
155162
(source === "claude"
156163
? [
157164
formatCodexBridgeMessage(source, message),
158165
"Treat this as direct agent-to-agent coordination. Do not reply to the human.",
159-
'Send a message to the other agent with "send_to_agent" only when you have something useful for them to act on.',
166+
'Send a message to the other agent with "send_message" only when you have something useful for them to act on.',
160167
"Do not acknowledge receipt without new information.",
161168
]
162169
: [
163170
`Message from ${capitalize(source)} via the loop bridge:`,
164171
message.trim(),
165172
"Treat this as direct agent-to-agent coordination. Do not reply to the human.",
166-
'Send a message to the other agent with "send_to_agent" only when you have something useful for them to act on.',
173+
'Send a message to the other agent with "send_message" only when you have something useful for them to act on.',
167174
"Do not acknowledge receipt without new information.",
168175
]
169176
).join("\n\n");
@@ -284,7 +291,7 @@ const drainBridge = async (
284291
const result = await tryRunPairedAgent(
285292
state,
286293
message.target,
287-
forwardBridgePrompt(message.source, message.message)
294+
forwardBridgePrompt(message)
288295
);
289296
if (!result) {
290297
return { deliveredToPrimary };

src/loop/tmux.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,14 +202,14 @@ const buildPrimaryPrompt = (
202202
const parts = [
203203
`Agent-to-agent pair programming: you are the primary ${capitalize(opts.agent)} agent for this run.`,
204204
`Task:\n${task.trim()}`,
205-
`Your peer is ${peer}. Do the initial pass yourself, then use "send_to_agent" when you want review or targeted help from ${peer}.`,
205+
`Your peer is ${peer}. Do the initial pass yourself, then use "send_message" when you want review or targeted help from ${peer}.`,
206206
];
207207
appendProofPrompt(parts, opts.proof);
208208
parts.push(SPAWN_TEAM_WITH_WORKTREE_ISOLATION);
209209
parts.push(pairedBridgeGuidance(opts.agent, runId, serverName));
210210
parts.push(pairedWorkflowGuidance(opts, opts.agent));
211211
parts.push(
212-
`${peer} should send a short ready message. Wait briefly if it arrives, then inspect the repo and start. Ask ${peer} for review once you have concrete work or a specific question.`
212+
`Inspect the repo and start. Ask ${peer} for review once you have concrete work or a specific question.`
213213
);
214214
return parts.join("\n\n");
215215
};
@@ -245,7 +245,7 @@ const buildInteractivePrimaryPrompt = (
245245
const parts = [
246246
`Agent-to-agent pair programming: you are the primary ${capitalize(opts.agent)} agent for this run.`,
247247
"No task has been assigned yet.",
248-
`Your peer is ${peer}. Use "send_to_agent" for review or help once the human gives you a task.`,
248+
`Your peer is ${peer}. Use "send_message" for review or help once the human gives you a task.`,
249249
];
250250
appendProofPrompt(parts, opts.proof);
251251
parts.push(

0 commit comments

Comments
 (0)