@unlinearity/cli-wechat-bridge 1.1.5 → 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 +157 -142
- package/bin/_run-entry.mjs +20 -5
- package/dist/bridge/bridge-adapters.claude.js +2 -1
- package/dist/bridge/bridge-adapters.codex.js +214 -23
- package/dist/bridge/bridge-adapters.core.js +2 -1
- package/dist/bridge/bridge-adapters.pi.js +1 -1
- package/dist/bridge/bridge-adapters.shared.js +2 -1
- package/dist/bridge/bridge-final-reply.js +2 -65
- package/dist/bridge/bridge-state.js +1 -1
- package/dist/bridge/bridge-utils.js +4 -96
- package/dist/bridge/wechat-bridge.js +226 -189
- 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/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 +214 -149
- package/dist/i18n/messages-en.js +2 -2
- package/dist/i18n/messages-zh.js +2 -2
- package/package.json +2 -2
|
@@ -1,17 +1,32 @@
|
|
|
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";
|
|
9
10
|
import { requestCodexVisibleThreadSwitch } from "../companion/codex-visible-client-link.js";
|
|
10
11
|
import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
|
|
11
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
|
+
}
|
|
12
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;
|
|
13
27
|
const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
|
|
14
28
|
const CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS = 120_000;
|
|
29
|
+
const CODEX_RESUME_REPLAY_SETTLE_MS = 5_000;
|
|
15
30
|
function getCodexThreadStatusType(value) {
|
|
16
31
|
if (typeof value === "string") {
|
|
17
32
|
return value;
|
|
@@ -69,8 +84,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
69
84
|
rpcRequestCounter = 0;
|
|
70
85
|
pendingRpcRequests = new Map();
|
|
71
86
|
subscribedThreadIds = new Set();
|
|
87
|
+
pendingThreadSubscriptions = new Map();
|
|
72
88
|
sharedThreadId = null;
|
|
73
89
|
announcedThreadId = null;
|
|
90
|
+
localThreadFollowBlockedUntilMs = 0;
|
|
91
|
+
pendingThreadStatusChecks = new Map();
|
|
92
|
+
threadStatusCheckEpoch = 0;
|
|
74
93
|
pendingThreadAnnouncement = null;
|
|
75
94
|
activeTurn = null;
|
|
76
95
|
bridgeOwnedTurnIds = new Set();
|
|
@@ -80,6 +99,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
80
99
|
interruptPendingTurnStart = false;
|
|
81
100
|
pendingThreadFollowId = null;
|
|
82
101
|
pendingVisibleResume = null;
|
|
102
|
+
bridgeResumeReplayThreadId = null;
|
|
103
|
+
bridgeResumeReplayUntilMs = 0;
|
|
83
104
|
pendingApprovalRequests = [];
|
|
84
105
|
pendingUserInputRequest = null;
|
|
85
106
|
pendingUserInputTimer = null;
|
|
@@ -108,6 +129,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
108
129
|
finalReplyCompletionTimer = null;
|
|
109
130
|
finalReplyCompletionTurnId = null;
|
|
110
131
|
resumeThreadId;
|
|
132
|
+
codexCliVersion = null;
|
|
111
133
|
localClientInstanceId = `${process.pid}-${Date.now().toString(36)}`;
|
|
112
134
|
constructor(options) {
|
|
113
135
|
super(options);
|
|
@@ -126,6 +148,13 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
126
148
|
if (this.isHeadlessRuntimeMode()) {
|
|
127
149
|
this.setStatus("starting", `Starting ${this.options.kind} runtime host...`);
|
|
128
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}.`);
|
|
129
158
|
await this.startAppServer();
|
|
130
159
|
await this.connectRpcClient();
|
|
131
160
|
await this.restoreInitialSharedThreadIfNeeded();
|
|
@@ -406,6 +435,24 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
406
435
|
}
|
|
407
436
|
return this.isNativePanelMode() ? Boolean(this.nativeProcess) : Boolean(this.pty);
|
|
408
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
|
+
}
|
|
409
456
|
shouldPollSessionLog() {
|
|
410
457
|
return (this.isCodexClientRunning() ||
|
|
411
458
|
this.pendingTurnStart ||
|
|
@@ -1335,13 +1382,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1335
1382
|
if (this.subscribedThreadIds.has(threadId)) {
|
|
1336
1383
|
return true;
|
|
1337
1384
|
}
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
return
|
|
1341
|
-
}
|
|
1342
|
-
catch {
|
|
1343
|
-
return false;
|
|
1385
|
+
const pending = this.pendingThreadSubscriptions.get(threadId);
|
|
1386
|
+
if (pending) {
|
|
1387
|
+
return await pending;
|
|
1344
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;
|
|
1345
1397
|
}
|
|
1346
1398
|
async ensureSharedThreadSubscribed(threadId) {
|
|
1347
1399
|
if (this.subscribedThreadIds.has(threadId)) {
|
|
@@ -1359,11 +1411,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1359
1411
|
if (!resumedThreadId) {
|
|
1360
1412
|
throw new Error("Codex did not return a thread id while subscribing the bridge client.");
|
|
1361
1413
|
}
|
|
1414
|
+
if (resumedThreadId !== threadId) {
|
|
1415
|
+
throw new Error(`Codex opened ${resumedThreadId}, expected ${threadId}.`);
|
|
1416
|
+
}
|
|
1417
|
+
this.validateCodexThreadWorkspaceIfPresent(response, threadId);
|
|
1362
1418
|
this.rememberBridgeOwnedThreadSignal(resumedThreadId);
|
|
1363
1419
|
this.subscribedThreadIds.add(resumedThreadId);
|
|
1364
|
-
if (resumedThreadId !== this.sharedThreadId) {
|
|
1365
|
-
this.updateSharedThread(resumedThreadId);
|
|
1366
|
-
}
|
|
1367
1420
|
}
|
|
1368
1421
|
async listAppServerResumeSessions(limit) {
|
|
1369
1422
|
const boundedLimit = Math.max(1, Math.min(100, limit));
|
|
@@ -1415,6 +1468,25 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1415
1468
|
}
|
|
1416
1469
|
return thread;
|
|
1417
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
|
+
}
|
|
1418
1490
|
async resumeVisibleSharedThread(threadId) {
|
|
1419
1491
|
const targetThreadId = threadId.trim();
|
|
1420
1492
|
if (!targetThreadId) {
|
|
@@ -1439,17 +1511,19 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1439
1511
|
if (this.state.status !== "idle") {
|
|
1440
1512
|
throw new Error(`Codex cannot switch threads while its status is ${this.state.status}.`);
|
|
1441
1513
|
}
|
|
1442
|
-
const readResponse = await this.sendRpcRequest("thread/read", {
|
|
1443
|
-
threadId: targetThreadId,
|
|
1444
|
-
includeTurns: false,
|
|
1445
|
-
});
|
|
1446
|
-
this.validateCodexResumeThread(readResponse, targetThreadId);
|
|
1447
1514
|
const previousThreadId = this.sharedThreadId;
|
|
1448
1515
|
let targetSubscribed = false;
|
|
1449
1516
|
this.pendingVisibleResume = { targetThreadId };
|
|
1450
|
-
this.setStatus("starting", `
|
|
1517
|
+
this.setStatus("starting", `Codex resume validating thread ${targetThreadId.slice(0, 12)}...`);
|
|
1451
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)}.`);
|
|
1452
1525
|
this.rememberBridgeOwnedThreadSignal(targetThreadId);
|
|
1526
|
+
this.setStatus("starting", `Codex resume requesting thread ${targetThreadId.slice(0, 12)}.`);
|
|
1453
1527
|
const resumeResponse = await this.sendRpcRequest("thread/resume", {
|
|
1454
1528
|
threadId: targetThreadId,
|
|
1455
1529
|
approvalPolicy: "on-request",
|
|
@@ -1460,6 +1534,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1460
1534
|
targetSubscribed = true;
|
|
1461
1535
|
this.validateCodexResumeThread(resumeResponse, targetThreadId);
|
|
1462
1536
|
this.subscribedThreadIds.add(targetThreadId);
|
|
1537
|
+
this.setStatus("starting", `Codex resume switching visible thread ${targetThreadId.slice(0, 12)}.`);
|
|
1463
1538
|
const visibleThreadId = await this.switchVisibleCodexThread(targetThreadId);
|
|
1464
1539
|
if (visibleThreadId !== targetThreadId) {
|
|
1465
1540
|
throw new Error(`The visible Codex client opened ${visibleThreadId}, expected ${targetThreadId}.`);
|
|
@@ -1474,6 +1549,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1474
1549
|
reason: "wechat_resume",
|
|
1475
1550
|
notify: true,
|
|
1476
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)}.`);
|
|
1477
1555
|
this.setStatus("idle");
|
|
1478
1556
|
if (previousThreadId && previousThreadId !== targetThreadId) {
|
|
1479
1557
|
await this.unsubscribeCodexThreadBestEffort(previousThreadId);
|
|
@@ -1483,7 +1561,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1483
1561
|
if (targetSubscribed && targetThreadId !== previousThreadId) {
|
|
1484
1562
|
await this.unsubscribeCodexThreadBestEffort(targetThreadId);
|
|
1485
1563
|
}
|
|
1486
|
-
this.setStatus("idle");
|
|
1564
|
+
this.setStatus("idle", `Codex resume failed for ${targetThreadId.slice(0, 12)}: ${describeUnknownError(error)}`);
|
|
1487
1565
|
throw error;
|
|
1488
1566
|
}
|
|
1489
1567
|
finally {
|
|
@@ -1533,6 +1611,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1533
1611
|
if (!resumedThreadId) {
|
|
1534
1612
|
throw new Error("Codex did not return a thread id while resuming the saved thread.");
|
|
1535
1613
|
}
|
|
1614
|
+
this.validateCodexThreadWorkspaceIfPresent(response, resumedThreadId);
|
|
1536
1615
|
this.rememberBridgeOwnedThreadSignal(resumedThreadId);
|
|
1537
1616
|
this.subscribedThreadIds.add(resumedThreadId);
|
|
1538
1617
|
this.sessionFilePath = null;
|
|
@@ -1680,6 +1759,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1680
1759
|
this.completedTurnOrder = [];
|
|
1681
1760
|
}
|
|
1682
1761
|
this.pendingInjectedInputs = [];
|
|
1762
|
+
this.threadStatusCheckEpoch += 1;
|
|
1763
|
+
this.pendingThreadStatusChecks.clear();
|
|
1764
|
+
this.clearBridgeResumeReplay();
|
|
1683
1765
|
this.recentBridgeThreadSignalAtById.clear();
|
|
1684
1766
|
this.sessionFinalText = null;
|
|
1685
1767
|
this.nextSessionFallbackScanAtMs = 0;
|
|
@@ -1746,6 +1828,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1746
1828
|
});
|
|
1747
1829
|
}
|
|
1748
1830
|
}
|
|
1831
|
+
blockLateLocalThreadFollow() {
|
|
1832
|
+
this.localThreadFollowBlockedUntilMs =
|
|
1833
|
+
Date.now() + CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS;
|
|
1834
|
+
}
|
|
1749
1835
|
clearPendingThreadAnnouncement() {
|
|
1750
1836
|
if (!this.pendingThreadAnnouncement) {
|
|
1751
1837
|
return;
|
|
@@ -1804,16 +1890,29 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1804
1890
|
pending.timer.unref?.();
|
|
1805
1891
|
}
|
|
1806
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
|
+
}
|
|
1807
1902
|
if (this.pendingVisibleResume) {
|
|
1808
1903
|
return;
|
|
1809
1904
|
}
|
|
1810
1905
|
this.interruptWechatTurnForLocalThreadSwitch(threadId);
|
|
1811
1906
|
if (!this.isNativePanelMode()) {
|
|
1907
|
+
const threadChanged = this.sharedThreadId !== threadId;
|
|
1812
1908
|
this.updateSharedThread(threadId, {
|
|
1813
1909
|
source: "local",
|
|
1814
1910
|
reason: options.reason,
|
|
1815
1911
|
notify: true,
|
|
1816
1912
|
});
|
|
1913
|
+
if (threadChanged) {
|
|
1914
|
+
void this.tryEnsureSharedThreadSubscribed(threadId);
|
|
1915
|
+
}
|
|
1817
1916
|
return;
|
|
1818
1917
|
}
|
|
1819
1918
|
this.updateSharedThread(threadId, {
|
|
@@ -1897,6 +1996,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1897
1996
|
}
|
|
1898
1997
|
return true;
|
|
1899
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
|
+
}
|
|
1900
2013
|
clearPendingApprovalState() {
|
|
1901
2014
|
this.pendingApprovalRequests = [];
|
|
1902
2015
|
this.pendingApproval = null;
|
|
@@ -2393,6 +2506,68 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2393
2506
|
if (!threadId) {
|
|
2394
2507
|
return;
|
|
2395
2508
|
}
|
|
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) {
|
|
2396
2571
|
if (!this.activeTurn ||
|
|
2397
2572
|
this.activeTurn.threadId === threadId ||
|
|
2398
2573
|
this.activeTurn.origin === "wechat") {
|
|
@@ -2410,12 +2585,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2410
2585
|
if (!threadId) {
|
|
2411
2586
|
return;
|
|
2412
2587
|
}
|
|
2588
|
+
if (this.isBridgeResumeReplay(threadId)) {
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2413
2591
|
if (this.isRecentlyBridgeOwnedThread(threadId)) {
|
|
2414
2592
|
return;
|
|
2415
2593
|
}
|
|
2416
2594
|
const thread = isRecord(params.thread) ? params.thread : null;
|
|
2417
|
-
if (thread
|
|
2418
|
-
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) {
|
|
2419
2600
|
return;
|
|
2420
2601
|
}
|
|
2421
2602
|
}
|
|
@@ -2435,6 +2616,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2435
2616
|
if (this.activeTurn?.turnId === trackedTurn.turnId) {
|
|
2436
2617
|
return;
|
|
2437
2618
|
}
|
|
2619
|
+
if (trackedTurn.origin === "local") {
|
|
2620
|
+
this.clearBridgeResumeReplay();
|
|
2621
|
+
}
|
|
2438
2622
|
if (trackedTurn.origin === "local" &&
|
|
2439
2623
|
trackedTurn.threadId !== this.sharedThreadId) {
|
|
2440
2624
|
if (!this.activeTurn ||
|
|
@@ -2513,6 +2697,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2513
2697
|
if (this.activeTurn?.turnId === trackedTurn.turnId) {
|
|
2514
2698
|
this.setActiveTurn(null);
|
|
2515
2699
|
}
|
|
2700
|
+
if (completedTrackedTurn.origin === "wechat") {
|
|
2701
|
+
this.blockLateLocalThreadFollow();
|
|
2702
|
+
}
|
|
2516
2703
|
this.cleanupTurnArtifacts(trackedTurn.turnId);
|
|
2517
2704
|
if (this.state.status !== "stopped" &&
|
|
2518
2705
|
(!this.activeTurn || this.activeTurn.turnId === trackedTurn.turnId)) {
|
|
@@ -2652,6 +2839,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2652
2839
|
if (!activeTurn || !finalText) {
|
|
2653
2840
|
return;
|
|
2654
2841
|
}
|
|
2842
|
+
if (activeTurn.origin === "wechat") {
|
|
2843
|
+
this.blockLateLocalThreadFollow();
|
|
2844
|
+
}
|
|
2655
2845
|
this.clearPendingApprovalState();
|
|
2656
2846
|
this.clearPendingUserInputState();
|
|
2657
2847
|
this.setActiveTurn(null);
|
|
@@ -2712,11 +2902,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2712
2902
|
this.appServerAuthToken = null;
|
|
2713
2903
|
}
|
|
2714
2904
|
describeAppServerLog() {
|
|
2715
|
-
const
|
|
2716
|
-
if (!
|
|
2905
|
+
const normalized = normalizeOutput(this.appServerLog).trim();
|
|
2906
|
+
if (!normalized) {
|
|
2717
2907
|
return "";
|
|
2718
2908
|
}
|
|
2719
|
-
|
|
2909
|
+
const summary = summarizeOutput(normalized, 500);
|
|
2910
|
+
return ` Recent app-server log: ${summary}`;
|
|
2720
2911
|
}
|
|
2721
2912
|
terminateCodexClient() {
|
|
2722
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;
|
|
@@ -7,7 +7,7 @@ import { spawn } from "node:child_process";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { assertNoReservedExtraCliArgs, buildCliEnvironment, describeUnknownError, isRecord, resolveSpawnTarget, } from "./bridge-adapters.shared.js";
|
|
9
9
|
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
10
|
-
import { normalizeOutput, nowIso } from "
|
|
10
|
+
import { normalizeOutput, nowIso } from "../core/text-utils.js";
|
|
11
11
|
const PI_TUI_CONNECT_WARNING_MS = 30_000;
|
|
12
12
|
const PI_TUI_REQUEST_TIMEOUT_MS = 30_000;
|
|
13
13
|
const PI_TUI_RECONNECT_TIMEOUT_MS = 10_000;
|
|
@@ -6,7 +6,8 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { t } from "../i18n/index.js";
|
|
8
8
|
import { spawn as spawnPty } from "node-pty";
|
|
9
|
-
import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, isStrictApprovalModeEnabled,
|
|
9
|
+
import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, isStrictApprovalModeEnabled, } from "./bridge-utils.js";
|
|
10
|
+
import { normalizeOutput, truncatePreview } from "../core/text-utils.js";
|
|
10
11
|
import { getNotificationThreadId, isRecord, } from "./bridge-adapter-common.js";
|
|
11
12
|
export { coerceWebSocketMessageData, describeUnknownError, getCodexRpcRequestId, getLocalCompanionCommandName, getNotificationThreadId, getNotificationTurnId, getSharedSessionIdFromAdapterState, isRecentIsoTimestamp, isRecord, normalizeCodexRpcError, quotePosixCommandArg, quoteWindowsCommandArg, } from "./bridge-adapter-common.js";
|
|
12
13
|
export const DEFAULT_COLS = 120;
|
|
@@ -1,65 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
export async function forwardWechatFinalReply(params) {
|
|
4
|
-
const { adapter, rawText, sender, onEmptyVisibleReply } = params;
|
|
5
|
-
const parsed = parseWechatFinalReply(rawText);
|
|
6
|
-
const sanitizedText = sanitizeWechatFinalReplyText(adapter, parsed.visibleText);
|
|
7
|
-
const visibleText = formatFinalReplyMessage(adapter, sanitizedText).trim();
|
|
8
|
-
if (visibleText) {
|
|
9
|
-
// Send long replies in bounded chunks: a single oversized sendmessage call
|
|
10
|
-
// can be rejected by the WeChat API, silently losing the whole reply.
|
|
11
|
-
const chunks = splitWechatTextIntoChunks(visibleText);
|
|
12
|
-
for (let index = 0; index < chunks.length; index += 1) {
|
|
13
|
-
const sent = await sender.sendText(chunks[index]);
|
|
14
|
-
if (sent === false) {
|
|
15
|
-
// The send channel is failing (e.g. an expired context token).
|
|
16
|
-
// Report what was dropped instead of ending mid-reply silently;
|
|
17
|
-
// this notice itself is best effort and may also fail.
|
|
18
|
-
const remainingChunks = chunks.length - index - 1;
|
|
19
|
-
if (remainingChunks > 0 || parsed.attachments.length > 0) {
|
|
20
|
-
const parts = [];
|
|
21
|
-
if (remainingChunks > 0) {
|
|
22
|
-
parts.push(`${remainingChunks} reply chunk(s)`);
|
|
23
|
-
}
|
|
24
|
-
if (parsed.attachments.length > 0) {
|
|
25
|
-
parts.push(`${parsed.attachments.length} attachment(s)`);
|
|
26
|
-
}
|
|
27
|
-
await sender.sendText(`[bridge] Reply delivery was interrupted; ${parts.join(" and ")} could not be sent. Send a new message to retry.`);
|
|
28
|
-
}
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
else if (adapter === "opencode" && parsed.visibleText.trim()) {
|
|
34
|
-
onEmptyVisibleReply?.({
|
|
35
|
-
adapter,
|
|
36
|
-
rawVisibleText: parsed.visibleText,
|
|
37
|
-
});
|
|
38
|
-
const sent = await sender.sendText(OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE);
|
|
39
|
-
if (sent === false) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
for (const attachment of parsed.attachments) {
|
|
44
|
-
try {
|
|
45
|
-
switch (attachment.kind) {
|
|
46
|
-
case "image":
|
|
47
|
-
await sender.sendImage(attachment.path);
|
|
48
|
-
break;
|
|
49
|
-
case "file":
|
|
50
|
-
await sender.sendFile(attachment.path);
|
|
51
|
-
break;
|
|
52
|
-
case "voice":
|
|
53
|
-
await sender.sendVoice(attachment.path);
|
|
54
|
-
break;
|
|
55
|
-
case "video":
|
|
56
|
-
await sender.sendVideo(attachment.path);
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
const errorText = error instanceof Error ? error.message : String(error ?? "unknown error");
|
|
62
|
-
await sender.sendText(`Failed to send ${attachment.kind} attachment: ${attachment.path}\n${errorText}`);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
1
|
+
/** @deprecated Import from ../channels/wechat/bridge-final-reply.ts instead. */
|
|
2
|
+
export * from "../channels/wechat/bridge-final-reply.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
|
|
3
|
-
import { buildInstanceId } from "
|
|
3
|
+
import { buildInstanceId } from "../core/text-utils.js";
|
|
4
4
|
import { writeJsonFileAtomic } from "../utils/atomic-file.js";
|
|
5
5
|
import { getProcessRecordByPid, isWechatBridgeCommandLine, } from "./bridge-process-reaper.js";
|
|
6
6
|
const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { parseBridgeControlCommand, } from "../core/bridge-control.js";
|
|
5
|
+
import { normalizeOutput, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
|
|
6
|
+
export { buildInstanceId, buildOneTimeCode, formatDuration, normalizeOutput, nowIso, stripAnsi, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
|
|
7
7
|
// Messages older than bridge start minus this grace window are treated as
|
|
8
8
|
// pre-start backlog and skipped. The window must absorb realistic clock skew
|
|
9
9
|
// between the local machine and WeChat server timestamps: with a small value,
|
|
@@ -99,29 +99,6 @@ const INLINE_REFERENCE_ONLY_FILE_EXTENSIONS = new Set([
|
|
|
99
99
|
const INLINE_MAAS_URL_RE = /https?:\/\/[^\s]*?\/([A-Za-z]:\\.+?(?:\.\s*[A-Za-z0-9]{2,8})+)(?:\?[^\n]*)?/g;
|
|
100
100
|
const INLINE_WINDOWS_PATH_RE = /(^|[^\w])`?([A-Za-z]:\\(?:[^\\/:*?"<>|\r\n`]+\\)*[^\\/:*?"<>|\r\n`]+?(?:\.\s*[A-Za-z0-9]{2,8})+)`?(?=$|[^\w])/gm;
|
|
101
101
|
const INLINE_HOME_RELATIVE_PATH_RE = /(^|[^\w])`?((?:~[\\/])?(?:Desktop|Documents|Downloads|Pictures|Videos|Music)[\\/](?:[^\\/:*?"<>|\r\n`]+[\\/])*[^\\/:*?"<>|\r\n`]+?(?:\.\s*[A-Za-z0-9]{2,8})+)`?(?=$|[^\w])/gim;
|
|
102
|
-
export function nowIso() {
|
|
103
|
-
return new Date().toISOString();
|
|
104
|
-
}
|
|
105
|
-
export function stripAnsi(text) {
|
|
106
|
-
return text.replace(ANSI_ESCAPE_RE, "");
|
|
107
|
-
}
|
|
108
|
-
export function normalizeOutput(text) {
|
|
109
|
-
return stripAnsi(text)
|
|
110
|
-
// eslint-disable-next-line no-control-regex
|
|
111
|
-
.replace(/\u0000/g, "")
|
|
112
|
-
.replace(/\r\n/g, "\n")
|
|
113
|
-
.replace(/\r/g, "\n");
|
|
114
|
-
}
|
|
115
|
-
export function truncatePreview(text, maxLength = 140) {
|
|
116
|
-
const normalized = normalizeOutput(text).trim().replace(/\s+/g, " ");
|
|
117
|
-
if (!normalized) {
|
|
118
|
-
return "(empty)";
|
|
119
|
-
}
|
|
120
|
-
if (normalized.length <= maxLength) {
|
|
121
|
-
return normalized;
|
|
122
|
-
}
|
|
123
|
-
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
124
|
-
}
|
|
125
102
|
export function isThinkingForwardEnabled() {
|
|
126
103
|
if (process.env.CLI_BRIDGE_THINKING_FORWARD === "1") {
|
|
127
104
|
return true;
|
|
@@ -150,51 +127,8 @@ export function formatThinkingForWechat(text, maxLength = 500) {
|
|
|
150
127
|
}
|
|
151
128
|
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
152
129
|
}
|
|
153
|
-
export function buildOneTimeCode(length = 6) {
|
|
154
|
-
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
155
|
-
let code = "";
|
|
156
|
-
while (code.length < length) {
|
|
157
|
-
code += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
158
|
-
}
|
|
159
|
-
return code;
|
|
160
|
-
}
|
|
161
|
-
export function buildInstanceId() {
|
|
162
|
-
return `bridge-${Date.now().toString(36)}-${buildOneTimeCode(6).toLowerCase()}`;
|
|
163
|
-
}
|
|
164
130
|
export function parseSystemCommand(text) {
|
|
165
|
-
|
|
166
|
-
if (!trimmed.startsWith("/")) {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
const [rawCommand, ...rest] = trimmed.split(/\s+/);
|
|
170
|
-
if (!rawCommand) {
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
const command = rawCommand.toLowerCase();
|
|
174
|
-
const argument = rest.join(" ").trim();
|
|
175
|
-
switch (command) {
|
|
176
|
-
case "/status":
|
|
177
|
-
return { type: "status" };
|
|
178
|
-
case "/resume":
|
|
179
|
-
return argument ? { type: "resume", target: argument } : { type: "resume" };
|
|
180
|
-
case "/new":
|
|
181
|
-
case "/new-session":
|
|
182
|
-
return { type: "new_session" };
|
|
183
|
-
case "/stop":
|
|
184
|
-
return { type: "stop" };
|
|
185
|
-
case "/reset":
|
|
186
|
-
return { type: "reset" };
|
|
187
|
-
case "/confirm":
|
|
188
|
-
case "/yes":
|
|
189
|
-
return { type: "confirm" };
|
|
190
|
-
case "/deny":
|
|
191
|
-
case "/no":
|
|
192
|
-
return { type: "deny" };
|
|
193
|
-
case "/answer":
|
|
194
|
-
return argument ? { type: "answer", raw: argument } : null;
|
|
195
|
-
default:
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
131
|
+
return parseBridgeControlCommand(text);
|
|
198
132
|
}
|
|
199
133
|
export function parseWechatControlCommand(text, options) {
|
|
200
134
|
const systemCommand = parseSystemCommand(text);
|
|
@@ -474,32 +408,6 @@ export function detectCliApproval(text) {
|
|
|
474
408
|
denyInput: matched.denyInput,
|
|
475
409
|
};
|
|
476
410
|
}
|
|
477
|
-
export function formatDuration(durationMs) {
|
|
478
|
-
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
479
|
-
return "0s";
|
|
480
|
-
}
|
|
481
|
-
const totalSeconds = Math.floor(durationMs / 1000);
|
|
482
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
483
|
-
const seconds = totalSeconds % 60;
|
|
484
|
-
if (!minutes) {
|
|
485
|
-
return `${seconds}s`;
|
|
486
|
-
}
|
|
487
|
-
return `${minutes}m ${seconds}s`;
|
|
488
|
-
}
|
|
489
|
-
export function summarizeOutput(text, maxLength = 280) {
|
|
490
|
-
const normalized = normalizeOutput(text)
|
|
491
|
-
.split("\n")
|
|
492
|
-
.map((line) => line.trim())
|
|
493
|
-
.filter(Boolean);
|
|
494
|
-
if (!normalized.length) {
|
|
495
|
-
return "(no output)";
|
|
496
|
-
}
|
|
497
|
-
const summary = normalized.slice(-6).join("\n");
|
|
498
|
-
if (summary.length <= maxLength) {
|
|
499
|
-
return summary;
|
|
500
|
-
}
|
|
501
|
-
return summary.slice(summary.length - maxLength);
|
|
502
|
-
}
|
|
503
411
|
export function formatStatusReport(bridgeState, adapterState) {
|
|
504
412
|
const pending = bridgeState.pendingConfirmation;
|
|
505
413
|
const pendingUserInput = bridgeState.pendingUserInput;
|