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.
@@ -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
- approvalPolicy: "on-request",
1089
- approvalsReviewer: "user",
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
- approvalPolicy: "on-request",
1546
- approvalsReviewer: "user",
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" || record.channelId === "wecom"
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 || (next !== "wechat" && next !== "wecom")) {
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
- : await ensureWechatCredentials({
238
- requireUserId: true,
239
- validateExisting: true,
240
- log,
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
- : "Saved WeChat credentials are missing userId.");
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
- : new WechatChannelDriver({
265
- transport,
266
- logError,
267
- buildInboundPrompt: (text, attachments) => buildWechatInboundPrompt(text, attachments.filter((attachment) => attachment.kind === "image" || attachment.kind === "file")),
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
- initialSharedSessionId: stateStore.getState().sharedSessionId ?? stateStore.getState().sharedThreadId,
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(`WeChat forward task failed: ${describeWechatTransportError(error)}`);
420
- stateStore.appendLog(`wechat_forward_failed: error=${truncatePreview(describeWechatTransportError(error), 400)}`);
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 channelPort = options.channelId === "wecom"
962
- ? new WecomChannelPort({
963
- transport: wecomTransport,
964
- sendText: (target, text, kind) => queueWechatMessage(stateStore.getState().authorizedUserId, text, toWechatSendContext(kind), target),
965
- onEmptyVisibleReply: (adapter, rawText) => {
966
- stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
967
- },
968
- })
969
- : new WechatChannelPort({
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: (adapter, rawText) => {
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) => {