@unlinearity/cli-wechat-bridge 1.1.6 → 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/LICENSE.txt +661 -661
- package/README.md +468 -475
- package/bin/_run-entry.mjs +29 -6
- package/bin/wechat-check-update.mjs +6 -1
- package/bin/wechat-claude.mjs +6 -1
- package/bin/wechat-codex.mjs +6 -1
- package/bin/wechat-daemon.mjs +6 -1
- package/bin/wechat-opencode.mjs +6 -1
- package/bin/wechat-pi.mjs +6 -1
- package/bin/wechat-setup.mjs +6 -1
- package/bin/wecom-check-update.mjs +10 -0
- package/bin/wecom-claude.mjs +10 -0
- package/bin/wecom-codex.mjs +10 -0
- package/bin/wecom-daemon.mjs +10 -0
- package/bin/wecom-opencode.mjs +10 -0
- package/bin/wecom-pi.mjs +10 -0
- package/bin/wecom-setup.mjs +10 -0
- package/dist/bridge/adapter-control.js +58 -0
- package/dist/bridge/bridge-adapters.claude.js +161 -4
- package/dist/bridge/bridge-adapters.codex.js +206 -14
- package/dist/bridge/bridge-adapters.core.js +25 -1
- package/dist/bridge/bridge-adapters.opencode.js +298 -20
- package/dist/bridge/bridge-adapters.pi.js +45 -0
- package/dist/bridge/bridge-adapters.shared.js +6 -0
- package/dist/bridge/bridge-controller.js +6 -0
- package/dist/bridge/bridge-final-reply.js +6 -0
- package/dist/bridge/bridge-process-reaper.js +41 -2
- package/dist/bridge/bridge-state.js +16 -3
- package/dist/bridge/bridge-utils.js +6 -31
- package/dist/bridge/claude-hook.js +6 -0
- package/dist/bridge/claude-native-controls.js +145 -0
- package/dist/bridge/codex-visible-thread-proxy.js +140 -0
- package/dist/bridge/native-terminal-control.js +271 -0
- package/dist/bridge/opencode-v2-compat.js +450 -0
- package/dist/bridge/wechat-bridge.js +380 -171
- package/dist/bridge/wechat-forwarding.js +6 -0
- package/dist/bridge/wechat-outbound-queue.js +6 -0
- package/dist/channels/wechat/bridge-final-reply.js +13 -22
- package/dist/channels/wechat/index.js +6 -0
- package/dist/channels/wechat/wechat-driver.js +191 -0
- package/dist/channels/wechat/wechat-forwarding.js +18 -0
- package/dist/channels/wechat/wechat-outbound-queue.js +49 -3
- package/dist/channels/wecom/bridge-final-reply.js +32 -0
- package/dist/channels/wecom/setup-cli.js +12 -0
- package/dist/channels/wecom/setup.js +182 -0
- package/dist/channels/wecom/wecom-channel-port.js +45 -0
- package/dist/channels/wecom/wecom-config.js +72 -0
- package/dist/channels/wecom/wecom-driver.js +106 -0
- package/dist/channels/wecom/wecom-media.js +147 -0
- package/dist/channels/wecom/wecom-message.js +149 -0
- package/dist/channels/wecom/wecom-transport.js +314 -0
- package/dist/commands/check-update.js +6 -0
- package/dist/companion/codex-remote-client.js +30 -3
- package/dist/companion/codex-visible-client-link.js +107 -0
- package/dist/companion/local-companion-link.js +6 -0
- package/dist/companion/local-companion-start.js +62 -11
- package/dist/companion/local-companion.js +25 -1
- package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
- package/dist/companion/opencode-v2-tui-bridge-plugin.js +93 -0
- package/dist/companion/pi-tui-bridge-extension.js +62 -3
- package/dist/core/bridge-control.js +10 -0
- package/dist/core/bridge-defer.js +25 -0
- package/dist/core/channel-driver.js +1 -0
- package/dist/core/channel-types.js +3 -1
- package/dist/core/conversation-routing.js +58 -0
- package/dist/core/direct-run.js +23 -0
- package/dist/core/inbound-message-claims.js +67 -0
- package/dist/core/outbound-target.js +32 -0
- package/dist/core/text-utils.js +6 -0
- package/dist/core/turn-coordinator.js +85 -0
- package/dist/daemon/daemon-link.js +9 -0
- package/dist/daemon/emoji-bindings.js +6 -0
- package/dist/daemon/wechat-daemon.js +675 -122
- package/dist/i18n/index.js +6 -0
- package/dist/i18n/messages-en.js +9 -1
- package/dist/i18n/messages-zh.js +9 -1
- package/dist/runtime/create-runtime-host.js +6 -0
- package/dist/runtime/legacy-adapter-runtime.js +15 -0
- package/dist/runtime/runtime-types.js +3 -0
- package/dist/utils/atomic-file.js +6 -0
- package/dist/utils/doctor.js +36 -10
- package/dist/utils/version-checker.js +17 -11
- package/dist/wechat/channel-config.js +6 -0
- package/dist/wechat/setup.js +119 -13
- package/dist/wechat/wechat-channel.js +6 -0
- package/dist/wechat/wechat-transport.js +169 -58
- package/package.json +16 -4
- package/scripts/ensure-node-pty-permissions.mjs +73 -73
|
@@ -1,3 +1,9 @@
|
|
|
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.
|
|
1
7
|
import fs from "node:fs";
|
|
2
8
|
import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
|
|
3
9
|
import { buildInstanceId } from "../core/text-utils.js";
|
|
@@ -79,6 +85,9 @@ export function normalizeBridgeLockPayload(value) {
|
|
|
79
85
|
startedAt: record.startedAt,
|
|
80
86
|
lifecycle: record.lifecycle === "companion_bound" ? "companion_bound" : "persistent",
|
|
81
87
|
legacyLifecycleFallback: hasExplicitLifecycle ? undefined : true,
|
|
88
|
+
...(record.channelId === "wechat" || record.channelId === "wecom"
|
|
89
|
+
? { channelId: record.channelId }
|
|
90
|
+
: {}),
|
|
82
91
|
};
|
|
83
92
|
}
|
|
84
93
|
export function readBridgeLockFile() {
|
|
@@ -148,11 +157,13 @@ function buildLockConflictError(lock) {
|
|
|
148
157
|
* pid is alive but belongs to something else, and "unknown" when the command
|
|
149
158
|
* line could not be resolved (probe failure, restricted permissions).
|
|
150
159
|
*/
|
|
151
|
-
export function classifyLockHolderProcess(pid) {
|
|
152
|
-
|
|
160
|
+
export function classifyLockHolderProcess(pid, dependencies = {}) {
|
|
161
|
+
const isProcessAlive = dependencies.isPidAlive ?? isPidAlive;
|
|
162
|
+
const getProcessRecord = dependencies.getProcessRecordByPid ?? getProcessRecordByPid;
|
|
163
|
+
if (!isProcessAlive(pid)) {
|
|
153
164
|
return "unknown";
|
|
154
165
|
}
|
|
155
|
-
const record =
|
|
166
|
+
const record = getProcessRecord(pid);
|
|
156
167
|
if (!record) {
|
|
157
168
|
return "unknown";
|
|
158
169
|
}
|
|
@@ -200,6 +211,7 @@ export class BridgeStateStore {
|
|
|
200
211
|
cwd: options.cwd,
|
|
201
212
|
startedAt: new Date(this.bridgeStartedAtMs).toISOString(),
|
|
202
213
|
lifecycle: options.lifecycle,
|
|
214
|
+
channelId: options.channelId,
|
|
203
215
|
};
|
|
204
216
|
this.acquireLock();
|
|
205
217
|
const shouldRestoreSessionState = options.sessionStartMode !== "new";
|
|
@@ -231,6 +243,7 @@ export class BridgeStateStore {
|
|
|
231
243
|
resumeConversationId: persistedResumeConversationId,
|
|
232
244
|
transcriptPath: persistedTranscriptPath,
|
|
233
245
|
lastActivityAt: persisted?.lastActivityAt,
|
|
246
|
+
channelId: options.channelId,
|
|
234
247
|
pendingConfirmation: null,
|
|
235
248
|
pendingUserInput: null,
|
|
236
249
|
};
|
|
@@ -1,3 +1,9 @@
|
|
|
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.
|
|
1
7
|
import fs from "node:fs";
|
|
2
8
|
import os from "node:os";
|
|
3
9
|
import path from "node:path";
|
|
@@ -1113,37 +1119,6 @@ export function isStrictApprovalModeEnabled(env = process.env) {
|
|
|
1113
1119
|
const value = env.CLI_BRIDGE_STRICT_APPROVAL?.trim().toLowerCase();
|
|
1114
1120
|
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
1115
1121
|
}
|
|
1116
|
-
// Outbound WeChat text size cap shared by the streaming OutputBatcher and the
|
|
1117
|
-
// final-reply forwarder. Long replies sent as a single sendmessage call can be
|
|
1118
|
-
// rejected by the server, which previously made long final replies vanish.
|
|
1119
|
-
export const WECHAT_TEXT_CHUNK_MAX_CHARS = 1_200;
|
|
1120
|
-
export function splitWechatTextIntoChunks(text, maxChars = WECHAT_TEXT_CHUNK_MAX_CHARS) {
|
|
1121
|
-
const normalized = text.trim();
|
|
1122
|
-
if (!normalized) {
|
|
1123
|
-
return [];
|
|
1124
|
-
}
|
|
1125
|
-
if (normalized.length <= maxChars) {
|
|
1126
|
-
return [normalized];
|
|
1127
|
-
}
|
|
1128
|
-
const chunks = [];
|
|
1129
|
-
let remaining = normalized;
|
|
1130
|
-
while (remaining.length > maxChars) {
|
|
1131
|
-
// Prefer breaking at a newline reasonably close to the cap so paragraphs
|
|
1132
|
-
// stay intact; fall back to a hard split.
|
|
1133
|
-
const window = remaining.slice(0, maxChars + 1);
|
|
1134
|
-
const newlineIndex = window.lastIndexOf("\n");
|
|
1135
|
-
const splitIndex = newlineIndex > maxChars / 2 ? newlineIndex : maxChars;
|
|
1136
|
-
const chunk = remaining.slice(0, splitIndex).trim();
|
|
1137
|
-
if (chunk) {
|
|
1138
|
-
chunks.push(chunk);
|
|
1139
|
-
}
|
|
1140
|
-
remaining = remaining.slice(splitIndex).trim();
|
|
1141
|
-
}
|
|
1142
|
-
if (remaining) {
|
|
1143
|
-
chunks.push(remaining);
|
|
1144
|
-
}
|
|
1145
|
-
return chunks;
|
|
1146
|
-
}
|
|
1147
1122
|
export function shouldDropStartupBacklogMessage(createdAtMs, bridgeStartedAtMs, graceMs = MESSAGE_START_GRACE_MS) {
|
|
1148
1123
|
// A missing or unparsable timestamp must not drop the message: treat it as
|
|
1149
1124
|
// fresh and let normal processing continue.
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
|
|
4
|
+
// Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
|
|
5
|
+
// This file is part of CLI-WeChat-Bridge. Modifications and derivative works
|
|
6
|
+
// must be released under AGPL-3.0-or-later with full source code; see
|
|
7
|
+
// LICENSE.txt. Network services built on it must offer source to users.
|
|
2
8
|
import crypto from "node:crypto";
|
|
3
9
|
import net from "node:net";
|
|
4
10
|
async function readStdin() {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { NativeTerminalControl, parseClaudeModelPicker, parseClaudeModelSwitchConfirmation, readClaudePermissionMode } from "./native-terminal-control.js";
|
|
2
|
+
export class ClaudeNativeControls {
|
|
3
|
+
previousMode = null;
|
|
4
|
+
terminal;
|
|
5
|
+
session;
|
|
6
|
+
constructor(terminal, session) {
|
|
7
|
+
this.terminal = terminal;
|
|
8
|
+
this.session = session;
|
|
9
|
+
}
|
|
10
|
+
assertPrompt() {
|
|
11
|
+
if (readClaudePermissionMode(this.terminal.text) === null) {
|
|
12
|
+
throw new Error("Claude must be at an empty native prompt before changing models or plan mode.");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async listModels() {
|
|
16
|
+
return this.terminal.run(async () => {
|
|
17
|
+
this.assertPrompt();
|
|
18
|
+
await this.terminal.openMenu("/model", "Select model");
|
|
19
|
+
const options = new Map();
|
|
20
|
+
const visited = new Set();
|
|
21
|
+
const initial = parseClaudeModelPicker(this.terminal.text).find((option) => option.focused)?.index;
|
|
22
|
+
while (true) {
|
|
23
|
+
const rows = parseClaudeModelPicker(this.terminal.text);
|
|
24
|
+
for (const row of rows) {
|
|
25
|
+
options.set(row.index, {
|
|
26
|
+
id: JSON.stringify({ index: row.index, label: row.label }),
|
|
27
|
+
displayName: row.label,
|
|
28
|
+
isCurrent: row.current || row.index === initial,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const focused = rows.find((row) => row.focused);
|
|
32
|
+
if (!focused)
|
|
33
|
+
throw new Error("Could not identify the focused Claude model. Close the menu and try again.");
|
|
34
|
+
if (visited.has(focused.index))
|
|
35
|
+
break;
|
|
36
|
+
visited.add(focused.index);
|
|
37
|
+
await this.terminal.key("\u001b[B");
|
|
38
|
+
}
|
|
39
|
+
// A clamped selector can start in the middle; walk upwards to capture earlier hidden rows too.
|
|
40
|
+
const visitedUp = new Set();
|
|
41
|
+
while (true) {
|
|
42
|
+
const rows = parseClaudeModelPicker(this.terminal.text);
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
options.set(row.index, { id: JSON.stringify({ index: row.index, label: row.label }), displayName: row.label, isCurrent: row.current || row.index === initial });
|
|
45
|
+
}
|
|
46
|
+
const focused = rows.find((row) => row.focused);
|
|
47
|
+
if (!focused)
|
|
48
|
+
throw new Error("Could not identify the focused Claude model.");
|
|
49
|
+
if (visitedUp.has(focused.index))
|
|
50
|
+
break;
|
|
51
|
+
visitedUp.add(focused.index);
|
|
52
|
+
if (focused.index === 1)
|
|
53
|
+
break;
|
|
54
|
+
await this.terminal.key("\u001b[A");
|
|
55
|
+
}
|
|
56
|
+
await this.terminal.closeMenu();
|
|
57
|
+
return [...options.entries()].filter(([index]) => visited.has(index) || visitedUp.has(index)).sort(([a], [b]) => a - b).map(([, option]) => option);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async selectModel(id) {
|
|
61
|
+
const target = JSON.parse(id);
|
|
62
|
+
if (!Number.isSafeInteger(target.index) || target.index < 1 || typeof target.label !== "string")
|
|
63
|
+
throw new Error("Invalid Claude model selection. Send /model again.");
|
|
64
|
+
return this.terminal.run(async () => {
|
|
65
|
+
this.assertPrompt();
|
|
66
|
+
await this.terminal.openMenu("/model", "Select model");
|
|
67
|
+
const visited = new Set();
|
|
68
|
+
while (true) {
|
|
69
|
+
const focused = parseClaudeModelPicker(this.terminal.text).find((row) => row.focused);
|
|
70
|
+
if (!focused)
|
|
71
|
+
throw new Error("Could not identify the focused Claude model.");
|
|
72
|
+
if (focused.index === target.index) {
|
|
73
|
+
if (focused.label !== target.label)
|
|
74
|
+
throw new Error("The Claude model list changed. Send /model again.");
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (visited.has(focused.index))
|
|
78
|
+
throw new Error("The Claude model is no longer selectable. Send /model again.");
|
|
79
|
+
visited.add(focused.index);
|
|
80
|
+
await this.terminal.key(focused.index < target.index ? "\u001b[B" : "\u001b[A");
|
|
81
|
+
}
|
|
82
|
+
if (!/s to use this session only/i.test(this.terminal.text)) {
|
|
83
|
+
throw new Error("This Claude version does not expose session-only model selection. Update Claude Code and try again.");
|
|
84
|
+
}
|
|
85
|
+
await this.terminal.submitMenu(undefined, "s");
|
|
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
|
+
}
|
|
99
|
+
await this.terminal.openMenu("/model", "Select model");
|
|
100
|
+
const applied = parseClaudeModelPicker(this.terminal.text).find((row) => row.focused);
|
|
101
|
+
if (!applied || applied.label !== target.label || applied.index !== target.index)
|
|
102
|
+
throw new Error("Claude did not confirm the selected model.");
|
|
103
|
+
await this.terminal.closeMenu();
|
|
104
|
+
return { id, displayName: target.label, isCurrent: true };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async setPlanMode(enabled) {
|
|
108
|
+
return this.terminal.run(async () => {
|
|
109
|
+
this.assertPrompt();
|
|
110
|
+
const current = readClaudePermissionMode(this.terminal.text);
|
|
111
|
+
const session = this.session();
|
|
112
|
+
if (this.previousMode?.session !== session)
|
|
113
|
+
this.previousMode = null;
|
|
114
|
+
if (enabled) {
|
|
115
|
+
if (current === "plan")
|
|
116
|
+
return true;
|
|
117
|
+
if (!["default", "acceptEdits", "bypassPermissions", "auto"].includes(current)) {
|
|
118
|
+
throw new Error(`Claude cannot restore permission mode ${current} through the native mode switch.`);
|
|
119
|
+
}
|
|
120
|
+
await this.terminal.command("/plan");
|
|
121
|
+
await this.terminal.waitFor(() => readClaudePermissionMode(this.terminal.text) === "plan");
|
|
122
|
+
this.previousMode = { session, mode: current };
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
if (current !== "plan") {
|
|
126
|
+
this.previousMode = null;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const target = this.previousMode?.mode ?? "default";
|
|
130
|
+
const visited = new Set();
|
|
131
|
+
while (true) {
|
|
132
|
+
const mode = readClaudePermissionMode(this.terminal.text);
|
|
133
|
+
if (mode === target)
|
|
134
|
+
break;
|
|
135
|
+
if (!mode || visited.has(mode))
|
|
136
|
+
throw new Error(`Claude could not restore ${target}; check the native permission mode.`);
|
|
137
|
+
visited.add(mode);
|
|
138
|
+
await this.terminal.key("\u001b[Z");
|
|
139
|
+
await this.terminal.waitFor(() => readClaudePermissionMode(this.terminal.text) !== mode);
|
|
140
|
+
}
|
|
141
|
+
this.previousMode = null;
|
|
142
|
+
return false;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -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,271 @@
|
|
|
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 xterm from "@xterm/headless";
|
|
8
|
+
const { Terminal } = xterm;
|
|
9
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
/** A screen mirror, never a second input source: only explicit control operations write keys. */
|
|
11
|
+
export class NativeTerminalControl {
|
|
12
|
+
terminal = new Terminal({ cols: 120, rows: 40, allowProposedApi: true, scrollback: 0, logLevel: "off" });
|
|
13
|
+
writes = Promise.resolve();
|
|
14
|
+
lastFrameAt = 0;
|
|
15
|
+
operation = null;
|
|
16
|
+
ownedMenu = null;
|
|
17
|
+
draft = false;
|
|
18
|
+
inputSequence = "";
|
|
19
|
+
inputEscapeTimer;
|
|
20
|
+
pasting = false;
|
|
21
|
+
options;
|
|
22
|
+
constructor(options) { this.options = options; }
|
|
23
|
+
get active() { return this.operation !== null; }
|
|
24
|
+
feed(text) {
|
|
25
|
+
this.writes = this.writes.then(() => new Promise((resolve) => this.terminal.write(text, () => {
|
|
26
|
+
this.lastFrameAt = Date.now();
|
|
27
|
+
resolve();
|
|
28
|
+
})));
|
|
29
|
+
}
|
|
30
|
+
resize(cols, rows) {
|
|
31
|
+
this.terminal.resize(Math.max(20, cols), Math.max(8, rows));
|
|
32
|
+
}
|
|
33
|
+
localInput(text) {
|
|
34
|
+
// stdin also carries terminal replies (colors, capabilities, cursor position).
|
|
35
|
+
// Parse across chunks before deciding that anything came from the keyboard.
|
|
36
|
+
// The adapter still forwards every original byte to the child PTY unchanged.
|
|
37
|
+
for (const char of text) {
|
|
38
|
+
clearTimeout(this.inputEscapeTimer);
|
|
39
|
+
if (!this.inputSequence) {
|
|
40
|
+
if (char !== "\u001b") {
|
|
41
|
+
this.keyboardInput(char);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
this.inputSequence = char;
|
|
45
|
+
// A lone Escape is a key; a following introducer belongs to a sequence.
|
|
46
|
+
this.inputEscapeTimer = setTimeout(() => {
|
|
47
|
+
this.inputSequence = "";
|
|
48
|
+
this.keyboardInput("\u001b");
|
|
49
|
+
}, 50);
|
|
50
|
+
this.inputEscapeTimer.unref?.();
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
this.inputSequence += char;
|
|
54
|
+
const sequence = this.inputSequence;
|
|
55
|
+
if (sequence.length === 2 && "[]P_^XO".includes(char))
|
|
56
|
+
continue;
|
|
57
|
+
const kind = sequence[1];
|
|
58
|
+
if (kind === "[" && !/[@-~]/.test(char))
|
|
59
|
+
continue;
|
|
60
|
+
if (kind && "]P_^X".includes(kind) && char !== "\u0007" && !sequence.endsWith("\u001b\\")) {
|
|
61
|
+
// Bound malformed control strings without treating their payload as text.
|
|
62
|
+
if (sequence.length > 65_536)
|
|
63
|
+
this.inputSequence = sequence.slice(0, 2);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
this.inputSequence = "";
|
|
67
|
+
if (kind && "]P_^X".includes(kind))
|
|
68
|
+
continue;
|
|
69
|
+
if (kind === "[") {
|
|
70
|
+
const body = sequence.slice(2);
|
|
71
|
+
if (body === "200~" || body === "201~") {
|
|
72
|
+
this.pasting = body === "200~";
|
|
73
|
+
this.keyboardInput("");
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// Device/status/window reports, Kitty capability replies and focus events.
|
|
77
|
+
if (/^(?:[?>]?[\d;]*c|\??\d+;\d+R|[\d;]+[nt]|\??[\d;]+\$y|\?[\d;]+u|[IO])$/.test(body))
|
|
78
|
+
continue;
|
|
79
|
+
// Printable keys in Kitty's extended keyboard protocol are still drafts.
|
|
80
|
+
const key = /^(\d+)(?::[\d:]*)?(?:;([\d:;]+))?u$/.exec(body);
|
|
81
|
+
if (key) {
|
|
82
|
+
const code = Number(key[1]);
|
|
83
|
+
const modifiers = key[2]?.split(";")[0] ?? "1";
|
|
84
|
+
if (modifiers.endsWith(":3"))
|
|
85
|
+
continue; // Key release.
|
|
86
|
+
if (code === 13 && modifiers === "1")
|
|
87
|
+
this.keyboardInput("\r");
|
|
88
|
+
else if (code >= 32 && code < 57344)
|
|
89
|
+
this.keyboardInput("x");
|
|
90
|
+
else
|
|
91
|
+
this.keyboardInput("");
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
this.keyboardInput(""); // Navigation and mouse input cancel controls.
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
this.keyboardInput(kind === "O" ? "" : char);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
keyboardInput(char) {
|
|
101
|
+
this.cancel("The local keyboard interrupted the remote control operation.");
|
|
102
|
+
if (!this.pasting && ["\r", "\n", "\u0003", "\u0015"].includes(char))
|
|
103
|
+
this.draft = false;
|
|
104
|
+
else if (char && (char >= " " && char !== "\u007f" || this.pasting))
|
|
105
|
+
this.draft = true;
|
|
106
|
+
}
|
|
107
|
+
cancel(message) {
|
|
108
|
+
if (this.operation)
|
|
109
|
+
this.operation.error = new Error(message);
|
|
110
|
+
}
|
|
111
|
+
async flush() { await this.writes; }
|
|
112
|
+
get lines() {
|
|
113
|
+
const buffer = this.terminal.buffer.active;
|
|
114
|
+
return Array.from({ length: this.terminal.rows }, (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
|
|
115
|
+
}
|
|
116
|
+
get text() { return this.lines.join("\n"); }
|
|
117
|
+
check() {
|
|
118
|
+
const operation = this.operation;
|
|
119
|
+
if (!operation)
|
|
120
|
+
throw new Error("No terminal control operation is active.");
|
|
121
|
+
if (operation.error)
|
|
122
|
+
throw operation.error;
|
|
123
|
+
if (operation.identity !== this.options.identity()) {
|
|
124
|
+
operation.error = new Error("The active CLI session changed during control. Try again.");
|
|
125
|
+
throw operation.error;
|
|
126
|
+
}
|
|
127
|
+
this.options.assertReady();
|
|
128
|
+
if (Date.now() > operation.deadline)
|
|
129
|
+
throw new Error("Native CLI control timed out; the result could not be confirmed.");
|
|
130
|
+
}
|
|
131
|
+
async run(action) {
|
|
132
|
+
if (this.operation)
|
|
133
|
+
throw new Error("Another CLI control operation is in progress.");
|
|
134
|
+
this.options.assertReady();
|
|
135
|
+
this.operation = { identity: this.options.identity(), deadline: Date.now() + 25_000 };
|
|
136
|
+
try {
|
|
137
|
+
await this.flush();
|
|
138
|
+
if (this.options.prepare) {
|
|
139
|
+
await this.options.prepare();
|
|
140
|
+
this.check();
|
|
141
|
+
this.draft = false; // The native input state is authoritative when available.
|
|
142
|
+
}
|
|
143
|
+
else if (this.draft && this.options.isPromptEmpty?.())
|
|
144
|
+
this.draft = false;
|
|
145
|
+
if (this.draft)
|
|
146
|
+
throw new Error("The local terminal has an input draft. Submit or clear it before using remote controls.");
|
|
147
|
+
const result = await action();
|
|
148
|
+
this.check();
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
const operation = this.operation;
|
|
153
|
+
let ready = false;
|
|
154
|
+
try {
|
|
155
|
+
this.options.assertReady();
|
|
156
|
+
ready = true;
|
|
157
|
+
}
|
|
158
|
+
catch { /* A task or approval may have taken over. */ }
|
|
159
|
+
// Never send an Escape into a different session or after local input has taken over.
|
|
160
|
+
if (ready && this.ownedMenu && !operation?.error && operation?.identity === this.options.identity() && this.hasMenu(this.ownedMenu)) {
|
|
161
|
+
this.options.write("\u001b");
|
|
162
|
+
await pause(100);
|
|
163
|
+
}
|
|
164
|
+
this.ownedMenu = null;
|
|
165
|
+
this.operation = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async key(text) {
|
|
169
|
+
this.check();
|
|
170
|
+
this.options.write(text);
|
|
171
|
+
await pause(100);
|
|
172
|
+
await this.flush();
|
|
173
|
+
this.check();
|
|
174
|
+
}
|
|
175
|
+
async waitFor(predicate) {
|
|
176
|
+
while (true) {
|
|
177
|
+
this.check();
|
|
178
|
+
await this.flush();
|
|
179
|
+
if (predicate())
|
|
180
|
+
return;
|
|
181
|
+
await pause(50);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async command(command) {
|
|
185
|
+
if (!/^\/[a-z]+$/.test(command))
|
|
186
|
+
throw new Error("Invalid native control command.");
|
|
187
|
+
await this.key(`\u001b[200~${command}\u001b[201~`);
|
|
188
|
+
await this.key("\r");
|
|
189
|
+
// Claude can consume the first Enter to accept a slash completion while its
|
|
190
|
+
// interactive UI is finishing startup. Retry only while that exact command
|
|
191
|
+
// remains at the *last* prompt; never submit an arbitrary draft or a dialog.
|
|
192
|
+
for (let retry = 0; retry < 3; retry++) {
|
|
193
|
+
// A lazy-loaded command can briefly leave its autocomplete on screen.
|
|
194
|
+
// Let the initial render complete before deciding whether Enter was consumed.
|
|
195
|
+
const until = Date.now() + 1_500;
|
|
196
|
+
do {
|
|
197
|
+
await pause(100);
|
|
198
|
+
await this.flush();
|
|
199
|
+
this.check();
|
|
200
|
+
} while (Date.now() < until && Date.now() - this.lastFrameAt < 500);
|
|
201
|
+
if (Date.now() - this.lastFrameAt < 500)
|
|
202
|
+
break;
|
|
203
|
+
const prompt = this.lines.findLast((line) => /^\s*❯/.test(line))?.trim();
|
|
204
|
+
if (prompt?.replace(/^❯\s*/, "").trim() !== command || /Select model|Select agent|Select variant/.test(this.text))
|
|
205
|
+
break;
|
|
206
|
+
await this.key("\r");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
hasMenu(title) {
|
|
210
|
+
return this.lines.some((line) => line.trim() === title || new RegExp(`^${title}\\s+esc$`, "i").test(line.trim()));
|
|
211
|
+
}
|
|
212
|
+
async openMenu(command, title) {
|
|
213
|
+
if (this.ownedMenu || /Select model|Select agent|Select variant/.test(this.text)) {
|
|
214
|
+
throw new Error("A native CLI menu is already open. Close it before using remote controls.");
|
|
215
|
+
}
|
|
216
|
+
await this.command(command);
|
|
217
|
+
await this.waitFor(() => this.hasMenu(title));
|
|
218
|
+
this.ownedMenu = title;
|
|
219
|
+
}
|
|
220
|
+
async closeMenu() {
|
|
221
|
+
const title = this.ownedMenu;
|
|
222
|
+
if (!title || !this.hasMenu(title))
|
|
223
|
+
throw new Error("The native selector changed unexpectedly.");
|
|
224
|
+
await this.key("\u001b");
|
|
225
|
+
await this.waitFor(() => !this.hasMenu(title));
|
|
226
|
+
this.ownedMenu = null;
|
|
227
|
+
}
|
|
228
|
+
async submitMenu(nextTitle, key = "\r") {
|
|
229
|
+
const title = this.ownedMenu;
|
|
230
|
+
if (!title || !this.hasMenu(title))
|
|
231
|
+
throw new Error("The native selector changed unexpectedly.");
|
|
232
|
+
await this.key(key);
|
|
233
|
+
await this.waitFor(() => !this.hasMenu(title));
|
|
234
|
+
this.ownedMenu = nextTitle && this.hasMenu(nextTitle) ? nextTitle : null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
export function parseClaudeModelPicker(text) {
|
|
238
|
+
const start = text.lastIndexOf("Select model");
|
|
239
|
+
if (start < 0)
|
|
240
|
+
return [];
|
|
241
|
+
return text.slice(start).split("\n").flatMap((line) => {
|
|
242
|
+
const match = /^\s*([❯>↑↓ ]*)\s*(\d+)\.\s+(.+?)\s*$/.exec(line);
|
|
243
|
+
if (!match)
|
|
244
|
+
return [];
|
|
245
|
+
return [{ index: Number(match[2]), label: match[3].replace(/[✓✔]/g, "").replace(/\s+/g, " ").trim(), current: /[✓✔]/.test(line), focused: /[❯>]/.test(match[1]) }];
|
|
246
|
+
});
|
|
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
|
+
}
|
|
260
|
+
export function readClaudePermissionMode(text) {
|
|
261
|
+
const lines = text.split("\n");
|
|
262
|
+
const prompt = lines.findLastIndex((line) => /^\s*❯/.test(line));
|
|
263
|
+
if (prompt < 0 || !/^\s*❯\s*$/.test(lines[prompt]))
|
|
264
|
+
return null;
|
|
265
|
+
const footer = lines.slice(prompt + 1).join("\n");
|
|
266
|
+
for (const [label, mode] of [["plan mode", "plan"], ["accept edits", "acceptEdits"], ["bypass permissions", "bypassPermissions"], ["auto", "auto"], ["don't ask", "dontAsk"]]) {
|
|
267
|
+
if (footer.includes(`${label} on`))
|
|
268
|
+
return mode;
|
|
269
|
+
}
|
|
270
|
+
return "default";
|
|
271
|
+
}
|