cli-wechat-bridge 1.2.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -72
- package/README_EN.md +544 -0
- package/bin/telegram-check-update.mjs +10 -0
- package/bin/telegram-claude.mjs +10 -0
- package/bin/telegram-codex.mjs +10 -0
- package/bin/telegram-daemon.mjs +10 -0
- package/bin/telegram-opencode.mjs +10 -0
- package/bin/telegram-pi.mjs +10 -0
- package/bin/telegram-setup.mjs +10 -0
- package/dist/bridge/bridge-adapters.codex.js +325 -16
- package/dist/bridge/bridge-state.js +3 -1
- package/dist/bridge/wechat-bridge.js +97 -30
- package/dist/channels/channel-driver-factory.js +12 -0
- package/dist/channels/telegram/setup-cli.js +12 -0
- package/dist/channels/telegram/setup.js +163 -0
- package/dist/channels/telegram/telegram-channel-port.js +47 -0
- package/dist/channels/telegram/telegram-config.js +66 -0
- package/dist/channels/telegram/telegram-driver.js +106 -0
- package/dist/channels/telegram/telegram-message.js +41 -0
- package/dist/channels/telegram/telegram-transport.js +378 -0
- package/dist/companion/local-companion-start.js +180 -61
- package/dist/core/channel-types.js +13 -5
- package/dist/daemon/wechat-daemon.js +51 -16
- package/dist/i18n/messages-en.js +1 -0
- package/dist/i18n/messages-zh.js +1 -0
- package/package.json +16 -5
- package/skills/codex-bridge/SKILL.md +35 -0
- package/skills/codex-bridge/agents/openai.yaml +4 -0
- package/skills/codex-bridge/scripts/install.mjs +28 -0
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// LICENSE.txt. Network services built on it must offer source to users.
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import { t } from "../i18n/index.js";
|
|
9
|
+
import net from "node:net";
|
|
10
|
+
import os from "node:os";
|
|
9
11
|
import path from "node:path";
|
|
10
12
|
import crypto from "node:crypto";
|
|
11
13
|
import { spawn as spawnChild, spawnSync } from "node:child_process";
|
|
14
|
+
import { WebSocket as WsPackageWebSocket } from "ws";
|
|
12
15
|
import { detectCliApproval, } from "./bridge-utils.js";
|
|
13
16
|
import { normalizeOutput, nowIso, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
|
|
14
17
|
import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
|
|
@@ -32,6 +35,86 @@ export function isCodexVersionInCompatibilityRange(version) {
|
|
|
32
35
|
const minor = parts[1] ?? -1;
|
|
33
36
|
return major === 0 && minor >= 149 && minor <= 161;
|
|
34
37
|
}
|
|
38
|
+
/** Resolve the shared Codex app-server daemon control socket path. */
|
|
39
|
+
export function resolveCodexDaemonControlSocketPath(codexHomeEnv = process.env.CODEX_HOME) {
|
|
40
|
+
const codexHome = codexHomeEnv && codexHomeEnv.trim()
|
|
41
|
+
? codexHomeEnv
|
|
42
|
+
: path.join(os.homedir(), ".codex");
|
|
43
|
+
return path.join(codexHome, "app-server-control", "app-server-control.sock");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Pick attachable threads from a `thread/list` response: only threads that are
|
|
47
|
+
* live in the shared daemon (`status.type !== "notLoaded"`), belong to the
|
|
48
|
+
* requested cwd, are root threads, accept direct input, and were not started
|
|
49
|
+
* by a bridge (`originator === "wechat-bridge"`). Newest activity wins.
|
|
50
|
+
*/
|
|
51
|
+
export function selectCodexDaemonAttachCandidates(response, cwd) {
|
|
52
|
+
if (!isRecord(response) || !Array.isArray(response.data)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const candidates = [];
|
|
56
|
+
for (const entry of response.data) {
|
|
57
|
+
if (!isRecord(entry))
|
|
58
|
+
continue;
|
|
59
|
+
const id = typeof entry.id === "string" ? entry.id : null;
|
|
60
|
+
if (!id)
|
|
61
|
+
continue;
|
|
62
|
+
const threadCwd = typeof entry.cwd === "string" ? entry.cwd : null;
|
|
63
|
+
if (!threadCwd || normalizeComparablePath(threadCwd) !== normalizeComparablePath(cwd)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (entry.parentThreadId !== null && entry.parentThreadId !== undefined)
|
|
67
|
+
continue;
|
|
68
|
+
if (entry.canAcceptDirectInput === false)
|
|
69
|
+
continue;
|
|
70
|
+
const status = isRecord(entry.status) ? entry.status.type : undefined;
|
|
71
|
+
if (typeof status !== "string" || status === "notLoaded")
|
|
72
|
+
continue;
|
|
73
|
+
const originator = typeof entry.originator === "string" ? entry.originator : null;
|
|
74
|
+
if (originator === "wechat-bridge")
|
|
75
|
+
continue;
|
|
76
|
+
const updatedAt = typeof entry.updatedAt === "number"
|
|
77
|
+
? entry.updatedAt
|
|
78
|
+
: typeof entry.lastUpdatedAt === "number"
|
|
79
|
+
? entry.lastUpdatedAt
|
|
80
|
+
: 0;
|
|
81
|
+
candidates.push({ id, updatedAt, originator });
|
|
82
|
+
}
|
|
83
|
+
return candidates.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resume params for the bridge's app-server client. In daemon-attach mode the
|
|
87
|
+
* thread already has an owner with its own settings; sending cwd/approval/
|
|
88
|
+
* sandbox overrides would rewrite the shared thread's settings
|
|
89
|
+
* (`thread/settings/updated`), so attach mode resumes without overrides.
|
|
90
|
+
*/
|
|
91
|
+
export function buildCodexThreadResumeParams(params) {
|
|
92
|
+
if (params.attachDaemon) {
|
|
93
|
+
return { threadId: params.threadId, excludeTurns: true };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
threadId: params.threadId,
|
|
97
|
+
cwd: params.cwd,
|
|
98
|
+
approvalPolicy: "on-request",
|
|
99
|
+
approvalsReviewer: "user",
|
|
100
|
+
sandbox: "workspace-write",
|
|
101
|
+
excludeTurns: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Turn params; daemon-attach mode inherits the thread's existing settings. */
|
|
105
|
+
export function buildCodexTurnStartParams(params) {
|
|
106
|
+
const input = [{ type: "text", text: params.text }];
|
|
107
|
+
if (params.attachDaemon) {
|
|
108
|
+
return { threadId: params.threadId, input };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
threadId: params.threadId,
|
|
112
|
+
cwd: params.cwd,
|
|
113
|
+
approvalPolicy: "on-request",
|
|
114
|
+
approvalsReviewer: "user",
|
|
115
|
+
input,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
35
118
|
const { CODEX_APP_SERVER_HOST, CODEX_APP_SERVER_READY_TIMEOUT_MS, CODEX_FINAL_REPLY_SETTLE_DELAY_MS, CODEX_RECENT_SESSION_KEY_LIMIT, CODEX_RPC_CONNECT_RETRY_MS, CODEX_RPC_RECONNECT_TIMEOUT_MS, CODEX_SESSION_FALLBACK_SCAN_INTERVAL_MS, CODEX_SESSION_LOCAL_MIRROR_FALLBACK_WINDOW_MS, CODEX_SESSION_POLL_INTERVAL_MS, CODEX_STARTUP_WARMUP_MS, CODEX_THREAD_SIGNAL_TTL_MS, INTERRUPT_SETTLE_DELAY_MS, appendBoundedLog, buildCodexApprovalRequest, buildCodexCliArgs, buildCodexDynamicToolCallFailureResponse, buildCodexMcpServerElicitationDeclineResponse, buildCodexPermissionsRequestApprovalResponse, buildCodexUserInputRequest, coerceWebSocketMessageData, delay, describeUnknownError, extractCodexFinalTextFromItem, extractCodexThreadFollowIdFromStatusChanged, extractCodexThreadStartedThreadId, extractCodexUserMessageText, findCodexSessionFile, findRecentCodexSessionFileForCwd, getCodexRpcRequestId, getCodexApprovalAutoResponse, getCodexWechatOutboundAttachmentDenyMessage, getNotificationThreadId, getNotificationTurnId, isRecord, isRecentIsoTimestamp, listCodexResumeSessions, normalizeComparablePath, normalizeCodexRpcError, reserveLocalPort, resolveSpawnTarget, shouldAutoCompleteCodexWechatTurnAfterFinalReply, shouldIgnoreCodexSessionReplayEntry, shouldRecoverCodexStaleBusyState, waitForTcpPort, } = shared;
|
|
36
119
|
const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
|
|
37
120
|
const CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS = 120_000;
|
|
@@ -157,6 +240,11 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
157
240
|
this.resumeThreadId = options.sessionStartMode === "new"
|
|
158
241
|
? null
|
|
159
242
|
: options.initialSharedSessionId ?? options.initialSharedThreadId ?? null;
|
|
243
|
+
if (options.codexAttachDaemon) {
|
|
244
|
+
// Daemon attach binds an explicit (or discovered) live thread; stale
|
|
245
|
+
// persisted session ids must not leak into the attach target.
|
|
246
|
+
this.resumeThreadId = options.codexAttachThreadId?.trim() || null;
|
|
247
|
+
}
|
|
160
248
|
if (this.resumeThreadId && options.renderMode !== "panel") {
|
|
161
249
|
this.state.sharedSessionId = this.resumeThreadId;
|
|
162
250
|
this.state.sharedThreadId = this.resumeThreadId;
|
|
@@ -176,6 +264,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
176
264
|
: `outside-supported-range=${this.codexCliVersion}`
|
|
177
265
|
: "unknown-version";
|
|
178
266
|
this.setStatus("starting", `Codex protocol compatibility: ${compatibility}.`);
|
|
267
|
+
if (this.options.codexAttachDaemon) {
|
|
268
|
+
await this.startDaemonAttach();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
179
271
|
await this.startAppServer();
|
|
180
272
|
await this.connectRpcClient();
|
|
181
273
|
try {
|
|
@@ -284,8 +376,56 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
284
376
|
if (!this.isHeadlessRuntimeMode()) {
|
|
285
377
|
throw new Error('WeChat /resume requires the managed "wechat-codex" visible client.');
|
|
286
378
|
}
|
|
379
|
+
if (this.options.codexAttachDaemon) {
|
|
380
|
+
await this.resumeDaemonAttachedThread(threadId);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
287
383
|
await this.resumeVisibleSharedThread(threadId);
|
|
288
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* WeChat /resume in daemon-attach mode: rejoin another thread on the shared
|
|
387
|
+
* daemon. Live threads rejoin in place; cold threads are loaded by the
|
|
388
|
+
* daemon (the bridge never becomes the rollout writer for an embedded
|
|
389
|
+
* desktop session — that resume fails with an active-writer error).
|
|
390
|
+
*/
|
|
391
|
+
async resumeDaemonAttachedThread(threadId) {
|
|
392
|
+
const trimmedThreadId = threadId.trim();
|
|
393
|
+
if (!trimmedThreadId) {
|
|
394
|
+
throw new Error("A thread id is required to resume a Codex thread.");
|
|
395
|
+
}
|
|
396
|
+
if (this.pendingApproval) {
|
|
397
|
+
throw new Error(t("adapter.codex.approvalPending"));
|
|
398
|
+
}
|
|
399
|
+
if (this.pendingTurnStart ||
|
|
400
|
+
this.activeTurn ||
|
|
401
|
+
this.state.status === "busy" ||
|
|
402
|
+
this.state.status === "awaiting_approval") {
|
|
403
|
+
throw new Error(t("adapter.codex.busy"));
|
|
404
|
+
}
|
|
405
|
+
const response = await this.sendRpcRequest("thread/resume", buildCodexThreadResumeParams({
|
|
406
|
+
threadId: trimmedThreadId,
|
|
407
|
+
cwd: this.options.cwd,
|
|
408
|
+
attachDaemon: true,
|
|
409
|
+
}));
|
|
410
|
+
this.validateCodexResumeThread(response, trimmedThreadId);
|
|
411
|
+
if (isRecord(response) && isRecord(response.thread) && typeof response.thread.path === "string") {
|
|
412
|
+
this.sessionFilePath = response.thread.path;
|
|
413
|
+
}
|
|
414
|
+
const previousThreadId = this.sharedThreadId;
|
|
415
|
+
this.subscribedThreadIds.add(trimmedThreadId);
|
|
416
|
+
this.sessionReadOffset = 0;
|
|
417
|
+
this.sessionPartialLine = "";
|
|
418
|
+
this.sessionFinalText = null;
|
|
419
|
+
this.pendingThreadFollowId = null;
|
|
420
|
+
this.updateSharedThread(trimmedThreadId, {
|
|
421
|
+
source: "wechat",
|
|
422
|
+
reason: "wechat_resume",
|
|
423
|
+
notify: true,
|
|
424
|
+
});
|
|
425
|
+
if (previousThreadId && previousThreadId !== trimmedThreadId) {
|
|
426
|
+
await this.unsubscribeCodexThreadBestEffort(previousThreadId);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
289
429
|
async listModels() {
|
|
290
430
|
if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
|
|
291
431
|
throw new Error(t("adapter.codex.modelThreadRequired"));
|
|
@@ -564,6 +704,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
564
704
|
}
|
|
565
705
|
isCodexClientRunning() {
|
|
566
706
|
if (this.isHeadlessRuntimeMode()) {
|
|
707
|
+
if (this.options.codexAttachDaemon) {
|
|
708
|
+
return Boolean(this.rpcSocket);
|
|
709
|
+
}
|
|
567
710
|
return Boolean(this.appServer);
|
|
568
711
|
}
|
|
569
712
|
return this.isNativePanelMode() ? Boolean(this.nativeProcess) : Boolean(this.pty);
|
|
@@ -1082,18 +1225,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1082
1225
|
this.state.activeTurnOrigin = "wechat";
|
|
1083
1226
|
this.setStatus("busy");
|
|
1084
1227
|
try {
|
|
1085
|
-
const response = await this.sendRpcRequest("turn/start", {
|
|
1228
|
+
const response = await this.sendRpcRequest("turn/start", buildCodexTurnStartParams({
|
|
1086
1229
|
threadId,
|
|
1230
|
+
text,
|
|
1087
1231
|
cwd: this.options.cwd,
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
input: [
|
|
1091
|
-
{
|
|
1092
|
-
type: "text",
|
|
1093
|
-
text,
|
|
1094
|
-
},
|
|
1095
|
-
],
|
|
1096
|
-
});
|
|
1232
|
+
attachDaemon: Boolean(this.options.codexAttachDaemon),
|
|
1233
|
+
}));
|
|
1097
1234
|
const turnId = this.extractTurnIdFromResponse(response);
|
|
1098
1235
|
if (!turnId) {
|
|
1099
1236
|
throw new Error("Codex did not return a turn id for the requested turn.");
|
|
@@ -1147,6 +1284,178 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1147
1284
|
this.armInterruptFallback();
|
|
1148
1285
|
return true;
|
|
1149
1286
|
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Daemon-attach mode: instead of spawning an owned app-server, join the
|
|
1289
|
+
* shared Codex app-server daemon (codex >= 0.162) as an additional client
|
|
1290
|
+
* and rebind the bridge to a live thread in this workspace. The daemon and
|
|
1291
|
+
* the owning TUI keep their lifecycle; the bridge is a guest connection.
|
|
1292
|
+
*/
|
|
1293
|
+
async startDaemonAttach() {
|
|
1294
|
+
this.setStatus("starting", "Attaching to the shared Codex app-server daemon...");
|
|
1295
|
+
await this.connectDaemonRpcClient();
|
|
1296
|
+
try {
|
|
1297
|
+
await this.attachDaemonThread();
|
|
1298
|
+
}
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
await this.disconnectRpcClient();
|
|
1301
|
+
throw error;
|
|
1302
|
+
}
|
|
1303
|
+
this.shuttingDown = false;
|
|
1304
|
+
this.cleanPanelExitInProgress = false;
|
|
1305
|
+
this.hasAcceptedInput = true;
|
|
1306
|
+
this.state.startedAt = nowIso();
|
|
1307
|
+
this.state.pendingApproval = null;
|
|
1308
|
+
this.afterStart();
|
|
1309
|
+
this.setStatus("idle", "Attached to the live Codex daemon session.");
|
|
1310
|
+
}
|
|
1311
|
+
async connectDaemonRpcClient() {
|
|
1312
|
+
if (this.rpcSocket) {
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const socket = await this.openDaemonRpcSocket();
|
|
1316
|
+
this.attachRpcSocket(socket);
|
|
1317
|
+
await this.initializeRpcClient();
|
|
1318
|
+
}
|
|
1319
|
+
async openDaemonRpcSocket() {
|
|
1320
|
+
const socketPath = resolveCodexDaemonControlSocketPath();
|
|
1321
|
+
if (!fs.existsSync(socketPath)) {
|
|
1322
|
+
throw new Error(`No shared Codex app-server daemon socket at ${socketPath}. Attach mode requires Codex >= 0.162 with its shared background server (the default). Sessions started with --no-daemon, --oss, or a --profile, and desktop-app embedded sessions, cannot be attached.`);
|
|
1323
|
+
}
|
|
1324
|
+
return await new Promise((resolve, reject) => {
|
|
1325
|
+
// ws 8.22 ignores the `agent` option and installs its own TCP
|
|
1326
|
+
// createConnection; passing createConnection at the top level is
|
|
1327
|
+
// forwarded to http.request and takes precedence, which is how the
|
|
1328
|
+
// Unix control socket is dialed.
|
|
1329
|
+
const socket = new WsPackageWebSocket("ws://localhost/", {
|
|
1330
|
+
createConnection: () => net.connect(socketPath),
|
|
1331
|
+
});
|
|
1332
|
+
let settled = false;
|
|
1333
|
+
const timer = setTimeout(() => {
|
|
1334
|
+
if (settled) {
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
settled = true;
|
|
1338
|
+
try {
|
|
1339
|
+
socket.close();
|
|
1340
|
+
}
|
|
1341
|
+
catch {
|
|
1342
|
+
// Best effort cleanup after timeout.
|
|
1343
|
+
}
|
|
1344
|
+
reject(new Error(`Timed out connecting to the Codex daemon socket ${socketPath}.`));
|
|
1345
|
+
}, CODEX_APP_SERVER_READY_TIMEOUT_MS);
|
|
1346
|
+
const cleanup = () => {
|
|
1347
|
+
clearTimeout(timer);
|
|
1348
|
+
};
|
|
1349
|
+
socket.once("open", () => {
|
|
1350
|
+
if (settled) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
settled = true;
|
|
1354
|
+
cleanup();
|
|
1355
|
+
resolve(socket);
|
|
1356
|
+
});
|
|
1357
|
+
socket.once("error", (error) => {
|
|
1358
|
+
if (settled) {
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
settled = true;
|
|
1362
|
+
cleanup();
|
|
1363
|
+
reject(new Error(`Failed to connect to the Codex daemon socket ${socketPath}: ${String(error)}`));
|
|
1364
|
+
});
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Bind the bridge to a live daemon thread. An explicit thread id wins;
|
|
1369
|
+
* otherwise the newest live interactive thread in the workspace cwd is
|
|
1370
|
+
* selected. `thread/resume` on a running thread rejoins it (history plus a
|
|
1371
|
+
* live subscription) without taking the rollout writer lock, which stays
|
|
1372
|
+
* with the owning client.
|
|
1373
|
+
*/
|
|
1374
|
+
async attachDaemonThread() {
|
|
1375
|
+
const targetThreadId = this.resumeThreadId ?? await this.discoverDaemonAttachThreadId();
|
|
1376
|
+
this.setStatus("starting", `Attaching Codex daemon thread ${targetThreadId.slice(0, 12)}...`);
|
|
1377
|
+
const response = await this.sendRpcRequest("thread/resume", buildCodexThreadResumeParams({
|
|
1378
|
+
threadId: targetThreadId,
|
|
1379
|
+
cwd: this.options.cwd,
|
|
1380
|
+
attachDaemon: true,
|
|
1381
|
+
}));
|
|
1382
|
+
const resumedThreadId = this.extractThreadIdFromResponse(response);
|
|
1383
|
+
if (!resumedThreadId) {
|
|
1384
|
+
throw new Error("Codex daemon did not confirm the attached thread id.");
|
|
1385
|
+
}
|
|
1386
|
+
if (resumedThreadId !== targetThreadId) {
|
|
1387
|
+
throw new Error(`Codex daemon opened ${resumedThreadId}, expected ${targetThreadId}.`);
|
|
1388
|
+
}
|
|
1389
|
+
this.validateCodexThreadWorkspaceIfPresent(response, resumedThreadId);
|
|
1390
|
+
if (isRecord(response) && isRecord(response.thread) && typeof response.thread.path === "string") {
|
|
1391
|
+
this.sessionFilePath = response.thread.path;
|
|
1392
|
+
}
|
|
1393
|
+
this.subscribedThreadIds.add(resumedThreadId);
|
|
1394
|
+
this.resumeThreadId = null;
|
|
1395
|
+
this.updateSharedThread(resumedThreadId, {
|
|
1396
|
+
source: "restore",
|
|
1397
|
+
reason: "startup_restore",
|
|
1398
|
+
notify: true,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
async discoverDaemonAttachThreadId() {
|
|
1402
|
+
const response = await this.sendRpcRequest("thread/list", {
|
|
1403
|
+
limit: 50,
|
|
1404
|
+
sortKey: "updated_at",
|
|
1405
|
+
sortDirection: "desc",
|
|
1406
|
+
sourceKinds: ["cli", "vscode"],
|
|
1407
|
+
archived: false,
|
|
1408
|
+
cwd: this.options.cwd,
|
|
1409
|
+
});
|
|
1410
|
+
const candidates = selectCodexDaemonAttachCandidates(response, this.options.cwd);
|
|
1411
|
+
if (candidates.length === 0) {
|
|
1412
|
+
throw new Error(`No live Codex daemon session found for ${this.options.cwd}. Open codex (>= 0.162, default shared-server mode) in this directory, then retry. Embedded sessions (desktop app, --no-daemon, --oss, --profile) cannot be attached.`);
|
|
1413
|
+
}
|
|
1414
|
+
if (candidates.length > 1) {
|
|
1415
|
+
const listed = candidates
|
|
1416
|
+
.slice(0, 5)
|
|
1417
|
+
.map((candidate) => candidate.id.slice(0, 8))
|
|
1418
|
+
.join(", ");
|
|
1419
|
+
this.setStatus("starting", `Multiple live Codex daemon sessions for ${this.options.cwd}; attaching the newest (${listed}).`);
|
|
1420
|
+
}
|
|
1421
|
+
return candidates[0].id;
|
|
1422
|
+
}
|
|
1423
|
+
/** Reconnect after the daemon socket dropped unexpectedly. */
|
|
1424
|
+
async reconnectDaemonRpcClient() {
|
|
1425
|
+
const reconnectDeadline = Date.now() + CODEX_RPC_RECONNECT_TIMEOUT_MS;
|
|
1426
|
+
let lastError = "Codex daemon connection closed.";
|
|
1427
|
+
while (!this.shuttingDown &&
|
|
1428
|
+
!this.cleanPanelExitInProgress &&
|
|
1429
|
+
Date.now() < reconnectDeadline) {
|
|
1430
|
+
try {
|
|
1431
|
+
await this.connectDaemonRpcClient();
|
|
1432
|
+
// The daemon may have restarted; rejoin the bound thread. Cold threads
|
|
1433
|
+
// are reloaded from disk, so this also recovers after daemon restarts.
|
|
1434
|
+
if (this.state.sharedThreadId) {
|
|
1435
|
+
await this.ensureSharedThreadSubscribed(this.state.sharedThreadId);
|
|
1436
|
+
}
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1439
|
+
catch (error) {
|
|
1440
|
+
lastError = describeUnknownError(error);
|
|
1441
|
+
await delay(CODEX_RPC_CONNECT_RETRY_MS);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (shouldSuppressCodexTransportFatalError({
|
|
1445
|
+
transportShuttingDown: this.rpcShuttingDown,
|
|
1446
|
+
shuttingDown: this.shuttingDown,
|
|
1447
|
+
cleanPanelExitInProgress: this.cleanPanelExitInProgress,
|
|
1448
|
+
})) {
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
this.emit({
|
|
1452
|
+
type: "fatal_error",
|
|
1453
|
+
message: `Lost the connection to the shared Codex app-server daemon (${this.lastRpcCloseDetail || lastError}). The attached session may have closed; restart the attach bridge.`,
|
|
1454
|
+
timestamp: nowIso(),
|
|
1455
|
+
});
|
|
1456
|
+
this.terminateCodexClient();
|
|
1457
|
+
return false;
|
|
1458
|
+
}
|
|
1150
1459
|
async startAppServer() {
|
|
1151
1460
|
if (this.appServer) {
|
|
1152
1461
|
return;
|
|
@@ -1398,6 +1707,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1398
1707
|
})) {
|
|
1399
1708
|
return false;
|
|
1400
1709
|
}
|
|
1710
|
+
if (this.options.codexAttachDaemon) {
|
|
1711
|
+
return await this.reconnectDaemonRpcClient();
|
|
1712
|
+
}
|
|
1401
1713
|
if (!this.appServer || !this.appServerPort) {
|
|
1402
1714
|
if (shouldSuppressCodexTransportFatalError({
|
|
1403
1715
|
transportShuttingDown: this.appServerShuttingDown,
|
|
@@ -1539,14 +1851,11 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1539
1851
|
if (this.subscribedThreadIds.has(threadId)) {
|
|
1540
1852
|
return;
|
|
1541
1853
|
}
|
|
1542
|
-
const response = await this.sendRpcRequest("thread/resume", {
|
|
1854
|
+
const response = await this.sendRpcRequest("thread/resume", buildCodexThreadResumeParams({
|
|
1543
1855
|
threadId,
|
|
1544
1856
|
cwd: this.options.cwd,
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
sandbox: "workspace-write",
|
|
1548
|
-
excludeTurns: true,
|
|
1549
|
-
});
|
|
1857
|
+
attachDaemon: Boolean(this.options.codexAttachDaemon),
|
|
1858
|
+
}));
|
|
1550
1859
|
const resumedThreadId = this.extractThreadIdFromResponse(response);
|
|
1551
1860
|
if (!resumedThreadId) {
|
|
1552
1861
|
throw new Error("Codex did not return a thread id while subscribing the bridge client.");
|
|
@@ -85,7 +85,9 @@ export function normalizeBridgeLockPayload(value) {
|
|
|
85
85
|
startedAt: record.startedAt,
|
|
86
86
|
lifecycle: record.lifecycle === "companion_bound" ? "companion_bound" : "persistent",
|
|
87
87
|
legacyLifecycleFallback: hasExplicitLifecycle ? undefined : true,
|
|
88
|
-
...(record.channelId === "wechat" ||
|
|
88
|
+
...(record.channelId === "wechat" ||
|
|
89
|
+
record.channelId === "wecom" ||
|
|
90
|
+
record.channelId === "telegram"
|
|
89
91
|
? { channelId: record.channelId }
|
|
90
92
|
: {}),
|
|
91
93
|
};
|
|
@@ -28,10 +28,15 @@ import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
|
|
|
28
28
|
import { isDirectModuleRun } from "../core/direct-run.js";
|
|
29
29
|
import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
|
|
30
30
|
import { ensureWecomAccount } from "../channels/wecom/setup.js";
|
|
31
|
+
import { ensureTelegramAccount } from "../channels/telegram/setup.js";
|
|
32
|
+
import { TelegramTransport } from "../channels/telegram/telegram-transport.js";
|
|
33
|
+
import { TelegramChannelDriver } from "../channels/telegram/telegram-driver.js";
|
|
34
|
+
import { TelegramChannelPort } from "../channels/telegram/telegram-channel-port.js";
|
|
31
35
|
import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
|
|
32
36
|
import { WecomTransport } from "../channels/wecom/wecom-transport.js";
|
|
33
37
|
import { WecomChannelDriver } from "../channels/wecom/wecom-driver.js";
|
|
34
38
|
import { WechatChannelDriver } from "../channels/wechat/wechat-driver.js";
|
|
39
|
+
import { isBridgeChannelId } from "../core/channel-types.js";
|
|
35
40
|
import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
|
|
36
41
|
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, } from "../wechat/wechat-transport.js";
|
|
37
42
|
import { getPendingChannelMessagesFile, getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
|
|
@@ -100,6 +105,8 @@ export function parseCliArgs(argv) {
|
|
|
100
105
|
let lifecycle = "persistent";
|
|
101
106
|
let sessionStartMode = "restore";
|
|
102
107
|
let channelId = "wechat";
|
|
108
|
+
let codexAttachDaemon = false;
|
|
109
|
+
let codexAttachThreadId;
|
|
103
110
|
for (let i = 0; i < argv.length; i += 1) {
|
|
104
111
|
const arg = argv[i];
|
|
105
112
|
const next = argv[i + 1];
|
|
@@ -147,7 +154,7 @@ export function parseCliArgs(argv) {
|
|
|
147
154
|
i += 1;
|
|
148
155
|
break;
|
|
149
156
|
case "--channel":
|
|
150
|
-
if (!next ||
|
|
157
|
+
if (!isBridgeChannelId(next) || next === "local") {
|
|
151
158
|
throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
|
|
152
159
|
}
|
|
153
160
|
channelId = next;
|
|
@@ -156,6 +163,16 @@ export function parseCliArgs(argv) {
|
|
|
156
163
|
case "--shutdown-on-parent-exit":
|
|
157
164
|
lifecycle = "companion_bound";
|
|
158
165
|
break;
|
|
166
|
+
case "--codex-attach-daemon":
|
|
167
|
+
codexAttachDaemon = true;
|
|
168
|
+
break;
|
|
169
|
+
case "--codex-thread-id":
|
|
170
|
+
if (!next) {
|
|
171
|
+
throw new Error("--codex-thread-id requires a value");
|
|
172
|
+
}
|
|
173
|
+
codexAttachThreadId = next;
|
|
174
|
+
i += 1;
|
|
175
|
+
break;
|
|
159
176
|
case "--help":
|
|
160
177
|
case "-h":
|
|
161
178
|
printUsageAndExit();
|
|
@@ -167,6 +184,9 @@ export function parseCliArgs(argv) {
|
|
|
167
184
|
if (!adapter) {
|
|
168
185
|
throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
|
|
169
186
|
}
|
|
187
|
+
if (codexAttachDaemon && adapter !== "codex") {
|
|
188
|
+
throw new Error("--codex-attach-daemon requires --adapter codex");
|
|
189
|
+
}
|
|
170
190
|
const defaultCommand = resolveDefaultAdapterCommand(adapter);
|
|
171
191
|
return {
|
|
172
192
|
adapter,
|
|
@@ -176,6 +196,8 @@ export function parseCliArgs(argv) {
|
|
|
176
196
|
lifecycle,
|
|
177
197
|
sessionStartMode,
|
|
178
198
|
channelId,
|
|
199
|
+
codexAttachDaemon,
|
|
200
|
+
codexAttachThreadId,
|
|
179
201
|
};
|
|
180
202
|
}
|
|
181
203
|
function toWechatSendContext(kind) {
|
|
@@ -207,7 +229,7 @@ function toLegacyInboundWechatMessage(message) {
|
|
|
207
229
|
function printUsageAndExit() {
|
|
208
230
|
process.stdout.write([
|
|
209
231
|
"Internal bridge runtime usage:",
|
|
210
|
-
" npm run bridge -- --adapter <codex|claude|opencode|pi> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
|
|
232
|
+
" npm run bridge -- --adapter <codex|claude|opencode|pi> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>] [--codex-attach-daemon [--codex-thread-id <id>]]",
|
|
211
233
|
"",
|
|
212
234
|
"This entry is internal. Users should run a wechat-* or wecom-* launcher.",
|
|
213
235
|
"",
|
|
@@ -232,17 +254,24 @@ async function main() {
|
|
|
232
254
|
const wecomAccount = options.channelId === "wecom"
|
|
233
255
|
? await ensureWecomAccount({ log })
|
|
234
256
|
: null;
|
|
257
|
+
const telegramAccount = options.channelId === "telegram"
|
|
258
|
+
? await ensureTelegramAccount({ log })
|
|
259
|
+
: null;
|
|
235
260
|
const credentials = wecomAccount
|
|
236
261
|
? { userId: wecomAccount.operatorUserId }
|
|
237
|
-
:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
262
|
+
: telegramAccount
|
|
263
|
+
? { userId: telegramAccount.operatorUserId }
|
|
264
|
+
: await ensureWechatCredentials({
|
|
265
|
+
requireUserId: true,
|
|
266
|
+
validateExisting: true,
|
|
267
|
+
log,
|
|
268
|
+
});
|
|
242
269
|
if (!credentials.userId) {
|
|
243
270
|
throw new Error(options.channelId === "wecom"
|
|
244
271
|
? "Saved WeCom credentials are missing operatorUserId."
|
|
245
|
-
:
|
|
272
|
+
: options.channelId === "telegram"
|
|
273
|
+
? "Saved Telegram credentials are missing operatorUserId."
|
|
274
|
+
: "Saved WeChat credentials are missing userId.");
|
|
246
275
|
}
|
|
247
276
|
const transport = new WeChatTransport({ log, logError });
|
|
248
277
|
const wecomTransport = wecomAccount
|
|
@@ -254,6 +283,15 @@ async function main() {
|
|
|
254
283
|
},
|
|
255
284
|
})
|
|
256
285
|
: null;
|
|
286
|
+
const telegramTransport = telegramAccount
|
|
287
|
+
? new TelegramTransport({
|
|
288
|
+
account: telegramAccount,
|
|
289
|
+
logger: {
|
|
290
|
+
log: (message) => log(message),
|
|
291
|
+
error: (message) => logError(message),
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
: null;
|
|
257
295
|
const channelDriver = wecomTransport
|
|
258
296
|
? new WecomChannelDriver({
|
|
259
297
|
transport: wecomTransport,
|
|
@@ -261,11 +299,18 @@ async function main() {
|
|
|
261
299
|
operatorId: credentials.userId,
|
|
262
300
|
logError,
|
|
263
301
|
})
|
|
264
|
-
:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
302
|
+
: telegramTransport
|
|
303
|
+
? new TelegramChannelDriver({
|
|
304
|
+
transport: telegramTransport,
|
|
305
|
+
accountId: telegramAccount?.botUsername,
|
|
306
|
+
operatorId: credentials.userId,
|
|
307
|
+
logError,
|
|
308
|
+
})
|
|
309
|
+
: new WechatChannelDriver({
|
|
310
|
+
transport,
|
|
311
|
+
logError,
|
|
312
|
+
buildInboundPrompt: (text, attachments) => buildWechatInboundPrompt(text, attachments.filter((attachment) => attachment.kind === "image" || attachment.kind === "file")),
|
|
313
|
+
});
|
|
269
314
|
// 非阻塞地检查更新(不影响启动速度,也避免首次登录时打断二维码输出)
|
|
270
315
|
// unref:不能让这个延迟检查把 event loop 挂活(如 --doctor 或快速退出场景),
|
|
271
316
|
// 否则会与强制退出 teardown 竞态。
|
|
@@ -327,9 +372,15 @@ async function main() {
|
|
|
327
372
|
profile: options.profile,
|
|
328
373
|
lifecycle: options.lifecycle,
|
|
329
374
|
sessionStartMode: options.sessionStartMode,
|
|
330
|
-
|
|
375
|
+
// Daemon attach binds an explicit or discovered live thread; a stale
|
|
376
|
+
// persisted session id must not leak into the attach target.
|
|
377
|
+
initialSharedSessionId: options.codexAttachDaemon
|
|
378
|
+
? options.codexAttachThreadId
|
|
379
|
+
: stateStore.getState().sharedSessionId ?? stateStore.getState().sharedThreadId,
|
|
331
380
|
initialResumeConversationId: stateStore.getState().resumeConversationId,
|
|
332
381
|
initialTranscriptPath: stateStore.getState().transcriptPath,
|
|
382
|
+
codexAttachDaemon: options.codexAttachDaemon,
|
|
383
|
+
codexAttachThreadId: options.codexAttachThreadId,
|
|
333
384
|
});
|
|
334
385
|
const pendingWechatMessages = new PendingWechatMessageStore(options.channelId === "wechat"
|
|
335
386
|
? getPendingWechatMessagesFile(options.cwd)
|
|
@@ -416,8 +467,8 @@ async function main() {
|
|
|
416
467
|
const trackWechatForwardTask = (task) => {
|
|
417
468
|
const tracked = task
|
|
418
469
|
.catch((error) => {
|
|
419
|
-
logError(
|
|
420
|
-
stateStore.appendLog(
|
|
470
|
+
logError(`${channelDriver.displayName} forward task failed: ${describeWechatTransportError(error)}`);
|
|
471
|
+
stateStore.appendLog(`${channelDriver.id}_forward_failed: error=${truncatePreview(describeWechatTransportError(error), 400)}`);
|
|
421
472
|
})
|
|
422
473
|
.finally(() => {
|
|
423
474
|
pendingWechatForwardTasks.delete(tracked);
|
|
@@ -663,6 +714,7 @@ async function main() {
|
|
|
663
714
|
},
|
|
664
715
|
requestShutdown,
|
|
665
716
|
wecomTransport,
|
|
717
|
+
telegramTransport,
|
|
666
718
|
getWecomTarget: () => turns.resolveTarget(operatorConversation),
|
|
667
719
|
});
|
|
668
720
|
await adapter.start();
|
|
@@ -957,28 +1009,43 @@ function syncSharedSessionState(stateStore, adapter) {
|
|
|
957
1009
|
}
|
|
958
1010
|
}
|
|
959
1011
|
export function wireAdapterEvents(params) {
|
|
960
|
-
const { adapter, channelDriver, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, wecomTransport, getWecomTarget, } = params;
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
:
|
|
1012
|
+
const { adapter, channelDriver, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, wecomTransport, telegramTransport, getWecomTarget, } = params;
|
|
1013
|
+
const remotePortCallbacks = {
|
|
1014
|
+
sendText: (target, text, kind) => queueWechatMessage(stateStore.getState().authorizedUserId, text, toWechatSendContext(kind), target),
|
|
1015
|
+
onEmptyVisibleReply: (adapter, rawText) => {
|
|
1016
|
+
stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
|
|
1017
|
+
},
|
|
1018
|
+
};
|
|
1019
|
+
// Every remote channel must provide its own port; never fall back to WeChat.
|
|
1020
|
+
const portFactories = {
|
|
1021
|
+
wecom: () => {
|
|
1022
|
+
if (!wecomTransport) {
|
|
1023
|
+
throw new Error("The wecom channel port requires a WeCom transport.");
|
|
1024
|
+
}
|
|
1025
|
+
return new WecomChannelPort({ transport: wecomTransport, ...remotePortCallbacks });
|
|
1026
|
+
},
|
|
1027
|
+
telegram: () => {
|
|
1028
|
+
if (!telegramTransport) {
|
|
1029
|
+
throw new Error("The telegram channel port requires a Telegram transport.");
|
|
1030
|
+
}
|
|
1031
|
+
return new TelegramChannelPort({ transport: telegramTransport, ...remotePortCallbacks });
|
|
1032
|
+
},
|
|
1033
|
+
wechat: () => new WechatChannelPort({
|
|
970
1034
|
sendText: (recipientId, text, context) => queueWechatMessage(recipientId, text, context),
|
|
971
1035
|
sendImage: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendImage(filePath, { recipientId })),
|
|
972
1036
|
sendFile: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId })),
|
|
973
1037
|
sendVoice: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVoice(filePath, recipientId)),
|
|
974
1038
|
sendVideo: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVideo(filePath, { recipientId })),
|
|
975
|
-
onEmptyVisibleReply:
|
|
976
|
-
stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
|
|
977
|
-
},
|
|
1039
|
+
onEmptyVisibleReply: remotePortCallbacks.onEmptyVisibleReply,
|
|
978
1040
|
onTextSent: (_adapter, text) => {
|
|
979
1041
|
stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
|
|
980
1042
|
},
|
|
981
|
-
})
|
|
1043
|
+
}),
|
|
1044
|
+
};
|
|
1045
|
+
if (options.channelId === "local") {
|
|
1046
|
+
throw new Error("The local channel does not support standalone bridge output.");
|
|
1047
|
+
}
|
|
1048
|
+
const channelPort = portFactories[options.channelId]();
|
|
982
1049
|
let lastFinalReplyAtMs = 0;
|
|
983
1050
|
let eventForwardChain = Promise.resolve();
|
|
984
1051
|
adapter.setEventSink((event) => {
|