@unlinearity/cli-wechat-bridge 1.2.0 → 1.2.2
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 +25 -10
- 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/adapter-control.js +19 -12
- package/dist/bridge/bridge-adapters.claude.js +219 -115
- package/dist/bridge/bridge-adapters.codex.js +295 -90
- package/dist/bridge/bridge-adapters.core.js +84 -7
- package/dist/bridge/bridge-adapters.opencode.js +344 -74
- package/dist/bridge/bridge-adapters.pi.js +23 -18
- package/dist/bridge/bridge-adapters.shared.js +9 -9
- package/dist/bridge/bridge-session-resume.js +13 -6
- package/dist/bridge/bridge-utils.js +94 -144
- package/dist/bridge/claude-hooks.js +60 -3
- package/dist/bridge/claude-native-controls.js +32 -13
- package/dist/bridge/codex-visible-thread-proxy.js +140 -0
- package/dist/bridge/language-control.js +33 -0
- package/dist/bridge/native-terminal-control.js +38 -10
- package/dist/bridge/opencode-v2-compat.js +458 -0
- package/dist/bridge/wechat-bridge.js +57 -42
- package/dist/channels/channel-driver-factory.js +44 -0
- package/dist/channels/local/local-channel-port.js +40 -0
- package/dist/channels/local/local-driver.js +217 -0
- package/dist/channels/wechat/wechat-driver.js +11 -2
- package/dist/channels/wecom/wecom-driver.js +8 -1
- package/dist/companion/local-companion.js +27 -7
- package/dist/companion/opencode-v2-tui-bridge-plugin.js +93 -0
- package/dist/companion/pi-tui-bridge-extension.js +10 -4
- package/dist/core/bridge-control.js +3 -0
- package/dist/core/bridge-defer.js +3 -2
- package/dist/core/bridge-event-forwarder.js +3 -0
- package/dist/core/channel-types.js +5 -1
- package/dist/daemon/wechat-daemon.js +202 -124
- package/dist/i18n/index.js +25 -11
- package/dist/i18n/messages-en.js +244 -1
- package/dist/i18n/messages-zh.js +244 -1
- package/dist/runtime/legacy-adapter-runtime.js +7 -4
- package/dist/wechat/channel-config.js +3 -2
- package/package.json +4 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 { getLocale, parseLocale, setLocale, t } from "../i18n/index.js";
|
|
8
|
+
import { writeJsonFileAtomic } from "../utils/atomic-file.js";
|
|
9
|
+
let languageChangeChain = Promise.resolve();
|
|
10
|
+
/** Language changes never touch turn ownership or pending interactions. */
|
|
11
|
+
export function handleLanguageControl(params) {
|
|
12
|
+
const run = languageChangeChain.then(() => applyLanguageControl(params));
|
|
13
|
+
languageChangeChain = run.catch(() => undefined);
|
|
14
|
+
return run;
|
|
15
|
+
}
|
|
16
|
+
async function applyLanguageControl(params) {
|
|
17
|
+
if (!params.target)
|
|
18
|
+
return t("language.current", { locale: getLocale() });
|
|
19
|
+
const locale = parseLocale(params.target);
|
|
20
|
+
if (!locale)
|
|
21
|
+
return t("language.usage");
|
|
22
|
+
try {
|
|
23
|
+
writeJsonFileAtomic(params.settingsFile, { locale });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return t("language.saveFailed", { error: error instanceof Error ? error.message : String(error) });
|
|
27
|
+
}
|
|
28
|
+
setLocale(locale);
|
|
29
|
+
process.env.CLI_BRIDGE_LANG = locale;
|
|
30
|
+
const results = await Promise.allSettled((params.adapters ?? []).map((adapter) => adapter.updateLocale?.(locale) ?? true));
|
|
31
|
+
const needsRestart = results.some((result) => result.status === "rejected" || result.value === false);
|
|
32
|
+
return t("language.changed", { locale }) + (needsRestart ? `\n${t("language.companionRestart")}` : "");
|
|
33
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// must be released under AGPL-3.0-or-later with full source code; see
|
|
6
6
|
// LICENSE.txt. Network services built on it must offer source to users.
|
|
7
7
|
import xterm from "@xterm/headless";
|
|
8
|
+
import { t } from "../i18n/index.js";
|
|
8
9
|
const { Terminal } = xterm;
|
|
9
10
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
11
|
/** A screen mirror, never a second input source: only explicit control operations write keys. */
|
|
@@ -117,20 +118,20 @@ export class NativeTerminalControl {
|
|
|
117
118
|
check() {
|
|
118
119
|
const operation = this.operation;
|
|
119
120
|
if (!operation)
|
|
120
|
-
throw new Error("
|
|
121
|
+
throw new Error(t("native.control.inactive"));
|
|
121
122
|
if (operation.error)
|
|
122
123
|
throw operation.error;
|
|
123
124
|
if (operation.identity !== this.options.identity()) {
|
|
124
|
-
operation.error = new Error("
|
|
125
|
+
operation.error = new Error(t("native.control.sessionChanged"));
|
|
125
126
|
throw operation.error;
|
|
126
127
|
}
|
|
127
128
|
this.options.assertReady();
|
|
128
129
|
if (Date.now() > operation.deadline)
|
|
129
|
-
throw new Error("
|
|
130
|
+
throw new Error(t("native.control.timeout"));
|
|
130
131
|
}
|
|
131
132
|
async run(action) {
|
|
132
133
|
if (this.operation)
|
|
133
|
-
throw new Error("
|
|
134
|
+
throw new Error(t("native.control.busy"));
|
|
134
135
|
this.options.assertReady();
|
|
135
136
|
this.operation = { identity: this.options.identity(), deadline: Date.now() + 25_000 };
|
|
136
137
|
try {
|
|
@@ -143,7 +144,7 @@ export class NativeTerminalControl {
|
|
|
143
144
|
else if (this.draft && this.options.isPromptEmpty?.())
|
|
144
145
|
this.draft = false;
|
|
145
146
|
if (this.draft)
|
|
146
|
-
throw new Error("
|
|
147
|
+
throw new Error(t("native.control.draft"));
|
|
147
148
|
const result = await action();
|
|
148
149
|
this.check();
|
|
149
150
|
return result;
|
|
@@ -183,7 +184,7 @@ export class NativeTerminalControl {
|
|
|
183
184
|
}
|
|
184
185
|
async command(command) {
|
|
185
186
|
if (!/^\/[a-z]+$/.test(command))
|
|
186
|
-
throw new Error("
|
|
187
|
+
throw new Error(t("native.control.invalid"));
|
|
187
188
|
await this.key(`\u001b[200~${command}\u001b[201~`);
|
|
188
189
|
await this.key("\r");
|
|
189
190
|
// Claude can consume the first Enter to accept a slash completion while its
|
|
@@ -211,7 +212,7 @@ export class NativeTerminalControl {
|
|
|
211
212
|
}
|
|
212
213
|
async openMenu(command, title) {
|
|
213
214
|
if (this.ownedMenu || /Select model|Select agent|Select variant/.test(this.text)) {
|
|
214
|
-
throw new Error("
|
|
215
|
+
throw new Error(t("native.control.menuOpen"));
|
|
215
216
|
}
|
|
216
217
|
await this.command(command);
|
|
217
218
|
await this.waitFor(() => this.hasMenu(title));
|
|
@@ -220,7 +221,7 @@ export class NativeTerminalControl {
|
|
|
220
221
|
async closeMenu() {
|
|
221
222
|
const title = this.ownedMenu;
|
|
222
223
|
if (!title || !this.hasMenu(title))
|
|
223
|
-
throw new Error("
|
|
224
|
+
throw new Error(t("native.control.selectorChanged"));
|
|
224
225
|
await this.key("\u001b");
|
|
225
226
|
await this.waitFor(() => !this.hasMenu(title));
|
|
226
227
|
this.ownedMenu = null;
|
|
@@ -228,7 +229,7 @@ export class NativeTerminalControl {
|
|
|
228
229
|
async submitMenu(nextTitle, key = "\r") {
|
|
229
230
|
const title = this.ownedMenu;
|
|
230
231
|
if (!title || !this.hasMenu(title))
|
|
231
|
-
throw new Error("
|
|
232
|
+
throw new Error(t("native.control.selectorChanged"));
|
|
232
233
|
await this.key(key);
|
|
233
234
|
await this.waitFor(() => !this.hasMenu(title));
|
|
234
235
|
this.ownedMenu = nextTitle && this.hasMenu(nextTitle) ? nextTitle : null;
|
|
@@ -245,10 +246,22 @@ export function parseClaudeModelPicker(text) {
|
|
|
245
246
|
return [{ index: Number(match[2]), label: match[3].replace(/[✓✔]/g, "").replace(/\s+/g, " ").trim(), current: /[✓✔]/.test(line), focused: /[❯>]/.test(match[1]) }];
|
|
246
247
|
});
|
|
247
248
|
}
|
|
249
|
+
export function parseClaudeModelSwitchConfirmation(text) {
|
|
250
|
+
const lines = text.split("\n");
|
|
251
|
+
const start = lines.findLastIndex((line) => line.trim() === "Switch model?");
|
|
252
|
+
if (start < 0)
|
|
253
|
+
return null;
|
|
254
|
+
const choices = lines.slice(start + 1);
|
|
255
|
+
const yes = choices.map((line) => /^\s*(❯|>)?\s*1\. Yes, switch to (.+?)\s*$/.exec(line)).find((match) => match);
|
|
256
|
+
const noIndex = choices.findIndex((line) => /^\s*(?:❯|>)?\s*2\. No, go back\s*$/.test(line));
|
|
257
|
+
if (!yes || noIndex < 0 || choices.slice(noIndex + 1).some((line) => /^\s*❯/.test(line)))
|
|
258
|
+
return null;
|
|
259
|
+
return { model: yes[2], yesFocused: Boolean(yes[1]) };
|
|
260
|
+
}
|
|
248
261
|
export function readClaudePermissionMode(text) {
|
|
249
262
|
const lines = text.split("\n");
|
|
250
263
|
const prompt = lines.findLastIndex((line) => /^\s*❯/.test(line));
|
|
251
|
-
if (prompt < 0 ||
|
|
264
|
+
if (prompt < 0 || !isClaudePromptVisuallyEmpty(lines[prompt]))
|
|
252
265
|
return null;
|
|
253
266
|
const footer = lines.slice(prompt + 1).join("\n");
|
|
254
267
|
for (const [label, mode] of [["plan mode", "plan"], ["accept edits", "acceptEdits"], ["bypass permissions", "bypassPermissions"], ["auto", "auto"], ["don't ask", "dontAsk"]]) {
|
|
@@ -257,3 +270,18 @@ export function readClaudePermissionMode(text) {
|
|
|
257
270
|
}
|
|
258
271
|
return "default";
|
|
259
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Claude renders idle decorations inside the input box: an automatic context
|
|
275
|
+
* chip like `[⧉ In file.ts]` and a suggestion placeholder like
|
|
276
|
+
* Try "how does <filepath> work?". Both appear on a freshly started session
|
|
277
|
+
* before any user input; neither is a draft. Real typed input still fails
|
|
278
|
+
* this check (and the local-input mirror blocks controls independently).
|
|
279
|
+
*/
|
|
280
|
+
function isClaudePromptVisuallyEmpty(line) {
|
|
281
|
+
const body = line
|
|
282
|
+
.replace(/^\s*❯\s*/, "")
|
|
283
|
+
.replace(/^\[⧉ [^\]]*\]\s*/, "")
|
|
284
|
+
.replace(/^Try "[^"]*"\s*$/, "")
|
|
285
|
+
.replace(/\s+$/, "");
|
|
286
|
+
return body.length === 0;
|
|
287
|
+
}
|