cli-wechat-bridge 1.1.4 → 1.1.5

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,11 +6,53 @@ import { detectCliApproval, normalizeOutput, nowIso, truncatePreview, } from "./
6
6
  import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
7
7
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
8
8
  import * as shared from "./bridge-adapters.shared.js";
9
+ import { requestCodexVisibleThreadSwitch } from "../companion/codex-visible-client-link.js";
9
10
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
10
11
  import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
11
12
  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;
12
13
  const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
13
14
  const CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS = 120_000;
15
+ function getCodexThreadStatusType(value) {
16
+ if (typeof value === "string") {
17
+ return value;
18
+ }
19
+ return isRecord(value) && typeof value.type === "string" ? value.type : null;
20
+ }
21
+ function getCodexThreadTitle(thread) {
22
+ const name = typeof thread.name === "string" ? normalizeOutput(thread.name).trim() : "";
23
+ const preview = typeof thread.preview === "string" ? normalizeOutput(thread.preview).trim() : "";
24
+ const threadId = typeof thread.id === "string" ? thread.id : "";
25
+ return truncatePreview(name || preview || `Codex thread ${threadId.slice(0, 8)}`, 120);
26
+ }
27
+ export function buildCodexResumeCandidatesFromThreadList(response, cwd, limit) {
28
+ if (!isRecord(response) || !Array.isArray(response.data)) {
29
+ throw new Error("Codex returned an invalid thread/list response.");
30
+ }
31
+ const currentCwd = normalizeComparablePath(cwd);
32
+ return response.data
33
+ .filter((thread) => isRecord(thread))
34
+ .filter((thread) => typeof thread.id === "string")
35
+ .filter((thread) => {
36
+ const threadCwd = typeof thread.cwd === "string" ? thread.cwd : "";
37
+ return threadCwd && normalizeComparablePath(threadCwd) === currentCwd;
38
+ })
39
+ .filter((thread) => typeof thread.parentThreadId !== "string")
40
+ .map((thread) => {
41
+ const updatedAt = typeof thread.updatedAt === "number" && Number.isFinite(thread.updatedAt)
42
+ ? thread.updatedAt * 1_000
43
+ : 0;
44
+ const threadId = thread.id;
45
+ return {
46
+ sessionId: threadId,
47
+ threadId,
48
+ title: getCodexThreadTitle(thread),
49
+ lastUpdatedAt: new Date(updatedAt).toISOString(),
50
+ source: "codex",
51
+ };
52
+ })
53
+ .sort((left, right) => Date.parse(right.lastUpdatedAt) - Date.parse(left.lastUpdatedAt))
54
+ .slice(0, Math.max(1, limit));
55
+ }
14
56
  export class CodexPtyAdapter extends AbstractPtyAdapter {
15
57
  runtimeKind = "codex_runtime_host";
16
58
  appServer = null;
@@ -37,6 +79,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
37
79
  pendingTurnThreadId = null;
38
80
  interruptPendingTurnStart = false;
39
81
  pendingThreadFollowId = null;
82
+ pendingVisibleResume = null;
40
83
  pendingApprovalRequests = [];
41
84
  pendingUserInputRequest = null;
42
85
  pendingUserInputTimer = null;
@@ -165,13 +208,16 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
165
208
  this.writeToPty("\r");
166
209
  }
167
210
  async listResumeSessions(limit = 10) {
211
+ if (this.usesRpcTurnTransport()) {
212
+ return await this.listAppServerResumeSessions(limit);
213
+ }
168
214
  return listCodexResumeSessions(this.options.cwd, limit);
169
215
  }
170
216
  async resumeSession(threadId) {
171
- if (this.isNativePanelMode()) {
172
- throw new Error('WeChat /resume is disabled in codex mode. Use /resume directly inside "wechat-codex"; WeChat will follow the active local thread.');
217
+ if (!this.isHeadlessRuntimeMode()) {
218
+ throw new Error('WeChat /resume requires the managed "wechat-codex" visible client.');
173
219
  }
174
- await this.resumeSharedThread(threadId);
220
+ await this.resumeVisibleSharedThread(threadId);
175
221
  }
176
222
  async interrupt() {
177
223
  if (this.usesRpcTurnTransport()) {
@@ -292,6 +338,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
292
338
  profile: this.options.profile,
293
339
  sharedSessionId: this.state.sharedSessionId,
294
340
  sharedThreadId: this.state.sharedThreadId,
341
+ codexVisibleThreadId: this.state.sharedThreadId,
295
342
  resumeConversationId: this.state.resumeConversationId,
296
343
  transcriptPath: this.state.transcriptPath,
297
344
  startedAt: this.state.startedAt ?? nowIso(),
@@ -610,7 +657,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
610
657
  if (this.sessionFilePath && candidate.modifiedAtMs <= currentSessionModifiedAtMs) {
611
658
  return;
612
659
  }
613
- if (!this.activeTurn || this.activeTurn.threadId === candidate.threadId) {
660
+ if (!this.activeTurn ||
661
+ this.activeTurn.threadId === candidate.threadId ||
662
+ this.activeTurn.origin === "wechat") {
614
663
  this.trackLocalSharedThread(candidate.threadId, {
615
664
  reason: "local_session_fallback",
616
665
  signal: "session_fallback",
@@ -1316,6 +1365,147 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1316
1365
  this.updateSharedThread(resumedThreadId);
1317
1366
  }
1318
1367
  }
1368
+ async listAppServerResumeSessions(limit) {
1369
+ const boundedLimit = Math.max(1, Math.min(100, limit));
1370
+ const baseParams = {
1371
+ limit: boundedLimit,
1372
+ sortKey: "updated_at",
1373
+ sortDirection: "desc",
1374
+ sourceKinds: ["cli", "vscode"],
1375
+ archived: false,
1376
+ cwd: this.options.cwd,
1377
+ };
1378
+ try {
1379
+ const fastResponse = await this.sendRpcRequest("thread/list", {
1380
+ ...baseParams,
1381
+ useStateDbOnly: true,
1382
+ });
1383
+ const fastCandidates = buildCodexResumeCandidatesFromThreadList(fastResponse, this.options.cwd, boundedLimit);
1384
+ if (fastCandidates.length > 0) {
1385
+ return fastCandidates;
1386
+ }
1387
+ }
1388
+ catch {
1389
+ // Older supported app-server builds may not understand useStateDbOnly.
1390
+ // The scan-and-repair request below is also the authoritative fallback.
1391
+ }
1392
+ const response = await this.sendRpcRequest("thread/list", baseParams);
1393
+ return buildCodexResumeCandidatesFromThreadList(response, this.options.cwd, boundedLimit);
1394
+ }
1395
+ validateCodexResumeThread(response, targetThreadId) {
1396
+ if (!isRecord(response) || !isRecord(response.thread)) {
1397
+ throw new Error("Codex returned an invalid thread response.");
1398
+ }
1399
+ const thread = response.thread;
1400
+ if (thread.id !== targetThreadId) {
1401
+ throw new Error(`Codex opened ${String(thread.id)}, expected ${targetThreadId}.`);
1402
+ }
1403
+ if (typeof thread.cwd !== "string" ||
1404
+ normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
1405
+ throw new Error(`Codex thread ${targetThreadId} does not belong to ${this.options.cwd}.`);
1406
+ }
1407
+ if (typeof thread.parentThreadId === "string") {
1408
+ throw new Error(`Codex thread ${targetThreadId} is a subagent thread and cannot be resumed.`);
1409
+ }
1410
+ if (thread.ephemeral === true) {
1411
+ throw new Error(`Codex thread ${targetThreadId} is ephemeral and cannot be resumed.`);
1412
+ }
1413
+ if (getCodexThreadStatusType(thread.status) === "active") {
1414
+ throw new Error(`Codex thread ${targetThreadId} is still active. Stop its running task before resuming it from WeChat.`);
1415
+ }
1416
+ return thread;
1417
+ }
1418
+ async resumeVisibleSharedThread(threadId) {
1419
+ const targetThreadId = threadId.trim();
1420
+ if (!targetThreadId) {
1421
+ throw new Error("A thread id is required to resume a Codex thread.");
1422
+ }
1423
+ if (this.pendingVisibleResume) {
1424
+ throw new Error("Codex is already switching visible threads.");
1425
+ }
1426
+ if (this.pendingApproval || this.pendingApprovalRequests.length > 0) {
1427
+ throw new Error("A Codex approval request is pending. Reply with /confirm or /deny.");
1428
+ }
1429
+ if (this.pendingUserInputRequest || this.state.pendingUserInput) {
1430
+ throw new Error("Codex is waiting for user input. Reply with /answer or use /stop.");
1431
+ }
1432
+ if (this.pendingTurnStart ||
1433
+ this.activeTurn ||
1434
+ this.state.status === "busy" ||
1435
+ this.state.status === "awaiting_approval" ||
1436
+ this.state.status === "awaiting_input") {
1437
+ throw new Error("codex is still working. Wait for the current reply or use /stop.");
1438
+ }
1439
+ if (this.state.status !== "idle") {
1440
+ throw new Error(`Codex cannot switch threads while its status is ${this.state.status}.`);
1441
+ }
1442
+ const readResponse = await this.sendRpcRequest("thread/read", {
1443
+ threadId: targetThreadId,
1444
+ includeTurns: false,
1445
+ });
1446
+ this.validateCodexResumeThread(readResponse, targetThreadId);
1447
+ const previousThreadId = this.sharedThreadId;
1448
+ let targetSubscribed = false;
1449
+ this.pendingVisibleResume = { targetThreadId };
1450
+ this.setStatus("starting", `Switching Codex to thread ${targetThreadId.slice(0, 12)}...`);
1451
+ try {
1452
+ this.rememberBridgeOwnedThreadSignal(targetThreadId);
1453
+ const resumeResponse = await this.sendRpcRequest("thread/resume", {
1454
+ threadId: targetThreadId,
1455
+ approvalPolicy: "on-request",
1456
+ approvalsReviewer: "user",
1457
+ sandbox: "workspace-write",
1458
+ excludeTurns: true,
1459
+ });
1460
+ targetSubscribed = true;
1461
+ this.validateCodexResumeThread(resumeResponse, targetThreadId);
1462
+ this.subscribedThreadIds.add(targetThreadId);
1463
+ const visibleThreadId = await this.switchVisibleCodexThread(targetThreadId);
1464
+ if (visibleThreadId !== targetThreadId) {
1465
+ throw new Error(`The visible Codex client opened ${visibleThreadId}, expected ${targetThreadId}.`);
1466
+ }
1467
+ this.sessionFilePath = null;
1468
+ this.sessionReadOffset = 0;
1469
+ this.sessionPartialLine = "";
1470
+ this.sessionFinalText = null;
1471
+ this.pendingThreadFollowId = null;
1472
+ this.updateSharedThread(targetThreadId, {
1473
+ source: "wechat",
1474
+ reason: "wechat_resume",
1475
+ notify: true,
1476
+ });
1477
+ this.setStatus("idle");
1478
+ if (previousThreadId && previousThreadId !== targetThreadId) {
1479
+ await this.unsubscribeCodexThreadBestEffort(previousThreadId);
1480
+ }
1481
+ }
1482
+ catch (error) {
1483
+ if (targetSubscribed && targetThreadId !== previousThreadId) {
1484
+ await this.unsubscribeCodexThreadBestEffort(targetThreadId);
1485
+ }
1486
+ this.setStatus("idle");
1487
+ throw error;
1488
+ }
1489
+ finally {
1490
+ this.pendingVisibleResume = null;
1491
+ }
1492
+ }
1493
+ async switchVisibleCodexThread(threadId) {
1494
+ return await requestCodexVisibleThreadSwitch({
1495
+ cwd: this.options.cwd,
1496
+ instanceId: this.localClientInstanceId,
1497
+ threadId,
1498
+ });
1499
+ }
1500
+ async unsubscribeCodexThreadBestEffort(threadId) {
1501
+ try {
1502
+ await this.sendRpcRequest("thread/unsubscribe", { threadId });
1503
+ this.subscribedThreadIds.delete(threadId);
1504
+ }
1505
+ catch {
1506
+ // A failed unsubscribe must not undo an otherwise successful visible switch.
1507
+ }
1508
+ }
1319
1509
  async resumeSharedThread(threadId, options = {}) {
1320
1510
  const trimmedThreadId = threadId.trim();
1321
1511
  if (!trimmedThreadId) {
@@ -1614,6 +1804,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1614
1804
  pending.timer.unref?.();
1615
1805
  }
1616
1806
  trackLocalSharedThread(threadId, options) {
1807
+ if (this.pendingVisibleResume) {
1808
+ return;
1809
+ }
1810
+ this.interruptWechatTurnForLocalThreadSwitch(threadId);
1617
1811
  if (!this.isNativePanelMode()) {
1618
1812
  this.updateSharedThread(threadId, {
1619
1813
  source: "local",
@@ -1655,6 +1849,34 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1655
1849
  }
1656
1850
  this.schedulePendingThreadAnnouncement();
1657
1851
  }
1852
+ interruptWechatTurnForLocalThreadSwitch(nextThreadId) {
1853
+ const activeTurn = this.activeTurn;
1854
+ if (!activeTurn ||
1855
+ activeTurn.origin !== "wechat" ||
1856
+ activeTurn.threadId === nextThreadId) {
1857
+ return;
1858
+ }
1859
+ const interruptedTurnId = activeTurn.turnId;
1860
+ void this.sendRpcRequest("turn/interrupt", {
1861
+ threadId: activeTurn.threadId,
1862
+ turnId: interruptedTurnId,
1863
+ }).catch(() => undefined);
1864
+ this.clearInterruptTimer();
1865
+ this.clearFinalReplyCompletionTimerForTurn(interruptedTurnId);
1866
+ this.clearPendingApprovalState();
1867
+ this.clearPendingUserInputState();
1868
+ this.pendingThreadFollowId = null;
1869
+ this.cleanupTurnArtifacts(interruptedTurnId);
1870
+ this.rememberCompletedTurn(interruptedTurnId);
1871
+ this.bridgeOwnedTurnIds.add(interruptedTurnId);
1872
+ this.setActiveTurn(null);
1873
+ this.setStatus("idle");
1874
+ this.emit({
1875
+ type: "task_failed",
1876
+ message: "The active WeChat task was interrupted because the local Codex terminal switched threads.",
1877
+ timestamp: nowIso(),
1878
+ });
1879
+ }
1658
1880
  rememberBridgeOwnedThreadSignal(threadId) {
1659
1881
  const cutoff = Date.now() - CODEX_THREAD_SIGNAL_TTL_MS;
1660
1882
  for (const [candidateThreadId, recordedAtMs] of this.recentBridgeThreadSignalAtById.entries()) {
@@ -2171,7 +2393,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2171
2393
  if (!threadId) {
2172
2394
  return;
2173
2395
  }
2174
- if (!this.activeTurn || this.activeTurn.threadId === threadId) {
2396
+ if (!this.activeTurn ||
2397
+ this.activeTurn.threadId === threadId ||
2398
+ this.activeTurn.origin === "wechat") {
2175
2399
  this.trackLocalSharedThread(threadId, {
2176
2400
  reason: "local_follow",
2177
2401
  signal: "status_changed",
@@ -2195,7 +2419,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2195
2419
  return;
2196
2420
  }
2197
2421
  }
2198
- if (!this.activeTurn || this.activeTurn.threadId === threadId) {
2422
+ if (!this.activeTurn ||
2423
+ this.activeTurn.threadId === threadId ||
2424
+ this.activeTurn.origin === "wechat") {
2199
2425
  this.trackLocalSharedThread(threadId, {
2200
2426
  reason: "local_follow",
2201
2427
  signal: "thread_started",
@@ -2211,7 +2437,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2211
2437
  }
2212
2438
  if (trackedTurn.origin === "local" &&
2213
2439
  trackedTurn.threadId !== this.sharedThreadId) {
2214
- if (!this.activeTurn || this.activeTurn.threadId === trackedTurn.threadId) {
2440
+ if (!this.activeTurn ||
2441
+ this.activeTurn.threadId === trackedTurn.threadId ||
2442
+ this.activeTurn.origin === "wechat") {
2215
2443
  this.trackLocalSharedThread(trackedTurn.threadId, {
2216
2444
  reason: "local_turn",
2217
2445
  signal: "turn_started",