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
88 changes: 88 additions & 0 deletions src/loop/bridge-claude-registration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { buildClaudeChannelServerConfig } from "./bridge-config";

const CLAUDE_CHANNEL_SCOPE = "local";
const MCP_ALREADY_EXISTS_RE = /already exists/i;

interface BridgeCommandResult {
exitCode?: number | null;
stderr?: string | Uint8Array;
}

type BridgeCommand = (args: string[]) => BridgeCommandResult;

const stderrText = (value: BridgeCommandResult["stderr"]): string => {
if (!value) {
return "";
}
if (typeof value === "string") {
return value.trim();
}
return new TextDecoder().decode(value).trim();
};

const logClaudeChannelServerRemovalFailure = (
serverName: string,
detail: string,
log: (line: string) => void
): void => {
log(
`[loop] failed to remove Claude channel server "${serverName}": ${detail}`
);
};

export const registerClaudeChannelServer = (
launchArgv: string[],
serverName: string,
runDir: string,
runCommand: BridgeCommand
): void => {
const result = runCommand([
"claude",
"mcp",
"add-json",
"--scope",
CLAUDE_CHANNEL_SCOPE,
serverName,
buildClaudeChannelServerConfig(launchArgv, runDir),
]);
const stderr = stderrText(result.stderr);
if (result.exitCode === 0 || MCP_ALREADY_EXISTS_RE.test(stderr)) {
return;
}
const suffix = stderr ? `: ${stderr}` : ".";
throw new Error(`[loop] failed to register Claude channel server${suffix}`);
};

export const removeClaudeChannelServer = (
serverName: string,
runCommand: BridgeCommand,
log: (line: string) => void = console.error
): void => {
if (!serverName) {
return;
}
try {
const result = runCommand([
"claude",
"mcp",
"remove",
"--scope",
CLAUDE_CHANNEL_SCOPE,
serverName,
]);
if (result.exitCode === 0) {
return;
}
logClaudeChannelServerRemovalFailure(
serverName,
stderrText(result.stderr) || `exit code ${result.exitCode ?? "unknown"}`,
log
);
} catch (error: unknown) {
logClaudeChannelServerRemovalFailure(
serverName,
error instanceof Error ? error.message : String(error),
log
);
}
};
117 changes: 102 additions & 15 deletions src/loop/bridge-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { BRIDGE_SERVER, BRIDGE_SUBCOMMAND } from "./bridge-constants";
import { sanitizeBase } from "./git";
import { buildLaunchArgv } from "./launch";
import type { Agent } from "./types";

Expand All @@ -10,19 +11,110 @@ const CODEX_AUTO_APPROVED_BRIDGE_TOOLS = [
"receive_messages",
] as const;
const CODEX_BRIDGE_APPROVAL_MODE = "approve";
const REPO_ID_HASH_SUFFIX_RE = /-[0-9a-f]{12}$/;

const ensureParentDir = (path: string): void => {
mkdirSync(dirname(path), { recursive: true });
};

const stringifyToml = (value: string): string => JSON.stringify(value);

interface BridgeServerConfig {
args: string[];
command: string;
type: "stdio";
}

const buildBridgeServerConfig = (
runDir: string,
source: Agent,
launchArgv: string[]
): BridgeServerConfig => {
const [command, ...baseArgs] = launchArgv;
return {
args: [...baseArgs, BRIDGE_SUBCOMMAND, runDir, source],
command,
type: "stdio",
};
};

const buildBridgeFileConfig = (
serverName: string,
config: BridgeServerConfig
): { mcpServers: Record<string, BridgeServerConfig> } => ({
mcpServers: {
[serverName]: config,
},
});

export const legacyClaudeChannelServerName = (runId: string): string =>
`${BRIDGE_SERVER}-${sanitizeBase(runId)}`;

const readableRepoSegment = (repoId?: string): string | undefined => {
const value = repoId?.trim();
if (!value) {
return undefined;
}
return sanitizeBase(value.replace(REPO_ID_HASH_SUFFIX_RE, ""));
};

export const legacyRepoScopedClaudeChannelServerName = (
runId: string,
repoId: string
): string => `${BRIDGE_SERVER}-${sanitizeBase(repoId)}-${sanitizeBase(runId)}`;

export const claudeChannelServerName = (
runId: string,
repoId?: string
): string => {
const repoSegment = readableRepoSegment(repoId);
return repoSegment
? `${BRIDGE_SERVER}-${repoSegment}-${sanitizeBase(runId)}`
: legacyClaudeChannelServerName(runId);
};

export const generatedClaudeChannelServerNames = (
runId: string,
repoId?: string
): string[] => {
const names = [legacyClaudeChannelServerName(runId)];
if (!repoId?.trim()) {
return names;
}
return Array.from(
new Set([
claudeChannelServerName(runId, repoId),
legacyRepoScopedClaudeChannelServerName(runId, repoId),
...names,
])
);
};

export const resolveClaudeChannelServerName = (
runId: string,
repoId: string | undefined,
storedName?: string
): string => {
const nextServer = claudeChannelServerName(runId, repoId);
if (!storedName?.trim()) {
return nextServer;
}
return generatedClaudeChannelServerNames(runId, repoId).includes(storedName)
? nextServer
: storedName;
};

export const buildClaudeChannelServerConfig = (
launchArgv: string[],
runDir: string
): string =>
JSON.stringify(buildBridgeServerConfig(runDir, "claude", launchArgv));

export const buildCodexBridgeConfigArgs = (
runDir: string,
source: Agent
): string[] => {
const [command, ...baseArgs] = buildLaunchArgv();
const args = [...baseArgs, BRIDGE_SUBCOMMAND, runDir, source];
const config = buildBridgeServerConfig(runDir, source, buildLaunchArgv());
const approvalArgs = CODEX_AUTO_APPROVED_BRIDGE_TOOLS.flatMap((tool) => [
"-c",
`mcp_servers.${BRIDGE_SERVER}.tools.${tool}.approval_mode=${stringifyToml(
Expand All @@ -31,32 +123,27 @@ export const buildCodexBridgeConfigArgs = (
]);
return [
"-c",
`mcp_servers.${BRIDGE_SERVER}.command=${stringifyToml(command)}`,
`mcp_servers.${BRIDGE_SERVER}.command=${stringifyToml(config.command)}`,
"-c",
`mcp_servers.${BRIDGE_SERVER}.args=${JSON.stringify(args)}`,
`mcp_servers.${BRIDGE_SERVER}.args=${JSON.stringify(config.args)}`,
...approvalArgs,
];
};

export const ensureClaudeBridgeConfig = (
runDir: string,
source: Agent
source: Agent,
serverName = BRIDGE_SERVER
): string => {
const [command, ...baseArgs] = buildLaunchArgv();
const path = join(runDir, `${source}-mcp.json`);
ensureParentDir(path);
writeFileSync(
path,
`${JSON.stringify(
{
mcpServers: {
[BRIDGE_SERVER]: {
args: [...baseArgs, BRIDGE_SUBCOMMAND, runDir, source],
command,
type: "stdio",
},
},
},
buildBridgeFileConfig(
serverName,
buildBridgeServerConfig(runDir, source, buildLaunchArgv())
),
null,
2
)}\n`,
Expand Down
3 changes: 0 additions & 3 deletions src/loop/bridge-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ export const bridgeChatId = (runDir: string): string => {
return `codex_${runId}`;
};

export const isActiveBridgeChatId = (runDir: string, chatId: string): boolean =>
chatId === bridgeChatId(runDir);

export const acknowledgeBridgeDelivery = (
runDir: string,
message: BridgeMessage,
Expand Down
13 changes: 3 additions & 10 deletions src/loop/bridge-guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@ import type { Agent } from "./types";

const bridgeTargetLiteral = (agent: Agent): string => `target: "${agent}"`;

export const claudeReplyGuidance =
'When you are replying to an inbound channel message, use the "reply" tool and pass back the same chat_id.';

export const claudeTmuxReplyGuidance =
'Reply to inbound Codex channel messages with the "reply" tool and the same chat_id.';

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

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

export const sendProactiveCodexGuidance = (): string =>
`Use "send_to_agent" with ${bridgeTargetLiteral("codex")} only for new proactive messages to Codex; do not send Codex-facing responses as a human-facing message.`;
`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.`;

export const claudeChannelInstructions = (): string =>
[
`Messages from the Codex agent arrive as <channel source="${BRIDGE_SERVER}" chat_id="..." user="${CLAUDE_CHANNEL_USER}" ...>.`,
claudeReplyGuidance,
"Never answer the human when the inbound message came from Codex. Send the response back through the bridge tools instead.",
`Messages from the Codex agent arrive as <channel source="${BRIDGE_SERVER}" chat_id="..." user="${CLAUDE_CHANNEL_USER}" ...>. The chat_id is informational only.`,
sendProactiveCodexGuidance(),
"Never answer the human when the inbound message came from Codex. Send the response back through the bridge tools instead.",
bridgeStatusStuckGuidance,
].join("\n");
18 changes: 18 additions & 0 deletions src/loop/bridge-message-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Agent } from "./types";

const BRIDGE_PREFIX_RE =
/^(?:Message from (?:Claude|Codex) via the loop bridge:|(?:Claude|Codex):)\s*/i;

export const formatCodexBridgeMessage = (
source: Agent,
message: string
): string => {
const trimmed = message.trim();
if (!trimmed) {
return "";
}
return source === "claude" ? `Claude: ${trimmed}` : trimmed;
};

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