Skip to content
Closed
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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 79 additions & 2 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { appendFileSync, readFileSync, unlinkSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { ClaudeAdapter } from "./claude-adapter";
import { DaemonClient } from "./daemon-client";
import { buildContextMessage, initWorkspace, loadSessionConfig, updateSyncMode } from "./session-config";
import type { BridgeMessage } from "./types";

const CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
Expand All @@ -27,6 +28,14 @@ claude.setReplySender(async (msg: BridgeMessage) => {
return daemonClient.sendReply(msg);
});

claude.setContextReloader(injectSessionContext);
claude.setWorkspaceIniter(async () => initWorkspace());
claude.setSyncModeSwitcher(async (mode) => {
const configPath = updateSyncMode(mode);
await injectSessionContext();
return configPath;
});

daemonClient.on("codexMessage", (message) => {
log(`Forwarding daemon → Claude (${message.content.length} chars)`);
void claude.pushNotification(message);
Expand All @@ -44,17 +53,22 @@ daemonClient.on("disconnect", () => {
log("Daemon control connection closed");
void claude.pushNotification(systemMessage(
"system_daemon_disconnected",
"⚠️ AgentBridge daemon control connection lost. The Codex proxy may still be running in the background, but Claude cannot communicate bidirectionally right now.",
"⚠️ AgentBridge daemon control connection lost. Attempting to reconnect...",
));
void reconnectToDaemon();
});

claude.on("ready", async () => {
log(`MCP server ready (delivery mode: ${claude.getDeliveryMode()}) — ensuring AgentBridge daemon...`);
await connectToDaemon();
});

async function connectToDaemon() {
try {
await ensureDaemonRunning();
await daemonClient.connect();
daemonClient.attachClaude();
await injectSessionContext();
} catch (err: any) {
log(`Failed to connect to daemon: ${err.message}`);
await claude.pushNotification(
Expand All @@ -64,7 +78,70 @@ claude.on("ready", async () => {
),
);
}
});
}

async function injectSessionContext() {
const result = loadSessionConfig();
if (!result) return;

const { config, configDir } = result;
log(`Loaded session config from ${configDir}/.agentbridge.json`);

if (config.deliveryMode && !process.env.AGENTBRIDGE_MODE) {
log(`Note: deliveryMode="${config.deliveryMode}" in config (env var AGENTBRIDGE_MODE takes precedence)`);
}

const context = await buildContextMessage(config, configDir);
if (!context) return;

log(`Injecting session context (${context.length} chars) from .agentbridge.json`);
await claude.pushNotification(
systemMessage(
"system_session_context",
`📋 **Session context loaded from .agentbridge.json**\n\n${context}`,
),
);

// In master mode, Claude shares knowledge with Codex (Slave).
if (config.syncMode === "master") {
log("Master mode: relaying session context to Codex...");
const result = await daemonClient.sendReply({
id: `system_context_relay_${Date.now()}`,
source: "claude",
content: `📋 **Session context shared by Claude (Master) from .agentbridge.json**\n\n${context}`,
timestamp: Date.now(),
});
if (result.success) {
log("Master mode: session context relayed to Codex");
} else {
log(`Master mode: relay to Codex failed — ${result.error ?? "unknown error"}`);
}
}
}

async function reconnectToDaemon(attempt = 0) {
if (shuttingDown) return;

const delayMs = Math.min(1000 * 2 ** attempt, 30000);
if (attempt > 0) {
log(`Reconnect attempt ${attempt}, waiting ${delayMs}ms...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}

if (shuttingDown) return;

try {
await connectToDaemon();
log("Reconnected to AgentBridge daemon successfully");
void claude.pushNotification(systemMessage(
"system_daemon_reconnected",
"✅ AgentBridge daemon reconnected successfully.",
));
} catch (err: any) {
log(`Reconnect attempt ${attempt} failed: ${err.message}`);
void reconnectToDaemon(attempt + 1);
}
}

function systemMessage(idPrefix: string, content: string): BridgeMessage {
return {
Expand Down
152 changes: 152 additions & 0 deletions src/claude-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import { appendFileSync } from "node:fs";
import type { BridgeMessage } from "./types";

export type ReplySender = (msg: BridgeMessage) => Promise<{ success: boolean; error?: string }>;
export type ContextReloader = () => Promise<void>;
export type WorkspaceIniter = () => Promise<string>;
export type SyncModeSwitcher = (mode: "master" | "peer") => Promise<string>;
export type DeliveryMode = "push" | "pull" | "auto";

export const CLAUDE_INSTRUCTIONS = [
Expand Down Expand Up @@ -66,6 +69,9 @@ export class ClaudeAdapter extends EventEmitter {
private notificationSeq = 0;
private sessionId: string;
private replySender: ReplySender | null = null;
private contextReloader: ContextReloader | null = null;
private workspaceIniter: WorkspaceIniter | null = null;
private syncModeSwitcher: SyncModeSwitcher | null = null;

// Dual-mode transport
private readonly configuredMode: DeliveryMode;
Expand Down Expand Up @@ -111,6 +117,21 @@ export class ClaudeAdapter extends EventEmitter {
this.replySender = sender;
}

/** Register the async function that reloads .agentbridge.json and injects context. */
setContextReloader(reloader: ContextReloader) {
this.contextReloader = reloader;
}

/** Register the async function that creates a .agentbridge.json template. */
setWorkspaceIniter(initer: WorkspaceIniter) {
this.workspaceIniter = initer;
}

/** Register the async function that switches syncMode and reloads context. */
setSyncModeSwitcher(switcher: SyncModeSwitcher) {
this.syncModeSwitcher = switcher;
}

/** Returns the resolved delivery mode. */
getDeliveryMode(): "push" | "pull" {
return this.resolvedMode ?? "pull";
Expand Down Expand Up @@ -260,6 +281,42 @@ export class ClaudeAdapter extends EventEmitter {
required: [],
},
},
{
name: "reload_session_context",
description:
"Reload .agentbridge.json from the working directory and re-inject session context (knowledge files, roles, preamble). Use this after editing the config file without restarting Claude.",
inputSchema: {
type: "object" as const,
properties: {},
required: [],
},
},
{
name: "init_workspace",
description:
"Generate a .agentbridge.json template in the current workspace directory. Use this to set up a new collaboration workspace. After running, edit the generated file and call reload_session_context to apply it.",
inputSchema: {
type: "object" as const,
properties: {},
required: [],
},
},
{
name: "set_sync_mode",
description:
'Switch the knowledge sync mode. "master" = Claude fetches knowledge and shares with Codex. "peer" = both agents independently fetch their own knowledge. Updates .agentbridge.json and reloads session context.',
inputSchema: {
type: "object" as const,
properties: {
mode: {
type: "string",
enum: ["master", "peer"],
description: 'The sync mode to set: "master" or "peer".',
},
},
required: ["mode"],
},
},
],
}));

Expand All @@ -274,13 +331,108 @@ export class ClaudeAdapter extends EventEmitter {
return this.drainMessages();
}

if (name === "reload_session_context") {
return this.handleReloadSessionContext();
}

if (name === "init_workspace") {
return this.handleInitWorkspace();
}

if (name === "set_sync_mode") {
return this.handleSetSyncMode(args as Record<string, unknown>);
}

return {
content: [{ type: "text" as const, text: `Unknown tool: ${name}` }],
isError: true,
};
});
}

private async handleReloadSessionContext() {
if (!this.contextReloader) {
return {
content: [{ type: "text" as const, text: "Error: context reloader not registered." }],
isError: true,
};
}
try {
await this.contextReloader();
return {
content: [{ type: "text" as const, text: "Session context reloaded from .agentbridge.json." }],
};
} catch (err: any) {
return {
content: [{ type: "text" as const, text: `Error reloading session context: ${err.message}` }],
isError: true,
};
}
}

private async handleInitWorkspace() {
if (!this.workspaceIniter) {
return {
content: [{ type: "text" as const, text: "Error: workspace initer not registered." }],
isError: true,
};
}
try {
const configPath = await this.workspaceIniter();
return {
content: [
{
type: "text" as const,
text: [
`✅ Created ${configPath}`,
"",
"Next steps:",
"1. Edit the file to configure your workspace (knowledge paths, roles, preamble).",
"2. Call reload_session_context to apply the changes without restarting.",
].join("\n"),
},
],
};
} catch (err: any) {
return {
content: [{ type: "text" as const, text: `Error: ${err.message}` }],
isError: true,
};
}
}

private async handleSetSyncMode(args: Record<string, unknown>) {
const mode = args?.mode as string | undefined;
if (mode !== "master" && mode !== "peer") {
return {
content: [{ type: "text" as const, text: 'Error: mode must be "master" or "peer".' }],
isError: true,
};
}

if (!this.syncModeSwitcher) {
return {
content: [{ type: "text" as const, text: "Error: sync mode switcher not registered." }],
isError: true,
};
}

try {
const configPath = await this.syncModeSwitcher(mode);
return {
content: [{
type: "text" as const,
text: `✅ Sync mode switched to "${mode}".\n\nUpdated: ${configPath}\nSession context has been reloaded.`,
}],
};
} catch (err: any) {
return {
content: [{ type: "text" as const, text: `Error switching sync mode: ${err.message}` }],
isError: true,
};
}
}

private async handleReply(args: Record<string, unknown>) {
const text = args?.text as string | undefined;
if (!text) {
Expand Down
25 changes: 25 additions & 0 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { TuiConnectionState } from "./tui-connection-state";
import type { ControlClientMessage, ControlServerMessage, DaemonStatus } from "./control-protocol";
import type { BridgeMessage } from "./types";
import { buildContextMessage, loadSessionConfig } from "./session-config";

interface ControlSocketData {
clientId: number;
Expand Down Expand Up @@ -134,6 +135,18 @@ codex.on("ready", (threadId: string) => {
if (attachedClaude) {
notifyCodexClaudeOnline();
}

// In peer mode, Codex independently fetches knowledge on startup.
// In master mode, Claude fetches and relays — skip independent fetch here.
const sessionResult = loadSessionConfig();
const syncMode = sessionResult?.config.syncMode ?? "peer";
if (syncMode !== "master") {
injectSessionContextToCodex().catch((err) => {
log(`Failed to inject session context to Codex: ${err instanceof Error ? err.message : String(err)}`);
});
} else {
log("Master mode: Codex will receive knowledge from Claude (skipping independent fetch)");
}
});

codex.on("tuiConnected", (connId: number) => {
Expand Down Expand Up @@ -448,6 +461,18 @@ function notifyCodexClaudeOnline() {
codex.injectMessage("✅ AgentBridge connected to Claude Code.");
}

async function injectSessionContextToCodex() {
const result = loadSessionConfig();
if (!result) return;

const { config, configDir } = result;
const context = await buildContextMessage(config, configDir);
if (!context) return;

log(`Injecting session context into Codex (${context.length} chars)`);
codex.injectMessage(`📋 **Session context from .agentbridge.json**\n\n${context}`);
}

function systemMessage(idPrefix: string, content: string): BridgeMessage {
return {
id: `${idPrefix}_${++nextSystemMessageId}`,
Expand Down
Loading