@unlinearity/cli-wechat-bridge 1.1.3 → 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.
- package/README.md +110 -140
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi.mjs +1 -1
- package/dist/bridge/bridge-adapters.claude.js +448 -11
- package/dist/bridge/bridge-adapters.codex.js +262 -11
- package/dist/bridge/bridge-adapters.core.js +76 -4
- package/dist/bridge/bridge-adapters.js +0 -3
- package/dist/bridge/bridge-adapters.opencode.js +497 -49
- package/dist/bridge/bridge-adapters.pi.js +44 -8
- package/dist/bridge/bridge-adapters.shared.js +13 -100
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-state.js +41 -6
- package/dist/bridge/bridge-utils.js +63 -229
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +93 -140
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +309 -36
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +33 -2
- package/dist/companion/local-companion-start.js +44 -13
- package/dist/companion/local-companion.js +6 -3
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +168 -56
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +2 -3
- package/dist/i18n/messages-zh.js +2 -3
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +23 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -11
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +5 -12
- package/scripts/ensure-node-pty-permissions.mjs +73 -0
- package/bin/wechat-bridge-claude.mjs +0 -5
- package/bin/wechat-bridge-codex.mjs +0 -5
- package/bin/wechat-bridge-opencode.mjs +0 -5
- package/bin/wechat-bridge-pi.mjs +0 -5
- package/bin/wechat-bridge-shell.mjs +0 -5
- package/bin/wechat-bridge.mjs +0 -5
- package/bin/wechat-claude-start.mjs +0 -5
- package/bin/wechat-codex-start.mjs +0 -5
- package/bin/wechat-opencode-start.mjs +0 -5
- package/bin/wechat-pi-start.mjs +0 -5
- package/dist/bridge/bridge-adapters.shell.js +0 -363
|
@@ -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.
|
|
172
|
-
throw new Error('WeChat /resume
|
|
217
|
+
if (!this.isHeadlessRuntimeMode()) {
|
|
218
|
+
throw new Error('WeChat /resume requires the managed "wechat-codex" visible client.');
|
|
173
219
|
}
|
|
174
|
-
await this.
|
|
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 ||
|
|
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",
|
|
@@ -1186,6 +1235,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1186
1235
|
Date.now() < reconnectDeadline) {
|
|
1187
1236
|
try {
|
|
1188
1237
|
await this.connectRpcClient();
|
|
1238
|
+
// The new connection starts with an empty subscription set.
|
|
1239
|
+
// Resubscribe the shared thread so turn notifications and approval
|
|
1240
|
+
// requests keep flowing: without this, a mid-turn reconnect
|
|
1241
|
+
// silently stops delivery, and approvals answered over the new
|
|
1242
|
+
// connection (with the old request id) are ignored by the server.
|
|
1243
|
+
if (this.state.sharedThreadId) {
|
|
1244
|
+
await this.tryEnsureSharedThreadSubscribed(this.state.sharedThreadId);
|
|
1245
|
+
}
|
|
1189
1246
|
return true;
|
|
1190
1247
|
}
|
|
1191
1248
|
catch (error) {
|
|
@@ -1308,6 +1365,147 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1308
1365
|
this.updateSharedThread(resumedThreadId);
|
|
1309
1366
|
}
|
|
1310
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
|
+
}
|
|
1311
1509
|
async resumeSharedThread(threadId, options = {}) {
|
|
1312
1510
|
const trimmedThreadId = threadId.trim();
|
|
1313
1511
|
if (!trimmedThreadId) {
|
|
@@ -1394,7 +1592,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1394
1592
|
this.state.status !== "awaiting_input") {
|
|
1395
1593
|
return;
|
|
1396
1594
|
}
|
|
1397
|
-
|
|
1595
|
+
// Remember the interrupted turn as completed before resetting: the
|
|
1596
|
+
// server may still deliver a late turn/completed for it, and without
|
|
1597
|
+
// this entry it would be tracked as a brand-new local turn — emitting a
|
|
1598
|
+
// duplicate task_complete (or a ghost reply) for work we already
|
|
1599
|
+
// reported as interrupted.
|
|
1600
|
+
const interruptedTurnId = this.activeTurn?.turnId;
|
|
1601
|
+
this.resetTurnTracking({
|
|
1602
|
+
preserveThread: true,
|
|
1603
|
+
preserveCompletedTurns: true,
|
|
1604
|
+
});
|
|
1605
|
+
if (interruptedTurnId) {
|
|
1606
|
+
this.rememberCompletedTurn(interruptedTurnId);
|
|
1607
|
+
this.bridgeOwnedTurnIds.add(interruptedTurnId);
|
|
1608
|
+
}
|
|
1398
1609
|
this.setStatus("idle", "Codex task interrupted.");
|
|
1399
1610
|
this.emit({
|
|
1400
1611
|
type: "task_complete",
|
|
@@ -1463,9 +1674,11 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1463
1674
|
this.turnErrorById.clear();
|
|
1464
1675
|
this.turnLastActivityAtMs.clear();
|
|
1465
1676
|
this.mirroredUserInputTurnIds.clear();
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1677
|
+
if (!options.preserveCompletedTurns) {
|
|
1678
|
+
this.bridgeOwnedTurnIds.clear();
|
|
1679
|
+
this.completedTurnIds.clear();
|
|
1680
|
+
this.completedTurnOrder = [];
|
|
1681
|
+
}
|
|
1469
1682
|
this.pendingInjectedInputs = [];
|
|
1470
1683
|
this.recentBridgeThreadSignalAtById.clear();
|
|
1471
1684
|
this.sessionFinalText = null;
|
|
@@ -1591,6 +1804,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1591
1804
|
pending.timer.unref?.();
|
|
1592
1805
|
}
|
|
1593
1806
|
trackLocalSharedThread(threadId, options) {
|
|
1807
|
+
if (this.pendingVisibleResume) {
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
this.interruptWechatTurnForLocalThreadSwitch(threadId);
|
|
1594
1811
|
if (!this.isNativePanelMode()) {
|
|
1595
1812
|
this.updateSharedThread(threadId, {
|
|
1596
1813
|
source: "local",
|
|
@@ -1632,6 +1849,34 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1632
1849
|
}
|
|
1633
1850
|
this.schedulePendingThreadAnnouncement();
|
|
1634
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
|
+
}
|
|
1635
1880
|
rememberBridgeOwnedThreadSignal(threadId) {
|
|
1636
1881
|
const cutoff = Date.now() - CODEX_THREAD_SIGNAL_TTL_MS;
|
|
1637
1882
|
for (const [candidateThreadId, recordedAtMs] of this.recentBridgeThreadSignalAtById.entries()) {
|
|
@@ -2148,7 +2393,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2148
2393
|
if (!threadId) {
|
|
2149
2394
|
return;
|
|
2150
2395
|
}
|
|
2151
|
-
if (!this.activeTurn ||
|
|
2396
|
+
if (!this.activeTurn ||
|
|
2397
|
+
this.activeTurn.threadId === threadId ||
|
|
2398
|
+
this.activeTurn.origin === "wechat") {
|
|
2152
2399
|
this.trackLocalSharedThread(threadId, {
|
|
2153
2400
|
reason: "local_follow",
|
|
2154
2401
|
signal: "status_changed",
|
|
@@ -2172,7 +2419,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2172
2419
|
return;
|
|
2173
2420
|
}
|
|
2174
2421
|
}
|
|
2175
|
-
if (!this.activeTurn ||
|
|
2422
|
+
if (!this.activeTurn ||
|
|
2423
|
+
this.activeTurn.threadId === threadId ||
|
|
2424
|
+
this.activeTurn.origin === "wechat") {
|
|
2176
2425
|
this.trackLocalSharedThread(threadId, {
|
|
2177
2426
|
reason: "local_follow",
|
|
2178
2427
|
signal: "thread_started",
|
|
@@ -2188,7 +2437,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2188
2437
|
}
|
|
2189
2438
|
if (trackedTurn.origin === "local" &&
|
|
2190
2439
|
trackedTurn.threadId !== this.sharedThreadId) {
|
|
2191
|
-
if (!this.activeTurn ||
|
|
2440
|
+
if (!this.activeTurn ||
|
|
2441
|
+
this.activeTurn.threadId === trackedTurn.threadId ||
|
|
2442
|
+
this.activeTurn.origin === "wechat") {
|
|
2192
2443
|
this.trackLocalSharedThread(trackedTurn.threadId, {
|
|
2193
2444
|
reason: "local_turn",
|
|
2194
2445
|
signal: "turn_started",
|
|
@@ -45,6 +45,8 @@ export class LocalCompanionProxyAdapter {
|
|
|
45
45
|
eventSink = () => undefined;
|
|
46
46
|
server = null;
|
|
47
47
|
socket = null;
|
|
48
|
+
// Pending senders waiting for the companion's first state frame.
|
|
49
|
+
readyWaiters = new Set();
|
|
48
50
|
detachMessageListener = null;
|
|
49
51
|
requestCounter = 0;
|
|
50
52
|
endpoint = null;
|
|
@@ -99,11 +101,22 @@ export class LocalCompanionProxyAdapter {
|
|
|
99
101
|
});
|
|
100
102
|
this.server = server;
|
|
101
103
|
server.on("error", (error) => {
|
|
104
|
+
// Roll back the half-initialized server: start() early-returns while
|
|
105
|
+
// this.server is set, so a leftover handle would make every retry
|
|
106
|
+
// silently "succeed" without ever listening.
|
|
107
|
+
if (this.server === server) {
|
|
108
|
+
this.server = null;
|
|
109
|
+
}
|
|
110
|
+
server.close();
|
|
102
111
|
reject(error);
|
|
103
112
|
});
|
|
104
113
|
server.listen(0, CODEX_APP_SERVER_HOST, () => {
|
|
105
114
|
const address = server.address();
|
|
106
115
|
if (!address || typeof address === "string") {
|
|
116
|
+
if (this.server === server) {
|
|
117
|
+
this.server = null;
|
|
118
|
+
}
|
|
119
|
+
server.close();
|
|
107
120
|
reject(new Error(`Failed to allocate a local ${this.options.kind} companion port.`));
|
|
108
121
|
return;
|
|
109
122
|
}
|
|
@@ -174,8 +187,17 @@ export class LocalCompanionProxyAdapter {
|
|
|
174
187
|
const ok = await this.resolveApproval(action);
|
|
175
188
|
return ok ? 1 : 0;
|
|
176
189
|
}
|
|
177
|
-
async submitUserInput(
|
|
178
|
-
|
|
190
|
+
async submitUserInput(answers) {
|
|
191
|
+
try {
|
|
192
|
+
const result = await this.sendRequest({
|
|
193
|
+
command: "submit_user_input",
|
|
194
|
+
answers,
|
|
195
|
+
});
|
|
196
|
+
return result === true;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
179
201
|
}
|
|
180
202
|
async dispose() {
|
|
181
203
|
this.shuttingDown = true;
|
|
@@ -310,6 +332,9 @@ export class LocalCompanionProxyAdapter {
|
|
|
310
332
|
this.state.activeTurnOrigin = undefined;
|
|
311
333
|
this.state.pendingApprovalOrigin = undefined;
|
|
312
334
|
Object.assign(this.state, message.state);
|
|
335
|
+
if (this.state.pid) {
|
|
336
|
+
this.settleReadyWaiters();
|
|
337
|
+
}
|
|
313
338
|
this.eventSink({
|
|
314
339
|
type: "status",
|
|
315
340
|
status: this.state.status,
|
|
@@ -332,6 +357,10 @@ export class LocalCompanionProxyAdapter {
|
|
|
332
357
|
}
|
|
333
358
|
}
|
|
334
359
|
detachPanelSocket() {
|
|
360
|
+
this.settleReadyWaiters(new Error(formatCompanionNotConnectedMessage({
|
|
361
|
+
kind: this.options.kind,
|
|
362
|
+
launchMode: this.options.companionLaunchMode,
|
|
363
|
+
})));
|
|
335
364
|
this.rejectPendingRequests("Companion socket disconnected unexpectedly.");
|
|
336
365
|
this.detachMessageListener?.();
|
|
337
366
|
this.detachMessageListener = null;
|
|
@@ -420,8 +449,44 @@ export class LocalCompanionProxyAdapter {
|
|
|
420
449
|
}
|
|
421
450
|
this.pendingRequests.clear();
|
|
422
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Wait for the companion's first state frame (which reports the worker
|
|
454
|
+
* pid). The companion sends it only after its adapter finished starting —
|
|
455
|
+
* for opencode that spans spawning `opencode serve`, connecting, and the
|
|
456
|
+
* initial session scan — so a WeChat message arriving inside that window
|
|
457
|
+
* must wait instead of failing with "connected but not ready yet".
|
|
458
|
+
*/
|
|
459
|
+
waitForCompanionReady() {
|
|
460
|
+
if (this.state.pid) {
|
|
461
|
+
return Promise.resolve();
|
|
462
|
+
}
|
|
463
|
+
return new Promise((resolve, reject) => {
|
|
464
|
+
const entry = {
|
|
465
|
+
timer: setTimeout(() => {
|
|
466
|
+
this.readyWaiters.delete(entry);
|
|
467
|
+
reject(new Error(`${this.options.kind} companion is connected but not ready yet. Wait for it to finish starting.`));
|
|
468
|
+
}, 30_000),
|
|
469
|
+
settle: (error) => {
|
|
470
|
+
clearTimeout(entry.timer);
|
|
471
|
+
this.readyWaiters.delete(entry);
|
|
472
|
+
if (error) {
|
|
473
|
+
reject(error);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
resolve();
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
this.readyWaiters.add(entry);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
settleReadyWaiters(error) {
|
|
484
|
+
for (const entry of [...this.readyWaiters]) {
|
|
485
|
+
entry.settle(error);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
423
488
|
async sendRequest(payload) {
|
|
424
|
-
|
|
489
|
+
let socket = this.socket;
|
|
425
490
|
if (!socket) {
|
|
426
491
|
throw new Error(formatCompanionNotConnectedMessage({
|
|
427
492
|
kind: this.options.kind,
|
|
@@ -429,7 +494,14 @@ export class LocalCompanionProxyAdapter {
|
|
|
429
494
|
}));
|
|
430
495
|
}
|
|
431
496
|
if (!this.state.pid && payload.command !== "dispose") {
|
|
432
|
-
|
|
497
|
+
await this.waitForCompanionReady();
|
|
498
|
+
socket = this.socket;
|
|
499
|
+
if (!socket) {
|
|
500
|
+
throw new Error(formatCompanionNotConnectedMessage({
|
|
501
|
+
kind: this.options.kind,
|
|
502
|
+
launchMode: this.options.companionLaunchMode,
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
433
505
|
}
|
|
434
506
|
const id = `${++this.requestCounter}`;
|
|
435
507
|
const response = new Promise((resolve, reject) => {
|
|
@@ -3,7 +3,6 @@ import { LocalCompanionProxyAdapter } from "./bridge-adapters.core.js";
|
|
|
3
3
|
import { CodexPtyAdapter } from "./bridge-adapters.codex.js";
|
|
4
4
|
import { OpenCodeServerAdapter } from "./bridge-adapters.opencode.js";
|
|
5
5
|
import { PiTuiAdapter } from "./bridge-adapters.pi.js";
|
|
6
|
-
import { ShellAdapter } from "./bridge-adapters.shell.js";
|
|
7
6
|
export * from "./bridge-adapters.shared.js";
|
|
8
7
|
export function createBridgeAdapter(options) {
|
|
9
8
|
switch (options.kind) {
|
|
@@ -23,8 +22,6 @@ export function createBridgeAdapter(options) {
|
|
|
23
22
|
return options.renderMode === "companion"
|
|
24
23
|
? new PiTuiAdapter(options)
|
|
25
24
|
: new LocalCompanionProxyAdapter(options);
|
|
26
|
-
case "shell":
|
|
27
|
-
return new ShellAdapter(options);
|
|
28
25
|
default:
|
|
29
26
|
throw new Error(`Unsupported adapter: ${options.kind}`);
|
|
30
27
|
}
|