cli-wechat-bridge 1.1.4 → 1.1.6

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 (42) hide show
  1. package/README.md +153 -131
  2. package/bin/_run-entry.mjs +20 -17
  3. package/dist/bridge/bridge-adapters.claude.js +429 -11
  4. package/dist/bridge/bridge-adapters.codex.js +441 -22
  5. package/dist/bridge/bridge-adapters.core.js +2 -1
  6. package/dist/bridge/bridge-adapters.opencode.js +392 -53
  7. package/dist/bridge/bridge-adapters.pi.js +33 -7
  8. package/dist/bridge/bridge-adapters.shared.js +2 -1
  9. package/dist/bridge/bridge-controller.js +3 -0
  10. package/dist/bridge/bridge-final-reply.js +2 -65
  11. package/dist/bridge/bridge-session-resume.js +151 -0
  12. package/dist/bridge/bridge-state.js +1 -1
  13. package/dist/bridge/bridge-utils.js +30 -101
  14. package/dist/bridge/claude-hooks.js +1 -0
  15. package/dist/bridge/wechat-bridge.js +256 -211
  16. package/dist/bridge/wechat-forwarding.js +2 -58
  17. package/dist/bridge/wechat-outbound-queue.js +2 -0
  18. package/dist/channels/wechat/bridge-final-reply.js +65 -0
  19. package/dist/channels/wechat/channel-message.js +24 -0
  20. package/dist/channels/wechat/index.js +5 -0
  21. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  22. package/dist/channels/wechat/wechat-forwarding.js +79 -0
  23. package/dist/channels/wechat/wechat-outbound-queue.js +72 -0
  24. package/dist/companion/codex-remote-client.js +291 -32
  25. package/dist/companion/codex-visible-client-link.js +122 -0
  26. package/dist/companion/local-companion-link.js +26 -0
  27. package/dist/companion/local-companion-start.js +0 -1
  28. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  29. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  30. package/dist/core/bridge-control.js +32 -0
  31. package/dist/core/bridge-event-forwarder.js +50 -0
  32. package/dist/core/bridge-message-router.js +32 -0
  33. package/dist/core/channel-types.js +1 -0
  34. package/dist/core/text-utils.js +63 -0
  35. package/dist/daemon/wechat-daemon.js +242 -159
  36. package/dist/i18n/messages-en.js +2 -2
  37. package/dist/i18n/messages-zh.js +2 -2
  38. package/package.json +105 -109
  39. package/bin/wechat-claude-start.mjs +0 -10
  40. package/bin/wechat-codex-start.mjs +0 -10
  41. package/bin/wechat-opencode-start.mjs +0 -10
  42. package/bin/wechat-pi-start.mjs +0 -10
@@ -1,65 +1,2 @@
1
- import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, splitWechatTextIntoChunks, } from "./bridge-utils.js";
2
- export const OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE = "OpenCode 没有产生可发送到微信的可见回复。请查看本地终端输出,或重试这条消息。";
3
- export async function forwardWechatFinalReply(params) {
4
- const { adapter, rawText, sender, onEmptyVisibleReply } = params;
5
- const parsed = parseWechatFinalReply(rawText);
6
- const sanitizedText = sanitizeWechatFinalReplyText(adapter, parsed.visibleText);
7
- const visibleText = formatFinalReplyMessage(adapter, sanitizedText).trim();
8
- if (visibleText) {
9
- // Send long replies in bounded chunks: a single oversized sendmessage call
10
- // can be rejected by the WeChat API, silently losing the whole reply.
11
- const chunks = splitWechatTextIntoChunks(visibleText);
12
- for (let index = 0; index < chunks.length; index += 1) {
13
- const sent = await sender.sendText(chunks[index]);
14
- if (sent === false) {
15
- // The send channel is failing (e.g. an expired context token).
16
- // Report what was dropped instead of ending mid-reply silently;
17
- // this notice itself is best effort and may also fail.
18
- const remainingChunks = chunks.length - index - 1;
19
- if (remainingChunks > 0 || parsed.attachments.length > 0) {
20
- const parts = [];
21
- if (remainingChunks > 0) {
22
- parts.push(`${remainingChunks} reply chunk(s)`);
23
- }
24
- if (parsed.attachments.length > 0) {
25
- parts.push(`${parsed.attachments.length} attachment(s)`);
26
- }
27
- await sender.sendText(`[bridge] Reply delivery was interrupted; ${parts.join(" and ")} could not be sent. Send a new message to retry.`);
28
- }
29
- return;
30
- }
31
- }
32
- }
33
- else if (adapter === "opencode" && parsed.visibleText.trim()) {
34
- onEmptyVisibleReply?.({
35
- adapter,
36
- rawVisibleText: parsed.visibleText,
37
- });
38
- const sent = await sender.sendText(OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE);
39
- if (sent === false) {
40
- return;
41
- }
42
- }
43
- for (const attachment of parsed.attachments) {
44
- try {
45
- switch (attachment.kind) {
46
- case "image":
47
- await sender.sendImage(attachment.path);
48
- break;
49
- case "file":
50
- await sender.sendFile(attachment.path);
51
- break;
52
- case "voice":
53
- await sender.sendVoice(attachment.path);
54
- break;
55
- case "video":
56
- await sender.sendVideo(attachment.path);
57
- break;
58
- }
59
- }
60
- catch (error) {
61
- const errorText = error instanceof Error ? error.message : String(error ?? "unknown error");
62
- await sender.sendText(`Failed to send ${attachment.kind} attachment: ${attachment.path}\n${errorText}`);
63
- }
64
- }
65
- }
1
+ /** @deprecated Import from ../channels/wechat/bridge-final-reply.ts instead. */
2
+ export * from "../channels/wechat/bridge-final-reply.js";
@@ -0,0 +1,151 @@
1
+ import { formatResumeSessionList, formatSessionSwitchMessage, } from "./bridge-utils.js";
2
+ export const RESUME_SESSION_LIST_LIMIT = 8;
3
+ export const RESUME_SESSION_SNAPSHOT_TTL_MS = 5 * 60 * 1_000;
4
+ const RESUME_SESSION_PREFIX_SCAN_LIMIT = 100;
5
+ function describeError(error) {
6
+ return error instanceof Error ? error.message : String(error);
7
+ }
8
+ function adapterDisplayName(adapter) {
9
+ switch (adapter) {
10
+ case "codex":
11
+ return "Codex";
12
+ case "claude":
13
+ return "Claude";
14
+ case "opencode":
15
+ return "OpenCode";
16
+ case "pi":
17
+ return "Pi";
18
+ }
19
+ }
20
+ function adapterSessionNoun(adapter) {
21
+ return adapter === "codex" ? "thread" : "session";
22
+ }
23
+ export function isWechatResumeEnabled(adapter) {
24
+ return (adapter === "codex" ||
25
+ adapter === "claude" ||
26
+ adapter === "opencode" ||
27
+ adapter === "pi");
28
+ }
29
+ export function shouldForwardSessionSwitchEvent(reason) {
30
+ return reason !== "wechat_resume";
31
+ }
32
+ export class ResumeSessionCoordinator {
33
+ adapter;
34
+ runtime;
35
+ now;
36
+ snapshot = null;
37
+ constructor(options) {
38
+ this.adapter = options.adapter;
39
+ this.runtime = options.runtime;
40
+ this.now = options.now ?? Date.now;
41
+ }
42
+ clear() {
43
+ this.snapshot = null;
44
+ }
45
+ async execute(target) {
46
+ const normalizedTarget = target?.trim() ?? "";
47
+ if (!normalizedTarget) {
48
+ const candidates = await this.listCandidates(RESUME_SESSION_LIST_LIMIT);
49
+ this.snapshot = {
50
+ createdAtMs: this.now(),
51
+ candidates,
52
+ };
53
+ return {
54
+ kind: "list",
55
+ message: formatResumeSessionList({
56
+ adapter: this.adapter,
57
+ candidates,
58
+ currentSessionId: this.runtime.getState().sharedSessionId ??
59
+ this.runtime.getState().sharedThreadId,
60
+ }),
61
+ };
62
+ }
63
+ const candidate = await this.resolveCandidate(normalizedTarget);
64
+ const currentSessionId = this.runtime.getState().sharedSessionId ??
65
+ this.runtime.getState().sharedThreadId;
66
+ if (candidate.sessionId === currentSessionId) {
67
+ return {
68
+ kind: "already_active",
69
+ sessionId: candidate.sessionId,
70
+ message: `${adapterDisplayName(this.adapter)} ${adapterSessionNoun(this.adapter)} ${candidate.sessionId.slice(0, 12)} is already active.`,
71
+ };
72
+ }
73
+ await this.runtime.resumeSession(candidate.sessionId);
74
+ return {
75
+ kind: "resumed",
76
+ sessionId: candidate.sessionId,
77
+ message: formatSessionSwitchMessage({
78
+ adapter: this.adapter,
79
+ sessionId: candidate.sessionId,
80
+ source: "wechat",
81
+ reason: "wechat_resume",
82
+ }),
83
+ };
84
+ }
85
+ async resolveCandidate(target) {
86
+ if (/^\d+$/.test(target)) {
87
+ const snapshot = this.getCurrentSnapshot();
88
+ if (!snapshot) {
89
+ throw new Error("The recent session list has expired. Send /resume again before choosing a number.");
90
+ }
91
+ const index = Number(target) - 1;
92
+ const candidate = snapshot.candidates[index];
93
+ if (!candidate) {
94
+ throw new Error(`Resume selection ${target} is outside the displayed range. Send /resume again to refresh the list.`);
95
+ }
96
+ return candidate;
97
+ }
98
+ const snapshot = this.getCurrentSnapshot();
99
+ const snapshotMatch = this.findIdMatch(snapshot?.candidates ?? [], target);
100
+ if (snapshotMatch) {
101
+ return snapshotMatch;
102
+ }
103
+ const candidates = await this.listCandidates(RESUME_SESSION_PREFIX_SCAN_LIMIT);
104
+ const candidate = this.findIdMatch(candidates, target);
105
+ if (candidate) {
106
+ return candidate;
107
+ }
108
+ // A complete ID may be older than the recent candidate window. The
109
+ // adapter remains responsible for validating its existence and cwd.
110
+ return {
111
+ sessionId: target,
112
+ title: target,
113
+ lastUpdatedAt: new Date(0).toISOString(),
114
+ source: this.adapter,
115
+ };
116
+ }
117
+ findIdMatch(candidates, target) {
118
+ const normalizedTarget = target.toLowerCase();
119
+ const exact = candidates.find((candidate) => candidate.sessionId.toLowerCase() === normalizedTarget);
120
+ if (exact) {
121
+ return exact;
122
+ }
123
+ const prefixMatches = candidates.filter((candidate) => candidate.sessionId.toLowerCase().startsWith(normalizedTarget));
124
+ if (prefixMatches.length > 1) {
125
+ throw new Error(`Session ID prefix ${target} is ambiguous. Send /resume again and use a longer ID prefix.`);
126
+ }
127
+ return prefixMatches[0] ?? null;
128
+ }
129
+ getCurrentSnapshot() {
130
+ if (!this.snapshot) {
131
+ return null;
132
+ }
133
+ if (this.now() - this.snapshot.createdAtMs > RESUME_SESSION_SNAPSHOT_TTL_MS) {
134
+ this.snapshot = null;
135
+ return null;
136
+ }
137
+ return this.snapshot;
138
+ }
139
+ async listCandidates(limit) {
140
+ try {
141
+ return await this.runtime.listResumeSessions(limit);
142
+ }
143
+ catch (error) {
144
+ const message = describeError(error);
145
+ if (/^Failed to list\s/i.test(message)) {
146
+ throw error;
147
+ }
148
+ throw new Error(`Failed to list ${adapterDisplayName(this.adapter)} ${adapterSessionNoun(this.adapter)}s: ${message}`, { cause: error });
149
+ }
150
+ }
151
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
3
- import { buildInstanceId } from "./bridge-utils.js";
3
+ import { buildInstanceId } from "../core/text-utils.js";
4
4
  import { writeJsonFileAtomic } from "../utils/atomic-file.js";
5
5
  import { getProcessRecordByPid, isWechatBridgeCommandLine, } from "./bridge-process-reaper.js";
6
6
  const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
@@ -1,9 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- const ANSI_ESCAPE_RE =
5
- // eslint-disable-next-line no-control-regex
6
- /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
4
+ import { parseBridgeControlCommand, } from "../core/bridge-control.js";
5
+ import { normalizeOutput, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
6
+ export { buildInstanceId, buildOneTimeCode, formatDuration, normalizeOutput, nowIso, stripAnsi, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
7
7
  // Messages older than bridge start minus this grace window are treated as
8
8
  // pre-start backlog and skipped. The window must absorb realistic clock skew
9
9
  // between the local machine and WeChat server timestamps: with a small value,
@@ -99,29 +99,6 @@ const INLINE_REFERENCE_ONLY_FILE_EXTENSIONS = new Set([
99
99
  const INLINE_MAAS_URL_RE = /https?:\/\/[^\s]*?\/([A-Za-z]:\\.+?(?:\.\s*[A-Za-z0-9]{2,8})+)(?:\?[^\n]*)?/g;
100
100
  const INLINE_WINDOWS_PATH_RE = /(^|[^\w])`?([A-Za-z]:\\(?:[^\\/:*?"<>|\r\n`]+\\)*[^\\/:*?"<>|\r\n`]+?(?:\.\s*[A-Za-z0-9]{2,8})+)`?(?=$|[^\w])/gm;
101
101
  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
102
  export function isThinkingForwardEnabled() {
126
103
  if (process.env.CLI_BRIDGE_THINKING_FORWARD === "1") {
127
104
  return true;
@@ -150,51 +127,8 @@ export function formatThinkingForWechat(text, maxLength = 500) {
150
127
  }
151
128
  return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
152
129
  }
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
130
  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
- }
131
+ return parseBridgeControlCommand(text);
198
132
  }
199
133
  export function parseWechatControlCommand(text, options) {
200
134
  const systemCommand = parseSystemCommand(text);
@@ -474,32 +408,6 @@ export function detectCliApproval(text) {
474
408
  denyInput: matched.denyInput,
475
409
  };
476
410
  }
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
411
  export function formatStatusReport(bridgeState, adapterState) {
504
412
  const pending = bridgeState.pendingConfirmation;
505
413
  const pendingUserInput = bridgeState.pendingUserInput;
@@ -568,6 +476,20 @@ export function formatSessionSwitchMessage(params) {
568
476
  return `OpenCode session switched to ${shortSessionId}.`;
569
477
  }
570
478
  }
479
+ if (params.adapter === "pi") {
480
+ switch (params.reason) {
481
+ case "local_follow":
482
+ case "local_session_fallback":
483
+ case "local_turn":
484
+ return `Pi session switched to ${shortSessionId} from the local terminal.`;
485
+ case "wechat_resume":
486
+ return `Pi session switched to ${shortSessionId} from WeChat.`;
487
+ case "startup_restore":
488
+ return `Pi restored shared session ${shortSessionId} on startup.`;
489
+ default:
490
+ return `Pi session switched to ${shortSessionId}.`;
491
+ }
492
+ }
571
493
  switch (params.reason) {
572
494
  case "local_follow":
573
495
  case "local_session_fallback":
@@ -596,17 +518,24 @@ export function formatResumeSessionList(params) {
596
518
  ? "No saved Codex threads were found for this working directory."
597
519
  : adapter === "opencode"
598
520
  ? "No saved OpenCode sessions were found for this working directory."
599
- : "No saved sessions were found for this working directory.";
600
- }
601
- const title = adapter === "codex" ? "Recent Codex threads:" : adapter === "opencode" ? "Recent OpenCode sessions:" : "Recent sessions:";
602
- const resumeTargetLabel = adapter === "codex" ? "threadId" : "sessionId";
521
+ : adapter === "pi"
522
+ ? "No saved Pi sessions were found for this working directory."
523
+ : "No saved Claude sessions were found for this working directory.";
524
+ }
525
+ const title = adapter === "codex"
526
+ ? "Recent Codex threads:"
527
+ : adapter === "opencode"
528
+ ? "Recent OpenCode sessions:"
529
+ : adapter === "pi"
530
+ ? "Recent Pi sessions:"
531
+ : "Recent Claude sessions:";
603
532
  return [
604
533
  title,
605
534
  ...candidates.map((candidate, index) => {
606
535
  const marker = currentSessionId && candidate.sessionId === currentSessionId ? " [current]" : "";
607
536
  return `${index + 1}. ${candidate.title} (${candidate.lastUpdatedAt}, ${candidate.sessionId.slice(0, 12)})${marker}`;
608
537
  }),
609
- `Reply with /resume <number> or /resume <${resumeTargetLabel}>.`,
538
+ "Reply with /resume <number> or /resume <unique-id-prefix>.",
610
539
  ].join("\n");
611
540
  }
612
541
  export function formatResumeThreadList(candidates, currentThreadId) {
@@ -53,6 +53,7 @@ export function buildClaudeHookSettings(command) {
53
53
  return {
54
54
  hooks: {
55
55
  SessionStart: [hook],
56
+ SessionEnd: [hook],
56
57
  UserPromptSubmit: [hook],
57
58
  PermissionRequest: [hook],
58
59
  Notification: [