@unlinearity/cli-wechat-bridge 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +221 -223
- package/dist/bridge/bridge-adapter-common.js +95 -0
- package/dist/bridge/bridge-adapters.claude.js +1 -1
- package/dist/bridge/bridge-adapters.codex.js +18 -3
- package/dist/bridge/bridge-adapters.core.js +5 -1
- package/dist/bridge/bridge-adapters.opencode.js +220 -22
- package/dist/bridge/bridge-adapters.shared.js +4 -97
- package/dist/bridge/bridge-final-reply.js +18 -3
- package/dist/bridge/bridge-state.js +9 -3
- package/dist/bridge/bridge-utils.js +38 -7
- package/dist/bridge/claude-hooks.js +2 -2
- package/dist/bridge/wechat-bridge.js +97 -27
- package/dist/companion/codex-remote-client.js +3 -0
- package/dist/companion/local-companion-start.js +6 -1
- package/dist/companion/local-companion.js +3 -0
- package/dist/utils/version-checker.js +1 -1
- package/dist/wechat/wechat-transport.js +46 -2
- package/package.json +10 -2
|
@@ -8,6 +8,8 @@ import { attachLocalCompanionMessageListener, buildLocalCompanionToken, clearLoc
|
|
|
8
8
|
import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
|
|
9
9
|
import { buildClaudeFailureMessage, buildClaudeHookSettings, buildClaudePermissionDecisionHookOutput, buildClaudePermissionApprovalRequest, extractClaudeResumeConversationId, findInjectedClaudePromptIndex, normalizeClaudeAssistantMessage, parseClaudeHookPayload, } from "./claude-hooks.js";
|
|
10
10
|
import { detectCliApproval, isHighRiskShellCommand, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
|
|
11
|
+
import { coerceWebSocketMessageData, describeUnknownError, getCodexRpcRequestId, getLocalCompanionCommandName, getNotificationThreadId, getNotificationTurnId, getSharedSessionIdFromAdapterState, isRecentIsoTimestamp, isRecord, normalizeCodexRpcError, quotePosixCommandArg, quoteWindowsCommandArg, } from "./bridge-adapter-common.js";
|
|
12
|
+
export { coerceWebSocketMessageData, describeUnknownError, getCodexRpcRequestId, getLocalCompanionCommandName, getNotificationThreadId, getNotificationTurnId, getSharedSessionIdFromAdapterState, isRecentIsoTimestamp, isRecord, normalizeCodexRpcError, quotePosixCommandArg, quoteWindowsCommandArg, } from "./bridge-adapter-common.js";
|
|
11
13
|
export const DEFAULT_COLS = 120;
|
|
12
14
|
export const DEFAULT_ROWS = 30;
|
|
13
15
|
export const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -39,101 +41,6 @@ export const OPENCODE_SERVER_READY_TIMEOUT_MS = 10_000;
|
|
|
39
41
|
export const OPENCODE_SSE_RECONNECT_DELAY_MS = 2_000;
|
|
40
42
|
export const OPENCODE_SESSION_IDLE_SETTLE_MS = 1_500;
|
|
41
43
|
export const OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS = 12_000;
|
|
42
|
-
export function isRecord(value) {
|
|
43
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
-
}
|
|
45
|
-
export function getCodexRpcRequestId(value) {
|
|
46
|
-
return typeof value === "string" || typeof value === "number" ? value : null;
|
|
47
|
-
}
|
|
48
|
-
export function getNotificationThreadId(params) {
|
|
49
|
-
if (!isRecord(params)) {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
if (typeof params.threadId === "string") {
|
|
53
|
-
return params.threadId;
|
|
54
|
-
}
|
|
55
|
-
if (isRecord(params.thread) && typeof params.thread.id === "string") {
|
|
56
|
-
return params.thread.id;
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
export function getNotificationTurnId(params) {
|
|
61
|
-
if (!isRecord(params)) {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
if (typeof params.turnId === "string") {
|
|
65
|
-
return params.turnId;
|
|
66
|
-
}
|
|
67
|
-
if (isRecord(params.turn) && typeof params.turn.id === "string") {
|
|
68
|
-
return params.turn.id;
|
|
69
|
-
}
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
export function describeUnknownError(error) {
|
|
73
|
-
if (error instanceof Error && error.message) {
|
|
74
|
-
return error.message;
|
|
75
|
-
}
|
|
76
|
-
return String(error);
|
|
77
|
-
}
|
|
78
|
-
export function normalizeCodexRpcError(error) {
|
|
79
|
-
if (isRecord(error)) {
|
|
80
|
-
const message = typeof error.message === "string"
|
|
81
|
-
? error.message
|
|
82
|
-
: typeof error.code === "number"
|
|
83
|
-
? `RPC error ${error.code}`
|
|
84
|
-
: "";
|
|
85
|
-
const data = typeof error.data === "string"
|
|
86
|
-
? error.data
|
|
87
|
-
: typeof error.details === "string"
|
|
88
|
-
? error.details
|
|
89
|
-
: "";
|
|
90
|
-
const combined = [message, data].filter(Boolean).join(": ");
|
|
91
|
-
if (combined) {
|
|
92
|
-
return combined;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return describeUnknownError(error);
|
|
96
|
-
}
|
|
97
|
-
export function getLocalCompanionCommandName(kind) {
|
|
98
|
-
switch (kind) {
|
|
99
|
-
case "codex":
|
|
100
|
-
return "wechat-codex";
|
|
101
|
-
case "claude":
|
|
102
|
-
return "wechat-claude";
|
|
103
|
-
case "opencode":
|
|
104
|
-
return "wechat-opencode";
|
|
105
|
-
default:
|
|
106
|
-
return "local companion";
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
export function getSharedSessionIdFromAdapterState(state) {
|
|
110
|
-
return state.sharedSessionId ?? state.sharedThreadId;
|
|
111
|
-
}
|
|
112
|
-
export function quoteWindowsCommandArg(value) {
|
|
113
|
-
return `"${value.replace(/"/g, '""')}"`;
|
|
114
|
-
}
|
|
115
|
-
export function quotePosixCommandArg(value) {
|
|
116
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
117
|
-
}
|
|
118
|
-
export function isRecentIsoTimestamp(timestamp, maxAgeMs) {
|
|
119
|
-
const parsedMs = Date.parse(timestamp);
|
|
120
|
-
if (!Number.isFinite(parsedMs)) {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
return parsedMs >= Date.now() - maxAgeMs;
|
|
124
|
-
}
|
|
125
|
-
export function coerceWebSocketMessageData(data) {
|
|
126
|
-
if (typeof data === "string") {
|
|
127
|
-
return data;
|
|
128
|
-
}
|
|
129
|
-
if (data instanceof ArrayBuffer) {
|
|
130
|
-
return Buffer.from(data).toString("utf8");
|
|
131
|
-
}
|
|
132
|
-
if (ArrayBuffer.isView(data)) {
|
|
133
|
-
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
|
134
|
-
}
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
44
|
export function buildCodexCliArgs(remoteUrl, options = {}) {
|
|
138
45
|
assertNoReservedExtraCliArgs(options.extraCliArgs ?? [], ["--remote", "--remote-auth-token-env"], "Codex remote connection");
|
|
139
46
|
const args = [];
|
|
@@ -196,7 +103,7 @@ export function shouldIncludeClaudeNoAltScreen(command) {
|
|
|
196
103
|
if (cached !== undefined) {
|
|
197
104
|
return cached;
|
|
198
105
|
}
|
|
199
|
-
let supported
|
|
106
|
+
let supported;
|
|
200
107
|
try {
|
|
201
108
|
const probe = spawnSync(spawnTarget.file, [...spawnTarget.args, "--help"], {
|
|
202
109
|
cwd: process.cwd(),
|
|
@@ -660,7 +567,7 @@ export function escapePowerShellString(text) {
|
|
|
660
567
|
return text.replace(/`/g, "``").replace(/"/g, '`"');
|
|
661
568
|
}
|
|
662
569
|
export function escapePosixShellString(text) {
|
|
663
|
-
return `'${text.replace(/'/g, `'
|
|
570
|
+
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
664
571
|
}
|
|
665
572
|
export function buildShellProfileCommand(profilePath, family) {
|
|
666
573
|
const resolved = path.resolve(profilePath);
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, } from "./bridge-utils.js";
|
|
2
|
+
export const OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE = "OpenCode 没有产生可发送到微信的可见回复。请查看本地终端输出,或重试这条消息。";
|
|
2
3
|
export async function forwardWechatFinalReply(params) {
|
|
3
|
-
const { adapter, rawText, sender } = params;
|
|
4
|
+
const { adapter, rawText, sender, onEmptyVisibleReply } = params;
|
|
4
5
|
const parsed = parseWechatFinalReply(rawText);
|
|
5
|
-
const
|
|
6
|
+
const sanitizedText = sanitizeWechatFinalReplyText(adapter, parsed.visibleText);
|
|
7
|
+
const visibleText = formatFinalReplyMessage(adapter, sanitizedText).trim();
|
|
6
8
|
if (visibleText) {
|
|
7
|
-
await sender.sendText(visibleText);
|
|
9
|
+
const sent = await sender.sendText(visibleText);
|
|
10
|
+
if (sent === false) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
else if (adapter === "opencode" && parsed.visibleText.trim()) {
|
|
15
|
+
onEmptyVisibleReply?.({
|
|
16
|
+
adapter,
|
|
17
|
+
rawVisibleText: parsed.visibleText,
|
|
18
|
+
});
|
|
19
|
+
const sent = await sender.sendText(OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE);
|
|
20
|
+
if (sent === false) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
8
23
|
}
|
|
9
24
|
for (const attachment of parsed.attachments) {
|
|
10
25
|
try {
|
|
@@ -3,6 +3,14 @@ import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, ensureWorkspaceChannelDir, ensureCha
|
|
|
3
3
|
import { buildInstanceId } from "./bridge-utils.js";
|
|
4
4
|
const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
|
|
5
5
|
const ORPHAN_LOCK_RECLAIM_POLL_MS = 100;
|
|
6
|
+
export function resolveRestorableSharedSessionId(persisted, options) {
|
|
7
|
+
if (!persisted ||
|
|
8
|
+
persisted.cwd !== options.cwd ||
|
|
9
|
+
persisted.adapter !== options.adapter) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
return persisted.sharedSessionId ?? persisted.sharedThreadId;
|
|
13
|
+
}
|
|
6
14
|
function cloneState(state) {
|
|
7
15
|
return JSON.parse(JSON.stringify(state));
|
|
8
16
|
}
|
|
@@ -162,9 +170,7 @@ export class BridgeStateStore {
|
|
|
162
170
|
};
|
|
163
171
|
this.acquireLock();
|
|
164
172
|
const persisted = this.readStateFile();
|
|
165
|
-
const persistedSharedSessionId = persisted
|
|
166
|
-
? persisted.sharedSessionId ?? persisted.sharedThreadId
|
|
167
|
-
: undefined;
|
|
173
|
+
const persistedSharedSessionId = resolveRestorableSharedSessionId(persisted, options);
|
|
168
174
|
const persistedResumeConversationId = options.adapter === "claude" &&
|
|
169
175
|
persisted?.cwd === options.cwd &&
|
|
170
176
|
typeof persisted.resumeConversationId === "string"
|
|
@@ -101,6 +101,7 @@ export function stripAnsi(text) {
|
|
|
101
101
|
}
|
|
102
102
|
export function normalizeOutput(text) {
|
|
103
103
|
return stripAnsi(text)
|
|
104
|
+
// eslint-disable-next-line no-control-regex
|
|
104
105
|
.replace(/\u0000/g, "")
|
|
105
106
|
.replace(/\r\n/g, "\n")
|
|
106
107
|
.replace(/\r/g, "\n");
|
|
@@ -132,6 +133,9 @@ export function parseSystemCommand(text) {
|
|
|
132
133
|
return null;
|
|
133
134
|
}
|
|
134
135
|
const [rawCommand, ...rest] = trimmed.split(/\s+/);
|
|
136
|
+
if (!rawCommand) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
135
139
|
const command = rawCommand.toLowerCase();
|
|
136
140
|
const argument = rest.join(" ").trim();
|
|
137
141
|
switch (command) {
|
|
@@ -227,7 +231,11 @@ export function parseWechatFinalReply(text) {
|
|
|
227
231
|
return extractInlineWechatAttachments(normalized);
|
|
228
232
|
}
|
|
229
233
|
const attachments = [];
|
|
230
|
-
const
|
|
234
|
+
const attachmentBlock = match[1];
|
|
235
|
+
if (attachmentBlock === undefined) {
|
|
236
|
+
return extractInlineWechatAttachments(normalized);
|
|
237
|
+
}
|
|
238
|
+
const lines = attachmentBlock
|
|
231
239
|
.split("\n")
|
|
232
240
|
.map((line) => line.trim())
|
|
233
241
|
.filter(Boolean);
|
|
@@ -240,7 +248,11 @@ export function parseWechatFinalReply(text) {
|
|
|
240
248
|
return extractInlineWechatAttachments(normalized);
|
|
241
249
|
}
|
|
242
250
|
const kind = parsed[1];
|
|
243
|
-
const
|
|
251
|
+
const rawPath = parsed[2];
|
|
252
|
+
if (!rawPath) {
|
|
253
|
+
return extractInlineWechatAttachments(normalized);
|
|
254
|
+
}
|
|
255
|
+
const attachmentPath = resolveWechatAttachmentPath(rawPath);
|
|
244
256
|
if (!attachmentPath) {
|
|
245
257
|
return extractInlineWechatAttachments(normalized);
|
|
246
258
|
}
|
|
@@ -407,7 +419,11 @@ export function getInteractiveShellCommandRejectionMessage(command) {
|
|
|
407
419
|
if (!tokens.length) {
|
|
408
420
|
return null;
|
|
409
421
|
}
|
|
410
|
-
const
|
|
422
|
+
const firstToken = tokens[0];
|
|
423
|
+
if (!firstToken) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
const executable = normalizeShellExecutableToken(firstToken);
|
|
411
427
|
const args = tokens.slice(1);
|
|
412
428
|
const lowerArgs = args.map((arg) => arg.toLowerCase());
|
|
413
429
|
if (!executable) {
|
|
@@ -419,7 +435,7 @@ export function getInteractiveShellCommandRejectionMessage(command) {
|
|
|
419
435
|
switch (executable) {
|
|
420
436
|
case "python":
|
|
421
437
|
case "python3":
|
|
422
|
-
case "py":
|
|
438
|
+
case "py": {
|
|
423
439
|
if (!args.length) {
|
|
424
440
|
return buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
|
|
425
441
|
}
|
|
@@ -438,6 +454,7 @@ export function getInteractiveShellCommandRejectionMessage(command) {
|
|
|
438
454
|
return hasScriptLikeArg(args)
|
|
439
455
|
? null
|
|
440
456
|
: buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
|
|
457
|
+
}
|
|
441
458
|
case "node":
|
|
442
459
|
if (!args.length || lowerArgs.includes("-i") || lowerArgs.includes("--interactive")) {
|
|
443
460
|
return buildInteractiveShellCommandMessage(executable, 'Try "node script.js" or "node -e \\"...\\"" instead.');
|
|
@@ -725,6 +742,9 @@ function stripInlineOpenCodeReasoningPrefix(text) {
|
|
|
725
742
|
}
|
|
726
743
|
return current;
|
|
727
744
|
}
|
|
745
|
+
function isOpenCodeReasoningResidue(text) {
|
|
746
|
+
return !text.replace(/[\s"'“”‘’`.,!?;:()[\]{}<>…。!?;:()【】《》、,-]/g, "");
|
|
747
|
+
}
|
|
728
748
|
export function sanitizeWechatFinalReplyText(adapter, text) {
|
|
729
749
|
const normalized = adapter === "opencode"
|
|
730
750
|
? cleanupVisibleWechatReplyText(stripInlineOpenCodeReasoningPrefix(text))
|
|
@@ -775,7 +795,8 @@ export function sanitizeWechatFinalReplyText(adapter, text) {
|
|
|
775
795
|
return cleaned;
|
|
776
796
|
}
|
|
777
797
|
const tail = cleanupVisibleWechatReplyText(keptLines.slice(tailStartIndex).join("\n"));
|
|
778
|
-
|
|
798
|
+
const resolved = tail || cleaned;
|
|
799
|
+
return isOpenCodeReasoningResidue(resolved) ? "" : resolved;
|
|
779
800
|
}
|
|
780
801
|
function extractInlineWechatAttachments(text) {
|
|
781
802
|
const sanitized = text
|
|
@@ -946,7 +967,7 @@ function parseSingleUserInputAnswer(question, rawValue) {
|
|
|
946
967
|
};
|
|
947
968
|
}
|
|
948
969
|
const separatorIndex = trimmed.indexOf("|");
|
|
949
|
-
|
|
970
|
+
const selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
|
|
950
971
|
let note = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1).trim() : "";
|
|
951
972
|
const answers = [];
|
|
952
973
|
if (selection) {
|
|
@@ -956,7 +977,6 @@ function parseSingleUserInputAnswer(question, rawValue) {
|
|
|
956
977
|
}
|
|
957
978
|
else if (question.isOther) {
|
|
958
979
|
note = note ? `${selection}; ${note}` : selection;
|
|
959
|
-
selection = "";
|
|
960
980
|
}
|
|
961
981
|
else {
|
|
962
982
|
return {
|
|
@@ -986,6 +1006,11 @@ export function parsePendingUserInputAnswerCommand(raw, pending) {
|
|
|
986
1006
|
const answers = {};
|
|
987
1007
|
if (pending.questions.length === 1) {
|
|
988
1008
|
const question = pending.questions[0];
|
|
1009
|
+
if (!question) {
|
|
1010
|
+
return {
|
|
1011
|
+
error: "No pending user input question was found.",
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
989
1014
|
const parsed = parseSingleUserInputAnswer(question, input);
|
|
990
1015
|
if ("error" in parsed) {
|
|
991
1016
|
return parsed;
|
|
@@ -1068,6 +1093,9 @@ export function formatUserInputRequestMessage(pending, adapterState) {
|
|
|
1068
1093
|
lines.push("");
|
|
1069
1094
|
if (pending.questions.length === 1) {
|
|
1070
1095
|
const question = pending.questions[0];
|
|
1096
|
+
if (!question) {
|
|
1097
|
+
return lines.join("\n");
|
|
1098
|
+
}
|
|
1071
1099
|
if (question.options?.length) {
|
|
1072
1100
|
lines.push("Reply with /answer <option number or exact label>.");
|
|
1073
1101
|
if (question.isOther) {
|
|
@@ -1090,6 +1118,9 @@ export function formatUserInputRequestMessage(pending, adapterState) {
|
|
|
1090
1118
|
export function formatPendingUserInputReminder(pending) {
|
|
1091
1119
|
if (pending.questions.length === 1) {
|
|
1092
1120
|
const question = pending.questions[0];
|
|
1121
|
+
if (!question) {
|
|
1122
|
+
return "Codex is waiting for user input. Reply with /answer and your response, or use /stop to interrupt.";
|
|
1123
|
+
}
|
|
1093
1124
|
return `Codex is waiting for user input for ${question.header}. Reply with /answer and your response, or use /stop to interrupt.`;
|
|
1094
1125
|
}
|
|
1095
1126
|
return `Codex is waiting for answers to ${pending.questions.length} questions. Reply with /answer questionId=value; questionId2=value, or use /stop to interrupt.`;
|
|
@@ -108,7 +108,7 @@ function summarizeClaudePlan(plan) {
|
|
|
108
108
|
const description = lines.find((line) => !/^#+\s+/.test(line) &&
|
|
109
109
|
!/^[-*]\s+/.test(line) &&
|
|
110
110
|
!/^\d+\.\s+/.test(line));
|
|
111
|
-
return truncatePreview([heading, description].filter(Boolean).join(" - ") || lines[0], 180);
|
|
111
|
+
return truncatePreview([heading, description].filter(Boolean).join(" - ") || lines[0] || "(empty plan)", 180);
|
|
112
112
|
}
|
|
113
113
|
function summarizeClaudeToolInput(toolName, toolInput) {
|
|
114
114
|
if (!toolInput) {
|
|
@@ -214,7 +214,7 @@ export function extractClaudeTranscriptFinalReply(rawTranscript) {
|
|
|
214
214
|
if (!line) {
|
|
215
215
|
continue;
|
|
216
216
|
}
|
|
217
|
-
let parsed
|
|
217
|
+
let parsed;
|
|
218
218
|
try {
|
|
219
219
|
parsed = JSON.parse(line);
|
|
220
220
|
}
|
|
@@ -14,6 +14,8 @@ import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.j
|
|
|
14
14
|
const POLL_RETRY_BASE_MS = 1_000;
|
|
15
15
|
const POLL_RETRY_MAX_MS = 30_000;
|
|
16
16
|
const PARENT_PROCESS_POLL_MS = 5_000;
|
|
17
|
+
const WECHAT_SEND_MAX_ATTEMPTS = 3;
|
|
18
|
+
const WECHAT_SEND_RETRY_BASE_MS = 750;
|
|
17
19
|
function log(message) {
|
|
18
20
|
process.stderr.write(`[wechat-bridge] ${message}\n`);
|
|
19
21
|
}
|
|
@@ -73,6 +75,21 @@ export function formatUserFacingInboundError(params) {
|
|
|
73
75
|
export function formatWechatSendFailureLogEntry(params) {
|
|
74
76
|
return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
75
77
|
}
|
|
78
|
+
function formatWechatSendRetryLogEntry(params) {
|
|
79
|
+
return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
80
|
+
}
|
|
81
|
+
function isRetryableWechatSendError(error) {
|
|
82
|
+
const classification = classifyWechatTransportError(error);
|
|
83
|
+
if (classification.retryable) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const details = describeWechatTransportError(error);
|
|
87
|
+
return /^Error: sendmessage failed:/i.test(details) &&
|
|
88
|
+
!/errcode=-14\b.*session timeout/i.test(details);
|
|
89
|
+
}
|
|
90
|
+
function computeWechatSendRetryDelayMs(attempt) {
|
|
91
|
+
return WECHAT_SEND_RETRY_BASE_MS * attempt;
|
|
92
|
+
}
|
|
76
93
|
export function shouldWatchParentProcess(options) {
|
|
77
94
|
return (options.startupParentPid > 1 &&
|
|
78
95
|
(options.attachedToTerminal || options.lifecycle === "companion_bound"));
|
|
@@ -271,6 +288,7 @@ async function main() {
|
|
|
271
288
|
stateStore.appendLog(`Cleared stale companion endpoint for ${options.cwd} before adapter start.`);
|
|
272
289
|
let textSendChain = Promise.resolve();
|
|
273
290
|
let attachmentSendChain = Promise.resolve();
|
|
291
|
+
const pendingWechatForwardTasks = new Set();
|
|
274
292
|
let activeTask = null;
|
|
275
293
|
const deferredInboundMessages = [];
|
|
276
294
|
let drainingDeferredInboundMessages = false;
|
|
@@ -288,14 +306,53 @@ async function main() {
|
|
|
288
306
|
return run;
|
|
289
307
|
};
|
|
290
308
|
const queueWechatMessage = (senderId, text, context = "message") => {
|
|
291
|
-
return queueWechatTextAction(
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
309
|
+
return queueWechatTextAction(async () => {
|
|
310
|
+
for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
|
|
311
|
+
try {
|
|
312
|
+
await transport.sendText(senderId, text);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
|
|
317
|
+
const delayMs = computeWechatSendRetryDelayMs(attempt);
|
|
318
|
+
logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
|
|
319
|
+
stateStore.appendLog(formatWechatSendRetryLogEntry({
|
|
320
|
+
context,
|
|
321
|
+
recipientId: senderId,
|
|
322
|
+
attempt,
|
|
323
|
+
delayMs,
|
|
324
|
+
error: err,
|
|
325
|
+
}));
|
|
326
|
+
await delay(delayMs);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
|
|
330
|
+
stateStore.appendLog(formatWechatSendFailureLogEntry({
|
|
331
|
+
context,
|
|
332
|
+
recipientId: senderId,
|
|
333
|
+
error: err,
|
|
334
|
+
}));
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
const trackWechatForwardTask = (task) => {
|
|
342
|
+
const tracked = task
|
|
343
|
+
.catch((error) => {
|
|
344
|
+
logError(`WeChat forward task failed: ${describeWechatTransportError(error)}`);
|
|
345
|
+
stateStore.appendLog(`wechat_forward_failed: error=${truncatePreview(describeWechatTransportError(error), 400)}`);
|
|
346
|
+
})
|
|
347
|
+
.finally(() => {
|
|
348
|
+
pendingWechatForwardTasks.delete(tracked);
|
|
298
349
|
});
|
|
350
|
+
pendingWechatForwardTasks.add(tracked);
|
|
351
|
+
};
|
|
352
|
+
const waitForPendingWechatForwardTasks = async () => {
|
|
353
|
+
while (pendingWechatForwardTasks.size > 0) {
|
|
354
|
+
await Promise.allSettled([...pendingWechatForwardTasks]);
|
|
355
|
+
}
|
|
299
356
|
};
|
|
300
357
|
const outputBatcher = new OutputBatcher(async (text) => {
|
|
301
358
|
await queueWechatMessage(stateStore.getState().authorizedUserId, text);
|
|
@@ -377,6 +434,7 @@ async function main() {
|
|
|
377
434
|
}
|
|
378
435
|
try {
|
|
379
436
|
await outputBatcher.flushNow();
|
|
437
|
+
await waitForPendingWechatForwardTasks();
|
|
380
438
|
}
|
|
381
439
|
catch {
|
|
382
440
|
// Best effort flush.
|
|
@@ -384,6 +442,7 @@ async function main() {
|
|
|
384
442
|
try {
|
|
385
443
|
await textSendChain;
|
|
386
444
|
await attachmentSendChain;
|
|
445
|
+
await waitForPendingWechatForwardTasks();
|
|
387
446
|
}
|
|
388
447
|
catch {
|
|
389
448
|
// Best effort flush.
|
|
@@ -458,6 +517,7 @@ async function main() {
|
|
|
458
517
|
outputBatcher,
|
|
459
518
|
queueWechatAttachmentAction,
|
|
460
519
|
queueWechatMessage,
|
|
520
|
+
trackWechatForwardTask,
|
|
461
521
|
maybeDrainDeferredInboundMessages,
|
|
462
522
|
getActiveTask: () => activeTask,
|
|
463
523
|
clearActiveTask: () => {
|
|
@@ -622,7 +682,7 @@ function syncSharedSessionState(stateStore, adapter) {
|
|
|
622
682
|
}
|
|
623
683
|
}
|
|
624
684
|
function wireAdapterEvents(params) {
|
|
625
|
-
const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, updateLastOutputAt, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
|
|
685
|
+
const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, updateLastOutputAt, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
|
|
626
686
|
adapter.setEventSink((event) => {
|
|
627
687
|
syncSharedSessionState();
|
|
628
688
|
syncLocalClientEndpoint();
|
|
@@ -642,19 +702,28 @@ function wireAdapterEvents(params) {
|
|
|
642
702
|
break;
|
|
643
703
|
case "final_reply":
|
|
644
704
|
stateStore.appendLog(`final_reply: ${truncatePreview(event.text)}`);
|
|
645
|
-
|
|
705
|
+
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
646
706
|
await forwardWechatFinalReply({
|
|
647
707
|
adapter: options.adapter,
|
|
648
708
|
rawText: event.text,
|
|
709
|
+
onEmptyVisibleReply: ({ rawVisibleText }) => {
|
|
710
|
+
stateStore.appendLog(`empty_visible_final_reply: adapter=${options.adapter} raw=${truncatePreview(rawVisibleText)}`);
|
|
711
|
+
},
|
|
649
712
|
sender: {
|
|
650
|
-
sendText: (text) =>
|
|
713
|
+
sendText: async (text) => {
|
|
714
|
+
const sent = await queueWechatMessage(authorizedUserId, text, "final_reply");
|
|
715
|
+
if (sent) {
|
|
716
|
+
stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
|
|
717
|
+
}
|
|
718
|
+
return sent;
|
|
719
|
+
},
|
|
651
720
|
sendImage: (imagePath) => queueWechatAttachmentAction(() => transport.sendImage(imagePath, { recipientId: authorizedUserId })),
|
|
652
721
|
sendFile: (filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId: authorizedUserId })),
|
|
653
722
|
sendVoice: (voicePath) => queueWechatAttachmentAction(() => transport.sendVoice(voicePath, authorizedUserId)),
|
|
654
723
|
sendVideo: (videoPath) => queueWechatAttachmentAction(() => transport.sendVideo(videoPath, { recipientId: authorizedUserId })),
|
|
655
724
|
},
|
|
656
725
|
});
|
|
657
|
-
});
|
|
726
|
+
}));
|
|
658
727
|
break;
|
|
659
728
|
case "status":
|
|
660
729
|
if (event.message) {
|
|
@@ -667,56 +736,56 @@ function wireAdapterEvents(params) {
|
|
|
667
736
|
updateLastOutputAt();
|
|
668
737
|
stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
|
|
669
738
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
|
|
670
|
-
|
|
739
|
+
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
671
740
|
await queueWechatMessage(authorizedUserId, event.text, "notice");
|
|
672
|
-
});
|
|
741
|
+
}));
|
|
673
742
|
}
|
|
674
743
|
break;
|
|
675
744
|
case "approval_required":
|
|
676
|
-
|
|
745
|
+
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
677
746
|
const pending = toPendingApproval(event.request);
|
|
678
747
|
stateStore.setPendingConfirmation(pending);
|
|
679
748
|
stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
|
|
680
749
|
await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
|
|
681
|
-
});
|
|
750
|
+
}));
|
|
682
751
|
break;
|
|
683
752
|
case "mirrored_user_input":
|
|
684
753
|
stateStore.appendLog(`mirrored_local_input: ${truncatePreview(event.text)}`);
|
|
685
754
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
|
|
686
|
-
|
|
755
|
+
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
687
756
|
await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, event.text), "mirrored_user_input");
|
|
688
|
-
});
|
|
757
|
+
}));
|
|
689
758
|
}
|
|
690
759
|
break;
|
|
691
760
|
case "session_switched":
|
|
692
761
|
stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
|
|
693
762
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
|
|
694
|
-
|
|
763
|
+
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
695
764
|
await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
|
|
696
765
|
adapter: options.adapter,
|
|
697
766
|
sessionId: event.sessionId,
|
|
698
767
|
source: event.source,
|
|
699
768
|
reason: event.reason,
|
|
700
769
|
}), "session_switched");
|
|
701
|
-
});
|
|
770
|
+
}));
|
|
702
771
|
}
|
|
703
772
|
break;
|
|
704
773
|
case "thread_switched":
|
|
705
774
|
stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
|
|
706
775
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
|
|
707
|
-
|
|
776
|
+
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
708
777
|
await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
|
|
709
778
|
adapter: options.adapter,
|
|
710
779
|
sessionId: event.threadId,
|
|
711
780
|
source: event.source,
|
|
712
781
|
reason: event.reason,
|
|
713
782
|
}), "thread_switched");
|
|
714
|
-
});
|
|
783
|
+
}));
|
|
715
784
|
}
|
|
716
785
|
void maybeDrainDeferredInboundMessages();
|
|
717
786
|
break;
|
|
718
787
|
case "task_complete":
|
|
719
|
-
|
|
788
|
+
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
720
789
|
stateStore.clearPendingConfirmation();
|
|
721
790
|
if (options.adapter === "shell") {
|
|
722
791
|
const summary = buildCompletionSummary({
|
|
@@ -729,25 +798,25 @@ function wireAdapterEvents(params) {
|
|
|
729
798
|
}
|
|
730
799
|
clearActiveTask();
|
|
731
800
|
await maybeDrainDeferredInboundMessages();
|
|
732
|
-
});
|
|
801
|
+
}));
|
|
733
802
|
break;
|
|
734
803
|
case "task_failed":
|
|
735
|
-
|
|
804
|
+
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
736
805
|
stateStore.clearPendingConfirmation();
|
|
737
806
|
clearActiveTask();
|
|
738
807
|
await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, event.message), "task_failed");
|
|
739
808
|
await maybeDrainDeferredInboundMessages();
|
|
740
|
-
});
|
|
809
|
+
}));
|
|
741
810
|
break;
|
|
742
811
|
case "fatal_error":
|
|
743
812
|
logError(event.message);
|
|
744
813
|
stateStore.appendLog(`fatal_error: ${event.message}`);
|
|
745
814
|
stateStore.clearPendingConfirmation();
|
|
746
815
|
clearActiveTask();
|
|
747
|
-
|
|
816
|
+
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
748
817
|
await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(event.message), "fatal_error");
|
|
749
818
|
await maybeDrainDeferredInboundMessages();
|
|
750
|
-
});
|
|
819
|
+
}));
|
|
751
820
|
break;
|
|
752
821
|
case "shutdown_requested":
|
|
753
822
|
stateStore.appendLog(`shutdown_requested: ${event.reason}`);
|
|
@@ -774,6 +843,7 @@ async function handleInboundMessage(params) {
|
|
|
774
843
|
const systemCommand = parseWechatControlCommand(message.text, {
|
|
775
844
|
adapter: options.adapter,
|
|
776
845
|
hasPendingConfirmation: Boolean(state.pendingConfirmation),
|
|
846
|
+
hasPendingUserInput: Boolean(state.pendingUserInput),
|
|
777
847
|
});
|
|
778
848
|
if (message.senderId !== state.authorizedUserId) {
|
|
779
849
|
await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
|
|
@@ -13,6 +13,9 @@ export function parseCliArgs(argv) {
|
|
|
13
13
|
const cliArgs = [];
|
|
14
14
|
for (let i = 0; i < argv.length; i += 1) {
|
|
15
15
|
const arg = argv[i];
|
|
16
|
+
if (!arg) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
16
19
|
const next = argv[i + 1];
|
|
17
20
|
if (arg === "--help" || arg === "-h") {
|
|
18
21
|
process.stdout.write([
|
|
@@ -87,6 +87,9 @@ export function parseCliArgs(argv) {
|
|
|
87
87
|
const cliArgs = [];
|
|
88
88
|
for (let i = 0; i < argv.length; i += 1) {
|
|
89
89
|
const arg = argv[i];
|
|
90
|
+
if (!arg) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
90
93
|
const next = argv[i + 1];
|
|
91
94
|
if (arg === "--help" || arg === "-h") {
|
|
92
95
|
process.stdout.write([
|
|
@@ -173,7 +176,9 @@ async function stopExistingBridge(lock, requestedAdapter) {
|
|
|
173
176
|
catch (error) {
|
|
174
177
|
if (isPidAlive(pid)) {
|
|
175
178
|
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
-
throw new Error(`Failed to stop existing bridge pid=${pid}: ${message}
|
|
179
|
+
throw new Error(`Failed to stop existing bridge pid=${pid}: ${message}`, {
|
|
180
|
+
cause: error,
|
|
181
|
+
});
|
|
177
182
|
}
|
|
178
183
|
}
|
|
179
184
|
if (!(await waitForProcessExit(pid, 10_000))) {
|