cli-wechat-bridge 1.1.3 → 1.1.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.
- package/README.md +110 -140
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi.mjs +1 -1
- package/dist/bridge/bridge-adapters.claude.js +448 -11
- package/dist/bridge/bridge-adapters.codex.js +262 -11
- package/dist/bridge/bridge-adapters.core.js +76 -4
- package/dist/bridge/bridge-adapters.js +0 -3
- package/dist/bridge/bridge-adapters.opencode.js +497 -49
- package/dist/bridge/bridge-adapters.pi.js +44 -8
- package/dist/bridge/bridge-adapters.shared.js +13 -100
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-state.js +41 -6
- package/dist/bridge/bridge-utils.js +63 -229
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +93 -140
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +309 -36
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +33 -2
- package/dist/companion/local-companion-start.js +44 -13
- package/dist/companion/local-companion.js +6 -3
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +168 -56
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +2 -3
- package/dist/i18n/messages-zh.js +2 -3
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +23 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -11
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +2 -13
- package/bin/wechat-bridge-claude.mjs +0 -5
- package/bin/wechat-bridge-codex.mjs +0 -5
- package/bin/wechat-bridge-opencode.mjs +0 -5
- package/bin/wechat-bridge-pi.mjs +0 -5
- package/bin/wechat-bridge-shell.mjs +0 -5
- package/bin/wechat-bridge.mjs +0 -5
- package/bin/wechat-claude-start.mjs +0 -5
- package/bin/wechat-codex-start.mjs +0 -5
- package/bin/wechat-opencode-start.mjs +0 -5
- package/bin/wechat-pi-start.mjs +0 -5
- package/dist/bridge/bridge-adapters.shell.js +0 -363
|
@@ -5,9 +5,11 @@ import { delay } from "./bridge-adapters.shared.js";
|
|
|
5
5
|
import { t } from "../i18n/index.js";
|
|
6
6
|
import { BridgeController } from "./bridge-controller.js";
|
|
7
7
|
import { forwardWechatFinalReply } from "./bridge-final-reply.js";
|
|
8
|
+
import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "./bridge-session-resume.js";
|
|
9
|
+
import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "./wechat-forwarding.js";
|
|
8
10
|
import { ensureWechatCredentials } from "../wechat/setup.js";
|
|
9
11
|
import { BridgeStateStore } from "./bridge-state.js";
|
|
10
|
-
import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
|
|
12
|
+
import { getProcessRecordByPid, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "./bridge-process-reaper.js";
|
|
11
13
|
import { createRuntimeHost } from "../runtime/create-runtime-host.js";
|
|
12
14
|
import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
|
|
13
15
|
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
|
|
@@ -17,8 +19,11 @@ import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix,
|
|
|
17
19
|
const POLL_RETRY_BASE_MS = 1_000;
|
|
18
20
|
const POLL_RETRY_MAX_MS = 30_000;
|
|
19
21
|
const PARENT_PROCESS_POLL_MS = 5_000;
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
+
// Hard ceiling for the whole shutdown cleanup sequence before a forced exit.
|
|
23
|
+
const SHUTDOWN_FORCE_EXIT_TIMEOUT_MS = 10_000;
|
|
24
|
+
// Re-verify the parent's command line every N polls (5s each → ~60s) to keep
|
|
25
|
+
// the full-process probe off the hot path.
|
|
26
|
+
const PARENT_IDENTITY_CHECK_INTERVAL = 12;
|
|
22
27
|
function log(message) {
|
|
23
28
|
process.stderr.write(`[wechat-bridge] ${message}\n`);
|
|
24
29
|
}
|
|
@@ -42,63 +47,6 @@ function isPidAlive(pid) {
|
|
|
42
47
|
return false;
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
|
-
export function formatUserFacingBridgeFatalError(message) {
|
|
46
|
-
return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
|
|
47
|
-
}
|
|
48
|
-
export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
|
|
49
|
-
if (adapter !== "opencode" && adapter !== "pi") {
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
switch (eventType) {
|
|
53
|
-
case "stdout":
|
|
54
|
-
case "stderr":
|
|
55
|
-
case "thread_switched":
|
|
56
|
-
return false;
|
|
57
|
-
case "notice":
|
|
58
|
-
return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
|
|
59
|
-
case "mirrored_user_input":
|
|
60
|
-
return true;
|
|
61
|
-
default:
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
export function formatUserFacingInboundError(params) {
|
|
66
|
-
const { adapter, cwd, errorText, isUserFacingShellRejection } = params;
|
|
67
|
-
if (isUserFacingShellRejection) {
|
|
68
|
-
return errorText;
|
|
69
|
-
}
|
|
70
|
-
if (adapter === "opencode" &&
|
|
71
|
-
/opencode companion is not connected/i.test(errorText)) {
|
|
72
|
-
return cwd
|
|
73
|
-
? `OpenCode companion is not connected for bridge workspace:\n${cwd}\nRun "wechat-opencode" in that directory to reconnect the current local terminal, or run "wechat-bridge-opencode" and then "wechat-opencode" in your target project to replace this bridge.`
|
|
74
|
-
: 'OpenCode companion is not connected. Start "wechat-opencode" in this directory to reconnect it, then retry.';
|
|
75
|
-
}
|
|
76
|
-
return `Bridge error: ${errorText}`;
|
|
77
|
-
}
|
|
78
|
-
export function formatWechatSendFailureLogEntry(params) {
|
|
79
|
-
return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
80
|
-
}
|
|
81
|
-
export function formatWechatContextTokenStaleLogEntry(params) {
|
|
82
|
-
return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
83
|
-
}
|
|
84
|
-
function formatWechatSendRetryLogEntry(params) {
|
|
85
|
-
return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
86
|
-
}
|
|
87
|
-
export function isRetryableWechatSendError(error) {
|
|
88
|
-
if (isWechatContextTokenStaleError(error)) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
const classification = classifyWechatTransportError(error);
|
|
92
|
-
if (classification.retryable) {
|
|
93
|
-
return true;
|
|
94
|
-
}
|
|
95
|
-
const details = describeWechatTransportError(error);
|
|
96
|
-
return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
|
|
97
|
-
!/errcode=-14\b.*session timeout/i.test(details);
|
|
98
|
-
}
|
|
99
|
-
function computeWechatSendRetryDelayMs(attempt) {
|
|
100
|
-
return WECHAT_SEND_RETRY_BASE_MS * attempt;
|
|
101
|
-
}
|
|
102
50
|
export function shouldWatchParentProcess(options) {
|
|
103
51
|
return (options.startupParentPid > 1 &&
|
|
104
52
|
(options.attachedToTerminal || options.lifecycle === "companion_bound"));
|
|
@@ -159,7 +107,7 @@ export function parseCliArgs(argv) {
|
|
|
159
107
|
const next = argv[i + 1];
|
|
160
108
|
switch (arg) {
|
|
161
109
|
case "--adapter":
|
|
162
|
-
if (!next || !["codex", "claude", "opencode", "pi"
|
|
110
|
+
if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
|
|
163
111
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
164
112
|
}
|
|
165
113
|
adapter = next;
|
|
@@ -212,7 +160,7 @@ export function parseCliArgs(argv) {
|
|
|
212
160
|
}
|
|
213
161
|
}
|
|
214
162
|
if (!adapter) {
|
|
215
|
-
throw new Error("Missing required --adapter <codex|claude|opencode|pi
|
|
163
|
+
throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
|
|
216
164
|
}
|
|
217
165
|
const defaultCommand = resolveDefaultAdapterCommand(adapter);
|
|
218
166
|
return {
|
|
@@ -226,18 +174,10 @@ export function parseCliArgs(argv) {
|
|
|
226
174
|
}
|
|
227
175
|
function printUsageAndExit() {
|
|
228
176
|
process.stdout.write([
|
|
229
|
-
"
|
|
177
|
+
"Internal bridge runtime usage:",
|
|
178
|
+
" npm run bridge -- --adapter <codex|claude|opencode|pi> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
|
|
230
179
|
"",
|
|
231
|
-
"
|
|
232
|
-
" wechat-bridge-codex",
|
|
233
|
-
" wechat-bridge-claude --cwd ~/work/my-project",
|
|
234
|
-
" wechat-bridge-opencode --cwd ~/work/my-project",
|
|
235
|
-
" wechat-bridge-pi --cwd ~/work/my-project",
|
|
236
|
-
" wechat-bridge-shell --cmd pwsh # headless shell executor for non-interactive commands/scripts",
|
|
237
|
-
" wechat-bridge-shell --cmd bash # headless shell executor for non-interactive commands/scripts",
|
|
238
|
-
" wechat-bridge-codex --lifecycle companion_bound",
|
|
239
|
-
" bun run bridge:codex # repo-local development entrypoint",
|
|
240
|
-
" bun run bridge:opencode # repo-local development entrypoint",
|
|
180
|
+
"This entry is internal. Users should run wechat-codex, wechat-claude, wechat-opencode, wechat-pi, or wechat-daemon.",
|
|
241
181
|
"",
|
|
242
182
|
].join("\n"));
|
|
243
183
|
process.exit(0);
|
|
@@ -330,6 +270,10 @@ async function main() {
|
|
|
330
270
|
initialResumeConversationId: stateStore.getState().resumeConversationId,
|
|
331
271
|
initialTranscriptPath: stateStore.getState().transcriptPath,
|
|
332
272
|
});
|
|
273
|
+
const resumeCoordinator = new ResumeSessionCoordinator({
|
|
274
|
+
adapter: options.adapter,
|
|
275
|
+
runtime: adapter,
|
|
276
|
+
});
|
|
333
277
|
const controller = new BridgeController(adapter, options.cwd);
|
|
334
278
|
controller.clearLocalClientEndpoint();
|
|
335
279
|
stateStore.appendLog(`Cleared stale companion endpoint for ${options.cwd} before adapter start.`);
|
|
@@ -339,8 +283,6 @@ async function main() {
|
|
|
339
283
|
let activeTask = null;
|
|
340
284
|
const deferredInboundMessages = [];
|
|
341
285
|
let drainingDeferredInboundMessages = false;
|
|
342
|
-
let lastOutputAt = 0;
|
|
343
|
-
let lastHeartbeatAt = 0;
|
|
344
286
|
let consecutivePollFailures = 0;
|
|
345
287
|
let backlogNoticeSent = false;
|
|
346
288
|
const queueWechatTextAction = (action) => {
|
|
@@ -447,7 +389,6 @@ async function main() {
|
|
|
447
389
|
adapter,
|
|
448
390
|
});
|
|
449
391
|
activeTask = nextTask;
|
|
450
|
-
lastHeartbeatAt = 0;
|
|
451
392
|
}
|
|
452
393
|
catch (err) {
|
|
453
394
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
@@ -462,7 +403,6 @@ async function main() {
|
|
|
462
403
|
adapter: options.adapter,
|
|
463
404
|
cwd: options.cwd,
|
|
464
405
|
errorText,
|
|
465
|
-
isUserFacingShellRejection: false,
|
|
466
406
|
}), "inbound_error");
|
|
467
407
|
}
|
|
468
408
|
finally {
|
|
@@ -474,6 +414,10 @@ async function main() {
|
|
|
474
414
|
let shutdownPromise = null;
|
|
475
415
|
let requestedExitCode = 0;
|
|
476
416
|
let stdinDetached = false;
|
|
417
|
+
// Snapshot of the parent process command line at bridge startup (null when
|
|
418
|
+
// unresolvable). Compared against periodic re-probes to detect OS pid reuse.
|
|
419
|
+
const parentStartupCommandLine = getProcessRecordByPid(startupParentPid)?.commandLine ?? null;
|
|
420
|
+
let parentWatchPollCount = 0;
|
|
477
421
|
const parentWatchTimer = shouldWatchParentProcess({
|
|
478
422
|
startupParentPid,
|
|
479
423
|
attachedToTerminal,
|
|
@@ -481,6 +425,19 @@ async function main() {
|
|
|
481
425
|
})
|
|
482
426
|
? setInterval(() => {
|
|
483
427
|
if (shutdownPromise || isPidAlive(startupParentPid)) {
|
|
428
|
+
// Periodically re-verify the parent's command line so an OS pid
|
|
429
|
+
// reuse (dead parent's pid handed to an unrelated long-running
|
|
430
|
+
// process) cannot keep this companion-bound bridge alive forever.
|
|
431
|
+
parentWatchPollCount += 1;
|
|
432
|
+
if (parentWatchPollCount % PARENT_IDENTITY_CHECK_INTERVAL === 0) {
|
|
433
|
+
const record = getProcessRecordByPid(startupParentPid);
|
|
434
|
+
if (parentStartupCommandLine !== null &&
|
|
435
|
+
record !== null &&
|
|
436
|
+
record.commandLine !== parentStartupCommandLine) {
|
|
437
|
+
log(`Parent pid ${startupParentPid} was reused by another process. Stopping bridge.`);
|
|
438
|
+
void shutdown(0);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
484
441
|
return;
|
|
485
442
|
}
|
|
486
443
|
log(`Parent process ${startupParentPid} exited. Stopping bridge.`);
|
|
@@ -530,7 +487,17 @@ async function main() {
|
|
|
530
487
|
return;
|
|
531
488
|
}
|
|
532
489
|
log(message);
|
|
533
|
-
|
|
490
|
+
// Bound the whole cleanup: a dispose that hangs (child process refusing
|
|
491
|
+
// to exit) would otherwise keep the process alive indefinitely after the
|
|
492
|
+
// one-shot signal handlers are consumed.
|
|
493
|
+
const forceExitTimer = setTimeout(() => {
|
|
494
|
+
logError(`Shutdown cleanup exceeded ${formatDuration(SHUTDOWN_FORCE_EXIT_TIMEOUT_MS)}; forcing exit.`);
|
|
495
|
+
process.exit(requestedExitCode);
|
|
496
|
+
}, SHUTDOWN_FORCE_EXIT_TIMEOUT_MS);
|
|
497
|
+
void shutdown(exitCode).finally(() => {
|
|
498
|
+
clearTimeout(forceExitTimer);
|
|
499
|
+
process.exit(requestedExitCode);
|
|
500
|
+
});
|
|
534
501
|
};
|
|
535
502
|
process.once("SIGINT", () => {
|
|
536
503
|
requestShutdown("Received SIGINT. Stopping bridge.");
|
|
@@ -571,6 +538,7 @@ async function main() {
|
|
|
571
538
|
try {
|
|
572
539
|
wireAdapterEvents({
|
|
573
540
|
adapter,
|
|
541
|
+
resumeCoordinator,
|
|
574
542
|
options,
|
|
575
543
|
transport,
|
|
576
544
|
stateStore,
|
|
@@ -579,13 +547,8 @@ async function main() {
|
|
|
579
547
|
queueWechatMessage,
|
|
580
548
|
trackWechatForwardTask,
|
|
581
549
|
maybeDrainDeferredInboundMessages,
|
|
582
|
-
getActiveTask: () => activeTask,
|
|
583
550
|
clearActiveTask: () => {
|
|
584
551
|
activeTask = null;
|
|
585
|
-
lastHeartbeatAt = 0;
|
|
586
|
-
},
|
|
587
|
-
updateLastOutputAt: () => {
|
|
588
|
-
lastOutputAt = Date.now();
|
|
589
552
|
},
|
|
590
553
|
syncSharedSessionState: () => {
|
|
591
554
|
syncSharedSessionState(stateStore, adapter);
|
|
@@ -609,25 +572,33 @@ async function main() {
|
|
|
609
572
|
}
|
|
610
573
|
log(`Authorized WeChat user: ${credentials.userId}`);
|
|
611
574
|
if (options.adapter === "codex") {
|
|
612
|
-
log(
|
|
575
|
+
log("For source-mode debugging, open the visible Codex client with: npm run codex:panel");
|
|
613
576
|
}
|
|
614
577
|
else if (options.adapter === "opencode") {
|
|
615
|
-
log(
|
|
578
|
+
log("For source-mode debugging, open the visible OpenCode client with: npm run opencode:panel");
|
|
616
579
|
}
|
|
617
580
|
else if (options.adapter === "claude") {
|
|
618
|
-
log(
|
|
581
|
+
log("For source-mode debugging, open the visible Claude client with: npm run claude:companion");
|
|
619
582
|
}
|
|
620
|
-
else if (options.adapter === "
|
|
621
|
-
log("
|
|
583
|
+
else if (options.adapter === "pi") {
|
|
584
|
+
log("For source-mode debugging, open the visible Pi client with: npm run pi:companion");
|
|
622
585
|
}
|
|
623
586
|
loadEmojiBindings();
|
|
587
|
+
// Keep the standalone bridge's WeChat welcome brief: command and emoji
|
|
588
|
+
// reference material belongs to /status and /bindings, which users can
|
|
589
|
+
// request on demand. The daemon sends its own, fuller welcome.
|
|
624
590
|
const welcomeText = t("bridge.welcome", {
|
|
625
591
|
adapter: options.adapter,
|
|
626
592
|
cwd: options.cwd,
|
|
627
|
-
bindings: formatBindingsListMessage(listBindings()),
|
|
628
593
|
});
|
|
629
594
|
await queueWechatMessage(credentials.userId, welcomeText);
|
|
630
595
|
while (true) {
|
|
596
|
+
if (shutdownPromise) {
|
|
597
|
+
// Shutdown (e.g. SIGINT) was requested while we were awaiting the
|
|
598
|
+
// previous poll: stop consuming inbound messages instead of racing
|
|
599
|
+
// the concurrent cleanup/dispose with new sends.
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
631
602
|
if (!ensureRuntimeOwnership()) {
|
|
632
603
|
break;
|
|
633
604
|
}
|
|
@@ -654,7 +625,7 @@ async function main() {
|
|
|
654
625
|
await delay(delayMs);
|
|
655
626
|
continue;
|
|
656
627
|
}
|
|
657
|
-
if (!ensureRuntimeOwnership()) {
|
|
628
|
+
if (shutdownPromise || !ensureRuntimeOwnership()) {
|
|
658
629
|
break;
|
|
659
630
|
}
|
|
660
631
|
if (consecutivePollFailures > 0) {
|
|
@@ -686,8 +657,12 @@ async function main() {
|
|
|
686
657
|
options,
|
|
687
658
|
stateStore,
|
|
688
659
|
adapter,
|
|
660
|
+
resumeCoordinator,
|
|
689
661
|
queueWechatMessage,
|
|
690
662
|
outputBatcher,
|
|
663
|
+
clearActiveTask: () => {
|
|
664
|
+
activeTask = null;
|
|
665
|
+
},
|
|
691
666
|
deferInboundMessage: async (nextMessage) => {
|
|
692
667
|
deferredInboundMessages.push({
|
|
693
668
|
message: nextMessage,
|
|
@@ -699,32 +674,20 @@ async function main() {
|
|
|
699
674
|
}
|
|
700
675
|
catch (err) {
|
|
701
676
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
702
|
-
const isUserFacingShellRejection = err instanceof Error && err.name === "ShellCommandRejectedError";
|
|
703
677
|
logError(errorText);
|
|
704
|
-
stateStore.appendLog(
|
|
678
|
+
stateStore.appendLog(`inbound_error: ${errorText}`);
|
|
705
679
|
await queueWechatMessage(message.senderId, formatUserFacingInboundError({
|
|
706
680
|
adapter: options.adapter,
|
|
707
681
|
cwd: options.cwd,
|
|
708
682
|
errorText,
|
|
709
|
-
isUserFacingShellRejection,
|
|
710
683
|
}), "inbound_error");
|
|
711
684
|
}
|
|
712
685
|
if (nextTask) {
|
|
713
686
|
activeTask = nextTask;
|
|
714
|
-
lastHeartbeatAt = 0;
|
|
715
687
|
}
|
|
716
688
|
syncSharedSessionState(stateStore, adapter);
|
|
717
689
|
await maybeDrainDeferredInboundMessages();
|
|
718
690
|
}
|
|
719
|
-
const adapterState = adapter.getState();
|
|
720
|
-
const lastSignalAt = Math.max(lastHeartbeatAt, lastOutputAt || activeTask?.startedAt || 0);
|
|
721
|
-
if (activeTask &&
|
|
722
|
-
options.adapter === "shell" &&
|
|
723
|
-
adapterState.status === "busy" &&
|
|
724
|
-
Date.now() - lastSignalAt >= 30_000) {
|
|
725
|
-
lastHeartbeatAt = Date.now();
|
|
726
|
-
await queueWechatMessage(stateStore.getState().authorizedUserId, `${options.adapter} is still running. Waiting for more output...`);
|
|
727
|
-
}
|
|
728
691
|
}
|
|
729
692
|
}
|
|
730
693
|
finally {
|
|
@@ -756,7 +719,7 @@ function syncSharedSessionState(stateStore, adapter) {
|
|
|
756
719
|
}
|
|
757
720
|
}
|
|
758
721
|
function wireAdapterEvents(params) {
|
|
759
|
-
const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages,
|
|
722
|
+
const { adapter, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
|
|
760
723
|
adapter.setEventSink((event) => {
|
|
761
724
|
syncSharedSessionState();
|
|
762
725
|
syncLocalClientEndpoint();
|
|
@@ -772,7 +735,6 @@ function wireAdapterEvents(params) {
|
|
|
772
735
|
switch (event.type) {
|
|
773
736
|
case "stdout":
|
|
774
737
|
case "stderr":
|
|
775
|
-
updateLastOutputAt();
|
|
776
738
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
|
|
777
739
|
outputBatcher.push(event.text);
|
|
778
740
|
}
|
|
@@ -810,7 +772,6 @@ function wireAdapterEvents(params) {
|
|
|
810
772
|
void maybeDrainDeferredInboundMessages();
|
|
811
773
|
break;
|
|
812
774
|
case "notice":
|
|
813
|
-
updateLastOutputAt();
|
|
814
775
|
stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
|
|
815
776
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
|
|
816
777
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
@@ -819,7 +780,6 @@ function wireAdapterEvents(params) {
|
|
|
819
780
|
}
|
|
820
781
|
break;
|
|
821
782
|
case "thinking":
|
|
822
|
-
updateLastOutputAt();
|
|
823
783
|
if (event.text) {
|
|
824
784
|
const thinkingPreview = formatThinkingForWechat(event.text, 500);
|
|
825
785
|
if (thinkingPreview) {
|
|
@@ -855,8 +815,12 @@ function wireAdapterEvents(params) {
|
|
|
855
815
|
}
|
|
856
816
|
break;
|
|
857
817
|
case "session_switched":
|
|
818
|
+
if (event.source === "local") {
|
|
819
|
+
resumeCoordinator.clear();
|
|
820
|
+
}
|
|
858
821
|
stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
|
|
859
|
-
if (
|
|
822
|
+
if (shouldForwardSessionSwitchEvent(event.reason) &&
|
|
823
|
+
shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
|
|
860
824
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
861
825
|
await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
|
|
862
826
|
adapter: options.adapter,
|
|
@@ -868,8 +832,12 @@ function wireAdapterEvents(params) {
|
|
|
868
832
|
}
|
|
869
833
|
break;
|
|
870
834
|
case "thread_switched":
|
|
835
|
+
if (event.source === "local") {
|
|
836
|
+
resumeCoordinator.clear();
|
|
837
|
+
}
|
|
871
838
|
stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
|
|
872
|
-
if (
|
|
839
|
+
if (shouldForwardSessionSwitchEvent(event.reason) &&
|
|
840
|
+
shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
|
|
873
841
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
874
842
|
await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
|
|
875
843
|
adapter: options.adapter,
|
|
@@ -885,15 +853,6 @@ function wireAdapterEvents(params) {
|
|
|
885
853
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
886
854
|
stateStore.clearPendingConfirmation();
|
|
887
855
|
stateStore.clearPendingUserInput();
|
|
888
|
-
if (options.adapter === "shell") {
|
|
889
|
-
const summary = buildCompletionSummary({
|
|
890
|
-
adapter: options.adapter,
|
|
891
|
-
activeTask: getActiveTask(),
|
|
892
|
-
exitCode: event.exitCode,
|
|
893
|
-
recentOutput: outputBatcher.getRecentSummary(),
|
|
894
|
-
});
|
|
895
|
-
await queueWechatMessage(authorizedUserId, summary);
|
|
896
|
-
}
|
|
897
856
|
clearActiveTask();
|
|
898
857
|
await maybeDrainDeferredInboundMessages();
|
|
899
858
|
}));
|
|
@@ -925,18 +884,6 @@ function wireAdapterEvents(params) {
|
|
|
925
884
|
}
|
|
926
885
|
});
|
|
927
886
|
}
|
|
928
|
-
function buildCompletionSummary(params) {
|
|
929
|
-
const lines = [`${params.adapter} task complete.`];
|
|
930
|
-
if (params.activeTask) {
|
|
931
|
-
lines.push(`duration: ${formatDuration(Date.now() - params.activeTask.startedAt)}`);
|
|
932
|
-
lines.push(`input: ${params.activeTask.inputPreview}`);
|
|
933
|
-
}
|
|
934
|
-
if (typeof params.exitCode === "number") {
|
|
935
|
-
lines.push(`exit_code: ${params.exitCode}`);
|
|
936
|
-
}
|
|
937
|
-
lines.push(`recent_output:\n${params.recentOutput}`);
|
|
938
|
-
return lines.join("\n");
|
|
939
|
-
}
|
|
940
887
|
function formatInboundMessagePreview(message) {
|
|
941
888
|
if (message.text.trim()) {
|
|
942
889
|
return message.text;
|
|
@@ -950,7 +897,7 @@ function formatInboundMessagePreview(message) {
|
|
|
950
897
|
}
|
|
951
898
|
async function handleInboundMessage(params) {
|
|
952
899
|
let { message, } = params;
|
|
953
|
-
const { options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
|
|
900
|
+
const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, deferInboundMessage, } = params;
|
|
954
901
|
const state = stateStore.getState();
|
|
955
902
|
if (message.senderId !== state.authorizedUserId) {
|
|
956
903
|
await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
|
|
@@ -997,19 +944,23 @@ async function handleInboundMessage(params) {
|
|
|
997
944
|
await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
|
|
998
945
|
return null;
|
|
999
946
|
case "resume": {
|
|
1000
|
-
if (options.adapter
|
|
1001
|
-
await queueWechatMessage(message.senderId,
|
|
947
|
+
if (!isWechatResumeEnabled(options.adapter)) {
|
|
948
|
+
await queueWechatMessage(message.senderId, `WeChat /resume is disabled in ${options.adapter} mode. Use /resume directly inside "wechat-${options.adapter}"; WeChat will follow the active local session.`);
|
|
1002
949
|
return null;
|
|
1003
950
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
951
|
+
try {
|
|
952
|
+
if (systemCommand.target) {
|
|
953
|
+
await outputBatcher.flushNow();
|
|
954
|
+
}
|
|
955
|
+
const result = await resumeCoordinator.execute(systemCommand.target);
|
|
956
|
+
if (result.kind === "resumed") {
|
|
957
|
+
clearActiveTask();
|
|
958
|
+
}
|
|
959
|
+
await queueWechatMessage(message.senderId, result.message);
|
|
1007
960
|
}
|
|
1008
|
-
|
|
1009
|
-
await queueWechatMessage(message.senderId,
|
|
1010
|
-
return null;
|
|
961
|
+
catch (error) {
|
|
962
|
+
await queueWechatMessage(message.senderId, error instanceof Error ? error.message : String(error));
|
|
1011
963
|
}
|
|
1012
|
-
await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
|
|
1013
964
|
return null;
|
|
1014
965
|
}
|
|
1015
966
|
case "new_session": {
|
|
@@ -1022,6 +973,7 @@ async function handleInboundMessage(params) {
|
|
|
1022
973
|
stateStore.clearPendingConfirmation();
|
|
1023
974
|
stateStore.clearPendingUserInput();
|
|
1024
975
|
stateStore.clearSharedSessionId();
|
|
976
|
+
resumeCoordinator.clear();
|
|
1025
977
|
await adapter.createSession();
|
|
1026
978
|
stateStore.appendLog(`New ${options.adapter} session requested by owner.`);
|
|
1027
979
|
return null;
|
|
@@ -1039,6 +991,7 @@ async function handleInboundMessage(params) {
|
|
|
1039
991
|
stateStore.clearPendingConfirmation();
|
|
1040
992
|
stateStore.clearPendingUserInput();
|
|
1041
993
|
stateStore.clearSharedSessionId();
|
|
994
|
+
resumeCoordinator.clear();
|
|
1042
995
|
await adapter.reset();
|
|
1043
996
|
stateStore.appendLog("Worker reset by owner.");
|
|
1044
997
|
await queueWechatMessage(message.senderId, "Worker session has been reset.");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { truncatePreview } from "./bridge-utils.js";
|
|
2
|
+
import { classifyWechatTransportError, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
|
|
3
|
+
export const WECHAT_SEND_MAX_ATTEMPTS = 3;
|
|
4
|
+
const WECHAT_SEND_RETRY_BASE_MS = 750;
|
|
5
|
+
export function formatUserFacingBridgeFatalError(message) {
|
|
6
|
+
return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
|
|
7
|
+
}
|
|
8
|
+
export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
|
|
9
|
+
if (adapter !== "opencode" && adapter !== "pi") {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
switch (eventType) {
|
|
13
|
+
case "stdout":
|
|
14
|
+
case "stderr":
|
|
15
|
+
case "thread_switched":
|
|
16
|
+
return false;
|
|
17
|
+
case "notice":
|
|
18
|
+
return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
|
|
19
|
+
case "mirrored_user_input":
|
|
20
|
+
return true;
|
|
21
|
+
default:
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function formatUserFacingInboundError(params) {
|
|
26
|
+
const { adapter, cwd, errorText } = params;
|
|
27
|
+
if (adapter === "opencode" &&
|
|
28
|
+
/opencode companion is not connected/i.test(errorText)) {
|
|
29
|
+
return cwd
|
|
30
|
+
? `OpenCode is not connected for workspace:\n${cwd}\nRun "wechat-opencode" in that directory to recreate or reconnect the visible terminal.`
|
|
31
|
+
: 'OpenCode is not connected. Run "wechat-opencode" in this directory, then retry.';
|
|
32
|
+
}
|
|
33
|
+
return `Bridge error: ${errorText}`;
|
|
34
|
+
}
|
|
35
|
+
export function formatWechatSendFailureLogEntry(params) {
|
|
36
|
+
return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
37
|
+
}
|
|
38
|
+
export function formatWechatContextTokenStaleLogEntry(params) {
|
|
39
|
+
return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
40
|
+
}
|
|
41
|
+
export function formatWechatSendRetryLogEntry(params) {
|
|
42
|
+
return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
43
|
+
}
|
|
44
|
+
export function isRetryableWechatSendError(error) {
|
|
45
|
+
if (isWechatContextTokenStaleError(error)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const classification = classifyWechatTransportError(error);
|
|
49
|
+
if (classification.retryable) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
const details = describeWechatTransportError(error);
|
|
53
|
+
return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
|
|
54
|
+
!/errcode=-14\b.*session timeout/i.test(details);
|
|
55
|
+
}
|
|
56
|
+
export function computeWechatSendRetryDelayMs(attempt) {
|
|
57
|
+
return WECHAT_SEND_RETRY_BASE_MS * attempt;
|
|
58
|
+
}
|