Skip to content
Open
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: 13 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ Chat-Codex enables pairing protection for real Weixin and Feishu chats by defaul

After pairing succeeds, that chat is stored as a trusted route and remains usable after restarts. Pairing is scoped per chat route: different Weixin contacts and different Feishu private `chat_id` values each need to be paired once.

### Shared Codex App Server

By default, Chat-Codex starts its own stdio Codex app-server. Advanced setups can connect to an existing app-server over a Unix socket:

| Variable | Default | Description |
| --- | --- | --- |
| `CHAT_CODEX_APP_SERVER_ENDPOINT` | unset | Shared endpoint, currently `unix:///absolute/path.sock`. |
| `CHAT_CODEX_APP_SERVER_DAEMON` | unset | Set to `1` to use the shared Unix socket mode. |
| `CHAT_CODEX_APP_SERVER_SOCKET` | `~/.codex/app-server-control/app-server-control.sock` | Socket path used in daemon mode. |
| `CHAT_CODEX_MIRROR_DESKTOP_PROMPTS` | unset | Set to `1` to mirror Desktop user prompts to the chat route that owns the Codex session. |

The shared connection reconnects with backoff and restores trusted routes, session ownership, and active sessions after restart. Desktop prompt mirroring is opt-in and route-scoped; messages originating from Chat-Codex are not mirrored back.

## Tech Stack

| Area | Technology |
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,15 @@ Chat-Codex 对真实微信/飞书聊天默认启用配对保护。第一次从
| `CHAT_CODEX_BIN` | 未设置 | 覆盖 Codex CLI 可执行文件路径;主要用于 Windows Codex CLI 路径排障。 |
| `CHAT_CODEX_STATE_DIR` | 未设置 | 覆盖状态根目录;相对路径按启动 `chat-codex` 时的工作目录解析。 |
| `CHAT_CODEX_UPLOAD_DIR` | 未设置 | 覆盖上传目录;相对路径按启动 `chat-codex` 时的工作目录解析。 |
| `CHAT_CODEX_APP_SERVER_ENDPOINT` | 未设置 | 连接已有 Codex app-server;目前支持 `unix:///absolute/path.sock`。未设置时仍由 Chat-Codex 启动独立的 stdio app-server。 |
| `CHAT_CODEX_APP_SERVER_DAEMON` | 未设置 | 设为 `1` 时连接共享 Unix socket;可配合 `CHAT_CODEX_APP_SERVER_SOCKET` 指定路径。 |
| `CHAT_CODEX_APP_SERVER_SOCKET` | `~/.codex/app-server-control/app-server-control.sock` | 共享 app-server 的 Unix socket 路径,仅在 daemon 模式下使用。 |
| `CHAT_CODEX_MIRROR_DESKTOP_PROMPTS` | 未设置 | 设为 `1` 时,将同一 Codex session 中来自 Desktop 的用户消息镜像到其所属聊天 route。 |

旧版本曾默认写入启动目录下的 `state/` 和 `.chat-codex-uploads/`。升级后如果需要读取旧数据,可以把旧 `state/` 移到 `~/.chat-codex/state/`,或临时设置 `CHAT_CODEX_STATE_DIR=/old/start/dir/state`。

共享 app-server 属于高级配置。它会在断线后自动退避重连,并在重启后恢复可信 route、session owner 和活动 session。Desktop 消息镜像默认关闭,且只投递给该 session 已绑定的 route;Chat-Codex 自己发送的消息不会被再次镜像。

## 技术栈

| 模块 | 技术 |
Expand Down
3 changes: 2 additions & 1 deletion npm-shrinkwrap.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@
"@larksuiteoapi/node-sdk": "^1.71.1",
"ink": "^7.0.3",
"qrcode-terminal": "^0.12.0",
"react": "^19.2.6"
"react": "^19.2.6",
"ws": "^8.20.1"
},
"overrides": {
"axios": "^1.16.1",
Expand Down
13 changes: 12 additions & 1 deletion src/bridge/background-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { ChannelMessage, ChannelTarget } from "../protocol/channel.js";
import type { ChannelDeliveryPolicy } from "../protocol/delivery-policy.js";
import type { MemoryStateStore } from "../state/memory-state-store.js";
import type { BackgroundTurnState } from "./bridge-types.js";
import { composeFinalAnswer } from "./formatters.js";
import { composeFinalAnswer, desktopPromptMirrorText } from "./formatters.js";
import type { BridgeDelivery } from "./delivery.js";
import { contextCompactionNotice } from "./context-compaction.js";
import { BridgeProgressDelivery } from "./progress-delivery.js";
Expand Down Expand Up @@ -115,6 +115,17 @@ export class BridgeBackgroundTurns {
startedAt: event.startedAt ?? new Date().toISOString(),
});
this.startTypingKeepalive(state);
} else if (event.type === "user.input") {
try {
await this.delivery.sendText(state.target, desktopPromptMirrorText(event.text));
} catch (error) {
this.logger.warn("desktop prompt mirror delivery failed", {
routeKey: state.routeKey,
sessionId: event.sessionId,
turnId: event.turnId,
error: error instanceof Error ? error.message : String(error),
});
}
} else if (event.type === "context.compaction") {
if (this.shouldDeliverContextCompaction(state.routeKey, event.sessionId)) {
await this.delivery.sendText(state.target, contextCompactionNotice(event));
Expand Down
40 changes: 40 additions & 0 deletions src/bridge/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,53 @@ export class Bridge {
}

async start(): Promise<void> {
await this.restorePersistedRoutes();
this.stopBackgroundEvents = this.codex.onBackgroundEvent?.((event) => this.backgroundTurns.handle(event));
this.channels.onMessage((message) => this.handleMessage(message));
this.channels.onApprovalAction((action) => this.handleChannelApprovalAction(action));
await this.channels.start();
this.logger.info("bridge started", { channels: this.channels.ids().join(",") });
}

private async restorePersistedRoutes(): Promise<void> {
const resumed = new Set<string>();
for (const route of this.state.listRoutes()) {
if (!route.routeKey || !route.channelId || !route.conversationId) continue;
if (!this.state.isRouteTrusted(route.routeKey)) continue;
const senderId = route.identity?.lastSenderId ?? route.conversationId;
const message: ChannelMessage = {
id: `restored-route:${route.routeKey}`,
channelId: route.channelId,
accountId: route.accountId,
routeKey: route.routeKey,
conversation: {
kind: route.conversationKind,
id: route.conversationId,
displayName: route.displayName,
},
sender: { id: senderId },
timestamp: route.lastSeenAt ?? new Date().toISOString(),
text: "",
};
this.routeMessages.set(route.routeKey, message);
this.routeTargets.set(route.routeKey, replyTargetFromMessage(message));
const sessionId = route.activeSessionId;
const owner = sessionId ? this.state.getSessionOwner(sessionId) : undefined;
if (!sessionId || owner?.ownerRouteKey !== route.routeKey || resumed.has(sessionId)) continue;
try {
await this.codex.resumeSession(sessionId);
this.applyStoredSessionRunPolicy(sessionId);
resumed.add(sessionId);
} catch (error) {
this.logger.warn("persisted Codex session resume failed", {
sessionId,
routeKey: route.routeKey,
error: error instanceof Error ? error.message : String(error),
});
}
}
}

async stop(): Promise<void> {
this.routeSteering.clearAll();
this.pendingMedia.clearAll();
Expand Down
8 changes: 8 additions & 0 deletions src/bridge/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export function truncateForChannel(text: string, maxLength = 600): string {
return `${normalized.slice(0, maxLength)}...`;
}

export function desktopPromptMirrorText(text: string): string {
const normalized = text.trim();
const visible = normalized.length > 3000
? `${normalized.slice(0, 3000)}\n…(电脑端提示词过长,手机镜像已截断)`
: normalized;
return `【电脑端提示词】\n${visible}`;
}

export function isSteerableStatus(status: CodexSessionStatus["type"]): boolean {
return status === "running" || status === "waiting_approval" || status === "waiting_input";
}
Expand Down
12 changes: 12 additions & 0 deletions src/bridge/route-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { BridgeSessionFlow } from "./session-flow.js";
import { StaleCodexSessionBindingError, type StaleCodexSessionBindingInfo } from "./session-flow.js";
import {
composeFinalAnswer,
desktopPromptMirrorText,
truncateForChannel,
withSendFileInstruction,
} from "./formatters.js";
Expand Down Expand Up @@ -286,6 +287,17 @@ export class BridgeRouteQueue {
task: truncateForChannel(promptText || codexInputPlainText(prompt), 120),
startedAt: currentTurnStartedAt,
});
} else if (event.type === "user.input") {
try {
await this.delivery.sendText(target, desktopPromptMirrorText(event.text));
} catch (error) {
this.logger.warn("desktop prompt mirror delivery failed", {
routeKey: message.routeKey,
sessionId: event.sessionId,
turnId: event.turnId,
error: error instanceof Error ? error.message : String(error),
});
}
} else if (event.type === "context.compaction") {
await this.delivery.sendText(target, contextCompactionNotice(event));
} else if (event.type === "assistant.progress") {
Expand Down
3 changes: 3 additions & 0 deletions src/codex/app-server-codex-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface AppServerCodexAdapterOptions {
requestTimeoutMs?: number;
interruptTimeoutMs?: number;
compactTimeoutMs?: number;
appServerEndpoint?: string;
}

interface CompactWaiter {
Expand Down Expand Up @@ -117,6 +118,7 @@ export class AppServerCodexAdapter implements CodexAdapter {
onServerRequest: (request) => this.handleServerRequest(request),
onNotification: (notification) => this.handleNotification(notification),
onFatalError: (error) => this.handleFatalAppServerError(error),
appServerEndpoint: options.appServerEndpoint,
});
}

Expand Down Expand Up @@ -147,6 +149,7 @@ export class AppServerCodexAdapter implements CodexAdapter {
approvalsReviewer: approvalsReviewerForRunPolicy(this.defaultRunPolicy),
sandbox: sandboxModeForRunPolicy(this.defaultRunPolicy),
serviceName: "codex-chat-bridge",
threadSource: "user",
sessionStartSource: "startup",
});
const thread = objectValue(response.thread);
Expand Down
Loading