@unlinearity/cli-wechat-bridge 1.1.5 → 1.2.0
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 +434 -427
- package/bin/_run-entry.mjs +47 -9
- 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 +163 -5
- package/dist/bridge/bridge-adapters.codex.js +331 -29
- package/dist/bridge/bridge-adapters.core.js +27 -2
- package/dist/bridge/bridge-adapters.opencode.js +156 -1
- package/dist/bridge/bridge-adapters.pi.js +46 -1
- package/dist/bridge/bridge-adapters.shared.js +8 -1
- package/dist/bridge/bridge-controller.js +6 -0
- package/dist/bridge/bridge-final-reply.js +8 -65
- package/dist/bridge/bridge-process-reaper.js +41 -2
- package/dist/bridge/bridge-state.js +17 -4
- package/dist/bridge/bridge-utils.js +10 -127
- package/dist/bridge/claude-hook.js +6 -0
- package/dist/bridge/claude-native-controls.js +133 -0
- package/dist/bridge/native-terminal-control.js +259 -0
- package/dist/bridge/wechat-bridge.js +520 -274
- package/dist/bridge/wechat-forwarding.js +8 -58
- package/dist/bridge/wechat-outbound-queue.js +8 -0
- package/dist/channels/wechat/bridge-final-reply.js +56 -0
- package/dist/channels/wechat/channel-message.js +24 -0
- package/dist/channels/wechat/index.js +11 -0
- package/dist/channels/wechat/wechat-channel-port.js +67 -0
- package/dist/channels/wechat/wechat-driver.js +191 -0
- package/dist/channels/wechat/wechat-forwarding.js +97 -0
- package/dist/channels/wechat/wechat-outbound-queue.js +118 -0
- 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/pi-tui-bridge-extension.js +62 -3
- package/dist/core/bridge-control.js +42 -0
- package/dist/core/bridge-defer.js +25 -0
- package/dist/core/bridge-event-forwarder.js +50 -0
- package/dist/core/bridge-message-router.js +32 -0
- package/dist/core/channel-driver.js +1 -0
- package/dist/core/channel-types.js +3 -0
- 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 +69 -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 +827 -209
- package/dist/i18n/index.js +6 -0
- package/dist/i18n/messages-en.js +11 -3
- package/dist/i18n/messages-zh.js +11 -3
- 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 +14 -4
- package/scripts/ensure-node-pty-permissions.mjs +73 -73
|
@@ -1,9 +1,15 @@
|
|
|
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";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
10
|
+
import { parseBridgeControlCommand, } from "../core/bridge-control.js";
|
|
11
|
+
import { normalizeOutput, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
|
|
12
|
+
export { buildInstanceId, buildOneTimeCode, formatDuration, normalizeOutput, nowIso, stripAnsi, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
|
|
7
13
|
// Messages older than bridge start minus this grace window are treated as
|
|
8
14
|
// pre-start backlog and skipped. The window must absorb realistic clock skew
|
|
9
15
|
// between the local machine and WeChat server timestamps: with a small value,
|
|
@@ -99,29 +105,6 @@ const INLINE_REFERENCE_ONLY_FILE_EXTENSIONS = new Set([
|
|
|
99
105
|
const INLINE_MAAS_URL_RE = /https?:\/\/[^\s]*?\/([A-Za-z]:\\.+?(?:\.\s*[A-Za-z0-9]{2,8})+)(?:\?[^\n]*)?/g;
|
|
100
106
|
const INLINE_WINDOWS_PATH_RE = /(^|[^\w])`?([A-Za-z]:\\(?:[^\\/:*?"<>|\r\n`]+\\)*[^\\/:*?"<>|\r\n`]+?(?:\.\s*[A-Za-z0-9]{2,8})+)`?(?=$|[^\w])/gm;
|
|
101
107
|
const INLINE_HOME_RELATIVE_PATH_RE = /(^|[^\w])`?((?:~[\\/])?(?:Desktop|Documents|Downloads|Pictures|Videos|Music)[\\/](?:[^\\/:*?"<>|\r\n`]+[\\/])*[^\\/:*?"<>|\r\n`]+?(?:\.\s*[A-Za-z0-9]{2,8})+)`?(?=$|[^\w])/gim;
|
|
102
|
-
export function nowIso() {
|
|
103
|
-
return new Date().toISOString();
|
|
104
|
-
}
|
|
105
|
-
export function stripAnsi(text) {
|
|
106
|
-
return text.replace(ANSI_ESCAPE_RE, "");
|
|
107
|
-
}
|
|
108
|
-
export function normalizeOutput(text) {
|
|
109
|
-
return stripAnsi(text)
|
|
110
|
-
// eslint-disable-next-line no-control-regex
|
|
111
|
-
.replace(/\u0000/g, "")
|
|
112
|
-
.replace(/\r\n/g, "\n")
|
|
113
|
-
.replace(/\r/g, "\n");
|
|
114
|
-
}
|
|
115
|
-
export function truncatePreview(text, maxLength = 140) {
|
|
116
|
-
const normalized = normalizeOutput(text).trim().replace(/\s+/g, " ");
|
|
117
|
-
if (!normalized) {
|
|
118
|
-
return "(empty)";
|
|
119
|
-
}
|
|
120
|
-
if (normalized.length <= maxLength) {
|
|
121
|
-
return normalized;
|
|
122
|
-
}
|
|
123
|
-
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
124
|
-
}
|
|
125
108
|
export function isThinkingForwardEnabled() {
|
|
126
109
|
if (process.env.CLI_BRIDGE_THINKING_FORWARD === "1") {
|
|
127
110
|
return true;
|
|
@@ -150,51 +133,8 @@ export function formatThinkingForWechat(text, maxLength = 500) {
|
|
|
150
133
|
}
|
|
151
134
|
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
152
135
|
}
|
|
153
|
-
export function buildOneTimeCode(length = 6) {
|
|
154
|
-
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
155
|
-
let code = "";
|
|
156
|
-
while (code.length < length) {
|
|
157
|
-
code += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
158
|
-
}
|
|
159
|
-
return code;
|
|
160
|
-
}
|
|
161
|
-
export function buildInstanceId() {
|
|
162
|
-
return `bridge-${Date.now().toString(36)}-${buildOneTimeCode(6).toLowerCase()}`;
|
|
163
|
-
}
|
|
164
136
|
export function parseSystemCommand(text) {
|
|
165
|
-
|
|
166
|
-
if (!trimmed.startsWith("/")) {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
const [rawCommand, ...rest] = trimmed.split(/\s+/);
|
|
170
|
-
if (!rawCommand) {
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
const command = rawCommand.toLowerCase();
|
|
174
|
-
const argument = rest.join(" ").trim();
|
|
175
|
-
switch (command) {
|
|
176
|
-
case "/status":
|
|
177
|
-
return { type: "status" };
|
|
178
|
-
case "/resume":
|
|
179
|
-
return argument ? { type: "resume", target: argument } : { type: "resume" };
|
|
180
|
-
case "/new":
|
|
181
|
-
case "/new-session":
|
|
182
|
-
return { type: "new_session" };
|
|
183
|
-
case "/stop":
|
|
184
|
-
return { type: "stop" };
|
|
185
|
-
case "/reset":
|
|
186
|
-
return { type: "reset" };
|
|
187
|
-
case "/confirm":
|
|
188
|
-
case "/yes":
|
|
189
|
-
return { type: "confirm" };
|
|
190
|
-
case "/deny":
|
|
191
|
-
case "/no":
|
|
192
|
-
return { type: "deny" };
|
|
193
|
-
case "/answer":
|
|
194
|
-
return argument ? { type: "answer", raw: argument } : null;
|
|
195
|
-
default:
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
137
|
+
return parseBridgeControlCommand(text);
|
|
198
138
|
}
|
|
199
139
|
export function parseWechatControlCommand(text, options) {
|
|
200
140
|
const systemCommand = parseSystemCommand(text);
|
|
@@ -474,32 +414,6 @@ export function detectCliApproval(text) {
|
|
|
474
414
|
denyInput: matched.denyInput,
|
|
475
415
|
};
|
|
476
416
|
}
|
|
477
|
-
export function formatDuration(durationMs) {
|
|
478
|
-
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
479
|
-
return "0s";
|
|
480
|
-
}
|
|
481
|
-
const totalSeconds = Math.floor(durationMs / 1000);
|
|
482
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
483
|
-
const seconds = totalSeconds % 60;
|
|
484
|
-
if (!minutes) {
|
|
485
|
-
return `${seconds}s`;
|
|
486
|
-
}
|
|
487
|
-
return `${minutes}m ${seconds}s`;
|
|
488
|
-
}
|
|
489
|
-
export function summarizeOutput(text, maxLength = 280) {
|
|
490
|
-
const normalized = normalizeOutput(text)
|
|
491
|
-
.split("\n")
|
|
492
|
-
.map((line) => line.trim())
|
|
493
|
-
.filter(Boolean);
|
|
494
|
-
if (!normalized.length) {
|
|
495
|
-
return "(no output)";
|
|
496
|
-
}
|
|
497
|
-
const summary = normalized.slice(-6).join("\n");
|
|
498
|
-
if (summary.length <= maxLength) {
|
|
499
|
-
return summary;
|
|
500
|
-
}
|
|
501
|
-
return summary.slice(summary.length - maxLength);
|
|
502
|
-
}
|
|
503
417
|
export function formatStatusReport(bridgeState, adapterState) {
|
|
504
418
|
const pending = bridgeState.pendingConfirmation;
|
|
505
419
|
const pendingUserInput = bridgeState.pendingUserInput;
|
|
@@ -1205,37 +1119,6 @@ export function isStrictApprovalModeEnabled(env = process.env) {
|
|
|
1205
1119
|
const value = env.CLI_BRIDGE_STRICT_APPROVAL?.trim().toLowerCase();
|
|
1206
1120
|
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
1207
1121
|
}
|
|
1208
|
-
// Outbound WeChat text size cap shared by the streaming OutputBatcher and the
|
|
1209
|
-
// final-reply forwarder. Long replies sent as a single sendmessage call can be
|
|
1210
|
-
// rejected by the server, which previously made long final replies vanish.
|
|
1211
|
-
export const WECHAT_TEXT_CHUNK_MAX_CHARS = 1_200;
|
|
1212
|
-
export function splitWechatTextIntoChunks(text, maxChars = WECHAT_TEXT_CHUNK_MAX_CHARS) {
|
|
1213
|
-
const normalized = text.trim();
|
|
1214
|
-
if (!normalized) {
|
|
1215
|
-
return [];
|
|
1216
|
-
}
|
|
1217
|
-
if (normalized.length <= maxChars) {
|
|
1218
|
-
return [normalized];
|
|
1219
|
-
}
|
|
1220
|
-
const chunks = [];
|
|
1221
|
-
let remaining = normalized;
|
|
1222
|
-
while (remaining.length > maxChars) {
|
|
1223
|
-
// Prefer breaking at a newline reasonably close to the cap so paragraphs
|
|
1224
|
-
// stay intact; fall back to a hard split.
|
|
1225
|
-
const window = remaining.slice(0, maxChars + 1);
|
|
1226
|
-
const newlineIndex = window.lastIndexOf("\n");
|
|
1227
|
-
const splitIndex = newlineIndex > maxChars / 2 ? newlineIndex : maxChars;
|
|
1228
|
-
const chunk = remaining.slice(0, splitIndex).trim();
|
|
1229
|
-
if (chunk) {
|
|
1230
|
-
chunks.push(chunk);
|
|
1231
|
-
}
|
|
1232
|
-
remaining = remaining.slice(splitIndex).trim();
|
|
1233
|
-
}
|
|
1234
|
-
if (remaining) {
|
|
1235
|
-
chunks.push(remaining);
|
|
1236
|
-
}
|
|
1237
|
-
return chunks;
|
|
1238
|
-
}
|
|
1239
1122
|
export function shouldDropStartupBacklogMessage(createdAtMs, bridgeStartedAtMs, graceMs = MESSAGE_START_GRACE_MS) {
|
|
1240
1123
|
// A missing or unparsable timestamp must not drop the message: treat it as
|
|
1241
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,133 @@
|
|
|
1
|
+
import { NativeTerminalControl, parseClaudeModelPicker, 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(() => readClaudePermissionMode(this.terminal.text) !== null);
|
|
87
|
+
await this.terminal.openMenu("/model", "Select model");
|
|
88
|
+
const applied = parseClaudeModelPicker(this.terminal.text).find((row) => row.focused);
|
|
89
|
+
if (!applied || applied.label !== target.label || applied.index !== target.index)
|
|
90
|
+
throw new Error("Claude did not confirm the selected model.");
|
|
91
|
+
await this.terminal.closeMenu();
|
|
92
|
+
return { id, displayName: target.label, isCurrent: true };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async setPlanMode(enabled) {
|
|
96
|
+
return this.terminal.run(async () => {
|
|
97
|
+
this.assertPrompt();
|
|
98
|
+
const current = readClaudePermissionMode(this.terminal.text);
|
|
99
|
+
const session = this.session();
|
|
100
|
+
if (this.previousMode?.session !== session)
|
|
101
|
+
this.previousMode = null;
|
|
102
|
+
if (enabled) {
|
|
103
|
+
if (current === "plan")
|
|
104
|
+
return true;
|
|
105
|
+
if (!["default", "acceptEdits", "bypassPermissions", "auto"].includes(current)) {
|
|
106
|
+
throw new Error(`Claude cannot restore permission mode ${current} through the native mode switch.`);
|
|
107
|
+
}
|
|
108
|
+
await this.terminal.command("/plan");
|
|
109
|
+
await this.terminal.waitFor(() => readClaudePermissionMode(this.terminal.text) === "plan");
|
|
110
|
+
this.previousMode = { session, mode: current };
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (current !== "plan") {
|
|
114
|
+
this.previousMode = null;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const target = this.previousMode?.mode ?? "default";
|
|
118
|
+
const visited = new Set();
|
|
119
|
+
while (true) {
|
|
120
|
+
const mode = readClaudePermissionMode(this.terminal.text);
|
|
121
|
+
if (mode === target)
|
|
122
|
+
break;
|
|
123
|
+
if (!mode || visited.has(mode))
|
|
124
|
+
throw new Error(`Claude could not restore ${target}; check the native permission mode.`);
|
|
125
|
+
visited.add(mode);
|
|
126
|
+
await this.terminal.key("\u001b[Z");
|
|
127
|
+
await this.terminal.waitFor(() => readClaudePermissionMode(this.terminal.text) !== mode);
|
|
128
|
+
}
|
|
129
|
+
this.previousMode = null;
|
|
130
|
+
return false;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
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 readClaudePermissionMode(text) {
|
|
249
|
+
const lines = text.split("\n");
|
|
250
|
+
const prompt = lines.findLastIndex((line) => /^\s*❯/.test(line));
|
|
251
|
+
if (prompt < 0 || !/^\s*❯\s*$/.test(lines[prompt]))
|
|
252
|
+
return null;
|
|
253
|
+
const footer = lines.slice(prompt + 1).join("\n");
|
|
254
|
+
for (const [label, mode] of [["plan mode", "plan"], ["accept edits", "acceptEdits"], ["bypass permissions", "bypassPermissions"], ["auto", "auto"], ["don't ask", "dontAsk"]]) {
|
|
255
|
+
if (footer.includes(`${label} on`))
|
|
256
|
+
return mode;
|
|
257
|
+
}
|
|
258
|
+
return "default";
|
|
259
|
+
}
|