@unlinearity/cli-wechat-bridge 1.2.0 → 1.2.1
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 +4 -3
- package/bin/wechat-check-update.mjs +0 -0
- package/bin/wechat-claude.mjs +0 -0
- package/bin/wechat-codex.mjs +0 -0
- package/bin/wechat-daemon.mjs +0 -0
- package/bin/wechat-opencode.mjs +0 -0
- package/bin/wechat-pi.mjs +0 -0
- package/bin/wechat-setup.mjs +0 -0
- package/bin/wecom-check-update.mjs +0 -0
- package/bin/wecom-claude.mjs +0 -0
- package/bin/wecom-codex.mjs +0 -0
- package/bin/wecom-daemon.mjs +0 -0
- package/bin/wecom-opencode.mjs +0 -0
- package/bin/wecom-pi.mjs +0 -0
- package/bin/wecom-setup.mjs +0 -0
- package/dist/bridge/bridge-adapters.codex.js +89 -8
- package/dist/bridge/bridge-adapters.opencode.js +142 -19
- package/dist/bridge/claude-native-controls.js +14 -2
- package/dist/bridge/codex-visible-thread-proxy.js +140 -0
- package/dist/bridge/native-terminal-control.js +12 -0
- package/dist/bridge/opencode-v2-compat.js +450 -0
- package/dist/companion/opencode-v2-tui-bridge-plugin.js +93 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -53,10 +53,11 @@
|
|
|
53
53
|
|
|
54
54
|
- [Node.js](https://nodejs.org/en/download) `>= 22.13.0`(建议直接安装官网 LTS 版本)
|
|
55
55
|
- 已安装以下任意一种本地 CLI,并尽量保持最新版本:
|
|
56
|
-
- [Codex](https://github.com/openai/codex)
|
|
56
|
+
- [Codex](https://github.com/openai/codex)(已验证 `0.149.x`–`0.157.x`)
|
|
57
57
|
- [Claude Code](https://code.claude.com/docs/en/overview)
|
|
58
|
-
- [OpenCode](https://github.com/anomalyco/opencode)
|
|
59
|
-
- [Pi](https://github.com/earendil-works/pi)(已验证 `0.
|
|
58
|
+
- [OpenCode](https://github.com/anomalyco/opencode)(同时支持 `1.18.x` 与 `2.0.x`)
|
|
59
|
+
- [Pi](https://github.com/earendil-works/pi)(已验证 `0.85.1`;需要本机可执行 `pi` 命令)
|
|
60
|
+
- Bridge 仅调用安装在 `PATH` 中、可直接执行的独立 CLI;不会发现或调用 ChatGPT.app 等桌面应用内部捆绑的私有可执行文件。
|
|
60
61
|
- 已准备一个远程通道:个人微信使用 `wechat-setup`,企业微信使用 `wecom-setup`
|
|
61
62
|
|
|
62
63
|
### 2. 安装与更新
|
|
File without changes
|
package/bin/wechat-claude.mjs
CHANGED
|
File without changes
|
package/bin/wechat-codex.mjs
CHANGED
|
File without changes
|
package/bin/wechat-daemon.mjs
CHANGED
|
File without changes
|
package/bin/wechat-opencode.mjs
CHANGED
|
File without changes
|
package/bin/wechat-pi.mjs
CHANGED
|
File without changes
|
package/bin/wechat-setup.mjs
CHANGED
|
File without changes
|
|
File without changes
|
package/bin/wecom-claude.mjs
CHANGED
|
File without changes
|
package/bin/wecom-codex.mjs
CHANGED
|
File without changes
|
package/bin/wecom-daemon.mjs
CHANGED
|
File without changes
|
package/bin/wecom-opencode.mjs
CHANGED
|
File without changes
|
package/bin/wecom-pi.mjs
CHANGED
|
File without changes
|
package/bin/wecom-setup.mjs
CHANGED
|
File without changes
|
|
@@ -12,10 +12,11 @@ import { detectCliApproval, } from "./bridge-utils.js";
|
|
|
12
12
|
import { normalizeOutput, nowIso, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
|
|
13
13
|
import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
|
|
14
14
|
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
15
|
+
import { CodexVisibleThreadProxy } from "./codex-visible-thread-proxy.js";
|
|
15
16
|
import * as shared from "./bridge-adapters.shared.js";
|
|
16
17
|
import { requestCodexVisibleClientShutdown, requestCodexVisibleThreadSwitch, } from "../companion/codex-visible-client-link.js";
|
|
17
18
|
import { readLocalCompanionEndpoint } from "../companion/local-companion-link.js";
|
|
18
|
-
import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
|
|
19
|
+
import { appendBoundedLog as appendBridgeLog, BRIDGE_LOG_FILE, ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
|
|
19
20
|
import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
|
|
20
21
|
export function parseCodexCliVersion(output) {
|
|
21
22
|
const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
|
|
@@ -28,7 +29,7 @@ export function isCodexVersionInCompatibilityRange(version) {
|
|
|
28
29
|
}
|
|
29
30
|
const major = parts[0] ?? -1;
|
|
30
31
|
const minor = parts[1] ?? -1;
|
|
31
|
-
return major === 0 && minor >= 149 && minor <=
|
|
32
|
+
return major === 0 && minor >= 149 && minor <= 157;
|
|
32
33
|
}
|
|
33
34
|
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;
|
|
34
35
|
const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
|
|
@@ -80,6 +81,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
80
81
|
appServer = null;
|
|
81
82
|
nativeProcess = null;
|
|
82
83
|
appServerPort = null;
|
|
84
|
+
visibleThreadProxy = null;
|
|
85
|
+
visibleOutOfWorkspaceThreadId = null;
|
|
83
86
|
appServerShuttingDown = false;
|
|
84
87
|
appServerLog = "";
|
|
85
88
|
appServerAuthToken = null;
|
|
@@ -166,8 +169,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
166
169
|
this.setStatus("starting", `Codex protocol compatibility: ${compatibility}.`);
|
|
167
170
|
await this.startAppServer();
|
|
168
171
|
await this.connectRpcClient();
|
|
169
|
-
await this.restoreInitialSharedThreadIfNeeded();
|
|
170
172
|
try {
|
|
173
|
+
if (this.isHeadlessRuntimeMode() && this.appServerPort && this.appServerAuthToken) {
|
|
174
|
+
const trace = (message) => appendBridgeLog(BRIDGE_LOG_FILE, `[${nowIso()}] codex_visible_proxy: ${message}\n`);
|
|
175
|
+
this.visibleThreadProxy = await CodexVisibleThreadProxy.start({
|
|
176
|
+
upstreamUrl: `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`,
|
|
177
|
+
token: this.appServerAuthToken,
|
|
178
|
+
onTrace: trace,
|
|
179
|
+
onThreadOpened: (threadId, cwd) => this.handleVisibleCodexThreadOpened(threadId, cwd, trace),
|
|
180
|
+
onVisibleTurn: (threadId) => this.handleVisibleCodexTurn(threadId, trace),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
await this.restoreInitialSharedThreadIfNeeded();
|
|
171
184
|
if (this.isNativePanelMode()) {
|
|
172
185
|
await this.startNativeClient();
|
|
173
186
|
}
|
|
@@ -286,6 +299,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
286
299
|
return models;
|
|
287
300
|
}
|
|
288
301
|
async selectModel(modelId) {
|
|
302
|
+
this.assertVisibleCodexWorkspace();
|
|
289
303
|
const models = await this.listModels();
|
|
290
304
|
const selected = models.find((model) => model.id === modelId);
|
|
291
305
|
if (!selected)
|
|
@@ -294,6 +308,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
294
308
|
return selected;
|
|
295
309
|
}
|
|
296
310
|
async setPlanMode(enabled) {
|
|
311
|
+
this.assertVisibleCodexWorkspace();
|
|
297
312
|
if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
|
|
298
313
|
throw new Error("Codex plan mode requires an active app-server thread.");
|
|
299
314
|
}
|
|
@@ -464,7 +479,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
464
479
|
renderMode: "headless",
|
|
465
480
|
bridgeOwnerPid: process.pid,
|
|
466
481
|
serverPort: this.appServerPort,
|
|
467
|
-
serverUrl: `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`,
|
|
482
|
+
serverUrl: this.visibleThreadProxy?.url ?? `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`,
|
|
468
483
|
remoteAuthTokenEnv: CODEX_REMOTE_AUTH_TOKEN_ENV,
|
|
469
484
|
cwd: this.options.cwd,
|
|
470
485
|
command: this.options.command,
|
|
@@ -1020,6 +1035,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1020
1035
|
}
|
|
1021
1036
|
}
|
|
1022
1037
|
async sendPanelTurn(text) {
|
|
1038
|
+
this.assertVisibleCodexWorkspace();
|
|
1023
1039
|
if (this.isNativePanelMode() && !this.nativeProcess) {
|
|
1024
1040
|
throw new Error("codex panel is not running.");
|
|
1025
1041
|
}
|
|
@@ -1168,6 +1184,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1168
1184
|
});
|
|
1169
1185
|
this.appServer = null;
|
|
1170
1186
|
this.appServerPort = null;
|
|
1187
|
+
void this.visibleThreadProxy?.close();
|
|
1188
|
+
this.visibleThreadProxy = null;
|
|
1171
1189
|
this.appServerShuttingDown = false;
|
|
1172
1190
|
this.deleteAppServerAuthTokenFile();
|
|
1173
1191
|
this.appServerAuthToken = null;
|
|
@@ -1190,6 +1208,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1190
1208
|
});
|
|
1191
1209
|
this.appServer = null;
|
|
1192
1210
|
this.appServerPort = null;
|
|
1211
|
+
void this.visibleThreadProxy?.close();
|
|
1212
|
+
this.visibleThreadProxy = null;
|
|
1193
1213
|
this.appServerShuttingDown = false;
|
|
1194
1214
|
this.deleteAppServerAuthTokenFile();
|
|
1195
1215
|
this.appServerAuthToken = null;
|
|
@@ -1660,6 +1680,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1660
1680
|
reason: "wechat_resume",
|
|
1661
1681
|
notify: true,
|
|
1662
1682
|
});
|
|
1683
|
+
this.clearVisibleCodexWorkspaceBlock();
|
|
1663
1684
|
this.bridgeResumeReplayThreadId = targetThreadId;
|
|
1664
1685
|
this.bridgeResumeReplayUntilMs = Date.now() + CODEX_RESUME_REPLAY_SETTLE_MS;
|
|
1665
1686
|
this.setStatus("starting", `Codex resume committed thread ${targetThreadId.slice(0, 12)}.`);
|
|
@@ -1881,6 +1902,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1881
1902
|
if (!options.preserveThread) {
|
|
1882
1903
|
this.clearPendingThreadAnnouncement();
|
|
1883
1904
|
this.announcedThreadId = null;
|
|
1905
|
+
this.visibleOutOfWorkspaceThreadId = null;
|
|
1884
1906
|
}
|
|
1885
1907
|
if (!options.preserveThread) {
|
|
1886
1908
|
this.updateSharedThread(null);
|
|
@@ -2000,6 +2022,62 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2000
2022
|
}, CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS);
|
|
2001
2023
|
pending.timer.unref?.();
|
|
2002
2024
|
}
|
|
2025
|
+
handleVisibleCodexThreadOpened(threadId, cwd, trace) {
|
|
2026
|
+
if (this.pendingVisibleResume || this.shuttingDown) {
|
|
2027
|
+
trace?.(`ignored thread=${threadId.slice(0, 12)} reason=bridge_switch`);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
if (cwd && normalizeComparablePath(cwd) !== normalizeComparablePath(this.options.cwd)) {
|
|
2031
|
+
trace?.(`paused thread=${threadId.slice(0, 12)} reason=workspace`);
|
|
2032
|
+
this.interruptWechatTurnForLocalThreadSwitch(threadId);
|
|
2033
|
+
this.clearPendingThreadAnnouncement();
|
|
2034
|
+
this.pendingThreadFollowId = null;
|
|
2035
|
+
if (this.visibleOutOfWorkspaceThreadId !== threadId) {
|
|
2036
|
+
this.visibleOutOfWorkspaceThreadId = threadId;
|
|
2037
|
+
this.emit({
|
|
2038
|
+
type: "notice",
|
|
2039
|
+
level: "warning",
|
|
2040
|
+
text: `The visible Codex terminal switched to ${cwd}, outside the bridge workspace ${this.options.cwd}. WeChat input is paused instead of being sent to the previous session. Resume a session from the bridge workspace, or restart the bridge in ${cwd}.`,
|
|
2041
|
+
timestamp: nowIso(),
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
this.clearVisibleCodexWorkspaceBlock();
|
|
2047
|
+
trace?.(`follow thread=${threadId.slice(0, 12)} source=visible_open`);
|
|
2048
|
+
this.trackLocalSharedThread(threadId, { reason: "local_follow", signal: "visible_request" });
|
|
2049
|
+
}
|
|
2050
|
+
handleVisibleCodexTurn(threadId, trace) {
|
|
2051
|
+
if (this.pendingVisibleResume || this.shuttingDown)
|
|
2052
|
+
return;
|
|
2053
|
+
if (threadId === this.sharedThreadId) {
|
|
2054
|
+
this.clearVisibleCodexWorkspaceBlock();
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
if (!findCodexSessionFile(this.options.cwd, Date.now(), { threadId })) {
|
|
2058
|
+
trace?.(`ignored thread=${threadId.slice(0, 12)} reason=workspace_or_unpersisted`);
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
this.clearVisibleCodexWorkspaceBlock();
|
|
2062
|
+
trace?.(`follow thread=${threadId.slice(0, 12)} source=visible_turn`);
|
|
2063
|
+
this.trackLocalSharedThread(threadId, { reason: "local_follow", signal: "visible_request" });
|
|
2064
|
+
}
|
|
2065
|
+
clearVisibleCodexWorkspaceBlock() {
|
|
2066
|
+
if (!this.visibleOutOfWorkspaceThreadId)
|
|
2067
|
+
return;
|
|
2068
|
+
this.visibleOutOfWorkspaceThreadId = null;
|
|
2069
|
+
this.emit({
|
|
2070
|
+
type: "notice",
|
|
2071
|
+
level: "info",
|
|
2072
|
+
text: `The visible Codex terminal is back in the bridge workspace ${this.options.cwd}. WeChat input has resumed.`,
|
|
2073
|
+
timestamp: nowIso(),
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
assertVisibleCodexWorkspace() {
|
|
2077
|
+
if (this.visibleOutOfWorkspaceThreadId) {
|
|
2078
|
+
throw new Error(`The visible Codex terminal is outside the bridge workspace ${this.options.cwd}. WeChat input is paused; resume a session from this workspace or restart the bridge in the terminal's workspace.`);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2003
2081
|
trackLocalSharedThread(threadId, options) {
|
|
2004
2082
|
const weakSignal = options.signal === "status_changed" ||
|
|
2005
2083
|
options.signal === "thread_started" ||
|
|
@@ -2007,7 +2085,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2007
2085
|
if (weakSignal && Date.now() < this.localThreadFollowBlockedUntilMs) {
|
|
2008
2086
|
return;
|
|
2009
2087
|
}
|
|
2010
|
-
if (options.signal === "user_message" || options.signal === "turn_started") {
|
|
2088
|
+
if (options.signal === "user_message" || options.signal === "turn_started" || options.signal === "visible_request") {
|
|
2011
2089
|
this.localThreadFollowBlockedUntilMs = 0;
|
|
2012
2090
|
}
|
|
2013
2091
|
if (this.pendingVisibleResume) {
|
|
@@ -2343,7 +2421,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2343
2421
|
origin: "local",
|
|
2344
2422
|
};
|
|
2345
2423
|
}
|
|
2346
|
-
if (method === "turn/started" && !this.activeTurn) {
|
|
2424
|
+
if (method === "turn/started" && !this.activeTurn && !this.visibleThreadProxy) {
|
|
2347
2425
|
return {
|
|
2348
2426
|
threadId,
|
|
2349
2427
|
turnId,
|
|
@@ -2617,7 +2695,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2617
2695
|
if (!threadId) {
|
|
2618
2696
|
return;
|
|
2619
2697
|
}
|
|
2620
|
-
if (this.pendingVisibleResume) {
|
|
2698
|
+
if (this.pendingVisibleResume || this.visibleThreadProxy) {
|
|
2621
2699
|
return;
|
|
2622
2700
|
}
|
|
2623
2701
|
if (this.isBridgeResumeReplay(threadId)) {
|
|
@@ -2696,7 +2774,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2696
2774
|
if (!threadId) {
|
|
2697
2775
|
return;
|
|
2698
2776
|
}
|
|
2699
|
-
if (this.isBridgeResumeReplay(threadId)) {
|
|
2777
|
+
if (this.visibleThreadProxy || this.isBridgeResumeReplay(threadId)) {
|
|
2700
2778
|
return;
|
|
2701
2779
|
}
|
|
2702
2780
|
if (this.isRecentlyBridgeOwnedThread(threadId)) {
|
|
@@ -2974,6 +3052,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2974
3052
|
this.rememberCompletedTurn(turnId);
|
|
2975
3053
|
}
|
|
2976
3054
|
async stopAppServer() {
|
|
3055
|
+
const visibleThreadProxy = this.visibleThreadProxy;
|
|
3056
|
+
this.visibleThreadProxy = null;
|
|
3057
|
+
await visibleThreadProxy?.close();
|
|
2977
3058
|
if (!this.appServer) {
|
|
2978
3059
|
this.appServerPort = null;
|
|
2979
3060
|
this.appServerShuttingDown = false;
|
|
@@ -12,6 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { OPENCODE_SERVER_HOST, OPENCODE_SERVER_READY_TIMEOUT_MS, OPENCODE_SSE_RECONNECT_DELAY_MS, OPENCODE_SESSION_IDLE_SETTLE_MS, OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS, OPENCODE_HTTP_READY_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_INTERVAL_MS, buildCliEnvironment, isRecord, describeUnknownError, resolveSpawnTarget, reserveLocalPort, waitForTcpPort, delay, } from "./bridge-adapters.shared.js";
|
|
14
14
|
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
15
|
+
import { createOpenCodeV2CompatClient } from "./opencode-v2-compat.js";
|
|
15
16
|
import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
|
|
16
17
|
import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
|
|
17
18
|
const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPENCODE_DEBUG ?? "");
|
|
@@ -24,7 +25,11 @@ const TUI_SESSION_SELECT_SUPPRESSION_TTL_MS = 5_000;
|
|
|
24
25
|
const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
|
|
25
26
|
const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
|
|
26
27
|
const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
|
|
27
|
-
const OPENCODE_MAXIMUM_SUPPORTED_MAJOR =
|
|
28
|
+
const OPENCODE_MAXIMUM_SUPPORTED_MAJOR = 3;
|
|
29
|
+
const OPENCODE_VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
30
|
+
export function parseOpenCodeCliVersion(output) {
|
|
31
|
+
return /\bv?(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
|
|
32
|
+
}
|
|
28
33
|
const OPENCODE_TUI_ROUTE_READY_TIMEOUT_MS = 10_000;
|
|
29
34
|
const OPENCODE_TUI_ROUTE_MAX_BUFFER_SIZE = 1024 * 1024;
|
|
30
35
|
const MODULE_FILE = fileURLToPath(import.meta.url);
|
|
@@ -83,6 +88,8 @@ export class OpenCodeServerAdapter {
|
|
|
83
88
|
tuiRoutePort = 0;
|
|
84
89
|
tuiRouteConfigDir = null;
|
|
85
90
|
tuiRouteConfigPath = null;
|
|
91
|
+
tuiRoutePluginPath = null;
|
|
92
|
+
openCodeMajor = 1;
|
|
86
93
|
visibleTuiSessionId = null;
|
|
87
94
|
tuiRouteStateReceived = false;
|
|
88
95
|
expectedVisibleTuiSessionId = null;
|
|
@@ -250,6 +257,7 @@ export class OpenCodeServerAdapter {
|
|
|
250
257
|
this.shuttingDown = false;
|
|
251
258
|
this.setStatus("starting", "Starting OpenCode companion...");
|
|
252
259
|
try {
|
|
260
|
+
await this.probeOpenCodeVersion();
|
|
253
261
|
this.serverPort = await reserveLocalPort();
|
|
254
262
|
const serverProcess = await this.startServerProcess();
|
|
255
263
|
await waitForTcpPort(OPENCODE_SERVER_HOST, this.serverPort, OPENCODE_SERVER_READY_TIMEOUT_MS);
|
|
@@ -632,13 +640,28 @@ export class OpenCodeServerAdapter {
|
|
|
632
640
|
resolve(address.port);
|
|
633
641
|
});
|
|
634
642
|
});
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const pluginPath = path.join(MODULE_DIR, "..", "companion",
|
|
643
|
+
const pluginName = this.openCodeMajor >= 2
|
|
644
|
+
? "opencode-v2-tui-bridge-plugin"
|
|
645
|
+
: "opencode-tui-bridge-plugin";
|
|
646
|
+
const pluginPath = path.join(MODULE_DIR, "..", "companion", `${pluginName}${RUNTIME_ENTRY_EXTENSION}`);
|
|
639
647
|
if (!fs.existsSync(pluginPath)) {
|
|
640
648
|
throw new Error(`OpenCode TUI bridge plugin was not found: ${pluginPath}`);
|
|
641
649
|
}
|
|
650
|
+
const workspaceDir = ensureWorkspaceChannelDir(this.options.cwd).workspaceDir;
|
|
651
|
+
const configDir = fs.mkdtempSync(path.join(workspaceDir, "opencode-tui-route-"));
|
|
652
|
+
this.tuiRouteConfigDir = configDir;
|
|
653
|
+
if (this.openCodeMajor >= 2) {
|
|
654
|
+
fs.copyFileSync(pluginPath, path.join(configDir, `tui${RUNTIME_ENTRY_EXTENSION}`));
|
|
655
|
+
fs.writeFileSync(path.join(configDir, "package.json"), `${JSON.stringify({
|
|
656
|
+
name: "cli-wechat-bridge-opencode-tui-route",
|
|
657
|
+
private: true,
|
|
658
|
+
type: "module",
|
|
659
|
+
}, null, 2)}\n`, "utf8");
|
|
660
|
+
this.tuiRoutePluginPath = configDir;
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
this.tuiRoutePluginPath = pluginPath;
|
|
664
|
+
const configPath = path.join(configDir, "tui.json");
|
|
642
665
|
fs.writeFileSync(configPath, `${JSON.stringify({
|
|
643
666
|
plugin: [
|
|
644
667
|
[
|
|
@@ -650,7 +673,6 @@ export class OpenCodeServerAdapter {
|
|
|
650
673
|
],
|
|
651
674
|
],
|
|
652
675
|
}, null, 2)}\n`, "utf8");
|
|
653
|
-
this.tuiRouteConfigDir = configDir;
|
|
654
676
|
this.tuiRouteConfigPath = configPath;
|
|
655
677
|
}
|
|
656
678
|
acceptTuiRouteSocket(socket) {
|
|
@@ -765,6 +787,7 @@ export class OpenCodeServerAdapter {
|
|
|
765
787
|
const configDir = this.tuiRouteConfigDir;
|
|
766
788
|
this.tuiRouteConfigDir = null;
|
|
767
789
|
this.tuiRouteConfigPath = null;
|
|
790
|
+
this.tuiRoutePluginPath = null;
|
|
768
791
|
this.tuiRoutePort = 0;
|
|
769
792
|
this.tuiRouteToken = "";
|
|
770
793
|
this.tuiRouteStateReceived = false;
|
|
@@ -781,6 +804,50 @@ export class OpenCodeServerAdapter {
|
|
|
781
804
|
}
|
|
782
805
|
}
|
|
783
806
|
}
|
|
807
|
+
async probeOpenCodeVersion() {
|
|
808
|
+
const env = buildCliEnvironment(this.options.kind);
|
|
809
|
+
const target = resolveSpawnTarget(this.options.command, this.options.kind, { env });
|
|
810
|
+
const version = await new Promise((resolve, reject) => {
|
|
811
|
+
const child = spawnChildProcess(target.file, [...target.args, "--version"], {
|
|
812
|
+
cwd: this.options.cwd,
|
|
813
|
+
env,
|
|
814
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
815
|
+
windowsHide: true,
|
|
816
|
+
});
|
|
817
|
+
let output = "";
|
|
818
|
+
let settled = false;
|
|
819
|
+
const finish = (error) => {
|
|
820
|
+
if (settled)
|
|
821
|
+
return;
|
|
822
|
+
settled = true;
|
|
823
|
+
clearTimeout(timer);
|
|
824
|
+
if (error)
|
|
825
|
+
reject(error);
|
|
826
|
+
else
|
|
827
|
+
resolve(output);
|
|
828
|
+
};
|
|
829
|
+
const timer = setTimeout(() => {
|
|
830
|
+
if (child.pid != null) {
|
|
831
|
+
killProcessTreeSync(child.pid);
|
|
832
|
+
}
|
|
833
|
+
finish(new Error("Timed out checking the OpenCode CLI version."));
|
|
834
|
+
}, OPENCODE_VERSION_PROBE_TIMEOUT_MS);
|
|
835
|
+
child.stdout?.on("data", (chunk) => { output += chunk.toString("utf8"); });
|
|
836
|
+
child.stderr?.on("data", (chunk) => { output += chunk.toString("utf8"); });
|
|
837
|
+
child.once("error", (error) => finish(error));
|
|
838
|
+
child.once("exit", (code) => {
|
|
839
|
+
if (code === 0)
|
|
840
|
+
finish();
|
|
841
|
+
else
|
|
842
|
+
finish(new Error(`OpenCode --version exited with code ${code ?? "unknown"}.`));
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
const parsed = parseOpenCodeCliVersion(version);
|
|
846
|
+
if (!parsed || !this.isSupportedOpenCodeVersion(parsed)) {
|
|
847
|
+
throw new Error(`Unsupported OpenCode version ${parsed ?? (version.trim() || "unknown")}. Install OpenCode >=${OPENCODE_MINIMUM_SUPPORTED_VERSION} <${OPENCODE_MAXIMUM_SUPPORTED_MAJOR}.0.0.`);
|
|
848
|
+
}
|
|
849
|
+
this.openCodeMajor = Number(parsed.split(".")[0]);
|
|
850
|
+
}
|
|
784
851
|
async startServerProcess() {
|
|
785
852
|
const env = buildCliEnvironment(this.options.kind);
|
|
786
853
|
this.serverPassword = randomBytes(24).toString("hex");
|
|
@@ -886,8 +953,9 @@ export class OpenCodeServerAdapter {
|
|
|
886
953
|
if ((this.options.extraCliArgs ?? []).includes("--pure")) {
|
|
887
954
|
throw new Error("OpenCode --pure cannot be used with the WeChat bridge because local session following requires the managed TUI plugin.");
|
|
888
955
|
}
|
|
889
|
-
const args =
|
|
890
|
-
|
|
956
|
+
const args = this.openCodeMajor >= 2
|
|
957
|
+
? ["--server", this.getServerUrl()]
|
|
958
|
+
: ["attach", this.getServerUrl(), "--dir", this.options.cwd];
|
|
891
959
|
const sessionId = this.activeSessionId;
|
|
892
960
|
if (sessionId && (await this.hasSession(sessionId))) {
|
|
893
961
|
args.push("--session", sessionId);
|
|
@@ -897,7 +965,37 @@ export class OpenCodeServerAdapter {
|
|
|
897
965
|
}
|
|
898
966
|
buildNativeClientEnv() {
|
|
899
967
|
const env = buildCliEnvironment(this.options.kind);
|
|
900
|
-
if (this.
|
|
968
|
+
if (this.openCodeMajor >= 2 && this.tuiRoutePluginPath) {
|
|
969
|
+
let existingConfig = {};
|
|
970
|
+
if (env.OPENCODE_CLI_CONFIG_CONTENT) {
|
|
971
|
+
try {
|
|
972
|
+
const parsed = JSON.parse(env.OPENCODE_CLI_CONFIG_CONTENT);
|
|
973
|
+
if (isRecord(parsed))
|
|
974
|
+
existingConfig = parsed;
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
throw new Error(`OpenCode cannot merge the managed TUI plugin into invalid OPENCODE_CLI_CONFIG_CONTENT: ${describeUnknownError(error)}`, { cause: error });
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const existingPlugins = Array.isArray(existingConfig.plugins)
|
|
981
|
+
? existingConfig.plugins
|
|
982
|
+
: [];
|
|
983
|
+
env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({
|
|
984
|
+
...existingConfig,
|
|
985
|
+
plugins: [
|
|
986
|
+
...existingPlugins,
|
|
987
|
+
{
|
|
988
|
+
package: this.tuiRoutePluginPath,
|
|
989
|
+
options: {
|
|
990
|
+
port: this.tuiRoutePort,
|
|
991
|
+
token: this.tuiRouteToken,
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
],
|
|
995
|
+
});
|
|
996
|
+
env.OPENCODE_PASSWORD = this.serverPassword ?? "";
|
|
997
|
+
}
|
|
998
|
+
else if (this.tuiRouteConfigDir && this.tuiRouteConfigPath) {
|
|
901
999
|
if (env.OPENCODE_CONFIG_DIR && env.OPENCODE_TUI_CONFIG) {
|
|
902
1000
|
throw new Error("OpenCode cannot inject the managed TUI session plugin while both OPENCODE_CONFIG_DIR and OPENCODE_TUI_CONFIG are set.");
|
|
903
1001
|
}
|
|
@@ -911,7 +1009,7 @@ export class OpenCodeServerAdapter {
|
|
|
911
1009
|
if (this.serverPassword) {
|
|
912
1010
|
env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
|
|
913
1011
|
}
|
|
914
|
-
if (this.activeSessionId) {
|
|
1012
|
+
if (this.activeSessionId && this.openCodeMajor < 2) {
|
|
915
1013
|
env.OPENCODE_ROUTE = JSON.stringify({
|
|
916
1014
|
type: "session",
|
|
917
1015
|
sessionID: this.activeSessionId,
|
|
@@ -928,6 +1026,14 @@ export class OpenCodeServerAdapter {
|
|
|
928
1026
|
}
|
|
929
1027
|
async createSdkClient() {
|
|
930
1028
|
try {
|
|
1029
|
+
if (this.openCodeMajor >= 2) {
|
|
1030
|
+
this.client = await createOpenCodeV2CompatClient({
|
|
1031
|
+
baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
|
|
1032
|
+
directory: this.options.cwd,
|
|
1033
|
+
headers: this.buildServerAuthHeaders(),
|
|
1034
|
+
});
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
931
1037
|
const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
|
|
932
1038
|
this.client = createOpencodeClient({
|
|
933
1039
|
baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
|
|
@@ -937,7 +1043,8 @@ export class OpenCodeServerAdapter {
|
|
|
937
1043
|
});
|
|
938
1044
|
}
|
|
939
1045
|
catch (err) {
|
|
940
|
-
|
|
1046
|
+
const packageName = this.openCodeMajor >= 2 ? "@opencode/client" : "@opencode-ai/sdk";
|
|
1047
|
+
throw new Error(`Failed to load ${packageName}. Make sure it is installed: ${describeUnknownError(err)}`, { cause: err });
|
|
941
1048
|
}
|
|
942
1049
|
}
|
|
943
1050
|
async checkHealth() {
|
|
@@ -952,17 +1059,24 @@ export class OpenCodeServerAdapter {
|
|
|
952
1059
|
let lastError;
|
|
953
1060
|
while (Date.now() < deadline) {
|
|
954
1061
|
try {
|
|
955
|
-
const
|
|
1062
|
+
const healthPath = this.openCodeMajor >= 2 ? "/api/info" : "/global/health";
|
|
1063
|
+
const response = await fetch(`${baseUrl}${healthPath}`, {
|
|
956
1064
|
headers: this.buildServerAuthHeaders(),
|
|
957
1065
|
signal: AbortSignal.timeout(OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS),
|
|
958
1066
|
});
|
|
959
1067
|
if (response.ok) {
|
|
960
1068
|
const health = await response.json();
|
|
961
|
-
|
|
1069
|
+
const version = isRecord(health) && typeof health.version === "string"
|
|
1070
|
+
? health.version
|
|
1071
|
+
: undefined;
|
|
1072
|
+
const healthy = this.openCodeMajor >= 2
|
|
1073
|
+
? Boolean(version)
|
|
1074
|
+
: isRecord(health) && health.healthy === true;
|
|
1075
|
+
if (!healthy || !version) {
|
|
962
1076
|
lastError = new Error("OpenCode health check returned an invalid response.");
|
|
963
1077
|
}
|
|
964
|
-
else if (!this.isSupportedOpenCodeVersion(
|
|
965
|
-
throw new Error(`Unsupported OpenCode version ${
|
|
1078
|
+
else if (!this.isSupportedOpenCodeVersion(version)) {
|
|
1079
|
+
throw new Error(`Unsupported OpenCode version ${version}. Install OpenCode >=${OPENCODE_MINIMUM_SUPPORTED_VERSION} <${OPENCODE_MAXIMUM_SUPPORTED_MAJOR}.0.0.`);
|
|
966
1080
|
}
|
|
967
1081
|
else {
|
|
968
1082
|
return;
|
|
@@ -1099,10 +1213,9 @@ export class OpenCodeServerAdapter {
|
|
|
1099
1213
|
return;
|
|
1100
1214
|
}
|
|
1101
1215
|
this.sseAbortController = new AbortController();
|
|
1102
|
-
const sseLoops =
|
|
1103
|
-
this.runSseLoop("event")
|
|
1104
|
-
this.runSseLoop("global-event")
|
|
1105
|
-
];
|
|
1216
|
+
const sseLoops = this.openCodeMajor >= 2
|
|
1217
|
+
? [this.runSseLoop("event")]
|
|
1218
|
+
: [this.runSseLoop("event"), this.runSseLoop("global-event")];
|
|
1106
1219
|
this.sseLoopPromise = Promise.all(sseLoops).then(() => undefined);
|
|
1107
1220
|
}
|
|
1108
1221
|
async runSseLoop(streamName) {
|
|
@@ -2745,6 +2858,16 @@ export class OpenCodeServerAdapter {
|
|
|
2745
2858
|
}
|
|
2746
2859
|
}
|
|
2747
2860
|
async sendVisibleSessionSelection(session) {
|
|
2861
|
+
if (this.openCodeMajor >= 2) {
|
|
2862
|
+
if (!this.tuiRouteSocket || this.tuiRouteSocket.destroyed) {
|
|
2863
|
+
throw new Error("The OpenCode 2 TUI route bridge is not connected.");
|
|
2864
|
+
}
|
|
2865
|
+
this.tuiRouteSocket.write(`${JSON.stringify({
|
|
2866
|
+
type: "select_session",
|
|
2867
|
+
sessionId: session.id,
|
|
2868
|
+
})}\n`);
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2748
2871
|
if (!this.client?.tui) {
|
|
2749
2872
|
return;
|
|
2750
2873
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NativeTerminalControl, parseClaudeModelPicker, readClaudePermissionMode } from "./native-terminal-control.js";
|
|
1
|
+
import { NativeTerminalControl, parseClaudeModelPicker, parseClaudeModelSwitchConfirmation, readClaudePermissionMode } from "./native-terminal-control.js";
|
|
2
2
|
export class ClaudeNativeControls {
|
|
3
3
|
previousMode = null;
|
|
4
4
|
terminal;
|
|
@@ -83,7 +83,19 @@ export class ClaudeNativeControls {
|
|
|
83
83
|
throw new Error("This Claude version does not expose session-only model selection. Update Claude Code and try again.");
|
|
84
84
|
}
|
|
85
85
|
await this.terminal.submitMenu(undefined, "s");
|
|
86
|
-
await this.terminal.waitFor(() => readClaudePermissionMode(this.terminal.text) !== null);
|
|
86
|
+
await this.terminal.waitFor(() => parseClaudeModelSwitchConfirmation(this.terminal.text) !== null || readClaudePermissionMode(this.terminal.text) !== null);
|
|
87
|
+
const confirmation = parseClaudeModelSwitchConfirmation(this.terminal.text);
|
|
88
|
+
if (confirmation) {
|
|
89
|
+
const targetLabel = target.label.toLowerCase();
|
|
90
|
+
const confirmedModel = confirmation.model.toLowerCase();
|
|
91
|
+
const matchesTarget = targetLabel.includes(confirmedModel)
|
|
92
|
+
|| (targetLabel.startsWith("default (recommended)") && confirmedModel.endsWith(" (default)"));
|
|
93
|
+
if (!confirmation.yesFocused || !matchesTarget) {
|
|
94
|
+
throw new Error("Claude displayed an unexpected model switch confirmation. Check the visible terminal before trying again.");
|
|
95
|
+
}
|
|
96
|
+
await this.terminal.key("\r");
|
|
97
|
+
await this.terminal.waitFor(() => readClaudePermissionMode(this.terminal.text) !== null);
|
|
98
|
+
}
|
|
87
99
|
await this.terminal.openMenu("/model", "Select model");
|
|
88
100
|
const applied = parseClaudeModelPicker(this.terminal.text).find((row) => row.focused);
|
|
89
101
|
if (!applied || applied.label !== target.label || applied.index !== target.index)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
2
|
+
export class CodexVisibleThreadProxy {
|
|
3
|
+
server;
|
|
4
|
+
clients = new Set();
|
|
5
|
+
upstreams = new Set();
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.server = new WebSocketServer({
|
|
8
|
+
host: "127.0.0.1",
|
|
9
|
+
port: 0,
|
|
10
|
+
verifyClient: ({ req }) => req.headers.authorization === `Bearer ${options.token}`,
|
|
11
|
+
});
|
|
12
|
+
this.server.on("connection", (client) => {
|
|
13
|
+
options.onTrace?.("client_connected");
|
|
14
|
+
const upstream = new WebSocket(options.upstreamUrl, {
|
|
15
|
+
headers: { Authorization: `Bearer ${options.token}` },
|
|
16
|
+
});
|
|
17
|
+
this.clients.add(client);
|
|
18
|
+
this.upstreams.add(upstream);
|
|
19
|
+
const pendingRequests = new Map();
|
|
20
|
+
const queued = [];
|
|
21
|
+
let queuedBytes = 0;
|
|
22
|
+
client.on("message", (data, binary) => {
|
|
23
|
+
const bytes = Buffer.from(data);
|
|
24
|
+
if (!binary) {
|
|
25
|
+
try {
|
|
26
|
+
const request = JSON.parse(bytes.toString("utf8"));
|
|
27
|
+
if (request && typeof request === "object" && "method" in request) {
|
|
28
|
+
if (typeof request.method === "string" &&
|
|
29
|
+
(request.method.startsWith("thread/") || request.method === "turn/start")) {
|
|
30
|
+
const params = "params" in request && request.params && typeof request.params === "object"
|
|
31
|
+
? request.params : null;
|
|
32
|
+
const threadId = params && "threadId" in params && typeof params.threadId === "string"
|
|
33
|
+
? params.threadId.slice(0, 12) : "none";
|
|
34
|
+
options.onTrace?.(`request method=${request.method} thread=${threadId}`);
|
|
35
|
+
}
|
|
36
|
+
if (request.method === "turn/start" && "params" in request &&
|
|
37
|
+
request.params && typeof request.params === "object" &&
|
|
38
|
+
"threadId" in request.params && typeof request.params.threadId === "string") {
|
|
39
|
+
options.onVisibleTurn(request.params.threadId);
|
|
40
|
+
}
|
|
41
|
+
if ((request.method === "thread/resume" || request.method === "thread/start") &&
|
|
42
|
+
"id" in request && (typeof request.id === "number" || typeof request.id === "string")) {
|
|
43
|
+
pendingRequests.set(request.id, request.method);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Forward non-JSON messages unchanged.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (upstream.readyState === WebSocket.OPEN) {
|
|
52
|
+
upstream.send(bytes, { binary });
|
|
53
|
+
}
|
|
54
|
+
else if (upstream.readyState === WebSocket.CONNECTING && queuedBytes + bytes.length <= 1_048_576) {
|
|
55
|
+
queued.push({ data: bytes, binary });
|
|
56
|
+
queuedBytes += bytes.length;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
client.close();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
upstream.on("open", () => {
|
|
63
|
+
for (const message of queued)
|
|
64
|
+
upstream.send(message.data, { binary: message.binary });
|
|
65
|
+
queued.length = 0;
|
|
66
|
+
});
|
|
67
|
+
upstream.on("message", (data, binary) => {
|
|
68
|
+
if (!binary) {
|
|
69
|
+
try {
|
|
70
|
+
const response = JSON.parse(data.toString());
|
|
71
|
+
const pendingMethod = response && typeof response === "object" && "id" in response &&
|
|
72
|
+
(typeof response.id === "number" || typeof response.id === "string")
|
|
73
|
+
? pendingRequests.get(response.id) : undefined;
|
|
74
|
+
if (pendingMethod && response && typeof response === "object" && "id" in response &&
|
|
75
|
+
(typeof response.id === "number" || typeof response.id === "string")) {
|
|
76
|
+
pendingRequests.delete(response.id);
|
|
77
|
+
options.onTrace?.(`response method=${pendingMethod} success=${"result" in response}`);
|
|
78
|
+
}
|
|
79
|
+
if (pendingMethod && response && typeof response === "object" && "result" in response &&
|
|
80
|
+
response.result && typeof response.result === "object" &&
|
|
81
|
+
"thread" in response.result && response.result.thread &&
|
|
82
|
+
typeof response.result.thread === "object" && "id" in response.result.thread &&
|
|
83
|
+
typeof response.result.thread.id === "string" &&
|
|
84
|
+
!("parentThreadId" in response.result.thread &&
|
|
85
|
+
typeof response.result.thread.parentThreadId === "string") &&
|
|
86
|
+
!("ephemeral" in response.result.thread && response.result.thread.ephemeral === true)) {
|
|
87
|
+
const cwd = "cwd" in response.result.thread && typeof response.result.thread.cwd === "string"
|
|
88
|
+
? response.result.thread.cwd
|
|
89
|
+
: undefined;
|
|
90
|
+
options.onTrace?.(`opened thread=${response.result.thread.id.slice(0, 12)} cwd=${cwd ?? "unknown"}`);
|
|
91
|
+
options.onThreadOpened(response.result.thread.id, cwd);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// A malformed response must not interrupt the visible client.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (client.readyState === WebSocket.OPEN)
|
|
99
|
+
client.send(data, { binary });
|
|
100
|
+
});
|
|
101
|
+
client.on("close", () => upstream.terminate());
|
|
102
|
+
upstream.on("close", () => client.close());
|
|
103
|
+
client.on("error", () => upstream.terminate());
|
|
104
|
+
upstream.on("error", () => client.close());
|
|
105
|
+
client.on("close", () => {
|
|
106
|
+
this.clients.delete(client);
|
|
107
|
+
options.onTrace?.("client_disconnected");
|
|
108
|
+
});
|
|
109
|
+
upstream.on("close", () => this.upstreams.delete(upstream));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
static async start(options) {
|
|
113
|
+
const proxy = new CodexVisibleThreadProxy(options);
|
|
114
|
+
try {
|
|
115
|
+
await new Promise((resolve, reject) => {
|
|
116
|
+
proxy.server.once("listening", resolve);
|
|
117
|
+
proxy.server.once("error", reject);
|
|
118
|
+
});
|
|
119
|
+
return proxy;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
proxy.server.close();
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
get connected() {
|
|
127
|
+
return this.clients.size > 0;
|
|
128
|
+
}
|
|
129
|
+
get url() {
|
|
130
|
+
const address = this.server.address();
|
|
131
|
+
return `ws://127.0.0.1:${address.port}`;
|
|
132
|
+
}
|
|
133
|
+
async close() {
|
|
134
|
+
for (const client of this.clients)
|
|
135
|
+
client.terminate();
|
|
136
|
+
for (const upstream of this.upstreams)
|
|
137
|
+
upstream.terminate();
|
|
138
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -245,6 +245,18 @@ export function parseClaudeModelPicker(text) {
|
|
|
245
245
|
return [{ index: Number(match[2]), label: match[3].replace(/[✓✔]/g, "").replace(/\s+/g, " ").trim(), current: /[✓✔]/.test(line), focused: /[❯>]/.test(match[1]) }];
|
|
246
246
|
});
|
|
247
247
|
}
|
|
248
|
+
export function parseClaudeModelSwitchConfirmation(text) {
|
|
249
|
+
const lines = text.split("\n");
|
|
250
|
+
const start = lines.findLastIndex((line) => line.trim() === "Switch model?");
|
|
251
|
+
if (start < 0)
|
|
252
|
+
return null;
|
|
253
|
+
const choices = lines.slice(start + 1);
|
|
254
|
+
const yes = choices.map((line) => /^\s*(❯|>)?\s*1\. Yes, switch to (.+?)\s*$/.exec(line)).find((match) => match);
|
|
255
|
+
const noIndex = choices.findIndex((line) => /^\s*(?:❯|>)?\s*2\. No, go back\s*$/.test(line));
|
|
256
|
+
if (!yes || noIndex < 0 || choices.slice(noIndex + 1).some((line) => /^\s*❯/.test(line)))
|
|
257
|
+
return null;
|
|
258
|
+
return { model: yes[2], yesFocused: Boolean(yes[1]) };
|
|
259
|
+
}
|
|
248
260
|
export function readClaudePermissionMode(text) {
|
|
249
261
|
const lines = text.split("\n");
|
|
250
262
|
const prompt = lines.findLastIndex((line) => /^\s*❯/.test(line));
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
|
|
3
|
+
// Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
|
|
4
|
+
// This file is part of CLI-WeChat-Bridge. Modifications and derivative works
|
|
5
|
+
// must be released under AGPL-3.0-or-later with full source code; see
|
|
6
|
+
// LICENSE.txt. Network services built on it must offer source to users.
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function ok(data) {
|
|
11
|
+
return { data };
|
|
12
|
+
}
|
|
13
|
+
async function result(operation) {
|
|
14
|
+
try {
|
|
15
|
+
return ok(await operation());
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
return { error };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function location(directory) {
|
|
22
|
+
return typeof directory === "string" && directory
|
|
23
|
+
? { directory }
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
export function normalizeOpenCodeV2Session(value) {
|
|
27
|
+
const session = isRecord(value) ? value : {};
|
|
28
|
+
const sessionLocation = isRecord(session.location) ? session.location : {};
|
|
29
|
+
const time = isRecord(session.time) ? session.time : {};
|
|
30
|
+
const model = isRecord(session.model) ? session.model : undefined;
|
|
31
|
+
return {
|
|
32
|
+
...session,
|
|
33
|
+
id: typeof session.id === "string" ? session.id : "",
|
|
34
|
+
directory: typeof sessionLocation.directory === "string"
|
|
35
|
+
? sessionLocation.directory
|
|
36
|
+
: "",
|
|
37
|
+
workspaceID: typeof session.projectID === "string" ? session.projectID : undefined,
|
|
38
|
+
parentID: typeof session.parentID === "string" ? session.parentID : undefined,
|
|
39
|
+
time: {
|
|
40
|
+
...time,
|
|
41
|
+
created: typeof time.created === "number" ? time.created : Date.now(),
|
|
42
|
+
updated: typeof time.updated === "number" ? time.updated : Date.now(),
|
|
43
|
+
},
|
|
44
|
+
model: model
|
|
45
|
+
? {
|
|
46
|
+
...model,
|
|
47
|
+
id: typeof model.id === "string" ? model.id : "",
|
|
48
|
+
providerID: typeof model.providerID === "string" ? model.providerID : "",
|
|
49
|
+
variant: typeof model.variant === "string" ? model.variant : "default",
|
|
50
|
+
}
|
|
51
|
+
: undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function sessionFromCreatedEvent(data) {
|
|
55
|
+
const eventLocation = isRecord(data.location) ? data.location : {};
|
|
56
|
+
return normalizeOpenCodeV2Session({
|
|
57
|
+
id: data.sessionID,
|
|
58
|
+
projectID: data.projectID,
|
|
59
|
+
parentID: data.parentID,
|
|
60
|
+
title: data.title,
|
|
61
|
+
agent: data.agent,
|
|
62
|
+
model: data.model,
|
|
63
|
+
location: eventLocation,
|
|
64
|
+
time: { created: Date.now(), updated: Date.now() },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function textPart(data, text) {
|
|
68
|
+
const ordinal = typeof data.ordinal === "number" ? data.ordinal : 0;
|
|
69
|
+
return {
|
|
70
|
+
id: `${String(data.assistantMessageID ?? "message")}:text:${ordinal}`,
|
|
71
|
+
sessionID: data.sessionID,
|
|
72
|
+
messageID: data.assistantMessageID,
|
|
73
|
+
type: "text",
|
|
74
|
+
...(text === undefined ? {} : { text }),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function normalizeFormQuestions(form) {
|
|
78
|
+
const fields = Array.isArray(form.fields) ? form.fields : [];
|
|
79
|
+
return fields.flatMap((field) => {
|
|
80
|
+
if (!isRecord(field) || typeof field.key !== "string")
|
|
81
|
+
return [];
|
|
82
|
+
const configuredOptions = Array.isArray(field.options)
|
|
83
|
+
? field.options.filter(isRecord).map((option) => ({
|
|
84
|
+
label: typeof option.label === "string" ? option.label : String(option.value ?? ""),
|
|
85
|
+
description: typeof option.description === "string" ? option.description : "",
|
|
86
|
+
}))
|
|
87
|
+
: [];
|
|
88
|
+
const options = field.type === "boolean" && configuredOptions.length === 0
|
|
89
|
+
? [
|
|
90
|
+
{ label: "Yes", description: "Answer true." },
|
|
91
|
+
{ label: "No", description: "Answer false." },
|
|
92
|
+
]
|
|
93
|
+
: configuredOptions;
|
|
94
|
+
return [{
|
|
95
|
+
id: field.key,
|
|
96
|
+
header: typeof field.title === "string"
|
|
97
|
+
? field.title
|
|
98
|
+
: typeof form.title === "string"
|
|
99
|
+
? form.title
|
|
100
|
+
: "OpenCode question",
|
|
101
|
+
question: typeof field.description === "string"
|
|
102
|
+
? field.description
|
|
103
|
+
: typeof field.title === "string"
|
|
104
|
+
? field.title
|
|
105
|
+
: field.key,
|
|
106
|
+
options,
|
|
107
|
+
multiple: field.type === "multiselect",
|
|
108
|
+
custom: field.custom === true || options.length === 0,
|
|
109
|
+
}];
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function normalizeFormAnswer(field, values) {
|
|
113
|
+
if (field.type === "multiselect")
|
|
114
|
+
return values;
|
|
115
|
+
const value = values[0] ?? "";
|
|
116
|
+
if (field.type === "boolean") {
|
|
117
|
+
return /^(1|true|yes|y)$/i.test(String(value));
|
|
118
|
+
}
|
|
119
|
+
if (field.type === "number" || field.type === "integer") {
|
|
120
|
+
const number = Number(value);
|
|
121
|
+
return Number.isFinite(number) ? number : value;
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
/** Convert OpenCode 2 events to the stable event vocabulary consumed by the adapter. */
|
|
126
|
+
export function normalizeOpenCodeV2Event(value) {
|
|
127
|
+
if (!isRecord(value) || typeof value.type !== "string")
|
|
128
|
+
return [];
|
|
129
|
+
const data = isRecord(value.data) ? value.data : {};
|
|
130
|
+
const eventLocation = isRecord(value.location) ? value.location : {};
|
|
131
|
+
const directory = typeof eventLocation.directory === "string"
|
|
132
|
+
? eventLocation.directory
|
|
133
|
+
: undefined;
|
|
134
|
+
switch (value.type) {
|
|
135
|
+
case "session.created":
|
|
136
|
+
return [{ type: "session.created", directory, properties: { info: sessionFromCreatedEvent(data) } }];
|
|
137
|
+
case "session.deleted":
|
|
138
|
+
return [{ type: "session.deleted", directory, properties: { sessionID: data.sessionID } }];
|
|
139
|
+
case "session.status":
|
|
140
|
+
return [{ type: "session.status", directory, properties: data }];
|
|
141
|
+
case "session.execution.started":
|
|
142
|
+
return [{ type: "session.status", directory, properties: { sessionID: data.sessionID, status: { type: "busy" } } }];
|
|
143
|
+
case "session.execution.succeeded":
|
|
144
|
+
case "session.execution.interrupted":
|
|
145
|
+
return [{ type: "session.idle", directory, properties: { sessionID: data.sessionID } }];
|
|
146
|
+
case "session.execution.failed":
|
|
147
|
+
return [
|
|
148
|
+
{ type: "session.error", directory, properties: { sessionID: data.sessionID, error: data.error } },
|
|
149
|
+
{ type: "session.idle", directory, properties: { sessionID: data.sessionID } },
|
|
150
|
+
];
|
|
151
|
+
case "session.text.started":
|
|
152
|
+
return [{
|
|
153
|
+
type: "message.part.updated",
|
|
154
|
+
directory,
|
|
155
|
+
properties: { part: textPart(data, "") },
|
|
156
|
+
}];
|
|
157
|
+
case "session.text.delta":
|
|
158
|
+
return [{
|
|
159
|
+
type: "message.part.delta",
|
|
160
|
+
directory,
|
|
161
|
+
properties: {
|
|
162
|
+
sessionID: data.sessionID,
|
|
163
|
+
messageID: data.assistantMessageID,
|
|
164
|
+
partID: textPart(data).id,
|
|
165
|
+
field: "text",
|
|
166
|
+
delta: data.delta,
|
|
167
|
+
},
|
|
168
|
+
}];
|
|
169
|
+
case "session.text.ended":
|
|
170
|
+
return [{
|
|
171
|
+
type: "message.part.updated",
|
|
172
|
+
directory,
|
|
173
|
+
properties: { part: textPart(data, typeof data.text === "string" ? data.text : "") },
|
|
174
|
+
}];
|
|
175
|
+
case "session.inbox.enqueued": {
|
|
176
|
+
const item = isRecord(data.item) ? data.item : {};
|
|
177
|
+
const payload = isRecord(item.payload) ? item.payload : {};
|
|
178
|
+
if (item.type !== "user" || typeof payload.text !== "string")
|
|
179
|
+
return [];
|
|
180
|
+
const messageID = typeof data.inboxID === "string" ? data.inboxID : String(value.id ?? "user");
|
|
181
|
+
return [
|
|
182
|
+
{
|
|
183
|
+
type: "message.updated",
|
|
184
|
+
directory,
|
|
185
|
+
properties: {
|
|
186
|
+
sessionID: data.sessionID,
|
|
187
|
+
info: { id: messageID, sessionID: data.sessionID, role: "user" },
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
type: "message.part.updated",
|
|
192
|
+
directory,
|
|
193
|
+
properties: {
|
|
194
|
+
sessionID: data.sessionID,
|
|
195
|
+
part: {
|
|
196
|
+
id: `${messageID}:text:0`,
|
|
197
|
+
sessionID: data.sessionID,
|
|
198
|
+
messageID,
|
|
199
|
+
type: "text",
|
|
200
|
+
text: payload.text,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
case "permission.asked":
|
|
207
|
+
return [{
|
|
208
|
+
type: "permission.asked",
|
|
209
|
+
directory,
|
|
210
|
+
properties: {
|
|
211
|
+
...data,
|
|
212
|
+
requestID: data.id,
|
|
213
|
+
permission: data.action,
|
|
214
|
+
patterns: data.resources,
|
|
215
|
+
},
|
|
216
|
+
}];
|
|
217
|
+
case "permission.replied":
|
|
218
|
+
return [{ type: "permission.replied", directory, properties: data }];
|
|
219
|
+
case "form.created": {
|
|
220
|
+
const form = isRecord(data.form) ? data.form : {};
|
|
221
|
+
return [{
|
|
222
|
+
type: "question.asked",
|
|
223
|
+
directory,
|
|
224
|
+
properties: {
|
|
225
|
+
sessionID: form.sessionID,
|
|
226
|
+
id: form.id,
|
|
227
|
+
requestID: form.id,
|
|
228
|
+
questions: normalizeFormQuestions(form),
|
|
229
|
+
},
|
|
230
|
+
}];
|
|
231
|
+
}
|
|
232
|
+
case "form.replied": {
|
|
233
|
+
const form = isRecord(data.form) ? data.form : data;
|
|
234
|
+
return [{
|
|
235
|
+
type: "question.replied",
|
|
236
|
+
directory,
|
|
237
|
+
properties: { sessionID: form.sessionID, requestID: form.id ?? data.formID },
|
|
238
|
+
}];
|
|
239
|
+
}
|
|
240
|
+
case "form.cancelled": {
|
|
241
|
+
const form = isRecord(data.form) ? data.form : data;
|
|
242
|
+
return [{
|
|
243
|
+
type: "question.rejected",
|
|
244
|
+
directory,
|
|
245
|
+
properties: { sessionID: form.sessionID, requestID: form.id ?? data.formID },
|
|
246
|
+
}];
|
|
247
|
+
}
|
|
248
|
+
case "server.connected":
|
|
249
|
+
case "session.idle":
|
|
250
|
+
case "tui.prompt.append":
|
|
251
|
+
case "tui.command.execute":
|
|
252
|
+
case "tui.session.select":
|
|
253
|
+
case "tui.toast.show":
|
|
254
|
+
return [{ type: value.type, directory, properties: data }];
|
|
255
|
+
default:
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function mapProviders(providersValue, modelsValue) {
|
|
260
|
+
const providers = isRecord(providersValue) && Array.isArray(providersValue.data)
|
|
261
|
+
? providersValue.data.filter(isRecord)
|
|
262
|
+
: [];
|
|
263
|
+
const models = isRecord(modelsValue) && Array.isArray(modelsValue.data)
|
|
264
|
+
? modelsValue.data.filter(isRecord)
|
|
265
|
+
: [];
|
|
266
|
+
const providerById = new Map();
|
|
267
|
+
for (const provider of providers) {
|
|
268
|
+
if (typeof provider.id === "string")
|
|
269
|
+
providerById.set(provider.id, provider);
|
|
270
|
+
}
|
|
271
|
+
for (const model of models) {
|
|
272
|
+
if (typeof model.providerID !== "string" || providerById.has(model.providerID))
|
|
273
|
+
continue;
|
|
274
|
+
providerById.set(model.providerID, {
|
|
275
|
+
id: model.providerID,
|
|
276
|
+
name: model.providerID,
|
|
277
|
+
activation: "enabled",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const all = Array.from(providerById.values()).map((provider) => {
|
|
281
|
+
const providerID = typeof provider.id === "string" ? provider.id : "";
|
|
282
|
+
const providerModels = Object.fromEntries(models
|
|
283
|
+
.filter((model) => model.providerID === providerID && typeof model.id === "string")
|
|
284
|
+
.map((model) => {
|
|
285
|
+
const variants = Array.isArray(model.variants)
|
|
286
|
+
? Object.fromEntries(model.variants.filter(isRecord).flatMap((variant) => typeof variant.id === "string" ? [[variant.id, variant]] : []))
|
|
287
|
+
: isRecord(model.variants)
|
|
288
|
+
? model.variants
|
|
289
|
+
: {};
|
|
290
|
+
return [model.id, {
|
|
291
|
+
...model,
|
|
292
|
+
name: typeof model.name === "string" ? model.name : model.id,
|
|
293
|
+
variants,
|
|
294
|
+
}];
|
|
295
|
+
}));
|
|
296
|
+
return { ...provider, models: providerModels };
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
all,
|
|
300
|
+
connected: all
|
|
301
|
+
.filter((provider) => provider.activation !== "disabled" && typeof provider.id === "string")
|
|
302
|
+
.map((provider) => provider.id),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
export async function createOpenCodeV2CompatClient(options) {
|
|
306
|
+
const { OpenCode } = await import("@opencode/client");
|
|
307
|
+
const client = OpenCode.make({ baseUrl: options.baseUrl, headers: options.headers });
|
|
308
|
+
const formFields = new Map();
|
|
309
|
+
const formSessions = new Map();
|
|
310
|
+
const permissionSessions = new Map();
|
|
311
|
+
const sessionStatuses = new Map();
|
|
312
|
+
const waitForPopulatedList = async (load) => {
|
|
313
|
+
let response;
|
|
314
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
315
|
+
response = await load();
|
|
316
|
+
if (isRecord(response) && Array.isArray(response.data) && response.data.length > 0) {
|
|
317
|
+
return response;
|
|
318
|
+
}
|
|
319
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
320
|
+
}
|
|
321
|
+
return response;
|
|
322
|
+
};
|
|
323
|
+
const listAvailableModels = (requestOptions) => waitForPopulatedList(() => client.model.list({ location: directoryLocation }, requestOptions));
|
|
324
|
+
async function* subscribe(input) {
|
|
325
|
+
for await (const event of client.event.subscribe(input)) {
|
|
326
|
+
if (isRecord(event) && event.type === "form.created" && isRecord(event.data) && isRecord(event.data.form)) {
|
|
327
|
+
const form = event.data.form;
|
|
328
|
+
if (typeof form.id === "string" && Array.isArray(form.fields)) {
|
|
329
|
+
formFields.set(form.id, form.fields.filter(isRecord).filter((field) => typeof field.key === "string"));
|
|
330
|
+
if (typeof form.sessionID === "string") {
|
|
331
|
+
formSessions.set(form.id, form.sessionID);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (isRecord(event) && event.type === "permission.asked" && isRecord(event.data)) {
|
|
336
|
+
if (typeof event.data.id === "string" && typeof event.data.sessionID === "string") {
|
|
337
|
+
permissionSessions.set(event.data.id, event.data.sessionID);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (isRecord(event) && isRecord(event.data) && typeof event.data.sessionID === "string") {
|
|
341
|
+
if (event.type === "session.execution.started") {
|
|
342
|
+
sessionStatuses.set(event.data.sessionID, "busy");
|
|
343
|
+
}
|
|
344
|
+
else if (event.type === "session.execution.succeeded" ||
|
|
345
|
+
event.type === "session.execution.failed" ||
|
|
346
|
+
event.type === "session.execution.interrupted" ||
|
|
347
|
+
event.type === "session.idle") {
|
|
348
|
+
sessionStatuses.set(event.data.sessionID, "idle");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const normalized of normalizeOpenCodeV2Event(event)) {
|
|
352
|
+
yield normalized;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const directoryLocation = location(options.directory);
|
|
357
|
+
return {
|
|
358
|
+
server: client.server,
|
|
359
|
+
app: {
|
|
360
|
+
agents: (_input, requestOptions) => result(async () => {
|
|
361
|
+
const response = await waitForPopulatedList(() => client.agent.list({ location: directoryLocation }, requestOptions));
|
|
362
|
+
return isRecord(response) && Array.isArray(response.data)
|
|
363
|
+
? response.data.filter(isRecord).map((agent) => ({
|
|
364
|
+
...agent,
|
|
365
|
+
name: typeof agent.id === "string" ? agent.id : agent.name,
|
|
366
|
+
}))
|
|
367
|
+
: [];
|
|
368
|
+
}),
|
|
369
|
+
},
|
|
370
|
+
provider: {
|
|
371
|
+
list: (_input, requestOptions) => result(async () => mapProviders(await client.provider.list({ location: directoryLocation }, requestOptions), await listAvailableModels(requestOptions))),
|
|
372
|
+
},
|
|
373
|
+
session: {
|
|
374
|
+
create: (_input, requestOptions) => result(async () => normalizeOpenCodeV2Session(await client.session.create({ location: directoryLocation }, requestOptions))),
|
|
375
|
+
list: (_input, requestOptions) => result(async () => {
|
|
376
|
+
const response = await client.session.list({ directory: options.directory, order: "desc" }, requestOptions);
|
|
377
|
+
return isRecord(response) && Array.isArray(response.data)
|
|
378
|
+
? response.data.map(normalizeOpenCodeV2Session)
|
|
379
|
+
: [];
|
|
380
|
+
}),
|
|
381
|
+
get: (input, requestOptions) => result(async () => normalizeOpenCodeV2Session(await client.session.get({ sessionID: input.sessionID }, requestOptions))),
|
|
382
|
+
status: (_input, requestOptions) => result(async () => {
|
|
383
|
+
const response = await client.session.list({ directory: options.directory, order: "desc" }, requestOptions);
|
|
384
|
+
const sessions = isRecord(response) && Array.isArray(response.data) ? response.data.filter(isRecord) : [];
|
|
385
|
+
return Object.fromEntries(sessions.flatMap((session) => {
|
|
386
|
+
if (typeof session.id !== "string")
|
|
387
|
+
return [];
|
|
388
|
+
return [[session.id, { type: sessionStatuses.get(session.id) ?? "idle" }]];
|
|
389
|
+
}));
|
|
390
|
+
}),
|
|
391
|
+
promptAsync: (input, requestOptions) => result(async () => {
|
|
392
|
+
const parts = Array.isArray(input.parts) ? input.parts.filter(isRecord) : [];
|
|
393
|
+
const text = parts.flatMap((part) => part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
|
|
394
|
+
await client.session.prompt({ sessionID: input.sessionID, text }, requestOptions);
|
|
395
|
+
return {};
|
|
396
|
+
}),
|
|
397
|
+
abort: (input, requestOptions) => result(async () => await client.session.interrupt({ sessionID: input.sessionID }, requestOptions)),
|
|
398
|
+
},
|
|
399
|
+
v2: {
|
|
400
|
+
session: {
|
|
401
|
+
switchAgent: (input, requestOptions) => result(async () => await client.session.switchAgent(input, requestOptions)),
|
|
402
|
+
switchModel: (input, requestOptions) => result(async () => await client.session.switchModel(input, requestOptions)),
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
permission: {
|
|
406
|
+
reply: (input, requestOptions) => result(async () => await client.permission.reply({
|
|
407
|
+
sessionID: input.sessionID ?? permissionSessions.get(String(input.requestID)),
|
|
408
|
+
requestID: input.requestID,
|
|
409
|
+
decision: input.reply === "reject" ? "reject" : input.reply,
|
|
410
|
+
}, requestOptions).finally(() => permissionSessions.delete(String(input.requestID)))),
|
|
411
|
+
},
|
|
412
|
+
question: {
|
|
413
|
+
reply: (input, requestOptions) => result(async () => {
|
|
414
|
+
const answers = Array.isArray(input.answers) ? input.answers : [];
|
|
415
|
+
const fields = formFields.get(String(input.requestID)) ?? [];
|
|
416
|
+
const answer = Object.fromEntries(fields.flatMap((field, index) => {
|
|
417
|
+
if (typeof field.key !== "string")
|
|
418
|
+
return [];
|
|
419
|
+
const values = Array.isArray(answers[index]) ? answers[index] : [];
|
|
420
|
+
return [[field.key, normalizeFormAnswer(field, values)]];
|
|
421
|
+
}));
|
|
422
|
+
await client.session.form.reply({
|
|
423
|
+
sessionID: input.sessionID ?? formSessions.get(String(input.requestID)),
|
|
424
|
+
formID: input.requestID,
|
|
425
|
+
answer,
|
|
426
|
+
}, requestOptions);
|
|
427
|
+
formFields.delete(String(input.requestID));
|
|
428
|
+
formSessions.delete(String(input.requestID));
|
|
429
|
+
return {};
|
|
430
|
+
}),
|
|
431
|
+
reject: (input, requestOptions) => result(async () => {
|
|
432
|
+
await client.session.form.cancel({
|
|
433
|
+
sessionID: input.sessionID ?? formSessions.get(String(input.requestID)),
|
|
434
|
+
formID: input.requestID,
|
|
435
|
+
}, requestOptions);
|
|
436
|
+
formFields.delete(String(input.requestID));
|
|
437
|
+
formSessions.delete(String(input.requestID));
|
|
438
|
+
return {};
|
|
439
|
+
}),
|
|
440
|
+
},
|
|
441
|
+
event: {
|
|
442
|
+
subscribe: async (_input, requestOptions) => ({
|
|
443
|
+
stream: subscribe(requestOptions),
|
|
444
|
+
}),
|
|
445
|
+
},
|
|
446
|
+
tui: {
|
|
447
|
+
selectSession: async () => ok({}),
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
|
|
3
|
+
// Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
|
|
4
|
+
// This file is part of CLI-WeChat-Bridge. Modifications and derivative works
|
|
5
|
+
// must be released under AGPL-3.0-or-later with full source code; see
|
|
6
|
+
// LICENSE.txt. Network services built on it must offer source to users.
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
const ROUTE_POLL_INTERVAL_MS = 100;
|
|
9
|
+
const MAX_BUFFER_SIZE = 1024 * 1024;
|
|
10
|
+
export default {
|
|
11
|
+
id: "cli-wechat-bridge.session-route-v2",
|
|
12
|
+
setup(context) {
|
|
13
|
+
const port = Number(context.options.port);
|
|
14
|
+
const token = typeof context.options.token === "string" ? context.options.token : "";
|
|
15
|
+
if (!Number.isInteger(port) || port <= 0 || !token) {
|
|
16
|
+
return () => undefined;
|
|
17
|
+
}
|
|
18
|
+
const socket = net.connect({ host: "127.0.0.1", port });
|
|
19
|
+
let connected = false;
|
|
20
|
+
let inputBuffer = "";
|
|
21
|
+
let lastSessionId;
|
|
22
|
+
let queuedFrame = null;
|
|
23
|
+
const writeFrame = (frame) => {
|
|
24
|
+
if (socket.destroyed)
|
|
25
|
+
return;
|
|
26
|
+
if (!connected) {
|
|
27
|
+
queuedFrame = frame;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
socket.write(`${JSON.stringify(frame)}\n`);
|
|
31
|
+
};
|
|
32
|
+
const publishRoute = () => {
|
|
33
|
+
const route = context.ui.router.current();
|
|
34
|
+
const sessionId = route.type === "session" && typeof route.sessionID === "string"
|
|
35
|
+
? route.sessionID
|
|
36
|
+
: null;
|
|
37
|
+
if (sessionId === lastSessionId)
|
|
38
|
+
return;
|
|
39
|
+
lastSessionId = sessionId;
|
|
40
|
+
writeFrame({
|
|
41
|
+
type: "route_state",
|
|
42
|
+
sessionId,
|
|
43
|
+
observedAt: new Date().toISOString(),
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
socket.setEncoding("utf8");
|
|
47
|
+
socket.on("connect", () => {
|
|
48
|
+
socket.setNoDelay(true);
|
|
49
|
+
socket.write(`${JSON.stringify({ type: "hello", token })}\n`);
|
|
50
|
+
connected = true;
|
|
51
|
+
publishRoute();
|
|
52
|
+
if (queuedFrame) {
|
|
53
|
+
socket.write(`${JSON.stringify(queuedFrame)}\n`);
|
|
54
|
+
queuedFrame = null;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
socket.on("data", (chunk) => {
|
|
58
|
+
inputBuffer += chunk;
|
|
59
|
+
if (inputBuffer.length > MAX_BUFFER_SIZE) {
|
|
60
|
+
socket.destroy();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (;;) {
|
|
64
|
+
const newline = inputBuffer.indexOf("\n");
|
|
65
|
+
if (newline < 0)
|
|
66
|
+
break;
|
|
67
|
+
const line = inputBuffer.slice(0, newline).trim();
|
|
68
|
+
inputBuffer = inputBuffer.slice(newline + 1);
|
|
69
|
+
if (!line)
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
const frame = JSON.parse(line);
|
|
73
|
+
if (frame.type === "select_session" && typeof frame.sessionId === "string") {
|
|
74
|
+
context.ui.router.navigate({ type: "session", sessionID: frame.sessionId });
|
|
75
|
+
publishRoute();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Ignore malformed bridge frames and keep the visible TUI alive.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
socket.on("error", () => {
|
|
84
|
+
// Keep the native TUI usable if the bridge-side observer disappears.
|
|
85
|
+
});
|
|
86
|
+
const timer = setInterval(publishRoute, ROUTE_POLL_INTERVAL_MS);
|
|
87
|
+
timer.unref?.();
|
|
88
|
+
return () => {
|
|
89
|
+
clearInterval(timer);
|
|
90
|
+
socket.destroy();
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unlinearity/cli-wechat-bridge",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "CLI bridge for Codex, Claude Code, OpenCode, and Pi over WeChat and WeCom",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -53,10 +53,12 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
55
55
|
"@opencode-ai/sdk": "^1.18.0",
|
|
56
|
+
"@opencode/client": "^2.0.18",
|
|
56
57
|
"@wecom/aibot-node-sdk": "1.0.7",
|
|
57
58
|
"@xterm/headless": "^6.0.0",
|
|
58
59
|
"node-pty": "^1.1.0",
|
|
59
|
-
"qrcode-terminal": "^0.12.0"
|
|
60
|
+
"qrcode-terminal": "^0.12.0",
|
|
61
|
+
"ws": "^8.22.0"
|
|
60
62
|
},
|
|
61
63
|
"overrides": {
|
|
62
64
|
"@hono/node-server": "2.1.1",
|