cli-wechat-bridge 1.1.3 → 1.1.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 +98 -135
- package/bin/_run-entry.mjs +12 -0
- package/bin/wechat-claude-start.mjs +7 -2
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex-start.mjs +7 -2
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode-start.mjs +7 -2
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi-start.mjs +7 -2
- package/bin/wechat-pi.mjs +1 -1
- package/dist/bridge/bridge-adapters.claude.js +21 -1
- package/dist/bridge/bridge-adapters.codex.js +27 -4
- package/dist/bridge/bridge-adapters.core.js +76 -4
- package/dist/bridge/bridge-adapters.js +0 -3
- package/dist/bridge/bridge-adapters.opencode.js +130 -21
- package/dist/bridge/bridge-adapters.pi.js +12 -2
- package/dist/bridge/bridge-adapters.shared.js +13 -100
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-state.js +41 -6
- package/dist/bridge/bridge-utils.js +37 -224
- package/dist/bridge/wechat-bridge.js +75 -130
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +20 -6
- package/dist/companion/local-companion-link.js +7 -2
- package/dist/companion/local-companion-start.js +45 -13
- package/dist/companion/local-companion.js +6 -3
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +148 -54
- 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 -9
- 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/dist/bridge/bridge-adapters.shell.js +0 -363
|
@@ -5,11 +5,12 @@ 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 { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "./wechat-forwarding.js";
|
|
8
9
|
import { ensureWechatCredentials } from "../wechat/setup.js";
|
|
9
10
|
import { BridgeStateStore } from "./bridge-state.js";
|
|
10
|
-
import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
|
|
11
|
+
import { getProcessRecordByPid, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "./bridge-process-reaper.js";
|
|
11
12
|
import { createRuntimeHost } from "../runtime/create-runtime-host.js";
|
|
12
|
-
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
|
+
import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatResumeSessionList, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
|
|
13
14
|
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
|
|
14
15
|
import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
|
|
15
16
|
import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
|
|
@@ -17,8 +18,11 @@ import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix,
|
|
|
17
18
|
const POLL_RETRY_BASE_MS = 1_000;
|
|
18
19
|
const POLL_RETRY_MAX_MS = 30_000;
|
|
19
20
|
const PARENT_PROCESS_POLL_MS = 5_000;
|
|
20
|
-
|
|
21
|
-
const
|
|
21
|
+
// Hard ceiling for the whole shutdown cleanup sequence before a forced exit.
|
|
22
|
+
const SHUTDOWN_FORCE_EXIT_TIMEOUT_MS = 10_000;
|
|
23
|
+
// Re-verify the parent's command line every N polls (5s each → ~60s) to keep
|
|
24
|
+
// the full-process probe off the hot path.
|
|
25
|
+
const PARENT_IDENTITY_CHECK_INTERVAL = 12;
|
|
22
26
|
function log(message) {
|
|
23
27
|
process.stderr.write(`[wechat-bridge] ${message}\n`);
|
|
24
28
|
}
|
|
@@ -42,63 +46,6 @@ function isPidAlive(pid) {
|
|
|
42
46
|
return false;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
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
49
|
export function shouldWatchParentProcess(options) {
|
|
103
50
|
return (options.startupParentPid > 1 &&
|
|
104
51
|
(options.attachedToTerminal || options.lifecycle === "companion_bound"));
|
|
@@ -159,7 +106,7 @@ export function parseCliArgs(argv) {
|
|
|
159
106
|
const next = argv[i + 1];
|
|
160
107
|
switch (arg) {
|
|
161
108
|
case "--adapter":
|
|
162
|
-
if (!next || !["codex", "claude", "opencode", "pi"
|
|
109
|
+
if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
|
|
163
110
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
164
111
|
}
|
|
165
112
|
adapter = next;
|
|
@@ -212,7 +159,7 @@ export function parseCliArgs(argv) {
|
|
|
212
159
|
}
|
|
213
160
|
}
|
|
214
161
|
if (!adapter) {
|
|
215
|
-
throw new Error("Missing required --adapter <codex|claude|opencode|pi
|
|
162
|
+
throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
|
|
216
163
|
}
|
|
217
164
|
const defaultCommand = resolveDefaultAdapterCommand(adapter);
|
|
218
165
|
return {
|
|
@@ -226,18 +173,10 @@ export function parseCliArgs(argv) {
|
|
|
226
173
|
}
|
|
227
174
|
function printUsageAndExit() {
|
|
228
175
|
process.stdout.write([
|
|
229
|
-
"
|
|
176
|
+
"Internal bridge runtime usage:",
|
|
177
|
+
" 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
178
|
"",
|
|
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",
|
|
179
|
+
"This entry is internal. Users should run wechat-codex, wechat-claude, wechat-opencode, wechat-pi, or wechat-daemon.",
|
|
241
180
|
"",
|
|
242
181
|
].join("\n"));
|
|
243
182
|
process.exit(0);
|
|
@@ -339,8 +278,6 @@ async function main() {
|
|
|
339
278
|
let activeTask = null;
|
|
340
279
|
const deferredInboundMessages = [];
|
|
341
280
|
let drainingDeferredInboundMessages = false;
|
|
342
|
-
let lastOutputAt = 0;
|
|
343
|
-
let lastHeartbeatAt = 0;
|
|
344
281
|
let consecutivePollFailures = 0;
|
|
345
282
|
let backlogNoticeSent = false;
|
|
346
283
|
const queueWechatTextAction = (action) => {
|
|
@@ -447,7 +384,6 @@ async function main() {
|
|
|
447
384
|
adapter,
|
|
448
385
|
});
|
|
449
386
|
activeTask = nextTask;
|
|
450
|
-
lastHeartbeatAt = 0;
|
|
451
387
|
}
|
|
452
388
|
catch (err) {
|
|
453
389
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
@@ -462,7 +398,6 @@ async function main() {
|
|
|
462
398
|
adapter: options.adapter,
|
|
463
399
|
cwd: options.cwd,
|
|
464
400
|
errorText,
|
|
465
|
-
isUserFacingShellRejection: false,
|
|
466
401
|
}), "inbound_error");
|
|
467
402
|
}
|
|
468
403
|
finally {
|
|
@@ -474,6 +409,10 @@ async function main() {
|
|
|
474
409
|
let shutdownPromise = null;
|
|
475
410
|
let requestedExitCode = 0;
|
|
476
411
|
let stdinDetached = false;
|
|
412
|
+
// Snapshot of the parent process command line at bridge startup (null when
|
|
413
|
+
// unresolvable). Compared against periodic re-probes to detect OS pid reuse.
|
|
414
|
+
const parentStartupCommandLine = getProcessRecordByPid(startupParentPid)?.commandLine ?? null;
|
|
415
|
+
let parentWatchPollCount = 0;
|
|
477
416
|
const parentWatchTimer = shouldWatchParentProcess({
|
|
478
417
|
startupParentPid,
|
|
479
418
|
attachedToTerminal,
|
|
@@ -481,6 +420,19 @@ async function main() {
|
|
|
481
420
|
})
|
|
482
421
|
? setInterval(() => {
|
|
483
422
|
if (shutdownPromise || isPidAlive(startupParentPid)) {
|
|
423
|
+
// Periodically re-verify the parent's command line so an OS pid
|
|
424
|
+
// reuse (dead parent's pid handed to an unrelated long-running
|
|
425
|
+
// process) cannot keep this companion-bound bridge alive forever.
|
|
426
|
+
parentWatchPollCount += 1;
|
|
427
|
+
if (parentWatchPollCount % PARENT_IDENTITY_CHECK_INTERVAL === 0) {
|
|
428
|
+
const record = getProcessRecordByPid(startupParentPid);
|
|
429
|
+
if (parentStartupCommandLine !== null &&
|
|
430
|
+
record !== null &&
|
|
431
|
+
record.commandLine !== parentStartupCommandLine) {
|
|
432
|
+
log(`Parent pid ${startupParentPid} was reused by another process. Stopping bridge.`);
|
|
433
|
+
void shutdown(0);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
484
436
|
return;
|
|
485
437
|
}
|
|
486
438
|
log(`Parent process ${startupParentPid} exited. Stopping bridge.`);
|
|
@@ -530,7 +482,17 @@ async function main() {
|
|
|
530
482
|
return;
|
|
531
483
|
}
|
|
532
484
|
log(message);
|
|
533
|
-
|
|
485
|
+
// Bound the whole cleanup: a dispose that hangs (child process refusing
|
|
486
|
+
// to exit) would otherwise keep the process alive indefinitely after the
|
|
487
|
+
// one-shot signal handlers are consumed.
|
|
488
|
+
const forceExitTimer = setTimeout(() => {
|
|
489
|
+
logError(`Shutdown cleanup exceeded ${formatDuration(SHUTDOWN_FORCE_EXIT_TIMEOUT_MS)}; forcing exit.`);
|
|
490
|
+
process.exit(requestedExitCode);
|
|
491
|
+
}, SHUTDOWN_FORCE_EXIT_TIMEOUT_MS);
|
|
492
|
+
void shutdown(exitCode).finally(() => {
|
|
493
|
+
clearTimeout(forceExitTimer);
|
|
494
|
+
process.exit(requestedExitCode);
|
|
495
|
+
});
|
|
534
496
|
};
|
|
535
497
|
process.once("SIGINT", () => {
|
|
536
498
|
requestShutdown("Received SIGINT. Stopping bridge.");
|
|
@@ -579,13 +541,8 @@ async function main() {
|
|
|
579
541
|
queueWechatMessage,
|
|
580
542
|
trackWechatForwardTask,
|
|
581
543
|
maybeDrainDeferredInboundMessages,
|
|
582
|
-
getActiveTask: () => activeTask,
|
|
583
544
|
clearActiveTask: () => {
|
|
584
545
|
activeTask = null;
|
|
585
|
-
lastHeartbeatAt = 0;
|
|
586
|
-
},
|
|
587
|
-
updateLastOutputAt: () => {
|
|
588
|
-
lastOutputAt = Date.now();
|
|
589
546
|
},
|
|
590
547
|
syncSharedSessionState: () => {
|
|
591
548
|
syncSharedSessionState(stateStore, adapter);
|
|
@@ -609,25 +566,33 @@ async function main() {
|
|
|
609
566
|
}
|
|
610
567
|
log(`Authorized WeChat user: ${credentials.userId}`);
|
|
611
568
|
if (options.adapter === "codex") {
|
|
612
|
-
log(
|
|
569
|
+
log("For source-mode debugging, open the visible Codex client with: npm run codex:panel");
|
|
613
570
|
}
|
|
614
571
|
else if (options.adapter === "opencode") {
|
|
615
|
-
log(
|
|
572
|
+
log("For source-mode debugging, open the visible OpenCode client with: npm run opencode:panel");
|
|
616
573
|
}
|
|
617
574
|
else if (options.adapter === "claude") {
|
|
618
|
-
log(
|
|
575
|
+
log("For source-mode debugging, open the visible Claude client with: npm run claude:companion");
|
|
619
576
|
}
|
|
620
|
-
else if (options.adapter === "
|
|
621
|
-
log("
|
|
577
|
+
else if (options.adapter === "pi") {
|
|
578
|
+
log("For source-mode debugging, open the visible Pi client with: npm run pi:companion");
|
|
622
579
|
}
|
|
623
580
|
loadEmojiBindings();
|
|
581
|
+
// Keep the standalone bridge's WeChat welcome brief: command and emoji
|
|
582
|
+
// reference material belongs to /status and /bindings, which users can
|
|
583
|
+
// request on demand. The daemon sends its own, fuller welcome.
|
|
624
584
|
const welcomeText = t("bridge.welcome", {
|
|
625
585
|
adapter: options.adapter,
|
|
626
586
|
cwd: options.cwd,
|
|
627
|
-
bindings: formatBindingsListMessage(listBindings()),
|
|
628
587
|
});
|
|
629
588
|
await queueWechatMessage(credentials.userId, welcomeText);
|
|
630
589
|
while (true) {
|
|
590
|
+
if (shutdownPromise) {
|
|
591
|
+
// Shutdown (e.g. SIGINT) was requested while we were awaiting the
|
|
592
|
+
// previous poll: stop consuming inbound messages instead of racing
|
|
593
|
+
// the concurrent cleanup/dispose with new sends.
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
631
596
|
if (!ensureRuntimeOwnership()) {
|
|
632
597
|
break;
|
|
633
598
|
}
|
|
@@ -654,7 +619,7 @@ async function main() {
|
|
|
654
619
|
await delay(delayMs);
|
|
655
620
|
continue;
|
|
656
621
|
}
|
|
657
|
-
if (!ensureRuntimeOwnership()) {
|
|
622
|
+
if (shutdownPromise || !ensureRuntimeOwnership()) {
|
|
658
623
|
break;
|
|
659
624
|
}
|
|
660
625
|
if (consecutivePollFailures > 0) {
|
|
@@ -699,32 +664,20 @@ async function main() {
|
|
|
699
664
|
}
|
|
700
665
|
catch (err) {
|
|
701
666
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
702
|
-
const isUserFacingShellRejection = err instanceof Error && err.name === "ShellCommandRejectedError";
|
|
703
667
|
logError(errorText);
|
|
704
|
-
stateStore.appendLog(
|
|
668
|
+
stateStore.appendLog(`inbound_error: ${errorText}`);
|
|
705
669
|
await queueWechatMessage(message.senderId, formatUserFacingInboundError({
|
|
706
670
|
adapter: options.adapter,
|
|
707
671
|
cwd: options.cwd,
|
|
708
672
|
errorText,
|
|
709
|
-
isUserFacingShellRejection,
|
|
710
673
|
}), "inbound_error");
|
|
711
674
|
}
|
|
712
675
|
if (nextTask) {
|
|
713
676
|
activeTask = nextTask;
|
|
714
|
-
lastHeartbeatAt = 0;
|
|
715
677
|
}
|
|
716
678
|
syncSharedSessionState(stateStore, adapter);
|
|
717
679
|
await maybeDrainDeferredInboundMessages();
|
|
718
680
|
}
|
|
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
681
|
}
|
|
729
682
|
}
|
|
730
683
|
finally {
|
|
@@ -756,7 +709,7 @@ function syncSharedSessionState(stateStore, adapter) {
|
|
|
756
709
|
}
|
|
757
710
|
}
|
|
758
711
|
function wireAdapterEvents(params) {
|
|
759
|
-
const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages,
|
|
712
|
+
const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
|
|
760
713
|
adapter.setEventSink((event) => {
|
|
761
714
|
syncSharedSessionState();
|
|
762
715
|
syncLocalClientEndpoint();
|
|
@@ -772,7 +725,6 @@ function wireAdapterEvents(params) {
|
|
|
772
725
|
switch (event.type) {
|
|
773
726
|
case "stdout":
|
|
774
727
|
case "stderr":
|
|
775
|
-
updateLastOutputAt();
|
|
776
728
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
|
|
777
729
|
outputBatcher.push(event.text);
|
|
778
730
|
}
|
|
@@ -810,7 +762,6 @@ function wireAdapterEvents(params) {
|
|
|
810
762
|
void maybeDrainDeferredInboundMessages();
|
|
811
763
|
break;
|
|
812
764
|
case "notice":
|
|
813
|
-
updateLastOutputAt();
|
|
814
765
|
stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
|
|
815
766
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
|
|
816
767
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
@@ -819,7 +770,6 @@ function wireAdapterEvents(params) {
|
|
|
819
770
|
}
|
|
820
771
|
break;
|
|
821
772
|
case "thinking":
|
|
822
|
-
updateLastOutputAt();
|
|
823
773
|
if (event.text) {
|
|
824
774
|
const thinkingPreview = formatThinkingForWechat(event.text, 500);
|
|
825
775
|
if (thinkingPreview) {
|
|
@@ -885,15 +835,6 @@ function wireAdapterEvents(params) {
|
|
|
885
835
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
886
836
|
stateStore.clearPendingConfirmation();
|
|
887
837
|
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
838
|
clearActiveTask();
|
|
898
839
|
await maybeDrainDeferredInboundMessages();
|
|
899
840
|
}));
|
|
@@ -925,18 +866,6 @@ function wireAdapterEvents(params) {
|
|
|
925
866
|
}
|
|
926
867
|
});
|
|
927
868
|
}
|
|
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
869
|
function formatInboundMessagePreview(message) {
|
|
941
870
|
if (message.text.trim()) {
|
|
942
871
|
return message.text;
|
|
@@ -1006,7 +935,23 @@ async function handleInboundMessage(params) {
|
|
|
1006
935
|
return null;
|
|
1007
936
|
}
|
|
1008
937
|
if (options.adapter === "opencode") {
|
|
1009
|
-
|
|
938
|
+
try {
|
|
939
|
+
if (systemCommand.target) {
|
|
940
|
+
await adapter.resumeSession(systemCommand.target);
|
|
941
|
+
await queueWechatMessage(message.senderId, `Resumed OpenCode session ${systemCommand.target}.`);
|
|
942
|
+
}
|
|
943
|
+
else {
|
|
944
|
+
const candidates = await adapter.listResumeSessions(8);
|
|
945
|
+
await queueWechatMessage(message.senderId, formatResumeSessionList({
|
|
946
|
+
adapter: options.adapter,
|
|
947
|
+
candidates,
|
|
948
|
+
currentSessionId: adapter.getState().sharedSessionId,
|
|
949
|
+
}));
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
await queueWechatMessage(message.senderId, `Failed to resume OpenCode session: ${error instanceof Error ? error.message : String(error)}`);
|
|
954
|
+
}
|
|
1010
955
|
return null;
|
|
1011
956
|
}
|
|
1012
957
|
await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
|
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,7 @@ function log(message) {
|
|
|
10
10
|
}
|
|
11
11
|
export function parseCliArgs(argv) {
|
|
12
12
|
let cwd = process.cwd();
|
|
13
|
+
let sessionStartMode;
|
|
13
14
|
const cliArgs = [];
|
|
14
15
|
for (let i = 0; i < argv.length; i += 1) {
|
|
15
16
|
const arg = argv[i];
|
|
@@ -19,9 +20,10 @@ export function parseCliArgs(argv) {
|
|
|
19
20
|
const next = argv[i + 1];
|
|
20
21
|
if (arg === "--help" || arg === "-h") {
|
|
21
22
|
process.stdout.write([
|
|
22
|
-
"
|
|
23
|
+
"Internal Codex client usage: npm run codex:panel -- [--cwd <path>] [--session-start-mode <restore|new>] [...codex args]",
|
|
23
24
|
"",
|
|
24
|
-
|
|
25
|
+
"Connects the visible native Codex client to the bridge runtime for the current directory.",
|
|
26
|
+
"Use --session-start-mode new to open the client without resuming the shared thread.",
|
|
25
27
|
"Unknown arguments are forwarded to the Codex client.",
|
|
26
28
|
"",
|
|
27
29
|
].join("\n"));
|
|
@@ -35,17 +37,25 @@ export function parseCliArgs(argv) {
|
|
|
35
37
|
i += 1;
|
|
36
38
|
continue;
|
|
37
39
|
}
|
|
40
|
+
if (arg === "--session-start-mode") {
|
|
41
|
+
if (next !== "restore" && next !== "new") {
|
|
42
|
+
throw new Error(`Invalid --session-start-mode value: ${next ?? "(missing)"}`);
|
|
43
|
+
}
|
|
44
|
+
sessionStartMode = next;
|
|
45
|
+
i += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
38
48
|
cliArgs.push(arg);
|
|
39
49
|
}
|
|
40
|
-
return { cwd, cliArgs };
|
|
50
|
+
return { cwd, cliArgs, sessionStartMode };
|
|
41
51
|
}
|
|
42
52
|
export function readCodexRuntimeEndpoint(cwd) {
|
|
43
53
|
const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
|
|
44
54
|
if (!endpoint || endpoint.kind !== "codex") {
|
|
45
|
-
throw new Error(`No active Codex
|
|
55
|
+
throw new Error(`No active Codex runtime was found for ${cwd}. Run "wechat-codex" in that directory to recreate it.`);
|
|
46
56
|
}
|
|
47
57
|
if (endpoint.runtimeKind !== "codex_runtime_host" || (!endpoint.serverUrl && !endpoint.serverPort)) {
|
|
48
|
-
throw new Error(`The running Codex
|
|
58
|
+
throw new Error(`The running Codex runtime for ${cwd} uses an older local client protocol. Run "wechat-codex" in that directory to replace it.`);
|
|
49
59
|
}
|
|
50
60
|
return endpoint;
|
|
51
61
|
}
|
|
@@ -55,7 +65,9 @@ export function buildRemoteCodexClientArgs(endpoint, options = {}) {
|
|
|
55
65
|
const remoteUrl = endpoint.serverUrl ?? `ws://127.0.0.1:${endpoint.serverPort ?? endpoint.port}`;
|
|
56
66
|
const args = buildCodexCliArgs(remoteUrl, {
|
|
57
67
|
profile: endpoint.profile,
|
|
58
|
-
|
|
68
|
+
// "new" skips resume so the visible client opens a fresh Codex session
|
|
69
|
+
// instead of rejoining the shared thread.
|
|
70
|
+
resumeThreadId: options.sessionStartMode === "new" ? undefined : endpoint.sharedThreadId,
|
|
59
71
|
});
|
|
60
72
|
const tokenEnvName = endpoint.remoteAuthTokenEnv ?? CODEX_REMOTE_AUTH_TOKEN_ENV;
|
|
61
73
|
return [...args, "--remote-auth-token-env", tokenEnvName, ...extraCliArgs];
|
|
@@ -70,6 +82,7 @@ export async function runCodexRemoteClientFromEndpoint(endpoint, options = {}) {
|
|
|
70
82
|
const spawnTarget = resolveSpawnTarget(endpoint.command, "codex");
|
|
71
83
|
const args = buildRemoteCodexClientArgs(endpoint, {
|
|
72
84
|
extraCliArgs: options.extraCliArgs,
|
|
85
|
+
sessionStartMode: options.sessionStartMode,
|
|
73
86
|
});
|
|
74
87
|
const env = buildRemoteCodexClientEnv(endpoint);
|
|
75
88
|
return await new Promise((resolve, reject) => {
|
|
@@ -107,6 +120,7 @@ export async function runCodexRemoteClient(options) {
|
|
|
107
120
|
const endpoint = readCodexRuntimeEndpoint(options.cwd);
|
|
108
121
|
return await runCodexRemoteClientFromEndpoint(endpoint, {
|
|
109
122
|
extraCliArgs: options.cliArgs,
|
|
123
|
+
sessionStartMode: options.sessionStartMode,
|
|
110
124
|
});
|
|
111
125
|
}
|
|
112
126
|
async function main() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import { ensureWorkspaceChannelDir, getWorkspaceAdapterEndpointFile, getWorkspaceChannelPaths, } from "../wechat/channel-config.js";
|
|
4
|
+
import { writeJsonFileAtomic } from "../utils/atomic-file.js";
|
|
4
5
|
import { LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
|
|
5
6
|
function normalizeEndpoint(value) {
|
|
6
7
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -90,9 +91,13 @@ export function writeLocalCompanionEndpoint(endpoint, options = {}) {
|
|
|
90
91
|
const payload = {
|
|
91
92
|
...serializeEndpoint(endpoint),
|
|
92
93
|
};
|
|
93
|
-
|
|
94
|
+
// Atomic writes: the endpoint files are rewritten on every adapter event
|
|
95
|
+
// while companions poll them concurrently; a torn write reads as JSON.parse
|
|
96
|
+
// failure → "no endpoint" → healthy bridges get restarted or duplicate
|
|
97
|
+
// clients launched.
|
|
98
|
+
writeJsonFileAtomic(getWorkspaceAdapterEndpointFile(endpoint.cwd, endpoint.kind), payload);
|
|
94
99
|
if (options.writeLegacy !== false) {
|
|
95
|
-
|
|
100
|
+
writeJsonFileAtomic(endpointFile, payload);
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
export function readLocalCompanionEndpoint(cwd, options = {}) {
|