@unlinearity/cli-wechat-bridge 1.1.4 → 1.1.6
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 +153 -131
- package/bin/_run-entry.mjs +20 -17
- package/dist/bridge/bridge-adapters.claude.js +429 -11
- package/dist/bridge/bridge-adapters.codex.js +441 -22
- package/dist/bridge/bridge-adapters.core.js +2 -1
- package/dist/bridge/bridge-adapters.opencode.js +392 -53
- package/dist/bridge/bridge-adapters.pi.js +33 -7
- package/dist/bridge/bridge-adapters.shared.js +2 -1
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-final-reply.js +2 -65
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-state.js +1 -1
- package/dist/bridge/bridge-utils.js +30 -101
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +256 -211
- package/dist/bridge/wechat-forwarding.js +2 -58
- package/dist/bridge/wechat-outbound-queue.js +2 -0
- package/dist/channels/wechat/bridge-final-reply.js +65 -0
- package/dist/channels/wechat/channel-message.js +24 -0
- package/dist/channels/wechat/index.js +5 -0
- package/dist/channels/wechat/wechat-channel-port.js +67 -0
- package/dist/channels/wechat/wechat-forwarding.js +79 -0
- package/dist/channels/wechat/wechat-outbound-queue.js +72 -0
- package/dist/companion/codex-remote-client.js +291 -32
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +26 -0
- package/dist/companion/local-companion-start.js +0 -1
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/core/bridge-control.js +32 -0
- package/dist/core/bridge-event-forwarder.js +50 -0
- package/dist/core/bridge-message-router.js +32 -0
- package/dist/core/channel-types.js +1 -0
- package/dist/core/text-utils.js +63 -0
- package/dist/daemon/wechat-daemon.js +242 -159
- package/dist/i18n/messages-en.js +2 -2
- package/dist/i18n/messages-zh.js +2 -2
- package/package.json +2 -6
- package/bin/wechat-claude-start.mjs +0 -10
- package/bin/wechat-codex-start.mjs +0 -10
- package/bin/wechat-opencode-start.mjs +0 -10
- package/bin/wechat-pi-start.mjs +0 -10
|
@@ -1,16 +1,73 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
|
-
import { spawn as spawnChild } from "node:child_process";
|
|
5
|
-
import { detectCliApproval,
|
|
4
|
+
import { spawn as spawnChild, spawnSync } from "node:child_process";
|
|
5
|
+
import { detectCliApproval, } from "./bridge-utils.js";
|
|
6
|
+
import { normalizeOutput, nowIso, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
|
|
6
7
|
import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
|
|
7
8
|
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
8
9
|
import * as shared from "./bridge-adapters.shared.js";
|
|
10
|
+
import { requestCodexVisibleThreadSwitch } from "../companion/codex-visible-client-link.js";
|
|
9
11
|
import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
|
|
10
12
|
import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
|
|
13
|
+
export function parseCodexCliVersion(output) {
|
|
14
|
+
const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
|
|
15
|
+
return match?.[1] ?? null;
|
|
16
|
+
}
|
|
17
|
+
export function isCodexVersionInCompatibilityRange(version) {
|
|
18
|
+
const parts = version.split(".").map((part) => Number(part));
|
|
19
|
+
if (parts.length !== 3 || parts.some((part) => !Number.isInteger(part))) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const major = parts[0] ?? -1;
|
|
23
|
+
const minor = parts[1] ?? -1;
|
|
24
|
+
return major === 0 && minor >= 149 && minor <= 151;
|
|
25
|
+
}
|
|
11
26
|
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
27
|
const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
|
|
13
28
|
const CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS = 120_000;
|
|
29
|
+
const CODEX_RESUME_REPLAY_SETTLE_MS = 5_000;
|
|
30
|
+
function getCodexThreadStatusType(value) {
|
|
31
|
+
if (typeof value === "string") {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
return isRecord(value) && typeof value.type === "string" ? value.type : null;
|
|
35
|
+
}
|
|
36
|
+
function getCodexThreadTitle(thread) {
|
|
37
|
+
const name = typeof thread.name === "string" ? normalizeOutput(thread.name).trim() : "";
|
|
38
|
+
const preview = typeof thread.preview === "string" ? normalizeOutput(thread.preview).trim() : "";
|
|
39
|
+
const threadId = typeof thread.id === "string" ? thread.id : "";
|
|
40
|
+
return truncatePreview(name || preview || `Codex thread ${threadId.slice(0, 8)}`, 120);
|
|
41
|
+
}
|
|
42
|
+
export function buildCodexResumeCandidatesFromThreadList(response, cwd, limit) {
|
|
43
|
+
if (!isRecord(response) || !Array.isArray(response.data)) {
|
|
44
|
+
throw new Error("Codex returned an invalid thread/list response.");
|
|
45
|
+
}
|
|
46
|
+
const currentCwd = normalizeComparablePath(cwd);
|
|
47
|
+
return response.data
|
|
48
|
+
.filter((thread) => isRecord(thread))
|
|
49
|
+
.filter((thread) => typeof thread.id === "string")
|
|
50
|
+
.filter((thread) => {
|
|
51
|
+
const threadCwd = typeof thread.cwd === "string" ? thread.cwd : "";
|
|
52
|
+
return threadCwd && normalizeComparablePath(threadCwd) === currentCwd;
|
|
53
|
+
})
|
|
54
|
+
.filter((thread) => typeof thread.parentThreadId !== "string")
|
|
55
|
+
.map((thread) => {
|
|
56
|
+
const updatedAt = typeof thread.updatedAt === "number" && Number.isFinite(thread.updatedAt)
|
|
57
|
+
? thread.updatedAt * 1_000
|
|
58
|
+
: 0;
|
|
59
|
+
const threadId = thread.id;
|
|
60
|
+
return {
|
|
61
|
+
sessionId: threadId,
|
|
62
|
+
threadId,
|
|
63
|
+
title: getCodexThreadTitle(thread),
|
|
64
|
+
lastUpdatedAt: new Date(updatedAt).toISOString(),
|
|
65
|
+
source: "codex",
|
|
66
|
+
};
|
|
67
|
+
})
|
|
68
|
+
.sort((left, right) => Date.parse(right.lastUpdatedAt) - Date.parse(left.lastUpdatedAt))
|
|
69
|
+
.slice(0, Math.max(1, limit));
|
|
70
|
+
}
|
|
14
71
|
export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
15
72
|
runtimeKind = "codex_runtime_host";
|
|
16
73
|
appServer = null;
|
|
@@ -27,8 +84,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
27
84
|
rpcRequestCounter = 0;
|
|
28
85
|
pendingRpcRequests = new Map();
|
|
29
86
|
subscribedThreadIds = new Set();
|
|
87
|
+
pendingThreadSubscriptions = new Map();
|
|
30
88
|
sharedThreadId = null;
|
|
31
89
|
announcedThreadId = null;
|
|
90
|
+
localThreadFollowBlockedUntilMs = 0;
|
|
91
|
+
pendingThreadStatusChecks = new Map();
|
|
92
|
+
threadStatusCheckEpoch = 0;
|
|
32
93
|
pendingThreadAnnouncement = null;
|
|
33
94
|
activeTurn = null;
|
|
34
95
|
bridgeOwnedTurnIds = new Set();
|
|
@@ -37,6 +98,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
37
98
|
pendingTurnThreadId = null;
|
|
38
99
|
interruptPendingTurnStart = false;
|
|
39
100
|
pendingThreadFollowId = null;
|
|
101
|
+
pendingVisibleResume = null;
|
|
102
|
+
bridgeResumeReplayThreadId = null;
|
|
103
|
+
bridgeResumeReplayUntilMs = 0;
|
|
40
104
|
pendingApprovalRequests = [];
|
|
41
105
|
pendingUserInputRequest = null;
|
|
42
106
|
pendingUserInputTimer = null;
|
|
@@ -65,6 +129,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
65
129
|
finalReplyCompletionTimer = null;
|
|
66
130
|
finalReplyCompletionTurnId = null;
|
|
67
131
|
resumeThreadId;
|
|
132
|
+
codexCliVersion = null;
|
|
68
133
|
localClientInstanceId = `${process.pid}-${Date.now().toString(36)}`;
|
|
69
134
|
constructor(options) {
|
|
70
135
|
super(options);
|
|
@@ -83,6 +148,13 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
83
148
|
if (this.isHeadlessRuntimeMode()) {
|
|
84
149
|
this.setStatus("starting", `Starting ${this.options.kind} runtime host...`);
|
|
85
150
|
}
|
|
151
|
+
this.codexCliVersion = this.detectCodexCliVersion();
|
|
152
|
+
const compatibility = this.codexCliVersion
|
|
153
|
+
? isCodexVersionInCompatibilityRange(this.codexCliVersion)
|
|
154
|
+
? `compatible=${this.codexCliVersion}`
|
|
155
|
+
: `outside-supported-range=${this.codexCliVersion}`
|
|
156
|
+
: "unknown-version";
|
|
157
|
+
this.setStatus("starting", `Codex protocol compatibility: ${compatibility}.`);
|
|
86
158
|
await this.startAppServer();
|
|
87
159
|
await this.connectRpcClient();
|
|
88
160
|
await this.restoreInitialSharedThreadIfNeeded();
|
|
@@ -165,13 +237,16 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
165
237
|
this.writeToPty("\r");
|
|
166
238
|
}
|
|
167
239
|
async listResumeSessions(limit = 10) {
|
|
240
|
+
if (this.usesRpcTurnTransport()) {
|
|
241
|
+
return await this.listAppServerResumeSessions(limit);
|
|
242
|
+
}
|
|
168
243
|
return listCodexResumeSessions(this.options.cwd, limit);
|
|
169
244
|
}
|
|
170
245
|
async resumeSession(threadId) {
|
|
171
|
-
if (this.
|
|
172
|
-
throw new Error('WeChat /resume
|
|
246
|
+
if (!this.isHeadlessRuntimeMode()) {
|
|
247
|
+
throw new Error('WeChat /resume requires the managed "wechat-codex" visible client.');
|
|
173
248
|
}
|
|
174
|
-
await this.
|
|
249
|
+
await this.resumeVisibleSharedThread(threadId);
|
|
175
250
|
}
|
|
176
251
|
async interrupt() {
|
|
177
252
|
if (this.usesRpcTurnTransport()) {
|
|
@@ -292,6 +367,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
292
367
|
profile: this.options.profile,
|
|
293
368
|
sharedSessionId: this.state.sharedSessionId,
|
|
294
369
|
sharedThreadId: this.state.sharedThreadId,
|
|
370
|
+
codexVisibleThreadId: this.state.sharedThreadId,
|
|
295
371
|
resumeConversationId: this.state.resumeConversationId,
|
|
296
372
|
transcriptPath: this.state.transcriptPath,
|
|
297
373
|
startedAt: this.state.startedAt ?? nowIso(),
|
|
@@ -359,6 +435,24 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
359
435
|
}
|
|
360
436
|
return this.isNativePanelMode() ? Boolean(this.nativeProcess) : Boolean(this.pty);
|
|
361
437
|
}
|
|
438
|
+
detectCodexCliVersion() {
|
|
439
|
+
try {
|
|
440
|
+
const target = resolveSpawnTarget(this.options.command, "codex", {
|
|
441
|
+
forwardArgs: ["--version"],
|
|
442
|
+
});
|
|
443
|
+
const result = spawnSync(target.file, target.args, {
|
|
444
|
+
cwd: this.options.cwd,
|
|
445
|
+
env: this.buildEnv(),
|
|
446
|
+
encoding: "utf8",
|
|
447
|
+
timeout: 5_000,
|
|
448
|
+
windowsHide: true,
|
|
449
|
+
});
|
|
450
|
+
return parseCodexCliVersion(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
362
456
|
shouldPollSessionLog() {
|
|
363
457
|
return (this.isCodexClientRunning() ||
|
|
364
458
|
this.pendingTurnStart ||
|
|
@@ -610,7 +704,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
610
704
|
if (this.sessionFilePath && candidate.modifiedAtMs <= currentSessionModifiedAtMs) {
|
|
611
705
|
return;
|
|
612
706
|
}
|
|
613
|
-
if (!this.activeTurn ||
|
|
707
|
+
if (!this.activeTurn ||
|
|
708
|
+
this.activeTurn.threadId === candidate.threadId ||
|
|
709
|
+
this.activeTurn.origin === "wechat") {
|
|
614
710
|
this.trackLocalSharedThread(candidate.threadId, {
|
|
615
711
|
reason: "local_session_fallback",
|
|
616
712
|
signal: "session_fallback",
|
|
@@ -1286,13 +1382,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1286
1382
|
if (this.subscribedThreadIds.has(threadId)) {
|
|
1287
1383
|
return true;
|
|
1288
1384
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
return
|
|
1292
|
-
}
|
|
1293
|
-
catch {
|
|
1294
|
-
return false;
|
|
1385
|
+
const pending = this.pendingThreadSubscriptions.get(threadId);
|
|
1386
|
+
if (pending) {
|
|
1387
|
+
return await pending;
|
|
1295
1388
|
}
|
|
1389
|
+
const attempt = this.ensureSharedThreadSubscribed(threadId)
|
|
1390
|
+
.then(() => true)
|
|
1391
|
+
.catch(() => false)
|
|
1392
|
+
.finally(() => {
|
|
1393
|
+
this.pendingThreadSubscriptions.delete(threadId);
|
|
1394
|
+
});
|
|
1395
|
+
this.pendingThreadSubscriptions.set(threadId, attempt);
|
|
1396
|
+
return await attempt;
|
|
1296
1397
|
}
|
|
1297
1398
|
async ensureSharedThreadSubscribed(threadId) {
|
|
1298
1399
|
if (this.subscribedThreadIds.has(threadId)) {
|
|
@@ -1310,10 +1411,177 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1310
1411
|
if (!resumedThreadId) {
|
|
1311
1412
|
throw new Error("Codex did not return a thread id while subscribing the bridge client.");
|
|
1312
1413
|
}
|
|
1414
|
+
if (resumedThreadId !== threadId) {
|
|
1415
|
+
throw new Error(`Codex opened ${resumedThreadId}, expected ${threadId}.`);
|
|
1416
|
+
}
|
|
1417
|
+
this.validateCodexThreadWorkspaceIfPresent(response, threadId);
|
|
1313
1418
|
this.rememberBridgeOwnedThreadSignal(resumedThreadId);
|
|
1314
1419
|
this.subscribedThreadIds.add(resumedThreadId);
|
|
1315
|
-
|
|
1316
|
-
|
|
1420
|
+
}
|
|
1421
|
+
async listAppServerResumeSessions(limit) {
|
|
1422
|
+
const boundedLimit = Math.max(1, Math.min(100, limit));
|
|
1423
|
+
const baseParams = {
|
|
1424
|
+
limit: boundedLimit,
|
|
1425
|
+
sortKey: "updated_at",
|
|
1426
|
+
sortDirection: "desc",
|
|
1427
|
+
sourceKinds: ["cli", "vscode"],
|
|
1428
|
+
archived: false,
|
|
1429
|
+
cwd: this.options.cwd,
|
|
1430
|
+
};
|
|
1431
|
+
try {
|
|
1432
|
+
const fastResponse = await this.sendRpcRequest("thread/list", {
|
|
1433
|
+
...baseParams,
|
|
1434
|
+
useStateDbOnly: true,
|
|
1435
|
+
});
|
|
1436
|
+
const fastCandidates = buildCodexResumeCandidatesFromThreadList(fastResponse, this.options.cwd, boundedLimit);
|
|
1437
|
+
if (fastCandidates.length > 0) {
|
|
1438
|
+
return fastCandidates;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
catch {
|
|
1442
|
+
// Older supported app-server builds may not understand useStateDbOnly.
|
|
1443
|
+
// The scan-and-repair request below is also the authoritative fallback.
|
|
1444
|
+
}
|
|
1445
|
+
const response = await this.sendRpcRequest("thread/list", baseParams);
|
|
1446
|
+
return buildCodexResumeCandidatesFromThreadList(response, this.options.cwd, boundedLimit);
|
|
1447
|
+
}
|
|
1448
|
+
validateCodexResumeThread(response, targetThreadId) {
|
|
1449
|
+
if (!isRecord(response) || !isRecord(response.thread)) {
|
|
1450
|
+
throw new Error("Codex returned an invalid thread response.");
|
|
1451
|
+
}
|
|
1452
|
+
const thread = response.thread;
|
|
1453
|
+
if (thread.id !== targetThreadId) {
|
|
1454
|
+
throw new Error(`Codex opened ${String(thread.id)}, expected ${targetThreadId}.`);
|
|
1455
|
+
}
|
|
1456
|
+
if (typeof thread.cwd !== "string" ||
|
|
1457
|
+
normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
|
|
1458
|
+
throw new Error(`Codex thread ${targetThreadId} does not belong to ${this.options.cwd}.`);
|
|
1459
|
+
}
|
|
1460
|
+
if (typeof thread.parentThreadId === "string") {
|
|
1461
|
+
throw new Error(`Codex thread ${targetThreadId} is a subagent thread and cannot be resumed.`);
|
|
1462
|
+
}
|
|
1463
|
+
if (thread.ephemeral === true) {
|
|
1464
|
+
throw new Error(`Codex thread ${targetThreadId} is ephemeral and cannot be resumed.`);
|
|
1465
|
+
}
|
|
1466
|
+
if (getCodexThreadStatusType(thread.status) === "active") {
|
|
1467
|
+
throw new Error(`Codex thread ${targetThreadId} is still active. Stop its running task before resuming it from WeChat.`);
|
|
1468
|
+
}
|
|
1469
|
+
return thread;
|
|
1470
|
+
}
|
|
1471
|
+
validateCodexThreadWorkspaceIfPresent(response, targetThreadId) {
|
|
1472
|
+
if (!isRecord(response) || !isRecord(response.thread)) {
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
const thread = response.thread;
|
|
1476
|
+
if (typeof thread.id === "string" && thread.id !== targetThreadId) {
|
|
1477
|
+
throw new Error(`Codex opened ${thread.id}, expected ${targetThreadId}.`);
|
|
1478
|
+
}
|
|
1479
|
+
if (typeof thread.cwd === "string" &&
|
|
1480
|
+
normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
|
|
1481
|
+
throw new Error(`Codex thread ${targetThreadId} does not belong to ${this.options.cwd}.`);
|
|
1482
|
+
}
|
|
1483
|
+
if (typeof thread.parentThreadId === "string") {
|
|
1484
|
+
throw new Error(`Codex thread ${targetThreadId} is a subagent thread.`);
|
|
1485
|
+
}
|
|
1486
|
+
if (thread.ephemeral === true) {
|
|
1487
|
+
throw new Error(`Codex thread ${targetThreadId} is ephemeral.`);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
async resumeVisibleSharedThread(threadId) {
|
|
1491
|
+
const targetThreadId = threadId.trim();
|
|
1492
|
+
if (!targetThreadId) {
|
|
1493
|
+
throw new Error("A thread id is required to resume a Codex thread.");
|
|
1494
|
+
}
|
|
1495
|
+
if (this.pendingVisibleResume) {
|
|
1496
|
+
throw new Error("Codex is already switching visible threads.");
|
|
1497
|
+
}
|
|
1498
|
+
if (this.pendingApproval || this.pendingApprovalRequests.length > 0) {
|
|
1499
|
+
throw new Error("A Codex approval request is pending. Reply with /confirm or /deny.");
|
|
1500
|
+
}
|
|
1501
|
+
if (this.pendingUserInputRequest || this.state.pendingUserInput) {
|
|
1502
|
+
throw new Error("Codex is waiting for user input. Reply with /answer or use /stop.");
|
|
1503
|
+
}
|
|
1504
|
+
if (this.pendingTurnStart ||
|
|
1505
|
+
this.activeTurn ||
|
|
1506
|
+
this.state.status === "busy" ||
|
|
1507
|
+
this.state.status === "awaiting_approval" ||
|
|
1508
|
+
this.state.status === "awaiting_input") {
|
|
1509
|
+
throw new Error("codex is still working. Wait for the current reply or use /stop.");
|
|
1510
|
+
}
|
|
1511
|
+
if (this.state.status !== "idle") {
|
|
1512
|
+
throw new Error(`Codex cannot switch threads while its status is ${this.state.status}.`);
|
|
1513
|
+
}
|
|
1514
|
+
const previousThreadId = this.sharedThreadId;
|
|
1515
|
+
let targetSubscribed = false;
|
|
1516
|
+
this.pendingVisibleResume = { targetThreadId };
|
|
1517
|
+
this.setStatus("starting", `Codex resume validating thread ${targetThreadId.slice(0, 12)}...`);
|
|
1518
|
+
try {
|
|
1519
|
+
const readResponse = await this.sendRpcRequest("thread/read", {
|
|
1520
|
+
threadId: targetThreadId,
|
|
1521
|
+
includeTurns: false,
|
|
1522
|
+
});
|
|
1523
|
+
this.validateCodexResumeThread(readResponse, targetThreadId);
|
|
1524
|
+
this.setStatus("starting", `Codex resume validated thread ${targetThreadId.slice(0, 12)}.`);
|
|
1525
|
+
this.rememberBridgeOwnedThreadSignal(targetThreadId);
|
|
1526
|
+
this.setStatus("starting", `Codex resume requesting thread ${targetThreadId.slice(0, 12)}.`);
|
|
1527
|
+
const resumeResponse = await this.sendRpcRequest("thread/resume", {
|
|
1528
|
+
threadId: targetThreadId,
|
|
1529
|
+
approvalPolicy: "on-request",
|
|
1530
|
+
approvalsReviewer: "user",
|
|
1531
|
+
sandbox: "workspace-write",
|
|
1532
|
+
excludeTurns: true,
|
|
1533
|
+
});
|
|
1534
|
+
targetSubscribed = true;
|
|
1535
|
+
this.validateCodexResumeThread(resumeResponse, targetThreadId);
|
|
1536
|
+
this.subscribedThreadIds.add(targetThreadId);
|
|
1537
|
+
this.setStatus("starting", `Codex resume switching visible thread ${targetThreadId.slice(0, 12)}.`);
|
|
1538
|
+
const visibleThreadId = await this.switchVisibleCodexThread(targetThreadId);
|
|
1539
|
+
if (visibleThreadId !== targetThreadId) {
|
|
1540
|
+
throw new Error(`The visible Codex client opened ${visibleThreadId}, expected ${targetThreadId}.`);
|
|
1541
|
+
}
|
|
1542
|
+
this.sessionFilePath = null;
|
|
1543
|
+
this.sessionReadOffset = 0;
|
|
1544
|
+
this.sessionPartialLine = "";
|
|
1545
|
+
this.sessionFinalText = null;
|
|
1546
|
+
this.pendingThreadFollowId = null;
|
|
1547
|
+
this.updateSharedThread(targetThreadId, {
|
|
1548
|
+
source: "wechat",
|
|
1549
|
+
reason: "wechat_resume",
|
|
1550
|
+
notify: true,
|
|
1551
|
+
});
|
|
1552
|
+
this.bridgeResumeReplayThreadId = targetThreadId;
|
|
1553
|
+
this.bridgeResumeReplayUntilMs = Date.now() + CODEX_RESUME_REPLAY_SETTLE_MS;
|
|
1554
|
+
this.setStatus("starting", `Codex resume committed thread ${targetThreadId.slice(0, 12)}.`);
|
|
1555
|
+
this.setStatus("idle");
|
|
1556
|
+
if (previousThreadId && previousThreadId !== targetThreadId) {
|
|
1557
|
+
await this.unsubscribeCodexThreadBestEffort(previousThreadId);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
catch (error) {
|
|
1561
|
+
if (targetSubscribed && targetThreadId !== previousThreadId) {
|
|
1562
|
+
await this.unsubscribeCodexThreadBestEffort(targetThreadId);
|
|
1563
|
+
}
|
|
1564
|
+
this.setStatus("idle", `Codex resume failed for ${targetThreadId.slice(0, 12)}: ${describeUnknownError(error)}`);
|
|
1565
|
+
throw error;
|
|
1566
|
+
}
|
|
1567
|
+
finally {
|
|
1568
|
+
this.pendingVisibleResume = null;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
async switchVisibleCodexThread(threadId) {
|
|
1572
|
+
return await requestCodexVisibleThreadSwitch({
|
|
1573
|
+
cwd: this.options.cwd,
|
|
1574
|
+
instanceId: this.localClientInstanceId,
|
|
1575
|
+
threadId,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
async unsubscribeCodexThreadBestEffort(threadId) {
|
|
1579
|
+
try {
|
|
1580
|
+
await this.sendRpcRequest("thread/unsubscribe", { threadId });
|
|
1581
|
+
this.subscribedThreadIds.delete(threadId);
|
|
1582
|
+
}
|
|
1583
|
+
catch {
|
|
1584
|
+
// A failed unsubscribe must not undo an otherwise successful visible switch.
|
|
1317
1585
|
}
|
|
1318
1586
|
}
|
|
1319
1587
|
async resumeSharedThread(threadId, options = {}) {
|
|
@@ -1343,6 +1611,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1343
1611
|
if (!resumedThreadId) {
|
|
1344
1612
|
throw new Error("Codex did not return a thread id while resuming the saved thread.");
|
|
1345
1613
|
}
|
|
1614
|
+
this.validateCodexThreadWorkspaceIfPresent(response, resumedThreadId);
|
|
1346
1615
|
this.rememberBridgeOwnedThreadSignal(resumedThreadId);
|
|
1347
1616
|
this.subscribedThreadIds.add(resumedThreadId);
|
|
1348
1617
|
this.sessionFilePath = null;
|
|
@@ -1490,6 +1759,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1490
1759
|
this.completedTurnOrder = [];
|
|
1491
1760
|
}
|
|
1492
1761
|
this.pendingInjectedInputs = [];
|
|
1762
|
+
this.threadStatusCheckEpoch += 1;
|
|
1763
|
+
this.pendingThreadStatusChecks.clear();
|
|
1764
|
+
this.clearBridgeResumeReplay();
|
|
1493
1765
|
this.recentBridgeThreadSignalAtById.clear();
|
|
1494
1766
|
this.sessionFinalText = null;
|
|
1495
1767
|
this.nextSessionFallbackScanAtMs = 0;
|
|
@@ -1556,6 +1828,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1556
1828
|
});
|
|
1557
1829
|
}
|
|
1558
1830
|
}
|
|
1831
|
+
blockLateLocalThreadFollow() {
|
|
1832
|
+
this.localThreadFollowBlockedUntilMs =
|
|
1833
|
+
Date.now() + CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS;
|
|
1834
|
+
}
|
|
1559
1835
|
clearPendingThreadAnnouncement() {
|
|
1560
1836
|
if (!this.pendingThreadAnnouncement) {
|
|
1561
1837
|
return;
|
|
@@ -1614,12 +1890,29 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1614
1890
|
pending.timer.unref?.();
|
|
1615
1891
|
}
|
|
1616
1892
|
trackLocalSharedThread(threadId, options) {
|
|
1893
|
+
const weakSignal = options.signal === "status_changed" ||
|
|
1894
|
+
options.signal === "thread_started" ||
|
|
1895
|
+
options.signal === "session_fallback";
|
|
1896
|
+
if (weakSignal && Date.now() < this.localThreadFollowBlockedUntilMs) {
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
if (options.signal === "user_message" || options.signal === "turn_started") {
|
|
1900
|
+
this.localThreadFollowBlockedUntilMs = 0;
|
|
1901
|
+
}
|
|
1902
|
+
if (this.pendingVisibleResume) {
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
this.interruptWechatTurnForLocalThreadSwitch(threadId);
|
|
1617
1906
|
if (!this.isNativePanelMode()) {
|
|
1907
|
+
const threadChanged = this.sharedThreadId !== threadId;
|
|
1618
1908
|
this.updateSharedThread(threadId, {
|
|
1619
1909
|
source: "local",
|
|
1620
1910
|
reason: options.reason,
|
|
1621
1911
|
notify: true,
|
|
1622
1912
|
});
|
|
1913
|
+
if (threadChanged) {
|
|
1914
|
+
void this.tryEnsureSharedThreadSubscribed(threadId);
|
|
1915
|
+
}
|
|
1623
1916
|
return;
|
|
1624
1917
|
}
|
|
1625
1918
|
this.updateSharedThread(threadId, {
|
|
@@ -1655,6 +1948,34 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1655
1948
|
}
|
|
1656
1949
|
this.schedulePendingThreadAnnouncement();
|
|
1657
1950
|
}
|
|
1951
|
+
interruptWechatTurnForLocalThreadSwitch(nextThreadId) {
|
|
1952
|
+
const activeTurn = this.activeTurn;
|
|
1953
|
+
if (!activeTurn ||
|
|
1954
|
+
activeTurn.origin !== "wechat" ||
|
|
1955
|
+
activeTurn.threadId === nextThreadId) {
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
const interruptedTurnId = activeTurn.turnId;
|
|
1959
|
+
void this.sendRpcRequest("turn/interrupt", {
|
|
1960
|
+
threadId: activeTurn.threadId,
|
|
1961
|
+
turnId: interruptedTurnId,
|
|
1962
|
+
}).catch(() => undefined);
|
|
1963
|
+
this.clearInterruptTimer();
|
|
1964
|
+
this.clearFinalReplyCompletionTimerForTurn(interruptedTurnId);
|
|
1965
|
+
this.clearPendingApprovalState();
|
|
1966
|
+
this.clearPendingUserInputState();
|
|
1967
|
+
this.pendingThreadFollowId = null;
|
|
1968
|
+
this.cleanupTurnArtifacts(interruptedTurnId);
|
|
1969
|
+
this.rememberCompletedTurn(interruptedTurnId);
|
|
1970
|
+
this.bridgeOwnedTurnIds.add(interruptedTurnId);
|
|
1971
|
+
this.setActiveTurn(null);
|
|
1972
|
+
this.setStatus("idle");
|
|
1973
|
+
this.emit({
|
|
1974
|
+
type: "task_failed",
|
|
1975
|
+
message: "The active WeChat task was interrupted because the local Codex terminal switched threads.",
|
|
1976
|
+
timestamp: nowIso(),
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1658
1979
|
rememberBridgeOwnedThreadSignal(threadId) {
|
|
1659
1980
|
const cutoff = Date.now() - CODEX_THREAD_SIGNAL_TTL_MS;
|
|
1660
1981
|
for (const [candidateThreadId, recordedAtMs] of this.recentBridgeThreadSignalAtById.entries()) {
|
|
@@ -1675,6 +1996,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1675
1996
|
}
|
|
1676
1997
|
return true;
|
|
1677
1998
|
}
|
|
1999
|
+
isBridgeResumeReplay(threadId) {
|
|
2000
|
+
if (this.bridgeResumeReplayThreadId !== threadId) {
|
|
2001
|
+
return false;
|
|
2002
|
+
}
|
|
2003
|
+
if (Date.now() >= this.bridgeResumeReplayUntilMs) {
|
|
2004
|
+
this.clearBridgeResumeReplay();
|
|
2005
|
+
return false;
|
|
2006
|
+
}
|
|
2007
|
+
return true;
|
|
2008
|
+
}
|
|
2009
|
+
clearBridgeResumeReplay() {
|
|
2010
|
+
this.bridgeResumeReplayThreadId = null;
|
|
2011
|
+
this.bridgeResumeReplayUntilMs = 0;
|
|
2012
|
+
}
|
|
1678
2013
|
clearPendingApprovalState() {
|
|
1679
2014
|
this.pendingApprovalRequests = [];
|
|
1680
2015
|
this.pendingApproval = null;
|
|
@@ -2171,7 +2506,71 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2171
2506
|
if (!threadId) {
|
|
2172
2507
|
return;
|
|
2173
2508
|
}
|
|
2174
|
-
if (
|
|
2509
|
+
if (this.pendingVisibleResume) {
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
if (this.isBridgeResumeReplay(threadId)) {
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
const notificationCwd = typeof params.cwd === "string"
|
|
2516
|
+
? params.cwd
|
|
2517
|
+
: isRecord(params.thread) && typeof params.thread.cwd === "string"
|
|
2518
|
+
? params.thread.cwd
|
|
2519
|
+
: null;
|
|
2520
|
+
if (notificationCwd &&
|
|
2521
|
+
normalizeComparablePath(notificationCwd) !== normalizeComparablePath(this.options.cwd)) {
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (!this.isTrustedThreadStatus(threadId, Boolean(notificationCwd))) {
|
|
2525
|
+
this.queueThreadStatusCwdCheck(threadId);
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
this.applyThreadStatusChanged(threadId);
|
|
2529
|
+
}
|
|
2530
|
+
isTrustedThreadStatus(threadId, hasCwd) {
|
|
2531
|
+
if (hasCwd || this.isNativePanelMode()) {
|
|
2532
|
+
return true;
|
|
2533
|
+
}
|
|
2534
|
+
return (this.subscribedThreadIds.has(threadId) ||
|
|
2535
|
+
this.activeTurn?.threadId === threadId ||
|
|
2536
|
+
this.pendingTurnThreadId === threadId ||
|
|
2537
|
+
this.pendingVisibleResume?.targetThreadId === threadId);
|
|
2538
|
+
}
|
|
2539
|
+
queueThreadStatusCwdCheck(threadId) {
|
|
2540
|
+
if (this.pendingThreadStatusChecks.has(threadId)) {
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
const epoch = this.threadStatusCheckEpoch;
|
|
2544
|
+
const check = this.sendRpcRequest("thread/read", {
|
|
2545
|
+
threadId,
|
|
2546
|
+
includeTurns: false,
|
|
2547
|
+
})
|
|
2548
|
+
.then((response) => {
|
|
2549
|
+
if (epoch !== this.threadStatusCheckEpoch) {
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
if (!isRecord(response) || !isRecord(response.thread)) {
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
const thread = response.thread;
|
|
2556
|
+
if (thread.id === threadId &&
|
|
2557
|
+
typeof thread.cwd === "string" &&
|
|
2558
|
+
normalizeComparablePath(thread.cwd) === normalizeComparablePath(this.options.cwd) &&
|
|
2559
|
+
typeof thread.parentThreadId !== "string" &&
|
|
2560
|
+
thread.ephemeral !== true) {
|
|
2561
|
+
this.applyThreadStatusChanged(threadId);
|
|
2562
|
+
}
|
|
2563
|
+
})
|
|
2564
|
+
.catch(() => undefined)
|
|
2565
|
+
.finally(() => {
|
|
2566
|
+
this.pendingThreadStatusChecks.delete(threadId);
|
|
2567
|
+
});
|
|
2568
|
+
this.pendingThreadStatusChecks.set(threadId, check);
|
|
2569
|
+
}
|
|
2570
|
+
applyThreadStatusChanged(threadId) {
|
|
2571
|
+
if (!this.activeTurn ||
|
|
2572
|
+
this.activeTurn.threadId === threadId ||
|
|
2573
|
+
this.activeTurn.origin === "wechat") {
|
|
2175
2574
|
this.trackLocalSharedThread(threadId, {
|
|
2176
2575
|
reason: "local_follow",
|
|
2177
2576
|
signal: "status_changed",
|
|
@@ -2186,16 +2585,24 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2186
2585
|
if (!threadId) {
|
|
2187
2586
|
return;
|
|
2188
2587
|
}
|
|
2588
|
+
if (this.isBridgeResumeReplay(threadId)) {
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2189
2591
|
if (this.isRecentlyBridgeOwnedThread(threadId)) {
|
|
2190
2592
|
return;
|
|
2191
2593
|
}
|
|
2192
2594
|
const thread = isRecord(params.thread) ? params.thread : null;
|
|
2193
|
-
if (thread
|
|
2194
|
-
if (
|
|
2595
|
+
if (thread) {
|
|
2596
|
+
if ((typeof thread.cwd === "string" &&
|
|
2597
|
+
normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) ||
|
|
2598
|
+
typeof thread.parentThreadId === "string" ||
|
|
2599
|
+
thread.ephemeral === true) {
|
|
2195
2600
|
return;
|
|
2196
2601
|
}
|
|
2197
2602
|
}
|
|
2198
|
-
if (!this.activeTurn ||
|
|
2603
|
+
if (!this.activeTurn ||
|
|
2604
|
+
this.activeTurn.threadId === threadId ||
|
|
2605
|
+
this.activeTurn.origin === "wechat") {
|
|
2199
2606
|
this.trackLocalSharedThread(threadId, {
|
|
2200
2607
|
reason: "local_follow",
|
|
2201
2608
|
signal: "thread_started",
|
|
@@ -2209,9 +2616,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2209
2616
|
if (this.activeTurn?.turnId === trackedTurn.turnId) {
|
|
2210
2617
|
return;
|
|
2211
2618
|
}
|
|
2619
|
+
if (trackedTurn.origin === "local") {
|
|
2620
|
+
this.clearBridgeResumeReplay();
|
|
2621
|
+
}
|
|
2212
2622
|
if (trackedTurn.origin === "local" &&
|
|
2213
2623
|
trackedTurn.threadId !== this.sharedThreadId) {
|
|
2214
|
-
if (!this.activeTurn ||
|
|
2624
|
+
if (!this.activeTurn ||
|
|
2625
|
+
this.activeTurn.threadId === trackedTurn.threadId ||
|
|
2626
|
+
this.activeTurn.origin === "wechat") {
|
|
2215
2627
|
this.trackLocalSharedThread(trackedTurn.threadId, {
|
|
2216
2628
|
reason: "local_turn",
|
|
2217
2629
|
signal: "turn_started",
|
|
@@ -2285,6 +2697,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2285
2697
|
if (this.activeTurn?.turnId === trackedTurn.turnId) {
|
|
2286
2698
|
this.setActiveTurn(null);
|
|
2287
2699
|
}
|
|
2700
|
+
if (completedTrackedTurn.origin === "wechat") {
|
|
2701
|
+
this.blockLateLocalThreadFollow();
|
|
2702
|
+
}
|
|
2288
2703
|
this.cleanupTurnArtifacts(trackedTurn.turnId);
|
|
2289
2704
|
if (this.state.status !== "stopped" &&
|
|
2290
2705
|
(!this.activeTurn || this.activeTurn.turnId === trackedTurn.turnId)) {
|
|
@@ -2424,6 +2839,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2424
2839
|
if (!activeTurn || !finalText) {
|
|
2425
2840
|
return;
|
|
2426
2841
|
}
|
|
2842
|
+
if (activeTurn.origin === "wechat") {
|
|
2843
|
+
this.blockLateLocalThreadFollow();
|
|
2844
|
+
}
|
|
2427
2845
|
this.clearPendingApprovalState();
|
|
2428
2846
|
this.clearPendingUserInputState();
|
|
2429
2847
|
this.setActiveTurn(null);
|
|
@@ -2484,11 +2902,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2484
2902
|
this.appServerAuthToken = null;
|
|
2485
2903
|
}
|
|
2486
2904
|
describeAppServerLog() {
|
|
2487
|
-
const
|
|
2488
|
-
if (!
|
|
2905
|
+
const normalized = normalizeOutput(this.appServerLog).trim();
|
|
2906
|
+
if (!normalized) {
|
|
2489
2907
|
return "";
|
|
2490
2908
|
}
|
|
2491
|
-
|
|
2909
|
+
const summary = summarizeOutput(normalized, 500);
|
|
2910
|
+
return ` Recent app-server log: ${summary}`;
|
|
2492
2911
|
}
|
|
2493
2912
|
terminateCodexClient() {
|
|
2494
2913
|
this.shuttingDown = true;
|
|
@@ -3,7 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { spawn as spawnPty } from "node-pty";
|
|
4
4
|
import { t } from "../i18n/index.js";
|
|
5
5
|
import { attachLocalCompanionMessageListener, buildLocalCompanionToken, clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, sendLocalCompanionMessage, updateLocalCompanionOccupancy, writeLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
|
|
6
|
-
import { detectCliApproval,
|
|
6
|
+
import { detectCliApproval, } from "./bridge-utils.js";
|
|
7
|
+
import { normalizeOutput, nowIso, truncatePreview } from "../core/text-utils.js";
|
|
7
8
|
import * as shared from "./bridge-adapters.shared.js";
|
|
8
9
|
import { LOCAL_CLIENT_PROTOCOL_VERSION } from "../runtime/runtime-types.js";
|
|
9
10
|
const { CODEX_APP_SERVER_HOST, INTERRUPT_SETTLE_DELAY_MS, buildCliEnvironment, buildPtySpawnOptions, buildSpawnDiagnostic, getLocalCompanionCommandName, getSharedSessionIdFromAdapterState, LOCAL_COMPANION_RECONNECT_GRACE_MS, resolveSpawnTarget, spawnFallbackProcess, } = shared;
|