cli-wechat-bridge 1.1.2 → 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 +129 -150
- 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 +10 -0
- package/bin/wechat-pi.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +2 -0
- package/dist/bridge/bridge-adapters.claude.js +21 -1
- package/dist/bridge/bridge-adapters.codex.js +85 -6
- package/dist/bridge/bridge-adapters.core.js +112 -6
- package/dist/bridge/bridge-adapters.js +5 -3
- package/dist/bridge/bridge-adapters.opencode.js +390 -52
- package/dist/bridge/bridge-adapters.pi.js +707 -0
- package/dist/bridge/bridge-adapters.shared.js +22 -102
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-state.js +42 -6
- package/dist/bridge/bridge-utils.js +94 -237
- package/dist/bridge/wechat-bridge.js +77 -131
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +20 -6
- package/dist/companion/local-companion-link.js +13 -3
- package/dist/companion/local-companion-start.js +64 -16
- package/dist/companion/local-companion.js +8 -5
- package/dist/companion/pi-tui-bridge-extension.js +256 -0
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +264 -77
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +4 -5
- package/dist/i18n/messages-zh.js +4 -5
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +27 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -9
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +113 -102
- 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-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") {
|
|
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 /^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", "
|
|
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|
|
|
162
|
+
throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
|
|
216
163
|
}
|
|
217
164
|
const defaultCommand = resolveDefaultAdapterCommand(adapter);
|
|
218
165
|
return {
|
|
@@ -226,17 +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-shell --cmd pwsh # headless shell executor for non-interactive commands/scripts",
|
|
236
|
-
" wechat-bridge-shell --cmd bash # headless shell executor for non-interactive commands/scripts",
|
|
237
|
-
" wechat-bridge-codex --lifecycle companion_bound",
|
|
238
|
-
" bun run bridge:codex # repo-local development entrypoint",
|
|
239
|
-
" 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.",
|
|
240
180
|
"",
|
|
241
181
|
].join("\n"));
|
|
242
182
|
process.exit(0);
|
|
@@ -338,8 +278,6 @@ async function main() {
|
|
|
338
278
|
let activeTask = null;
|
|
339
279
|
const deferredInboundMessages = [];
|
|
340
280
|
let drainingDeferredInboundMessages = false;
|
|
341
|
-
let lastOutputAt = 0;
|
|
342
|
-
let lastHeartbeatAt = 0;
|
|
343
281
|
let consecutivePollFailures = 0;
|
|
344
282
|
let backlogNoticeSent = false;
|
|
345
283
|
const queueWechatTextAction = (action) => {
|
|
@@ -446,7 +384,6 @@ async function main() {
|
|
|
446
384
|
adapter,
|
|
447
385
|
});
|
|
448
386
|
activeTask = nextTask;
|
|
449
|
-
lastHeartbeatAt = 0;
|
|
450
387
|
}
|
|
451
388
|
catch (err) {
|
|
452
389
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
@@ -461,7 +398,6 @@ async function main() {
|
|
|
461
398
|
adapter: options.adapter,
|
|
462
399
|
cwd: options.cwd,
|
|
463
400
|
errorText,
|
|
464
|
-
isUserFacingShellRejection: false,
|
|
465
401
|
}), "inbound_error");
|
|
466
402
|
}
|
|
467
403
|
finally {
|
|
@@ -473,6 +409,10 @@ async function main() {
|
|
|
473
409
|
let shutdownPromise = null;
|
|
474
410
|
let requestedExitCode = 0;
|
|
475
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;
|
|
476
416
|
const parentWatchTimer = shouldWatchParentProcess({
|
|
477
417
|
startupParentPid,
|
|
478
418
|
attachedToTerminal,
|
|
@@ -480,6 +420,19 @@ async function main() {
|
|
|
480
420
|
})
|
|
481
421
|
? setInterval(() => {
|
|
482
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
|
+
}
|
|
483
436
|
return;
|
|
484
437
|
}
|
|
485
438
|
log(`Parent process ${startupParentPid} exited. Stopping bridge.`);
|
|
@@ -529,7 +482,17 @@ async function main() {
|
|
|
529
482
|
return;
|
|
530
483
|
}
|
|
531
484
|
log(message);
|
|
532
|
-
|
|
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
|
+
});
|
|
533
496
|
};
|
|
534
497
|
process.once("SIGINT", () => {
|
|
535
498
|
requestShutdown("Received SIGINT. Stopping bridge.");
|
|
@@ -578,13 +541,8 @@ async function main() {
|
|
|
578
541
|
queueWechatMessage,
|
|
579
542
|
trackWechatForwardTask,
|
|
580
543
|
maybeDrainDeferredInboundMessages,
|
|
581
|
-
getActiveTask: () => activeTask,
|
|
582
544
|
clearActiveTask: () => {
|
|
583
545
|
activeTask = null;
|
|
584
|
-
lastHeartbeatAt = 0;
|
|
585
|
-
},
|
|
586
|
-
updateLastOutputAt: () => {
|
|
587
|
-
lastOutputAt = Date.now();
|
|
588
546
|
},
|
|
589
547
|
syncSharedSessionState: () => {
|
|
590
548
|
syncSharedSessionState(stateStore, adapter);
|
|
@@ -608,25 +566,33 @@ async function main() {
|
|
|
608
566
|
}
|
|
609
567
|
log(`Authorized WeChat user: ${credentials.userId}`);
|
|
610
568
|
if (options.adapter === "codex") {
|
|
611
|
-
log(
|
|
569
|
+
log("For source-mode debugging, open the visible Codex client with: npm run codex:panel");
|
|
612
570
|
}
|
|
613
571
|
else if (options.adapter === "opencode") {
|
|
614
|
-
log(
|
|
572
|
+
log("For source-mode debugging, open the visible OpenCode client with: npm run opencode:panel");
|
|
615
573
|
}
|
|
616
574
|
else if (options.adapter === "claude") {
|
|
617
|
-
log(
|
|
575
|
+
log("For source-mode debugging, open the visible Claude client with: npm run claude:companion");
|
|
618
576
|
}
|
|
619
|
-
else if (options.adapter === "
|
|
620
|
-
log("
|
|
577
|
+
else if (options.adapter === "pi") {
|
|
578
|
+
log("For source-mode debugging, open the visible Pi client with: npm run pi:companion");
|
|
621
579
|
}
|
|
622
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.
|
|
623
584
|
const welcomeText = t("bridge.welcome", {
|
|
624
585
|
adapter: options.adapter,
|
|
625
586
|
cwd: options.cwd,
|
|
626
|
-
bindings: formatBindingsListMessage(listBindings()),
|
|
627
587
|
});
|
|
628
588
|
await queueWechatMessage(credentials.userId, welcomeText);
|
|
629
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
|
+
}
|
|
630
596
|
if (!ensureRuntimeOwnership()) {
|
|
631
597
|
break;
|
|
632
598
|
}
|
|
@@ -653,7 +619,7 @@ async function main() {
|
|
|
653
619
|
await delay(delayMs);
|
|
654
620
|
continue;
|
|
655
621
|
}
|
|
656
|
-
if (!ensureRuntimeOwnership()) {
|
|
622
|
+
if (shutdownPromise || !ensureRuntimeOwnership()) {
|
|
657
623
|
break;
|
|
658
624
|
}
|
|
659
625
|
if (consecutivePollFailures > 0) {
|
|
@@ -698,32 +664,20 @@ async function main() {
|
|
|
698
664
|
}
|
|
699
665
|
catch (err) {
|
|
700
666
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
701
|
-
const isUserFacingShellRejection = err instanceof Error && err.name === "ShellCommandRejectedError";
|
|
702
667
|
logError(errorText);
|
|
703
|
-
stateStore.appendLog(
|
|
668
|
+
stateStore.appendLog(`inbound_error: ${errorText}`);
|
|
704
669
|
await queueWechatMessage(message.senderId, formatUserFacingInboundError({
|
|
705
670
|
adapter: options.adapter,
|
|
706
671
|
cwd: options.cwd,
|
|
707
672
|
errorText,
|
|
708
|
-
isUserFacingShellRejection,
|
|
709
673
|
}), "inbound_error");
|
|
710
674
|
}
|
|
711
675
|
if (nextTask) {
|
|
712
676
|
activeTask = nextTask;
|
|
713
|
-
lastHeartbeatAt = 0;
|
|
714
677
|
}
|
|
715
678
|
syncSharedSessionState(stateStore, adapter);
|
|
716
679
|
await maybeDrainDeferredInboundMessages();
|
|
717
680
|
}
|
|
718
|
-
const adapterState = adapter.getState();
|
|
719
|
-
const lastSignalAt = Math.max(lastHeartbeatAt, lastOutputAt || activeTask?.startedAt || 0);
|
|
720
|
-
if (activeTask &&
|
|
721
|
-
options.adapter === "shell" &&
|
|
722
|
-
adapterState.status === "busy" &&
|
|
723
|
-
Date.now() - lastSignalAt >= 30_000) {
|
|
724
|
-
lastHeartbeatAt = Date.now();
|
|
725
|
-
await queueWechatMessage(stateStore.getState().authorizedUserId, `${options.adapter} is still running. Waiting for more output...`);
|
|
726
|
-
}
|
|
727
681
|
}
|
|
728
682
|
}
|
|
729
683
|
finally {
|
|
@@ -755,7 +709,7 @@ function syncSharedSessionState(stateStore, adapter) {
|
|
|
755
709
|
}
|
|
756
710
|
}
|
|
757
711
|
function wireAdapterEvents(params) {
|
|
758
|
-
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;
|
|
759
713
|
adapter.setEventSink((event) => {
|
|
760
714
|
syncSharedSessionState();
|
|
761
715
|
syncLocalClientEndpoint();
|
|
@@ -771,7 +725,6 @@ function wireAdapterEvents(params) {
|
|
|
771
725
|
switch (event.type) {
|
|
772
726
|
case "stdout":
|
|
773
727
|
case "stderr":
|
|
774
|
-
updateLastOutputAt();
|
|
775
728
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
|
|
776
729
|
outputBatcher.push(event.text);
|
|
777
730
|
}
|
|
@@ -809,7 +762,6 @@ function wireAdapterEvents(params) {
|
|
|
809
762
|
void maybeDrainDeferredInboundMessages();
|
|
810
763
|
break;
|
|
811
764
|
case "notice":
|
|
812
|
-
updateLastOutputAt();
|
|
813
765
|
stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
|
|
814
766
|
if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
|
|
815
767
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
@@ -818,7 +770,6 @@ function wireAdapterEvents(params) {
|
|
|
818
770
|
}
|
|
819
771
|
break;
|
|
820
772
|
case "thinking":
|
|
821
|
-
updateLastOutputAt();
|
|
822
773
|
if (event.text) {
|
|
823
774
|
const thinkingPreview = formatThinkingForWechat(event.text, 500);
|
|
824
775
|
if (thinkingPreview) {
|
|
@@ -884,15 +835,6 @@ function wireAdapterEvents(params) {
|
|
|
884
835
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
885
836
|
stateStore.clearPendingConfirmation();
|
|
886
837
|
stateStore.clearPendingUserInput();
|
|
887
|
-
if (options.adapter === "shell") {
|
|
888
|
-
const summary = buildCompletionSummary({
|
|
889
|
-
adapter: options.adapter,
|
|
890
|
-
activeTask: getActiveTask(),
|
|
891
|
-
exitCode: event.exitCode,
|
|
892
|
-
recentOutput: outputBatcher.getRecentSummary(),
|
|
893
|
-
});
|
|
894
|
-
await queueWechatMessage(authorizedUserId, summary);
|
|
895
|
-
}
|
|
896
838
|
clearActiveTask();
|
|
897
839
|
await maybeDrainDeferredInboundMessages();
|
|
898
840
|
}));
|
|
@@ -924,18 +866,6 @@ function wireAdapterEvents(params) {
|
|
|
924
866
|
}
|
|
925
867
|
});
|
|
926
868
|
}
|
|
927
|
-
function buildCompletionSummary(params) {
|
|
928
|
-
const lines = [`${params.adapter} task complete.`];
|
|
929
|
-
if (params.activeTask) {
|
|
930
|
-
lines.push(`duration: ${formatDuration(Date.now() - params.activeTask.startedAt)}`);
|
|
931
|
-
lines.push(`input: ${params.activeTask.inputPreview}`);
|
|
932
|
-
}
|
|
933
|
-
if (typeof params.exitCode === "number") {
|
|
934
|
-
lines.push(`exit_code: ${params.exitCode}`);
|
|
935
|
-
}
|
|
936
|
-
lines.push(`recent_output:\n${params.recentOutput}`);
|
|
937
|
-
return lines.join("\n");
|
|
938
|
-
}
|
|
939
869
|
function formatInboundMessagePreview(message) {
|
|
940
870
|
if (message.text.trim()) {
|
|
941
871
|
return message.text;
|
|
@@ -1005,7 +935,23 @@ async function handleInboundMessage(params) {
|
|
|
1005
935
|
return null;
|
|
1006
936
|
}
|
|
1007
937
|
if (options.adapter === "opencode") {
|
|
1008
|
-
|
|
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
|
+
}
|
|
1009
955
|
return null;
|
|
1010
956
|
}
|
|
1011
957
|
await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
|
|
@@ -1122,9 +1068,9 @@ async function handleInboundMessage(params) {
|
|
|
1122
1068
|
return null;
|
|
1123
1069
|
}
|
|
1124
1070
|
if (adapterState.status === "busy") {
|
|
1125
|
-
if ((options.adapter === "codex" || options.adapter === "opencode") &&
|
|
1071
|
+
if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
|
|
1126
1072
|
adapterState.activeTurnOrigin === "local") {
|
|
1127
|
-
await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
|
|
1073
|
+
await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
|
|
1128
1074
|
return null;
|
|
1129
1075
|
}
|
|
1130
1076
|
await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
|
|
@@ -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,13 +1,18 @@
|
|
|
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)) {
|
|
7
8
|
return null;
|
|
8
9
|
}
|
|
9
10
|
const record = value;
|
|
10
|
-
const kind = record.kind === "codex" ||
|
|
11
|
+
const kind = record.kind === "codex" ||
|
|
12
|
+
record.kind === "claude" ||
|
|
13
|
+
record.kind === "opencode" ||
|
|
14
|
+
record.kind === "pi" ||
|
|
15
|
+
record.kind === "shell"
|
|
11
16
|
? record.kind
|
|
12
17
|
: "codex";
|
|
13
18
|
const sharedSessionId = typeof record.sharedSessionId === "string"
|
|
@@ -86,9 +91,13 @@ export function writeLocalCompanionEndpoint(endpoint, options = {}) {
|
|
|
86
91
|
const payload = {
|
|
87
92
|
...serializeEndpoint(endpoint),
|
|
88
93
|
};
|
|
89
|
-
|
|
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);
|
|
90
99
|
if (options.writeLegacy !== false) {
|
|
91
|
-
|
|
100
|
+
writeJsonFileAtomic(endpointFile, payload);
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
103
|
export function readLocalCompanionEndpoint(cwd, options = {}) {
|
|
@@ -118,6 +127,7 @@ export function clearLocalCompanionEndpoint(cwd, instanceId, options = {}) {
|
|
|
118
127
|
getWorkspaceAdapterEndpointFile(cwd, "codex"),
|
|
119
128
|
getWorkspaceAdapterEndpointFile(cwd, "claude"),
|
|
120
129
|
getWorkspaceAdapterEndpointFile(cwd, "opencode"),
|
|
130
|
+
getWorkspaceAdapterEndpointFile(cwd, "pi"),
|
|
121
131
|
getWorkspaceAdapterEndpointFile(cwd, "shell"),
|
|
122
132
|
];
|
|
123
133
|
for (const filePath of files) {
|