cli-wechat-bridge 1.1.6 → 1.1.8

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.
Files changed (52) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +454 -475
  3. package/bin/_run-entry.mjs +23 -5
  4. package/bin/wecom-check-update.mjs +5 -0
  5. package/bin/wecom-claude.mjs +5 -0
  6. package/bin/wecom-codex.mjs +5 -0
  7. package/bin/wecom-daemon.mjs +5 -0
  8. package/bin/wecom-opencode.mjs +5 -0
  9. package/bin/wecom-pi.mjs +5 -0
  10. package/bin/wecom-setup.mjs +5 -0
  11. package/dist/bridge/adapter-control.js +58 -0
  12. package/dist/bridge/bridge-adapters.claude.js +155 -4
  13. package/dist/bridge/bridge-adapters.codex.js +112 -7
  14. package/dist/bridge/bridge-adapters.core.js +19 -1
  15. package/dist/bridge/bridge-adapters.opencode.js +150 -1
  16. package/dist/bridge/bridge-adapters.pi.js +39 -0
  17. package/dist/bridge/bridge-process-reaper.js +35 -2
  18. package/dist/bridge/bridge-state.js +10 -3
  19. package/dist/bridge/claude-native-controls.js +133 -0
  20. package/dist/bridge/native-terminal-control.js +253 -0
  21. package/dist/bridge/wechat-bridge.js +389 -87
  22. package/dist/channels/wechat/wechat-outbound-queue.js +10 -3
  23. package/dist/channels/wecom/bridge-final-reply.js +26 -0
  24. package/dist/channels/wecom/setup-cli.js +6 -0
  25. package/dist/channels/wecom/setup.js +176 -0
  26. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  27. package/dist/channels/wecom/wecom-config.js +66 -0
  28. package/dist/channels/wecom/wecom-media.js +141 -0
  29. package/dist/channels/wecom/wecom-message.js +149 -0
  30. package/dist/channels/wecom/wecom-transport.js +308 -0
  31. package/dist/companion/codex-remote-client.js +24 -3
  32. package/dist/companion/codex-visible-client-link.js +101 -0
  33. package/dist/companion/local-companion-start.js +56 -11
  34. package/dist/companion/local-companion.js +19 -1
  35. package/dist/companion/pi-tui-bridge-extension.js +56 -3
  36. package/dist/core/bridge-control.js +8 -0
  37. package/dist/core/channel-types.js +3 -1
  38. package/dist/core/conversation-routing.js +48 -0
  39. package/dist/core/direct-run.js +17 -0
  40. package/dist/core/inbound-message-claims.js +61 -0
  41. package/dist/daemon/daemon-link.js +3 -0
  42. package/dist/daemon/wechat-daemon.js +335 -60
  43. package/dist/i18n/messages-en.js +3 -1
  44. package/dist/i18n/messages-zh.js +3 -1
  45. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  46. package/dist/runtime/runtime-types.js +3 -0
  47. package/dist/utils/doctor.js +30 -10
  48. package/dist/utils/version-checker.js +11 -11
  49. package/dist/wechat/setup.js +113 -13
  50. package/dist/wechat/wechat-transport.js +96 -58
  51. package/package.json +116 -104
  52. package/scripts/ensure-node-pty-permissions.mjs +73 -73
@@ -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,253 @@
1
+ import xterm from "@xterm/headless";
2
+ const { Terminal } = xterm;
3
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ /** A screen mirror, never a second input source: only explicit control operations write keys. */
5
+ export class NativeTerminalControl {
6
+ terminal = new Terminal({ cols: 120, rows: 40, allowProposedApi: true, scrollback: 0, logLevel: "off" });
7
+ writes = Promise.resolve();
8
+ lastFrameAt = 0;
9
+ operation = null;
10
+ ownedMenu = null;
11
+ draft = false;
12
+ inputSequence = "";
13
+ inputEscapeTimer;
14
+ pasting = false;
15
+ options;
16
+ constructor(options) { this.options = options; }
17
+ get active() { return this.operation !== null; }
18
+ feed(text) {
19
+ this.writes = this.writes.then(() => new Promise((resolve) => this.terminal.write(text, () => {
20
+ this.lastFrameAt = Date.now();
21
+ resolve();
22
+ })));
23
+ }
24
+ resize(cols, rows) {
25
+ this.terminal.resize(Math.max(20, cols), Math.max(8, rows));
26
+ }
27
+ localInput(text) {
28
+ // stdin also carries terminal replies (colors, capabilities, cursor position).
29
+ // Parse across chunks before deciding that anything came from the keyboard.
30
+ // The adapter still forwards every original byte to the child PTY unchanged.
31
+ for (const char of text) {
32
+ clearTimeout(this.inputEscapeTimer);
33
+ if (!this.inputSequence) {
34
+ if (char !== "\u001b") {
35
+ this.keyboardInput(char);
36
+ continue;
37
+ }
38
+ this.inputSequence = char;
39
+ // A lone Escape is a key; a following introducer belongs to a sequence.
40
+ this.inputEscapeTimer = setTimeout(() => {
41
+ this.inputSequence = "";
42
+ this.keyboardInput("\u001b");
43
+ }, 50);
44
+ this.inputEscapeTimer.unref?.();
45
+ continue;
46
+ }
47
+ this.inputSequence += char;
48
+ const sequence = this.inputSequence;
49
+ if (sequence.length === 2 && "[]P_^XO".includes(char))
50
+ continue;
51
+ const kind = sequence[1];
52
+ if (kind === "[" && !/[@-~]/.test(char))
53
+ continue;
54
+ if (kind && "]P_^X".includes(kind) && char !== "\u0007" && !sequence.endsWith("\u001b\\")) {
55
+ // Bound malformed control strings without treating their payload as text.
56
+ if (sequence.length > 65_536)
57
+ this.inputSequence = sequence.slice(0, 2);
58
+ continue;
59
+ }
60
+ this.inputSequence = "";
61
+ if (kind && "]P_^X".includes(kind))
62
+ continue;
63
+ if (kind === "[") {
64
+ const body = sequence.slice(2);
65
+ if (body === "200~" || body === "201~") {
66
+ this.pasting = body === "200~";
67
+ this.keyboardInput("");
68
+ continue;
69
+ }
70
+ // Device/status/window reports, Kitty capability replies and focus events.
71
+ if (/^(?:[?>]?[\d;]*c|\??\d+;\d+R|[\d;]+[nt]|\??[\d;]+\$y|\?[\d;]+u|[IO])$/.test(body))
72
+ continue;
73
+ // Printable keys in Kitty's extended keyboard protocol are still drafts.
74
+ const key = /^(\d+)(?::[\d:]*)?(?:;([\d:;]+))?u$/.exec(body);
75
+ if (key) {
76
+ const code = Number(key[1]);
77
+ const modifiers = key[2]?.split(";")[0] ?? "1";
78
+ if (modifiers.endsWith(":3"))
79
+ continue; // Key release.
80
+ if (code === 13 && modifiers === "1")
81
+ this.keyboardInput("\r");
82
+ else if (code >= 32 && code < 57344)
83
+ this.keyboardInput("x");
84
+ else
85
+ this.keyboardInput("");
86
+ }
87
+ else
88
+ this.keyboardInput(""); // Navigation and mouse input cancel controls.
89
+ continue;
90
+ }
91
+ this.keyboardInput(kind === "O" ? "" : char);
92
+ }
93
+ }
94
+ keyboardInput(char) {
95
+ this.cancel("The local keyboard interrupted the remote control operation.");
96
+ if (!this.pasting && ["\r", "\n", "\u0003", "\u0015"].includes(char))
97
+ this.draft = false;
98
+ else if (char && (char >= " " && char !== "\u007f" || this.pasting))
99
+ this.draft = true;
100
+ }
101
+ cancel(message) {
102
+ if (this.operation)
103
+ this.operation.error = new Error(message);
104
+ }
105
+ async flush() { await this.writes; }
106
+ get lines() {
107
+ const buffer = this.terminal.buffer.active;
108
+ return Array.from({ length: this.terminal.rows }, (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
109
+ }
110
+ get text() { return this.lines.join("\n"); }
111
+ check() {
112
+ const operation = this.operation;
113
+ if (!operation)
114
+ throw new Error("No terminal control operation is active.");
115
+ if (operation.error)
116
+ throw operation.error;
117
+ if (operation.identity !== this.options.identity()) {
118
+ operation.error = new Error("The active CLI session changed during control. Try again.");
119
+ throw operation.error;
120
+ }
121
+ this.options.assertReady();
122
+ if (Date.now() > operation.deadline)
123
+ throw new Error("Native CLI control timed out; the result could not be confirmed.");
124
+ }
125
+ async run(action) {
126
+ if (this.operation)
127
+ throw new Error("Another CLI control operation is in progress.");
128
+ this.options.assertReady();
129
+ this.operation = { identity: this.options.identity(), deadline: Date.now() + 25_000 };
130
+ try {
131
+ await this.flush();
132
+ if (this.options.prepare) {
133
+ await this.options.prepare();
134
+ this.check();
135
+ this.draft = false; // The native input state is authoritative when available.
136
+ }
137
+ else if (this.draft && this.options.isPromptEmpty?.())
138
+ this.draft = false;
139
+ if (this.draft)
140
+ throw new Error("The local terminal has an input draft. Submit or clear it before using remote controls.");
141
+ const result = await action();
142
+ this.check();
143
+ return result;
144
+ }
145
+ finally {
146
+ const operation = this.operation;
147
+ let ready = false;
148
+ try {
149
+ this.options.assertReady();
150
+ ready = true;
151
+ }
152
+ catch { /* A task or approval may have taken over. */ }
153
+ // Never send an Escape into a different session or after local input has taken over.
154
+ if (ready && this.ownedMenu && !operation?.error && operation?.identity === this.options.identity() && this.hasMenu(this.ownedMenu)) {
155
+ this.options.write("\u001b");
156
+ await pause(100);
157
+ }
158
+ this.ownedMenu = null;
159
+ this.operation = null;
160
+ }
161
+ }
162
+ async key(text) {
163
+ this.check();
164
+ this.options.write(text);
165
+ await pause(100);
166
+ await this.flush();
167
+ this.check();
168
+ }
169
+ async waitFor(predicate) {
170
+ while (true) {
171
+ this.check();
172
+ await this.flush();
173
+ if (predicate())
174
+ return;
175
+ await pause(50);
176
+ }
177
+ }
178
+ async command(command) {
179
+ if (!/^\/[a-z]+$/.test(command))
180
+ throw new Error("Invalid native control command.");
181
+ await this.key(`\u001b[200~${command}\u001b[201~`);
182
+ await this.key("\r");
183
+ // Claude can consume the first Enter to accept a slash completion while its
184
+ // interactive UI is finishing startup. Retry only while that exact command
185
+ // remains at the *last* prompt; never submit an arbitrary draft or a dialog.
186
+ for (let retry = 0; retry < 3; retry++) {
187
+ // A lazy-loaded command can briefly leave its autocomplete on screen.
188
+ // Let the initial render complete before deciding whether Enter was consumed.
189
+ const until = Date.now() + 1_500;
190
+ do {
191
+ await pause(100);
192
+ await this.flush();
193
+ this.check();
194
+ } while (Date.now() < until && Date.now() - this.lastFrameAt < 500);
195
+ if (Date.now() - this.lastFrameAt < 500)
196
+ break;
197
+ const prompt = this.lines.findLast((line) => /^\s*❯/.test(line))?.trim();
198
+ if (prompt?.replace(/^❯\s*/, "").trim() !== command || /Select model|Select agent|Select variant/.test(this.text))
199
+ break;
200
+ await this.key("\r");
201
+ }
202
+ }
203
+ hasMenu(title) {
204
+ return this.lines.some((line) => line.trim() === title || new RegExp(`^${title}\\s+esc$`, "i").test(line.trim()));
205
+ }
206
+ async openMenu(command, title) {
207
+ if (this.ownedMenu || /Select model|Select agent|Select variant/.test(this.text)) {
208
+ throw new Error("A native CLI menu is already open. Close it before using remote controls.");
209
+ }
210
+ await this.command(command);
211
+ await this.waitFor(() => this.hasMenu(title));
212
+ this.ownedMenu = title;
213
+ }
214
+ async closeMenu() {
215
+ const title = this.ownedMenu;
216
+ if (!title || !this.hasMenu(title))
217
+ throw new Error("The native selector changed unexpectedly.");
218
+ await this.key("\u001b");
219
+ await this.waitFor(() => !this.hasMenu(title));
220
+ this.ownedMenu = null;
221
+ }
222
+ async submitMenu(nextTitle, key = "\r") {
223
+ const title = this.ownedMenu;
224
+ if (!title || !this.hasMenu(title))
225
+ throw new Error("The native selector changed unexpectedly.");
226
+ await this.key(key);
227
+ await this.waitFor(() => !this.hasMenu(title));
228
+ this.ownedMenu = nextTitle && this.hasMenu(nextTitle) ? nextTitle : null;
229
+ }
230
+ }
231
+ export function parseClaudeModelPicker(text) {
232
+ const start = text.lastIndexOf("Select model");
233
+ if (start < 0)
234
+ return [];
235
+ return text.slice(start).split("\n").flatMap((line) => {
236
+ const match = /^\s*([❯>↑↓ ]*)\s*(\d+)\.\s+(.+?)\s*$/.exec(line);
237
+ if (!match)
238
+ return [];
239
+ return [{ index: Number(match[2]), label: match[3].replace(/[✓✔]/g, "").replace(/\s+/g, " ").trim(), current: /[✓✔]/.test(line), focused: /[❯>]/.test(match[1]) }];
240
+ });
241
+ }
242
+ export function readClaudePermissionMode(text) {
243
+ const lines = text.split("\n");
244
+ const prompt = lines.findLastIndex((line) => /^\s*❯/.test(line));
245
+ if (prompt < 0 || !/^\s*❯\s*$/.test(lines[prompt]))
246
+ return null;
247
+ const footer = lines.slice(prompt + 1).join("\n");
248
+ for (const [label, mode] of [["plan mode", "plan"], ["accept edits", "acceptEdits"], ["bypass permissions", "bypassPermissions"], ["auto", "auto"], ["don't ask", "dontAsk"]]) {
249
+ if (footer.includes(`${label} on`))
250
+ return mode;
251
+ }
252
+ return "default";
253
+ }