cli-wechat-bridge 1.0.5

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 (54) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +637 -0
  3. package/bin/_run-entry.mjs +35 -0
  4. package/bin/wechat-bridge-claude.mjs +5 -0
  5. package/bin/wechat-bridge-codex.mjs +5 -0
  6. package/bin/wechat-bridge-opencode.mjs +5 -0
  7. package/bin/wechat-bridge-shell.mjs +5 -0
  8. package/bin/wechat-bridge.mjs +5 -0
  9. package/bin/wechat-check-update.mjs +5 -0
  10. package/bin/wechat-claude-start.mjs +5 -0
  11. package/bin/wechat-claude.mjs +5 -0
  12. package/bin/wechat-codex-start.mjs +5 -0
  13. package/bin/wechat-codex.mjs +5 -0
  14. package/bin/wechat-daemon.mjs +5 -0
  15. package/bin/wechat-opencode-start.mjs +5 -0
  16. package/bin/wechat-opencode.mjs +5 -0
  17. package/bin/wechat-setup.mjs +5 -0
  18. package/dist/bridge/bridge-adapter-common.js +95 -0
  19. package/dist/bridge/bridge-adapters.claude.js +829 -0
  20. package/dist/bridge/bridge-adapters.codex.js +2228 -0
  21. package/dist/bridge/bridge-adapters.core.js +717 -0
  22. package/dist/bridge/bridge-adapters.js +26 -0
  23. package/dist/bridge/bridge-adapters.opencode.js +2129 -0
  24. package/dist/bridge/bridge-adapters.shared.js +1005 -0
  25. package/dist/bridge/bridge-adapters.shell.js +363 -0
  26. package/dist/bridge/bridge-controller.js +48 -0
  27. package/dist/bridge/bridge-final-reply.js +46 -0
  28. package/dist/bridge/bridge-process-reaper.js +348 -0
  29. package/dist/bridge/bridge-state.js +362 -0
  30. package/dist/bridge/bridge-types.js +1 -0
  31. package/dist/bridge/bridge-utils.js +1240 -0
  32. package/dist/bridge/claude-hook.js +82 -0
  33. package/dist/bridge/claude-hooks.js +267 -0
  34. package/dist/bridge/wechat-bridge.js +1026 -0
  35. package/dist/commands/check-update.js +30 -0
  36. package/dist/companion/codex-panel-link.js +72 -0
  37. package/dist/companion/codex-panel.js +179 -0
  38. package/dist/companion/codex-remote-client.js +124 -0
  39. package/dist/companion/local-companion-link.js +240 -0
  40. package/dist/companion/local-companion-start.js +420 -0
  41. package/dist/companion/local-companion.js +424 -0
  42. package/dist/daemon/daemon-link.js +175 -0
  43. package/dist/daemon/wechat-daemon.js +1202 -0
  44. package/dist/media/media-types.js +1 -0
  45. package/dist/runtime/create-runtime-host.js +12 -0
  46. package/dist/runtime/legacy-adapter-runtime.js +46 -0
  47. package/dist/runtime/runtime-types.js +5 -0
  48. package/dist/utils/version-checker.js +161 -0
  49. package/dist/wechat/channel-config.js +196 -0
  50. package/dist/wechat/setup.js +283 -0
  51. package/dist/wechat/standalone-bot.js +355 -0
  52. package/dist/wechat/wechat-channel.js +492 -0
  53. package/dist/wechat/wechat-transport.js +1213 -0
  54. package/package.json +101 -0
@@ -0,0 +1,1240 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ const ANSI_ESCAPE_RE =
4
+ // eslint-disable-next-line no-control-regex
5
+ /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
6
+ export const MESSAGE_START_GRACE_MS = 5_000;
7
+ const WECHAT_ATTACHMENT_SEND_INTENT_RE = /\b(send|upload|attach|forward|share)\b/i;
8
+ const WECHAT_ATTACHMENT_SEND_INTENT_ZH_RE = /发送|发给我|发我|给我发|发过来|发一下|发来|发到|发微信|上传|转发|分享|传给我|传我|传到/;
9
+ const WECHAT_ATTACHMENT_SHORT_SEND_ZH_RE = /^(?:发|发呀|发呢|发吧|直接发|发给我|发我|发微信|发送微信)$/;
10
+ const WECHAT_ATTACHMENT_TARGET_RE = /\bwechat\b/i;
11
+ const WECHAT_ATTACHMENT_TARGET_ZH_RE = /微信/;
12
+ const WECHAT_ATTACHMENT_FILE_TERM_RE = /\b(file|attachment|pdf|document|docx?|xlsx?|pptx?|csv|txt|zip|rar|7z|image|photo|picture|screenshot|audio|voice|video|png|jpe?g|gif|webp|bmp|mp3|wav|m4a|ogg|aac|mov|mp4|mkv|avi)\b/i;
13
+ const WECHAT_ATTACHMENT_FILE_TERM_ZH_RE = /文件|附件|文档|压缩包|图片|照片|截图|音频|语音|视频|pdf|PDF/;
14
+ const LOCAL_ATTACHMENT_PATH_HINT_RE = /(?:[A-Za-z]:\\|(?:~[\\/])?(?:Desktop|Documents|Downloads|Pictures|Videos|Music)[\\/]|桌面|下载目录|下载文件夹)/i;
15
+ const WECHAT_ATTACHMENT_PROMPT_PREFIX = [
16
+ "[WeChat bridge note]",
17
+ "Your final reply will be forwarded back to a WeChat chat.",
18
+ "If the user asks you to send a local file or media to WeChat and you know the local path, do not say that you lack a WeChat sending tool.",
19
+ "For a real send request, prefer locating and sending the file directly instead of opening or reading it unless the user explicitly asked for that.",
20
+ "Put any brief visible reply text first, then end the message with exactly one trailing block like:",
21
+ "```wechat-attachments",
22
+ "file C:\\Users\\name\\Desktop\\document.pdf",
23
+ "```",
24
+ "Valid kinds: image, file, video, voice.",
25
+ "Use `file` for PDFs and ordinary documents. Only include files you truly intend to upload.",
26
+ "",
27
+ "[User request]",
28
+ ].join("\n");
29
+ const WECHAT_ATTACHMENT_BLOCK_RE = /\n```wechat-attachments[ \t]*\n([\s\S]*?)\n```[ \t]*$/;
30
+ const WECHAT_ATTACHMENT_KINDS = ["image", "file", "video", "voice"];
31
+ const INLINE_IMAGE_EXTENSIONS = new Set([
32
+ ".png",
33
+ ".jpg",
34
+ ".jpeg",
35
+ ".gif",
36
+ ".webp",
37
+ ".bmp",
38
+ ]);
39
+ const INLINE_VIDEO_EXTENSIONS = new Set([
40
+ ".mp4",
41
+ ".mov",
42
+ ".mkv",
43
+ ".avi",
44
+ ".webm",
45
+ ]);
46
+ const INLINE_VOICE_EXTENSIONS = new Set([
47
+ ".mp3",
48
+ ".wav",
49
+ ".m4a",
50
+ ".ogg",
51
+ ".aac",
52
+ ]);
53
+ const INLINE_REFERENCE_ONLY_FILE_EXTENSIONS = new Set([
54
+ ".bat",
55
+ ".c",
56
+ ".cc",
57
+ ".cjs",
58
+ ".cmd",
59
+ ".cpp",
60
+ ".cs",
61
+ ".cts",
62
+ ".cxx",
63
+ ".go",
64
+ ".h",
65
+ ".hh",
66
+ ".hpp",
67
+ ".java",
68
+ ".js",
69
+ ".jsx",
70
+ ".kt",
71
+ ".kts",
72
+ ".lua",
73
+ ".m",
74
+ ".mjs",
75
+ ".mm",
76
+ ".mts",
77
+ ".php",
78
+ ".pl",
79
+ ".ps1",
80
+ ".psd1",
81
+ ".psm1",
82
+ ".py",
83
+ ".rb",
84
+ ".rs",
85
+ ".scala",
86
+ ".sh",
87
+ ".swift",
88
+ ".ts",
89
+ ".tsx",
90
+ ".vb",
91
+ ".zsh",
92
+ ]);
93
+ const INLINE_MAAS_URL_RE = /https?:\/\/[^\s]*?\/([A-Za-z]:\\.+?(?:\.\s*[A-Za-z0-9]{2,8})+)(?:\?[^\n]*)?/g;
94
+ const INLINE_WINDOWS_PATH_RE = /(^|[^\w])`?([A-Za-z]:\\(?:[^\\/:*?"<>|\r\n`]+\\)*[^\\/:*?"<>|\r\n`]+?(?:\.\s*[A-Za-z0-9]{2,8})+)`?(?=$|[^\w])/gm;
95
+ 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;
96
+ export function nowIso() {
97
+ return new Date().toISOString();
98
+ }
99
+ export function stripAnsi(text) {
100
+ return text.replace(ANSI_ESCAPE_RE, "");
101
+ }
102
+ export function normalizeOutput(text) {
103
+ return stripAnsi(text)
104
+ // eslint-disable-next-line no-control-regex
105
+ .replace(/\u0000/g, "")
106
+ .replace(/\r\n/g, "\n")
107
+ .replace(/\r/g, "\n");
108
+ }
109
+ export function truncatePreview(text, maxLength = 140) {
110
+ const normalized = normalizeOutput(text).trim().replace(/\s+/g, " ");
111
+ if (!normalized) {
112
+ return "(empty)";
113
+ }
114
+ if (normalized.length <= maxLength) {
115
+ return normalized;
116
+ }
117
+ return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
118
+ }
119
+ export function buildOneTimeCode(length = 6) {
120
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
121
+ let code = "";
122
+ while (code.length < length) {
123
+ code += alphabet[Math.floor(Math.random() * alphabet.length)];
124
+ }
125
+ return code;
126
+ }
127
+ export function buildInstanceId() {
128
+ return `bridge-${Date.now().toString(36)}-${buildOneTimeCode(6).toLowerCase()}`;
129
+ }
130
+ export function parseSystemCommand(text) {
131
+ const trimmed = text.trim();
132
+ if (!trimmed.startsWith("/")) {
133
+ return null;
134
+ }
135
+ const [rawCommand, ...rest] = trimmed.split(/\s+/);
136
+ if (!rawCommand) {
137
+ return null;
138
+ }
139
+ const command = rawCommand.toLowerCase();
140
+ const argument = rest.join(" ").trim();
141
+ switch (command) {
142
+ case "/status":
143
+ return { type: "status" };
144
+ case "/resume":
145
+ return argument ? { type: "resume", target: argument } : { type: "resume" };
146
+ case "/new":
147
+ case "/new-session":
148
+ return { type: "new_session" };
149
+ case "/stop":
150
+ return { type: "stop" };
151
+ case "/reset":
152
+ return { type: "reset" };
153
+ case "/confirm":
154
+ return argument ? { type: "confirm", code: argument } : null;
155
+ case "/deny":
156
+ return { type: "deny" };
157
+ case "/answer":
158
+ return argument ? { type: "answer", raw: argument } : null;
159
+ default:
160
+ return null;
161
+ }
162
+ }
163
+ export function parseWechatControlCommand(text, options) {
164
+ const systemCommand = parseSystemCommand(text);
165
+ if (systemCommand) {
166
+ return systemCommand;
167
+ }
168
+ if (options.adapter !== "claude") {
169
+ return null;
170
+ }
171
+ const normalized = text.trim().toLowerCase();
172
+ if (!normalized) {
173
+ return null;
174
+ }
175
+ if (normalized === "/confirm") {
176
+ return { type: "confirm" };
177
+ }
178
+ if (!options.hasPendingConfirmation) {
179
+ return null;
180
+ }
181
+ switch (normalized) {
182
+ case "confirm":
183
+ case "yes":
184
+ return { type: "confirm" };
185
+ case "deny":
186
+ case "no":
187
+ return { type: "deny" };
188
+ default:
189
+ return null;
190
+ }
191
+ }
192
+ export function shouldInjectWechatAttachmentPrompt(text) {
193
+ const normalized = normalizeOutput(text).trim();
194
+ if (!normalized || normalized.includes("```wechat-attachments")) {
195
+ return false;
196
+ }
197
+ const mentionsSendIntent = WECHAT_ATTACHMENT_SEND_INTENT_RE.test(normalized) ||
198
+ WECHAT_ATTACHMENT_SEND_INTENT_ZH_RE.test(normalized) ||
199
+ WECHAT_ATTACHMENT_SHORT_SEND_ZH_RE.test(normalized);
200
+ if (!mentionsSendIntent) {
201
+ return false;
202
+ }
203
+ const mentionsWechatTarget = WECHAT_ATTACHMENT_TARGET_RE.test(normalized) ||
204
+ WECHAT_ATTACHMENT_TARGET_ZH_RE.test(normalized);
205
+ const mentionsFileOrMedia = WECHAT_ATTACHMENT_FILE_TERM_RE.test(normalized) ||
206
+ WECHAT_ATTACHMENT_FILE_TERM_ZH_RE.test(normalized);
207
+ const mentionsLocalPath = LOCAL_ATTACHMENT_PATH_HINT_RE.test(normalized);
208
+ const looksLikeShortSendCommand = normalized.length <= 32;
209
+ return (mentionsWechatTarget ||
210
+ mentionsFileOrMedia ||
211
+ mentionsLocalPath ||
212
+ looksLikeShortSendCommand);
213
+ }
214
+ function formatPromptByteSize(bytes) {
215
+ if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {
216
+ return null;
217
+ }
218
+ if (bytes >= 1024 * 1024) {
219
+ return `${(bytes / (1024 * 1024)).toFixed(bytes >= 100 * 1024 * 1024 ? 0 : 1)} MB`;
220
+ }
221
+ if (bytes >= 1024) {
222
+ return `${(bytes / 1024).toFixed(bytes >= 100 * 1024 ? 0 : 1)} KB`;
223
+ }
224
+ return `${bytes} B`;
225
+ }
226
+ function formatWechatInboundAttachmentPrompt(attachments) {
227
+ const lines = [
228
+ "[WeChat inbound attachments]",
229
+ "The user sent attachment files through WeChat. They have been saved locally. Read the local paths directly if the user's request requires inspecting them.",
230
+ ];
231
+ attachments.forEach((attachment, index) => {
232
+ const sizeText = formatPromptByteSize(attachment.sizeBytes);
233
+ const metadata = [
234
+ `kind=${attachment.kind}`,
235
+ attachment.fileName ? `name=${attachment.fileName}` : "",
236
+ sizeText ? `size=${sizeText}` : "",
237
+ ].filter(Boolean);
238
+ lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
239
+ });
240
+ return lines.join("\n");
241
+ }
242
+ export function buildWechatInboundPrompt(text, attachments = []) {
243
+ const trimmedAttachments = attachments.filter((attachment) => attachment.path.trim());
244
+ if (!trimmedAttachments.length) {
245
+ if (!shouldInjectWechatAttachmentPrompt(text)) {
246
+ return text;
247
+ }
248
+ const normalized = normalizeOutput(text).trim();
249
+ if (!normalized) {
250
+ return text;
251
+ }
252
+ return `${WECHAT_ATTACHMENT_PROMPT_PREFIX}\n${normalized}`;
253
+ }
254
+ const baseText = normalizeOutput(text).trim() || "Received WeChat attachment(s).";
255
+ const userPrompt = shouldInjectWechatAttachmentPrompt(baseText)
256
+ ? `${WECHAT_ATTACHMENT_PROMPT_PREFIX}\n${baseText.trim()}`
257
+ : baseText;
258
+ return `${userPrompt.trim()}\n\n${formatWechatInboundAttachmentPrompt(trimmedAttachments)}`;
259
+ }
260
+ export function parseWechatFinalReply(text) {
261
+ const normalized = normalizeOutput(text);
262
+ const withLeadingNewline = normalized.startsWith("\n")
263
+ ? normalized
264
+ : `\n${normalized}`;
265
+ const match = withLeadingNewline.match(WECHAT_ATTACHMENT_BLOCK_RE);
266
+ if (!match) {
267
+ return extractInlineWechatAttachments(normalized);
268
+ }
269
+ const attachments = [];
270
+ const attachmentBlock = match[1];
271
+ if (attachmentBlock === undefined) {
272
+ return extractInlineWechatAttachments(normalized);
273
+ }
274
+ const lines = attachmentBlock
275
+ .split("\n")
276
+ .map((line) => line.trim())
277
+ .filter(Boolean);
278
+ if (!lines.length) {
279
+ return extractInlineWechatAttachments(normalized);
280
+ }
281
+ for (const line of lines) {
282
+ const parsed = /^(image|file|video|voice)\s+(.+)$/.exec(line);
283
+ if (!parsed) {
284
+ return extractInlineWechatAttachments(normalized);
285
+ }
286
+ const kind = parsed[1];
287
+ const rawPath = parsed[2];
288
+ if (!rawPath) {
289
+ return extractInlineWechatAttachments(normalized);
290
+ }
291
+ const attachmentPath = resolveWechatAttachmentPath(rawPath);
292
+ if (!attachmentPath) {
293
+ return extractInlineWechatAttachments(normalized);
294
+ }
295
+ attachments.push({
296
+ kind,
297
+ path: attachmentPath,
298
+ });
299
+ }
300
+ const blockIndex = withLeadingNewline.length - match[0].length;
301
+ const visibleText = withLeadingNewline.slice(0, blockIndex).trim();
302
+ const parsedFromBlock = {
303
+ visibleText,
304
+ attachments,
305
+ };
306
+ return parsedFromBlock.attachments.length > 0
307
+ ? parsedFromBlock
308
+ : extractInlineWechatAttachments(normalized);
309
+ }
310
+ export function parseCodexSessionAgentMessage(line) {
311
+ const trimmed = line.trim();
312
+ if (!trimmed) {
313
+ return null;
314
+ }
315
+ let parsed;
316
+ try {
317
+ parsed = JSON.parse(trimmed);
318
+ }
319
+ catch {
320
+ return null;
321
+ }
322
+ if (parsed.type !== "event_msg" || parsed.payload?.type !== "agent_message") {
323
+ return null;
324
+ }
325
+ const message = typeof parsed.payload.message === "string"
326
+ ? normalizeOutput(parsed.payload.message).trim()
327
+ : "";
328
+ if (!message) {
329
+ return null;
330
+ }
331
+ return {
332
+ timestamp: parsed.timestamp,
333
+ phase: typeof parsed.payload.phase === "string" ? parsed.payload.phase : undefined,
334
+ message,
335
+ };
336
+ }
337
+ const HIGH_RISK_PATTERNS = [
338
+ /\bremove-item\b/i,
339
+ /\brd\b/i,
340
+ /\brmdir\b/i,
341
+ /\bdel\b/i,
342
+ /\berase\b/i,
343
+ /\bformat\b/i,
344
+ /\bshutdown\b/i,
345
+ /\bstop-computer\b/i,
346
+ /\brestart-computer\b/i,
347
+ /\bstop-process\b/i,
348
+ /\btaskkill\b/i,
349
+ /\breg\s+delete\b/i,
350
+ /\bsc\s+delete\b/i,
351
+ /\bdiskpart\b/i,
352
+ /\bgit\s+reset\s+--hard\b/i,
353
+ /\bgit\s+clean\s+-f/i,
354
+ /\bset-executionpolicy\b/i,
355
+ /\bstart-process\b.*\b-verb\s+runas\b/i,
356
+ /\b(?:invoke-expression|iex)\b/i,
357
+ /\bcurl\b.*\|\s*(?:iex|powershell)\b/i,
358
+ /\binvoke-webrequest\b.*\|\s*(?:iex|powershell)\b/i,
359
+ /\brm\b\s+-[A-Za-z-]*r[A-Za-z-]*/i,
360
+ /\bsudo\b/i,
361
+ /\bmkfs(?:\.\w+)?\b/i,
362
+ /\bdd\b/i,
363
+ /\breboot\b/i,
364
+ /\bsystemctl\b/i,
365
+ /\blaunchctl\b/i,
366
+ /\bcurl\b.*\|\s*(?:sh|bash|zsh)\b/i,
367
+ /\bwget\b.*\|\s*(?:sh|bash|zsh)\b/i,
368
+ ];
369
+ export function isHighRiskShellCommand(command) {
370
+ const normalized = command.trim();
371
+ if (!normalized) {
372
+ return false;
373
+ }
374
+ return HIGH_RISK_PATTERNS.some((pattern) => pattern.test(normalized));
375
+ }
376
+ const ALWAYS_INTERACTIVE_SHELL_COMMANDS = new Set([
377
+ "ftp",
378
+ "htop",
379
+ "irb",
380
+ "less",
381
+ "more",
382
+ "mongosh",
383
+ "mysql",
384
+ "nano",
385
+ "nvim",
386
+ "psql",
387
+ "redis-cli",
388
+ "screen",
389
+ "sftp",
390
+ "sqlite3",
391
+ "ssh",
392
+ "telnet",
393
+ "tmux",
394
+ "top",
395
+ "vi",
396
+ "vim",
397
+ "watch",
398
+ ]);
399
+ function tokenizeShellCommand(command, maxTokens = 16) {
400
+ const tokens = [];
401
+ let current = "";
402
+ let quote = null;
403
+ for (const char of command.trim()) {
404
+ if (quote) {
405
+ if (char === quote) {
406
+ quote = null;
407
+ }
408
+ else {
409
+ current += char;
410
+ }
411
+ continue;
412
+ }
413
+ if (char === '"' || char === "'") {
414
+ quote = char;
415
+ continue;
416
+ }
417
+ if (/\s/u.test(char)) {
418
+ if (current) {
419
+ tokens.push(current);
420
+ current = "";
421
+ if (tokens.length >= maxTokens) {
422
+ return tokens;
423
+ }
424
+ }
425
+ continue;
426
+ }
427
+ current += char;
428
+ }
429
+ if (current) {
430
+ tokens.push(current);
431
+ }
432
+ return tokens;
433
+ }
434
+ function normalizeShellExecutableToken(token) {
435
+ const trimmed = token.trim().replace(/^["']|["']$/g, "");
436
+ if (!trimmed) {
437
+ return "";
438
+ }
439
+ return path.parse(trimmed).name.toLowerCase();
440
+ }
441
+ function findCommandFlagIndex(args, supportedFlags) {
442
+ return args.findIndex((arg) => supportedFlags.includes(arg.toLowerCase()));
443
+ }
444
+ function hasScriptLikeArg(args) {
445
+ return args.some((arg) => Boolean(arg) && !arg.startsWith("-") && !arg.startsWith("/"));
446
+ }
447
+ function hasAnyCommandFlag(args, supportedFlags) {
448
+ return findCommandFlagIndex(args, supportedFlags) >= 0;
449
+ }
450
+ function buildInteractiveShellCommandMessage(executable, suggestion) {
451
+ return `Interactive command "${executable}" is not supported in shell mode yet. This shell bridge currently only supports non-interactive commands and scripts. ${suggestion}`;
452
+ }
453
+ export function getInteractiveShellCommandRejectionMessage(command) {
454
+ const tokens = tokenizeShellCommand(command);
455
+ if (!tokens.length) {
456
+ return null;
457
+ }
458
+ const firstToken = tokens[0];
459
+ if (!firstToken) {
460
+ return null;
461
+ }
462
+ const executable = normalizeShellExecutableToken(firstToken);
463
+ const args = tokens.slice(1);
464
+ const lowerArgs = args.map((arg) => arg.toLowerCase());
465
+ if (!executable) {
466
+ return null;
467
+ }
468
+ if (ALWAYS_INTERACTIVE_SHELL_COMMANDS.has(executable)) {
469
+ return buildInteractiveShellCommandMessage(executable, "Run a non-interactive command or script instead.");
470
+ }
471
+ switch (executable) {
472
+ case "python":
473
+ case "python3":
474
+ case "py": {
475
+ if (!args.length) {
476
+ return buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
477
+ }
478
+ if (lowerArgs.includes("-i") || lowerArgs.includes("--interactive")) {
479
+ return buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
480
+ }
481
+ if (hasAnyCommandFlag(lowerArgs, ["-c", "-h", "--help", "-v", "--version"])) {
482
+ return null;
483
+ }
484
+ const moduleFlagIndex = findCommandFlagIndex(lowerArgs, ["-m"]);
485
+ if (moduleFlagIndex >= 0) {
486
+ return moduleFlagIndex < args.length - 1
487
+ ? null
488
+ : buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -m module_name" instead.');
489
+ }
490
+ return hasScriptLikeArg(args)
491
+ ? null
492
+ : buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
493
+ }
494
+ case "node":
495
+ if (!args.length || lowerArgs.includes("-i") || lowerArgs.includes("--interactive")) {
496
+ return buildInteractiveShellCommandMessage(executable, 'Try "node script.js" or "node -e \\"...\\"" instead.');
497
+ }
498
+ if (hasAnyCommandFlag(lowerArgs, ["-e", "--eval", "-p", "--print", "-h", "--help", "-v", "--version"])) {
499
+ return null;
500
+ }
501
+ return hasScriptLikeArg(args)
502
+ ? null
503
+ : buildInteractiveShellCommandMessage(executable, 'Try "node script.js" or "node -e \\"...\\"" instead.');
504
+ case "cmd":
505
+ if (lowerArgs.includes("/?")) {
506
+ return null;
507
+ }
508
+ if (lowerArgs.includes("/k") || !lowerArgs.includes("/c")) {
509
+ return buildInteractiveShellCommandMessage(executable, 'Try "cmd /c <command>" or run the command directly instead.');
510
+ }
511
+ return null;
512
+ case "powershell":
513
+ case "pwsh":
514
+ if (!args.length ||
515
+ lowerArgs.includes("-noexit") ||
516
+ lowerArgs.includes("-nologo") && args.length === 1) {
517
+ return buildInteractiveShellCommandMessage(executable, `Try "${executable} -Command \\"...\\"" or "${executable} -File script.ps1" instead.`);
518
+ }
519
+ if (hasAnyCommandFlag(lowerArgs, ["-c", "-command", "-enc", "-encodedcommand", "-f", "-file", "-h", "-help", "-v", "-version", "-?"])) {
520
+ return null;
521
+ }
522
+ return hasScriptLikeArg(args)
523
+ ? null
524
+ : buildInteractiveShellCommandMessage(executable, `Try "${executable} -Command \\"...\\"" or "${executable} -File script.ps1" instead.`);
525
+ case "bash":
526
+ case "dash":
527
+ case "ksh":
528
+ case "sh":
529
+ case "zsh":
530
+ if (!args.length || lowerArgs.includes("-i")) {
531
+ return buildInteractiveShellCommandMessage(executable, `Try "${executable} -c '...'" or "${executable} script.sh" instead.`);
532
+ }
533
+ if (findCommandFlagIndex(lowerArgs, ["-c", "-lc"]) >= 0) {
534
+ return null;
535
+ }
536
+ if (hasAnyCommandFlag(lowerArgs, ["-h", "--help", "--version"])) {
537
+ return null;
538
+ }
539
+ return hasScriptLikeArg(args)
540
+ ? null
541
+ : buildInteractiveShellCommandMessage(executable, `Try "${executable} -c '...'" or "${executable} script.sh" instead.`);
542
+ default:
543
+ return null;
544
+ }
545
+ }
546
+ export function detectCliApproval(text) {
547
+ const normalized = normalizeOutput(text);
548
+ const compact = normalized.replace(/\s+/g, " ").trim();
549
+ if (!compact) {
550
+ return null;
551
+ }
552
+ const approvalPatterns = [
553
+ { pattern: /\bdo you want to allow\b/i, confirmInput: "y\r", denyInput: "n\r" },
554
+ { pattern: /\bapprove\b/i, confirmInput: "y\r", denyInput: "n\r" },
555
+ { pattern: /\ballow this\b/i, confirmInput: "y\r", denyInput: "n\r" },
556
+ { pattern: /\b\(y\/n\)\b/i, confirmInput: "y\r", denyInput: "n\r" },
557
+ { pattern: /\byes\/no\b/i, confirmInput: "yes\r", denyInput: "no\r" },
558
+ { pattern: /\bpress enter to continue\b/i, confirmInput: "\r" },
559
+ { pattern: /\bconfirm to continue\b/i, confirmInput: "y\r", denyInput: "n\r" },
560
+ ];
561
+ const matched = approvalPatterns.find(({ pattern }) => pattern.test(compact));
562
+ if (!matched) {
563
+ return null;
564
+ }
565
+ const preview = truncatePreview(compact, 160);
566
+ return {
567
+ source: "cli",
568
+ summary: "CLI approval is required before the session can continue.",
569
+ commandPreview: preview,
570
+ confirmInput: matched.confirmInput,
571
+ denyInput: matched.denyInput,
572
+ };
573
+ }
574
+ export function formatDuration(durationMs) {
575
+ if (!Number.isFinite(durationMs) || durationMs < 0) {
576
+ return "0s";
577
+ }
578
+ const totalSeconds = Math.floor(durationMs / 1000);
579
+ const minutes = Math.floor(totalSeconds / 60);
580
+ const seconds = totalSeconds % 60;
581
+ if (!minutes) {
582
+ return `${seconds}s`;
583
+ }
584
+ return `${minutes}m ${seconds}s`;
585
+ }
586
+ export function summarizeOutput(text, maxLength = 280) {
587
+ const normalized = normalizeOutput(text)
588
+ .split("\n")
589
+ .map((line) => line.trim())
590
+ .filter(Boolean);
591
+ if (!normalized.length) {
592
+ return "(no output)";
593
+ }
594
+ const summary = normalized.slice(-6).join("\n");
595
+ if (summary.length <= maxLength) {
596
+ return summary;
597
+ }
598
+ return summary.slice(summary.length - maxLength);
599
+ }
600
+ export function formatStatusReport(bridgeState, adapterState) {
601
+ const pending = bridgeState.pendingConfirmation;
602
+ const pendingUserInput = bridgeState.pendingUserInput;
603
+ const persistedSharedSessionId = bridgeState.sharedSessionId ?? bridgeState.sharedThreadId;
604
+ const sharedSessionId = adapterState.sharedSessionId ?? adapterState.sharedThreadId;
605
+ const lastSessionSwitchAt = adapterState.lastSessionSwitchAt ?? adapterState.lastThreadSwitchAt;
606
+ const lastSessionSwitchSource = adapterState.lastSessionSwitchSource ?? adapterState.lastThreadSwitchSource;
607
+ const lastSessionSwitchReason = adapterState.lastSessionSwitchReason ?? adapterState.lastThreadSwitchReason;
608
+ const formatEpochMs = (value) => typeof value === "number" && Number.isFinite(value)
609
+ ? new Date(value).toISOString()
610
+ : "(none)";
611
+ return [
612
+ `instance_id: ${bridgeState.instanceId}`,
613
+ `adapter: ${bridgeState.adapter}`,
614
+ `command: ${bridgeState.command}`,
615
+ `cwd: ${bridgeState.cwd}`,
616
+ `profile: ${bridgeState.profile ?? "(none)"}`,
617
+ `bridge_started_at: ${formatEpochMs(bridgeState.bridgeStartedAtMs)}`,
618
+ `authorized_user: ${bridgeState.authorizedUserId}`,
619
+ `ignored_backlog_count: ${bridgeState.ignoredBacklogCount}`,
620
+ `persisted_shared_session_id: ${persistedSharedSessionId ?? "(none)"}`,
621
+ `worker_status: ${adapterState.status}`,
622
+ `worker_pid: ${adapterState.pid ?? "(unknown)"}`,
623
+ `shared_session_id: ${sharedSessionId ?? "(none)"}`,
624
+ `last_session_switch_at: ${lastSessionSwitchAt ?? "(none)"}`,
625
+ `last_session_switch_source: ${lastSessionSwitchSource ?? "(none)"}`,
626
+ `last_session_switch_reason: ${lastSessionSwitchReason ?? "(none)"}`,
627
+ `active_turn_id: ${adapterState.activeTurnId ?? "(none)"}`,
628
+ `active_turn_origin: ${adapterState.activeTurnOrigin ?? "(none)"}`,
629
+ `pending_approval_origin: ${adapterState.pendingApprovalOrigin ?? "(none)"}`,
630
+ `pending_user_input_origin: ${adapterState.pendingUserInputOrigin ?? "(none)"}`,
631
+ `last_activity_at: ${bridgeState.lastActivityAt ?? "(none)"}`,
632
+ `last_input_at: ${adapterState.lastInputAt ?? "(none)"}`,
633
+ `last_output_at: ${adapterState.lastOutputAt ?? "(none)"}`,
634
+ `pending_confirmation: ${pending ? `${pending.source}:${pending.code}` : "(none)"}`,
635
+ `pending_user_input: ${pendingUserInput ? `${pendingUserInput.questions.length} question(s)` : "(none)"}`,
636
+ ].join("\n");
637
+ }
638
+ export function formatSessionSwitchMessage(params) {
639
+ const shortSessionId = params.sessionId.slice(0, 12);
640
+ if (params.adapter === "claude") {
641
+ switch (params.reason) {
642
+ case "local_follow":
643
+ case "local_session_fallback":
644
+ case "local_turn":
645
+ return `Claude session switched to ${shortSessionId} from the local terminal.`;
646
+ case "wechat_resume":
647
+ return `Claude session switched to ${shortSessionId} from WeChat.`;
648
+ case "startup_restore":
649
+ return `Claude restored shared session ${shortSessionId} on startup.`;
650
+ default:
651
+ return `Claude session switched to ${shortSessionId}.`;
652
+ }
653
+ }
654
+ if (params.adapter === "opencode") {
655
+ switch (params.reason) {
656
+ case "local_follow":
657
+ case "local_session_fallback":
658
+ case "local_turn":
659
+ return `OpenCode session switched to ${shortSessionId} from the local terminal.`;
660
+ case "wechat_resume":
661
+ return `OpenCode session switched to ${shortSessionId} from WeChat.`;
662
+ case "startup_restore":
663
+ return `OpenCode restored shared session ${shortSessionId} on startup.`;
664
+ default:
665
+ return `OpenCode session switched to ${shortSessionId}.`;
666
+ }
667
+ }
668
+ switch (params.reason) {
669
+ case "local_follow":
670
+ case "local_session_fallback":
671
+ case "local_turn":
672
+ return `Codex thread switched to ${shortSessionId} from the local terminal.`;
673
+ case "wechat_resume":
674
+ return `Codex thread switched to ${shortSessionId} from WeChat.`;
675
+ case "startup_restore":
676
+ return `Codex restored shared thread ${shortSessionId} on startup.`;
677
+ default:
678
+ return `Codex thread switched to ${shortSessionId}.`;
679
+ }
680
+ }
681
+ export function formatThreadSwitchMessage(params) {
682
+ return formatSessionSwitchMessage({
683
+ adapter: "codex",
684
+ sessionId: params.threadId,
685
+ source: params.source,
686
+ reason: params.reason,
687
+ });
688
+ }
689
+ export function formatResumeSessionList(params) {
690
+ const { adapter, candidates, currentSessionId } = params;
691
+ if (candidates.length === 0) {
692
+ return adapter === "codex"
693
+ ? "No saved Codex threads were found for this working directory."
694
+ : adapter === "opencode"
695
+ ? "No saved OpenCode sessions were found for this working directory."
696
+ : "No saved sessions were found for this working directory.";
697
+ }
698
+ const title = adapter === "codex" ? "Recent Codex threads:" : adapter === "opencode" ? "Recent OpenCode sessions:" : "Recent sessions:";
699
+ const resumeTargetLabel = adapter === "codex" ? "threadId" : "sessionId";
700
+ return [
701
+ title,
702
+ ...candidates.map((candidate, index) => {
703
+ const marker = currentSessionId && candidate.sessionId === currentSessionId ? " [current]" : "";
704
+ return `${index + 1}. ${candidate.title} (${candidate.lastUpdatedAt}, ${candidate.sessionId.slice(0, 12)})${marker}`;
705
+ }),
706
+ `Reply with /resume <number> or /resume <${resumeTargetLabel}>.`,
707
+ ].join("\n");
708
+ }
709
+ export function formatResumeThreadList(candidates, currentThreadId) {
710
+ return formatResumeSessionList({
711
+ adapter: "codex",
712
+ candidates: candidates.map((candidate) => ({
713
+ ...candidate,
714
+ sessionId: candidate.sessionId ?? candidate.threadId ?? "",
715
+ threadId: candidate.threadId ?? candidate.sessionId,
716
+ })),
717
+ currentSessionId: currentThreadId,
718
+ });
719
+ }
720
+ export function formatMirroredUserInputMessage(adapter, text) {
721
+ const label = adapter === "codex"
722
+ ? "Local Codex input"
723
+ : adapter === "claude"
724
+ ? "Local Claude input"
725
+ : adapter === "opencode"
726
+ ? "Local OpenCode input"
727
+ : "Local input";
728
+ return `${label}:\n${truncatePreview(text, 500)}`;
729
+ }
730
+ export function formatFinalReplyMessage(adapter, text) {
731
+ if (adapter === "claude" || adapter === "codex" || adapter === "opencode") {
732
+ return text;
733
+ }
734
+ // After the early return above, only "shell" remains.
735
+ const label = adapter === "codex" ? "Codex" : adapter === "claude" ? "Claude" : adapter === "opencode" ? "OpenCode" : adapter;
736
+ return `${label} final reply:\n${text}`;
737
+ }
738
+ const OPENCODE_WORKING_NOTICE_RE = /^OpenCode is still working on:\s*$/i;
739
+ const OPENCODE_TRANSIENT_NOTICE_RES = [
740
+ /^Bridge error: opencode companion is not connected\./i,
741
+ /^OpenCode companion is not connected(?: for bridge workspace)?:?$/i,
742
+ /^Run "wechat-(?:bridge-opencode|opencode(?:-start)?)".*$/i,
743
+ /^OpenCode session switched to \S+ from the local terminal\.$/i,
744
+ /^Local OpenCode input:\s*$/i,
745
+ ];
746
+ const OPENCODE_REASONING_LINE_RES = [
747
+ /\bCLAUDE\.md\b/i,
748
+ /\bNo tool needed\.?$/i,
749
+ /\bThe user said\b/i,
750
+ /\bI need to (?:respond|reply|answer|tell the user)\b/i,
751
+ /\bWe need to (?:respond|reply|answer)\b/i,
752
+ /\bI should\b/i,
753
+ /\bI(?:'ll| will) (?:respond|reply|answer|tell the user|provide)\b/i,
754
+ /\bI'll provide\b/i,
755
+ /^Let me (?:directly )?(?:answer|respond)\b/i,
756
+ /根据系统提示/i,
757
+ /系统提示中说/i,
758
+ /我需要(?:告诉用户|回答|回复)/,
759
+ /我们需要(?:回答|回复)/,
760
+ /^让我直接(?:回答|回复)/,
761
+ /^我要直接(?:回答|回复)/,
762
+ /^用户(?:说|问)了/,
763
+ ];
764
+ const OPENCODE_INLINE_REASONING_MARKER_RE = /\b(?:The user\b|I need to\b|I should\b|I(?:'ll| will)\b|We need to\b|Let me\b)/i;
765
+ const OPENCODE_INLINE_REASONING_SENTENCE_RE = /^(?:The user\b|I need to\b|I should\b|I(?:'ll| will)\b|We need to\b|Let me\b)[^.!?\n]*(?:[.!?]+)\s*/i;
766
+ function stripInlineOpenCodeReasoningPrefix(text) {
767
+ let current = text.trim();
768
+ const markerIndex = current.search(OPENCODE_INLINE_REASONING_MARKER_RE);
769
+ if (markerIndex > 0 && markerIndex <= 80) {
770
+ current = current.slice(markerIndex).trimStart();
771
+ }
772
+ for (let index = 0; index < 6; index += 1) {
773
+ const match = current.match(OPENCODE_INLINE_REASONING_SENTENCE_RE);
774
+ if (!match) {
775
+ break;
776
+ }
777
+ current = current.slice(match[0].length).trimStart();
778
+ }
779
+ return current;
780
+ }
781
+ function isOpenCodeReasoningResidue(text) {
782
+ return !text.replace(/[\s"'“”‘’`.,!?;:()[\]{}<>…。!?;:()【】《》、,-]/g, "");
783
+ }
784
+ export function sanitizeWechatFinalReplyText(adapter, text) {
785
+ const normalized = adapter === "opencode"
786
+ ? cleanupVisibleWechatReplyText(stripInlineOpenCodeReasoningPrefix(text))
787
+ : cleanupVisibleWechatReplyText(text);
788
+ if (!normalized || adapter !== "opencode") {
789
+ return normalized;
790
+ }
791
+ const keptLines = [];
792
+ let dropNextContextLine = false;
793
+ let sawDroppedMeta = false;
794
+ let tailStartIndex = 0;
795
+ for (const rawLine of normalized.split("\n")) {
796
+ const line = rawLine.trim();
797
+ if (!line) {
798
+ keptLines.push("");
799
+ continue;
800
+ }
801
+ if (dropNextContextLine) {
802
+ dropNextContextLine = false;
803
+ if (line.length <= 200) {
804
+ sawDroppedMeta = true;
805
+ tailStartIndex = keptLines.length;
806
+ continue;
807
+ }
808
+ }
809
+ if (OPENCODE_WORKING_NOTICE_RE.test(line)) {
810
+ sawDroppedMeta = true;
811
+ tailStartIndex = keptLines.length;
812
+ dropNextContextLine = true;
813
+ continue;
814
+ }
815
+ if (OPENCODE_TRANSIENT_NOTICE_RES.some((pattern) => pattern.test(line)) ||
816
+ OPENCODE_REASONING_LINE_RES.some((pattern) => pattern.test(line))) {
817
+ sawDroppedMeta = true;
818
+ tailStartIndex = keptLines.length;
819
+ continue;
820
+ }
821
+ const previousLine = keptLines.length > 0 ? keptLines[keptLines.length - 1] : undefined;
822
+ if (previousLine &&
823
+ previousLine.trim() &&
824
+ previousLine.trim().replace(/\s+/g, " ") === line.replace(/\s+/g, " ")) {
825
+ continue;
826
+ }
827
+ keptLines.push(line);
828
+ }
829
+ const cleaned = cleanupVisibleWechatReplyText(keptLines.join("\n"));
830
+ if (!sawDroppedMeta) {
831
+ return cleaned;
832
+ }
833
+ const tail = cleanupVisibleWechatReplyText(keptLines.slice(tailStartIndex).join("\n"));
834
+ const resolved = tail || cleaned;
835
+ return isOpenCodeReasoningResidue(resolved) ? "" : resolved;
836
+ }
837
+ function extractInlineWechatAttachments(text) {
838
+ const sanitized = text
839
+ .replace(/\\\n\s*/g, "\\")
840
+ .replace(/\.\s*\n?\s*([A-Za-z0-9]{2,8})(?=\?)/g, ".$1")
841
+ .replace(/\?\s+/g, "?");
842
+ const attachments = [];
843
+ const seenPaths = new Set();
844
+ let visibleText = sanitized;
845
+ const rememberAttachment = (candidatePath) => {
846
+ const attachmentPath = resolveWechatAttachmentPath(candidatePath);
847
+ if (!attachmentPath) {
848
+ return false;
849
+ }
850
+ const kind = inferInlineWechatAttachmentKind(attachmentPath);
851
+ if (!kind) {
852
+ return false;
853
+ }
854
+ if (!seenPaths.has(attachmentPath)) {
855
+ attachments.push({
856
+ kind,
857
+ path: attachmentPath,
858
+ });
859
+ seenPaths.add(attachmentPath);
860
+ }
861
+ return true;
862
+ };
863
+ visibleText = visibleText.replace(INLINE_MAAS_URL_RE, (fullMatch, candidatePath) => {
864
+ return rememberAttachment(candidatePath) ? "" : fullMatch;
865
+ });
866
+ visibleText = visibleText.replace(INLINE_WINDOWS_PATH_RE, (fullMatch, prefix, candidatePath) => {
867
+ return rememberAttachment(candidatePath) ? prefix : fullMatch;
868
+ });
869
+ visibleText = visibleText.replace(INLINE_HOME_RELATIVE_PATH_RE, (fullMatch, prefix, candidatePath) => {
870
+ return rememberAttachment(candidatePath) ? prefix : fullMatch;
871
+ });
872
+ return {
873
+ visibleText: cleanupVisibleWechatReplyText(visibleText),
874
+ attachments,
875
+ };
876
+ }
877
+ function resolveWechatAttachmentPath(candidatePath) {
878
+ const normalizedCandidate = normalizeWechatAttachmentCandidate(candidatePath);
879
+ if (!normalizedCandidate) {
880
+ return null;
881
+ }
882
+ if (path.isAbsolute(normalizedCandidate)) {
883
+ return path.normalize(normalizedCandidate);
884
+ }
885
+ const homeRelativeMatch = /^(?:~[\\/])?(Desktop|Documents|Downloads|Pictures|Videos|Music)([\\/].+)?$/i.exec(normalizedCandidate);
886
+ if (!homeRelativeMatch) {
887
+ return null;
888
+ }
889
+ const relativeTail = `${homeRelativeMatch[1]}${homeRelativeMatch[2] ?? ""}`;
890
+ const relativeSegments = relativeTail.split(/[\\/]+/).filter(Boolean);
891
+ if (!relativeSegments.length) {
892
+ return null;
893
+ }
894
+ return path.normalize(path.join(os.homedir(), ...relativeSegments));
895
+ }
896
+ function normalizeWechatAttachmentCandidate(candidatePath) {
897
+ return candidatePath
898
+ .trim()
899
+ .replace(/^`|`$/g, "")
900
+ .replace(/\.\s+([A-Za-z0-9]{2,8})(?=$|[?/\s])/g, ".$1")
901
+ .replace(/[\\/]+/g, path.sep);
902
+ }
903
+ function inferInlineWechatAttachmentKind(filePath) {
904
+ const extension = path.extname(filePath).toLowerCase();
905
+ if (INLINE_IMAGE_EXTENSIONS.has(extension)) {
906
+ return "image";
907
+ }
908
+ if (INLINE_VIDEO_EXTENSIONS.has(extension)) {
909
+ return "video";
910
+ }
911
+ if (INLINE_VOICE_EXTENSIONS.has(extension)) {
912
+ return "voice";
913
+ }
914
+ // Keep ordinary local files auto-sendable, but avoid turning common
915
+ // source/script references in prose into unintended WeChat uploads.
916
+ if (!extension || INLINE_REFERENCE_ONLY_FILE_EXTENSIONS.has(extension)) {
917
+ return null;
918
+ }
919
+ return "file";
920
+ }
921
+ function cleanupVisibleWechatReplyText(text) {
922
+ return text
923
+ .replace(/```[^\n]*\n\s*```/g, "")
924
+ .replace(/[ \t]+\n/g, "\n")
925
+ .replace(/\n[ \t]+\n/g, "\n\n")
926
+ .replace(/\n{3,}/g, "\n\n")
927
+ .trim();
928
+ }
929
+ export function formatTaskFailedMessage(adapter, text) {
930
+ const label = adapter === "codex" ? "Codex" : adapter === "claude" ? "Claude" : adapter === "opencode" ? "OpenCode" : adapter;
931
+ return `${label} task failed:\n${text}`;
932
+ }
933
+ export function formatApprovalMessage(pending, adapterState) {
934
+ const isClaude = adapterState.kind === "claude";
935
+ if (isClaude) {
936
+ return [
937
+ "Claude permission request.",
938
+ ...(pending.toolName ? [`tool: ${pending.toolName}`] : []),
939
+ ...(pending.detailPreview
940
+ ? [`${pending.detailLabel ?? "details"}: ${pending.detailPreview}`]
941
+ : pending.commandPreview
942
+ ? [`details: ${pending.commandPreview}`]
943
+ : []),
944
+ "Reply with /confirm, confirm, or yes to continue.",
945
+ "Reply with /deny, deny, or no to reject.",
946
+ ].join("\n");
947
+ }
948
+ return [
949
+ `${pending.source === "shell" ? "Shell" : "CLI"} approval is required.`,
950
+ `adapter: ${adapterState.kind}`,
951
+ `code: ${pending.code}`,
952
+ `summary: ${pending.summary}`,
953
+ `target: ${pending.commandPreview}`,
954
+ "Reply with /confirm <code> to continue or /deny to reject.",
955
+ ].join("\n");
956
+ }
957
+ export function formatPendingApprovalReminder(pending, adapterState) {
958
+ if (adapterState.kind === "claude") {
959
+ const target = pending.toolName
960
+ ? `${pending.toolName}${pending.detailPreview ? ` (${pending.detailPreview})` : ""}`
961
+ : pending.commandPreview;
962
+ return `Approval is pending for ${truncatePreview(target, 140)}. Reply with /confirm, confirm, or yes to continue, or /deny, deny, or no to reject.`;
963
+ }
964
+ return `Approval is pending for ${pending.commandPreview}. Reply with /confirm ${pending.code} or /deny.`;
965
+ }
966
+ function formatUserInputQuestionLabel(question, index) {
967
+ return `${index + 1}. ${question.header} [id: ${question.id}]`;
968
+ }
969
+ function resolveUserInputQuestion(pending, reference) {
970
+ const trimmed = reference.trim();
971
+ if (!trimmed) {
972
+ return null;
973
+ }
974
+ if (/^\d+$/.test(trimmed)) {
975
+ const index = Number(trimmed) - 1;
976
+ return pending.questions[index] ?? null;
977
+ }
978
+ const normalized = trimmed.toLowerCase();
979
+ return (pending.questions.find((question) => question.id.toLowerCase() === normalized) ?? null);
980
+ }
981
+ function resolveUserInputOptionLabel(question, value) {
982
+ if (!question.options?.length) {
983
+ return null;
984
+ }
985
+ const trimmed = value.trim();
986
+ if (/^\d+$/.test(trimmed)) {
987
+ const index = Number(trimmed) - 1;
988
+ return question.options[index]?.label ?? null;
989
+ }
990
+ const normalized = trimmed.toLowerCase();
991
+ return question.options.find((option) => option.label.toLowerCase() === normalized)?.label ?? null;
992
+ }
993
+ function parseSingleUserInputAnswer(question, rawValue) {
994
+ const trimmed = normalizeOutput(rawValue).trim();
995
+ if (!trimmed) {
996
+ return {
997
+ error: `Question "${question.header}" requires an answer.`,
998
+ };
999
+ }
1000
+ if (!question.options?.length) {
1001
+ return {
1002
+ answers: [trimmed],
1003
+ };
1004
+ }
1005
+ const separatorIndex = trimmed.indexOf("|");
1006
+ const selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
1007
+ let note = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1).trim() : "";
1008
+ const answers = [];
1009
+ if (selection) {
1010
+ const selectedLabel = resolveUserInputOptionLabel(question, selection);
1011
+ if (selectedLabel) {
1012
+ answers.push(selectedLabel);
1013
+ }
1014
+ else if (question.isOther) {
1015
+ note = note ? `${selection}; ${note}` : selection;
1016
+ }
1017
+ else {
1018
+ return {
1019
+ error: `Question "${question.header}" expects an option number or label.`,
1020
+ };
1021
+ }
1022
+ }
1023
+ if (note) {
1024
+ answers.push(`user_note: ${note}`);
1025
+ }
1026
+ if (answers.length === 0) {
1027
+ return {
1028
+ error: `Question "${question.header}" requires an answer.`,
1029
+ };
1030
+ }
1031
+ return {
1032
+ answers,
1033
+ };
1034
+ }
1035
+ export function parsePendingUserInputAnswerCommand(raw, pending) {
1036
+ const input = normalizeOutput(raw).trim();
1037
+ if (!input) {
1038
+ return {
1039
+ error: "Reply with /answer followed by your response.",
1040
+ };
1041
+ }
1042
+ const answers = {};
1043
+ if (pending.questions.length === 1) {
1044
+ const question = pending.questions[0];
1045
+ if (!question) {
1046
+ return {
1047
+ error: "No pending user input question was found.",
1048
+ };
1049
+ }
1050
+ const parsed = parseSingleUserInputAnswer(question, input);
1051
+ if ("error" in parsed) {
1052
+ return parsed;
1053
+ }
1054
+ answers[question.id] = parsed.answers;
1055
+ }
1056
+ else {
1057
+ const segments = input
1058
+ .split(/\s*(?:;|\n)\s*/)
1059
+ .map((segment) => segment.trim())
1060
+ .filter(Boolean);
1061
+ if (segments.length === 0) {
1062
+ return {
1063
+ error: "Reply with /answer questionId=value; questionId2=value.",
1064
+ };
1065
+ }
1066
+ for (const segment of segments) {
1067
+ const separatorIndex = segment.indexOf("=");
1068
+ if (separatorIndex <= 0) {
1069
+ return {
1070
+ error: `Each answer must use questionId=value. Invalid segment: ${segment}`,
1071
+ };
1072
+ }
1073
+ const reference = segment.slice(0, separatorIndex).trim();
1074
+ const rawValue = segment.slice(separatorIndex + 1).trim();
1075
+ const question = resolveUserInputQuestion(pending, reference);
1076
+ if (!question) {
1077
+ return {
1078
+ error: `Unknown question reference: ${reference}`,
1079
+ };
1080
+ }
1081
+ if (answers[question.id]) {
1082
+ return {
1083
+ error: `Question "${question.id}" was answered more than once.`,
1084
+ };
1085
+ }
1086
+ const parsed = parseSingleUserInputAnswer(question, rawValue);
1087
+ if ("error" in parsed) {
1088
+ return parsed;
1089
+ }
1090
+ answers[question.id] = parsed.answers;
1091
+ }
1092
+ const missing = pending.questions.filter((question) => !answers[question.id]);
1093
+ if (missing.length > 0) {
1094
+ return {
1095
+ error: `Missing answers for: ${missing.map((question) => question.id).join(", ")}.`,
1096
+ };
1097
+ }
1098
+ }
1099
+ return {
1100
+ answers,
1101
+ preview: truncatePreview(pending.questions
1102
+ .map((question) => `${question.id}=${(answers[question.id] ?? []).join(", ")}`)
1103
+ .join("; "), 180),
1104
+ };
1105
+ }
1106
+ export function formatUserInputRequestMessage(pending, adapterState) {
1107
+ const lines = [
1108
+ pending.summary,
1109
+ `adapter: ${adapterState.kind}`,
1110
+ ];
1111
+ const hasSecretQuestion = pending.questions.some((question) => question.isSecret);
1112
+ if (hasSecretQuestion) {
1113
+ lines.push("Warning: this prompt includes secret input upstream, but WeChat replies are not hidden.");
1114
+ }
1115
+ pending.questions.forEach((question, index) => {
1116
+ lines.push("");
1117
+ lines.push(formatUserInputQuestionLabel(question, index));
1118
+ lines.push(question.question);
1119
+ if (question.options?.length) {
1120
+ lines.push("options:");
1121
+ question.options.forEach((option, optionIndex) => {
1122
+ lines.push(` ${optionIndex + 1}. ${option.label} - ${truncatePreview(option.description, 160)}`);
1123
+ });
1124
+ }
1125
+ if (question.isOther) {
1126
+ lines.push("custom note: allowed");
1127
+ }
1128
+ });
1129
+ lines.push("");
1130
+ if (pending.questions.length === 1) {
1131
+ const question = pending.questions[0];
1132
+ if (!question) {
1133
+ return lines.join("\n");
1134
+ }
1135
+ if (question.options?.length) {
1136
+ lines.push("Reply with /answer <option number or exact label>.");
1137
+ if (question.isOther) {
1138
+ lines.push('Add "| your note" to include extra context.');
1139
+ }
1140
+ }
1141
+ else {
1142
+ lines.push("Reply with /answer <your answer>.");
1143
+ }
1144
+ }
1145
+ else {
1146
+ lines.push("Reply with /answer questionId=value; questionId2=value.");
1147
+ lines.push("You can use question numbers instead of ids.");
1148
+ lines.push("For option questions, value can be the option number or exact label.");
1149
+ lines.push('Add "| your note" after an option answer to include extra context.');
1150
+ }
1151
+ lines.push("Use /stop to interrupt the active turn instead.");
1152
+ return lines.join("\n");
1153
+ }
1154
+ export function formatPendingUserInputReminder(pending) {
1155
+ if (pending.questions.length === 1) {
1156
+ const question = pending.questions[0];
1157
+ if (!question) {
1158
+ return "Codex is waiting for user input. Reply with /answer and your response, or use /stop to interrupt.";
1159
+ }
1160
+ return `Codex is waiting for user input for ${question.header}. Reply with /answer and your response, or use /stop to interrupt.`;
1161
+ }
1162
+ return `Codex is waiting for answers to ${pending.questions.length} questions. Reply with /answer questionId=value; questionId2=value, or use /stop to interrupt.`;
1163
+ }
1164
+ export class OutputBatcher {
1165
+ onFlush;
1166
+ flushIntervalMs;
1167
+ maxChars;
1168
+ buffer = "";
1169
+ recentText = "";
1170
+ flushTimer = null;
1171
+ flushChain = Promise.resolve();
1172
+ constructor(onFlush, flushIntervalMs = 1_000, maxChars = 1_200) {
1173
+ this.onFlush = onFlush;
1174
+ this.flushIntervalMs = flushIntervalMs;
1175
+ this.maxChars = maxChars;
1176
+ }
1177
+ push(text) {
1178
+ const normalized = normalizeOutput(text);
1179
+ if (!normalized) {
1180
+ return;
1181
+ }
1182
+ this.buffer += normalized;
1183
+ this.recentText = (this.recentText + normalized).slice(-6_000);
1184
+ while (this.buffer.length >= this.maxChars) {
1185
+ const nextChunk = this.buffer.slice(0, this.maxChars);
1186
+ this.buffer = this.buffer.slice(this.maxChars);
1187
+ this.enqueueFlush(nextChunk);
1188
+ }
1189
+ this.ensureFlushTimer();
1190
+ }
1191
+ async flushNow() {
1192
+ if (this.flushTimer) {
1193
+ clearTimeout(this.flushTimer);
1194
+ this.flushTimer = null;
1195
+ }
1196
+ if (!this.buffer) {
1197
+ await this.flushChain;
1198
+ return;
1199
+ }
1200
+ const chunk = this.buffer;
1201
+ this.buffer = "";
1202
+ this.enqueueFlush(chunk);
1203
+ await this.flushChain;
1204
+ }
1205
+ clear() {
1206
+ this.buffer = "";
1207
+ this.recentText = "";
1208
+ if (this.flushTimer) {
1209
+ clearTimeout(this.flushTimer);
1210
+ this.flushTimer = null;
1211
+ }
1212
+ }
1213
+ getRecentSummary(maxLength = 280) {
1214
+ return summarizeOutput(this.recentText, maxLength);
1215
+ }
1216
+ ensureFlushTimer() {
1217
+ if (this.flushTimer || !this.buffer) {
1218
+ return;
1219
+ }
1220
+ this.flushTimer = setTimeout(() => {
1221
+ this.flushTimer = null;
1222
+ void this.flushNow();
1223
+ }, this.flushIntervalMs);
1224
+ }
1225
+ enqueueFlush(text) {
1226
+ const payload = text.trim();
1227
+ if (!payload) {
1228
+ return;
1229
+ }
1230
+ this.flushChain = this.flushChain
1231
+ .then(() => Promise.resolve(this.onFlush(payload)))
1232
+ .catch(() => undefined);
1233
+ }
1234
+ }
1235
+ export function shouldDropStartupBacklogMessage(createdAtMs, bridgeStartedAtMs, graceMs = MESSAGE_START_GRACE_MS) {
1236
+ if (!Number.isFinite(createdAtMs)) {
1237
+ return true;
1238
+ }
1239
+ return createdAtMs < bridgeStartedAtMs - graceMs;
1240
+ }