@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.
Files changed (89) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +434 -427
  3. package/bin/_run-entry.mjs +47 -9
  4. package/bin/wechat-check-update.mjs +6 -1
  5. package/bin/wechat-claude.mjs +6 -1
  6. package/bin/wechat-codex.mjs +6 -1
  7. package/bin/wechat-daemon.mjs +6 -1
  8. package/bin/wechat-opencode.mjs +6 -1
  9. package/bin/wechat-pi.mjs +6 -1
  10. package/bin/wechat-setup.mjs +6 -1
  11. package/bin/wecom-check-update.mjs +10 -0
  12. package/bin/wecom-claude.mjs +10 -0
  13. package/bin/wecom-codex.mjs +10 -0
  14. package/bin/wecom-daemon.mjs +10 -0
  15. package/bin/wecom-opencode.mjs +10 -0
  16. package/bin/wecom-pi.mjs +10 -0
  17. package/bin/wecom-setup.mjs +10 -0
  18. package/dist/bridge/adapter-control.js +58 -0
  19. package/dist/bridge/bridge-adapters.claude.js +163 -5
  20. package/dist/bridge/bridge-adapters.codex.js +331 -29
  21. package/dist/bridge/bridge-adapters.core.js +27 -2
  22. package/dist/bridge/bridge-adapters.opencode.js +156 -1
  23. package/dist/bridge/bridge-adapters.pi.js +46 -1
  24. package/dist/bridge/bridge-adapters.shared.js +8 -1
  25. package/dist/bridge/bridge-controller.js +6 -0
  26. package/dist/bridge/bridge-final-reply.js +8 -65
  27. package/dist/bridge/bridge-process-reaper.js +41 -2
  28. package/dist/bridge/bridge-state.js +17 -4
  29. package/dist/bridge/bridge-utils.js +10 -127
  30. package/dist/bridge/claude-hook.js +6 -0
  31. package/dist/bridge/claude-native-controls.js +133 -0
  32. package/dist/bridge/native-terminal-control.js +259 -0
  33. package/dist/bridge/wechat-bridge.js +520 -274
  34. package/dist/bridge/wechat-forwarding.js +8 -58
  35. package/dist/bridge/wechat-outbound-queue.js +8 -0
  36. package/dist/channels/wechat/bridge-final-reply.js +56 -0
  37. package/dist/channels/wechat/channel-message.js +24 -0
  38. package/dist/channels/wechat/index.js +11 -0
  39. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  40. package/dist/channels/wechat/wechat-driver.js +191 -0
  41. package/dist/channels/wechat/wechat-forwarding.js +97 -0
  42. package/dist/channels/wechat/wechat-outbound-queue.js +118 -0
  43. package/dist/channels/wecom/bridge-final-reply.js +32 -0
  44. package/dist/channels/wecom/setup-cli.js +12 -0
  45. package/dist/channels/wecom/setup.js +182 -0
  46. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  47. package/dist/channels/wecom/wecom-config.js +72 -0
  48. package/dist/channels/wecom/wecom-driver.js +106 -0
  49. package/dist/channels/wecom/wecom-media.js +147 -0
  50. package/dist/channels/wecom/wecom-message.js +149 -0
  51. package/dist/channels/wecom/wecom-transport.js +314 -0
  52. package/dist/commands/check-update.js +6 -0
  53. package/dist/companion/codex-remote-client.js +30 -3
  54. package/dist/companion/codex-visible-client-link.js +107 -0
  55. package/dist/companion/local-companion-link.js +6 -0
  56. package/dist/companion/local-companion-start.js +62 -11
  57. package/dist/companion/local-companion.js +25 -1
  58. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  59. package/dist/companion/pi-tui-bridge-extension.js +62 -3
  60. package/dist/core/bridge-control.js +42 -0
  61. package/dist/core/bridge-defer.js +25 -0
  62. package/dist/core/bridge-event-forwarder.js +50 -0
  63. package/dist/core/bridge-message-router.js +32 -0
  64. package/dist/core/channel-driver.js +1 -0
  65. package/dist/core/channel-types.js +3 -0
  66. package/dist/core/conversation-routing.js +58 -0
  67. package/dist/core/direct-run.js +23 -0
  68. package/dist/core/inbound-message-claims.js +67 -0
  69. package/dist/core/outbound-target.js +32 -0
  70. package/dist/core/text-utils.js +69 -0
  71. package/dist/core/turn-coordinator.js +85 -0
  72. package/dist/daemon/daemon-link.js +9 -0
  73. package/dist/daemon/emoji-bindings.js +6 -0
  74. package/dist/daemon/wechat-daemon.js +827 -209
  75. package/dist/i18n/index.js +6 -0
  76. package/dist/i18n/messages-en.js +11 -3
  77. package/dist/i18n/messages-zh.js +11 -3
  78. package/dist/runtime/create-runtime-host.js +6 -0
  79. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  80. package/dist/runtime/runtime-types.js +3 -0
  81. package/dist/utils/atomic-file.js +6 -0
  82. package/dist/utils/doctor.js +36 -10
  83. package/dist/utils/version-checker.js +17 -11
  84. package/dist/wechat/channel-config.js +6 -0
  85. package/dist/wechat/setup.js +119 -13
  86. package/dist/wechat/wechat-channel.js +6 -0
  87. package/dist/wechat/wechat-transport.js +169 -58
  88. package/package.json +14 -4
  89. 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
- const ANSI_ESCAPE_RE =
5
- // eslint-disable-next-line no-control-regex
6
- /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
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
- const trimmed = text.trim();
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
+ }