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
|
@@ -12,10 +12,11 @@ import { forwardWechatFinalReply } from "../bridge/bridge-final-reply.js";
|
|
|
12
12
|
import { readBridgeLockFile, } from "../bridge/bridge-state.js";
|
|
13
13
|
import { getProcessRecordByPid, isWechatDaemonCommandLine, killProcessTreeSync, listWechatDaemonProcesses, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
|
|
14
14
|
import { buildOneTimeCode, buildWechatInboundPrompt, formatApprovalMessage, formatDuration, formatMirroredUserInputMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatSessionSwitchMessage, formatTaskFailedMessage, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "../bridge/bridge-utils.js";
|
|
15
|
-
import { formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-
|
|
15
|
+
import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-forwarding.js";
|
|
16
16
|
import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
|
|
17
17
|
import { ensureWechatCredentials } from "../wechat/setup.js";
|
|
18
18
|
import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
|
|
19
|
+
import { formatResumeSessionList } from "../bridge/bridge-utils.js";
|
|
19
20
|
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, isWechatContextTokenStaleError, WeChatTransport, } from "../wechat/wechat-transport.js";
|
|
20
21
|
import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
|
|
21
22
|
import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
|
|
@@ -26,8 +27,6 @@ const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".
|
|
|
26
27
|
const DAEMON_HOST = "127.0.0.1";
|
|
27
28
|
const POLL_RETRY_BASE_MS = 1_000;
|
|
28
29
|
const POLL_RETRY_MAX_MS = 30_000;
|
|
29
|
-
const WECHAT_SEND_MAX_ATTEMPTS = 3;
|
|
30
|
-
const WECHAT_SEND_RETRY_BASE_MS = 750;
|
|
31
30
|
const SINGLE_BRIDGE_STOP_TIMEOUT_MS = 10_000;
|
|
32
31
|
const SINGLE_BRIDGE_FORCE_STOP_TIMEOUT_MS = 3_000;
|
|
33
32
|
const SINGLE_BRIDGE_STOP_POLL_MS = 250;
|
|
@@ -36,7 +35,7 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
|
|
|
36
35
|
const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
|
|
37
36
|
const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
|
|
38
37
|
const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
|
|
39
|
-
const DAEMON_ADAPTERS = ["codex", "claude", "opencode"];
|
|
38
|
+
const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
|
|
40
39
|
function log(message) {
|
|
41
40
|
process.stderr.write(`[wechat-daemon] ${message}\n`);
|
|
42
41
|
}
|
|
@@ -52,11 +51,8 @@ function computePollRetryDelayMs(consecutiveFailures) {
|
|
|
52
51
|
const exponent = Math.min(normalizedFailures - 1, 5);
|
|
53
52
|
return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
|
|
54
53
|
}
|
|
55
|
-
function computeWechatSendRetryDelayMs(attempt) {
|
|
56
|
-
return WECHAT_SEND_RETRY_BASE_MS * attempt;
|
|
57
|
-
}
|
|
58
54
|
function isDaemonAdapterKind(value) {
|
|
59
|
-
return value === "codex" || value === "claude" || value === "opencode";
|
|
55
|
+
return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
|
|
60
56
|
}
|
|
61
57
|
function isSameWorkspaceCwd(left, right) {
|
|
62
58
|
const normalizedLeft = path.resolve(left);
|
|
@@ -84,10 +80,10 @@ export function parseDaemonCliArgs(argv) {
|
|
|
84
80
|
const next = argv[i + 1];
|
|
85
81
|
if (arg === "--help" || arg === "-h") {
|
|
86
82
|
process.stdout.write([
|
|
87
|
-
"Usage: wechat-daemon [--cwd <path>] [--adapter <codex|claude|opencode>] [--profile <name-or-path>] [--no-open]",
|
|
83
|
+
"Usage: wechat-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]",
|
|
88
84
|
"",
|
|
89
|
-
"Keeps one WeChat connection alive and switches between Codex, Claude Code, and
|
|
90
|
-
"Send /codex, /claude, or /
|
|
85
|
+
"Keeps one WeChat connection alive and switches between Codex, Claude Code, OpenCode, and Pi from WeChat.",
|
|
86
|
+
"Send /codex, /claude, /opencode, or /pi in WeChat to switch the active terminal.",
|
|
91
87
|
"",
|
|
92
88
|
].join("\n"));
|
|
93
89
|
process.exit(0);
|
|
@@ -133,20 +129,38 @@ export function parseDaemonSwitchCommand(text) {
|
|
|
133
129
|
return "claude";
|
|
134
130
|
case "/opencode":
|
|
135
131
|
return "opencode";
|
|
132
|
+
case "/pi":
|
|
133
|
+
return "pi";
|
|
136
134
|
default:
|
|
137
135
|
return null;
|
|
138
136
|
}
|
|
139
137
|
}
|
|
138
|
+
export function parseDaemonSwitchDirective(text) {
|
|
139
|
+
const match = text
|
|
140
|
+
.trim()
|
|
141
|
+
.match(/^\/(codex|claude|opencode|pi)(?:\s+([\s\S]+))?$/i);
|
|
142
|
+
if (!match) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
adapter: match[1].toLowerCase(),
|
|
147
|
+
remainder: match[2]?.trim() ?? "",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
140
150
|
export function defaultDaemonSessionStartMode(adapter) {
|
|
141
|
-
return adapter === "
|
|
151
|
+
return adapter === "codex" ? "restore" : "new";
|
|
142
152
|
}
|
|
143
153
|
export function resolveDaemonSessionStartMode(params) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
154
|
+
// An explicit --session-start-mode wins over visible-client reuse: the user
|
|
155
|
+
// asked for a fresh (or restored) session on purpose, so silently degrading
|
|
156
|
+
// "new" back to "restore" because a window happens to be connected would
|
|
157
|
+
// ignore the request.
|
|
147
158
|
if (params.explicitSessionStartMode) {
|
|
148
159
|
return params.explicitSessionStartMode;
|
|
149
160
|
}
|
|
161
|
+
if (params.reuseExistingVisible && params.visibleConnected) {
|
|
162
|
+
return "restore";
|
|
163
|
+
}
|
|
150
164
|
if (params.adapter === "codex") {
|
|
151
165
|
return "restore";
|
|
152
166
|
}
|
|
@@ -340,6 +354,12 @@ function isVisibleClientAlive(cwd, adapter) {
|
|
|
340
354
|
clearLocalCompanionOccupancy(cwd, endpoint.instanceId, { adapter });
|
|
341
355
|
return false;
|
|
342
356
|
}
|
|
357
|
+
export function shouldRestartDeadCodexVisibleRuntime(params) {
|
|
358
|
+
return (params.adapter === "codex" &&
|
|
359
|
+
!params.slotCreated &&
|
|
360
|
+
params.hadVisibleClient &&
|
|
361
|
+
!params.visibleConnected);
|
|
362
|
+
}
|
|
343
363
|
function cleanupVisibleClientLauncher(launch) {
|
|
344
364
|
if (!launch.pid || !isPidAlive(launch.pid)) {
|
|
345
365
|
return false;
|
|
@@ -370,6 +390,24 @@ export async function waitForVisibleClientConnection(params, deps = {}) {
|
|
|
370
390
|
await sleepFn(Math.min(pollMs, remainingMs));
|
|
371
391
|
}
|
|
372
392
|
}
|
|
393
|
+
export async function waitForCodexVisibleThread(params, deps = {}) {
|
|
394
|
+
const timeoutMs = params.timeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
|
|
395
|
+
const pollMs = params.pollMs ?? VISIBLE_CLIENT_CONNECT_POLL_MS;
|
|
396
|
+
const sleepFn = deps.sleep ?? sleep;
|
|
397
|
+
const now = deps.now ?? (() => Date.now());
|
|
398
|
+
const deadline = now() + timeoutMs;
|
|
399
|
+
while (true) {
|
|
400
|
+
const threadId = params.getThreadId();
|
|
401
|
+
if (threadId) {
|
|
402
|
+
return threadId;
|
|
403
|
+
}
|
|
404
|
+
const remainingMs = deadline - now();
|
|
405
|
+
if (remainingMs <= 0) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
await sleepFn(Math.min(pollMs, remainingMs));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
373
411
|
function formatInboundMessagePreview(message) {
|
|
374
412
|
if (message.text.trim()) {
|
|
375
413
|
return message.text;
|
|
@@ -384,7 +422,7 @@ function formatInboundMessagePreview(message) {
|
|
|
384
422
|
function formatNoActiveAdapterMessage() {
|
|
385
423
|
return [
|
|
386
424
|
"No active terminal is selected.",
|
|
387
|
-
"Send /codex, /claude, or /
|
|
425
|
+
"Send /codex, /claude, /opencode, or /pi to choose one.",
|
|
388
426
|
].join("\n");
|
|
389
427
|
}
|
|
390
428
|
export function formatDaemonSwitchResultDetail(result) {
|
|
@@ -392,6 +430,9 @@ export function formatDaemonSwitchResultDetail(result) {
|
|
|
392
430
|
const previous = result.previousActiveAdapter
|
|
393
431
|
? ` Active terminal remains ${result.previousActiveAdapter}.`
|
|
394
432
|
: " No terminal is active yet.";
|
|
433
|
+
if (result.visibleConnected && result.visibleReady === false) {
|
|
434
|
+
return `The visible Codex CLI connected, but its active thread is not ready yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
|
|
435
|
+
}
|
|
395
436
|
if (result.openedVisible) {
|
|
396
437
|
return result.created
|
|
397
438
|
? `Started the bridge slot and tried to open the visible CLI, but it has not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`
|
|
@@ -442,6 +483,8 @@ class WechatDaemon {
|
|
|
442
483
|
authorizedUserId;
|
|
443
484
|
transport;
|
|
444
485
|
slots = new Map();
|
|
486
|
+
// Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
|
|
487
|
+
slotEnsureChains = new Map();
|
|
445
488
|
startedAt = new Date().toISOString();
|
|
446
489
|
bridgeStartedAtMs = Date.now();
|
|
447
490
|
backlogNoticeSent = false;
|
|
@@ -543,7 +586,7 @@ class WechatDaemon {
|
|
|
543
586
|
let consecutivePollFailures = 0;
|
|
544
587
|
log("WeChat daemon is ready.");
|
|
545
588
|
log(`Working directory: ${this.cwd}`);
|
|
546
|
-
log("Switch from WeChat with /codex, /claude, or /
|
|
589
|
+
log("Switch from WeChat with /codex, /claude, /opencode, or /pi.");
|
|
547
590
|
appendDaemonLog(`started: cwd=${this.cwd}`);
|
|
548
591
|
const activeSlot = this.getActiveSlot();
|
|
549
592
|
const welcomeText = t("daemon.welcome", {
|
|
@@ -597,14 +640,12 @@ class WechatDaemon {
|
|
|
597
640
|
}
|
|
598
641
|
catch (error) {
|
|
599
642
|
const errorText = error instanceof Error ? error.message : String(error);
|
|
600
|
-
const isUserFacingShellRejection = error instanceof Error && error.name === "ShellCommandRejectedError";
|
|
601
643
|
logError(errorText);
|
|
602
|
-
appendDaemonLog(
|
|
644
|
+
appendDaemonLog(`inbound_error: ${errorText}`);
|
|
603
645
|
await this.queueWechatMessage(message.senderId, formatUserFacingInboundError({
|
|
604
646
|
adapter: this.activeAdapter ?? "codex",
|
|
605
647
|
cwd: this.cwd,
|
|
606
648
|
errorText,
|
|
607
|
-
isUserFacingShellRejection,
|
|
608
649
|
}), "inbound_error");
|
|
609
650
|
}
|
|
610
651
|
}
|
|
@@ -616,6 +657,32 @@ class WechatDaemon {
|
|
|
616
657
|
}
|
|
617
658
|
await this.shutdownPromise;
|
|
618
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Remove a slot whose runtime reported a fatal (terminal) error. The dead
|
|
662
|
+
* runtime would otherwise stay in the slot map forever: every later message
|
|
663
|
+
* would fail against it ("adapter is not running") with no path back to a
|
|
664
|
+
* working adapter short of a manual /reset or daemon restart. After removal,
|
|
665
|
+
* the next switch directive (/codex etc.) rebuilds the adapter from scratch.
|
|
666
|
+
*/
|
|
667
|
+
disposeDeadSlot(slot) {
|
|
668
|
+
if (this.slots.get(slot.adapter) !== slot) {
|
|
669
|
+
return; // Already replaced by a newer ensureSlot run.
|
|
670
|
+
}
|
|
671
|
+
this.slots.delete(slot.adapter);
|
|
672
|
+
if (this.activeAdapter === slot.adapter) {
|
|
673
|
+
this.activeAdapter = null;
|
|
674
|
+
}
|
|
675
|
+
appendDaemonLog(`dead_slot_disposed: adapter=${slot.adapter}`);
|
|
676
|
+
void (async () => {
|
|
677
|
+
try {
|
|
678
|
+
await slot.runtime.dispose();
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// Best effort: the runtime already reported a fatal error.
|
|
682
|
+
}
|
|
683
|
+
slot.controller.clearLocalClientEndpoint();
|
|
684
|
+
})();
|
|
685
|
+
}
|
|
619
686
|
async cleanup() {
|
|
620
687
|
appendDaemonLog("shutdown_started");
|
|
621
688
|
for (const slot of this.slots.values()) {
|
|
@@ -680,6 +747,19 @@ class WechatDaemon {
|
|
|
680
747
|
}
|
|
681
748
|
}
|
|
682
749
|
async ensureSlot(adapter, options = {}) {
|
|
750
|
+
// Serialize per-adapter ensureSlot runs. The IPC handler, the WeChat poll
|
|
751
|
+
// loop, and startup can all call this concurrently; without serialization
|
|
752
|
+
// two callers would both observe an empty slot map, create two runtimes,
|
|
753
|
+
// and the second slots.set would orphan the first (duplicate PTY child,
|
|
754
|
+
// double visible client, endpoint files overwriting each other).
|
|
755
|
+
const tail = this.slotEnsureChains.get(adapter) ?? Promise.resolve();
|
|
756
|
+
const run = tail.then(() => this.runEnsureSlot(adapter, options),
|
|
757
|
+
// A rejected predecessor must not block this run — start fresh either way.
|
|
758
|
+
() => this.runEnsureSlot(adapter, options));
|
|
759
|
+
this.slotEnsureChains.set(adapter, run.then(() => undefined, () => undefined));
|
|
760
|
+
return run;
|
|
761
|
+
}
|
|
762
|
+
async runEnsureSlot(adapter, options = {}) {
|
|
683
763
|
const previousActiveAdapter = this.activeAdapter ?? undefined;
|
|
684
764
|
let slot = this.slots.get(adapter);
|
|
685
765
|
let created = false;
|
|
@@ -697,7 +777,24 @@ class WechatDaemon {
|
|
|
697
777
|
created = true;
|
|
698
778
|
}
|
|
699
779
|
let openedVisible = false;
|
|
780
|
+
const visibleEndpointBeforeProbe = readLocalCompanionEndpoint(this.cwd, {
|
|
781
|
+
adapter,
|
|
782
|
+
});
|
|
783
|
+
const hadVisibleClient = Boolean(visibleEndpointBeforeProbe?.companionPid ||
|
|
784
|
+
visibleEndpointBeforeProbe?.companionConnectedAt ||
|
|
785
|
+
visibleEndpointBeforeProbe?.sharedThreadId ||
|
|
786
|
+
visibleEndpointBeforeProbe?.sharedSessionId);
|
|
700
787
|
let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
|
|
788
|
+
if (shouldRestartDeadCodexVisibleRuntime({
|
|
789
|
+
adapter,
|
|
790
|
+
slotCreated: created,
|
|
791
|
+
hadVisibleClient,
|
|
792
|
+
visibleConnected,
|
|
793
|
+
})) {
|
|
794
|
+
await this.startFreshSlotSession(slot);
|
|
795
|
+
appendDaemonLog(`dead_visible_codex_runtime_restarted: cwd=${this.cwd}`);
|
|
796
|
+
}
|
|
797
|
+
const sharedSessionBeforeVisible = getSharedSessionIdFromAdapterState(slot.runtime.getState());
|
|
701
798
|
const sessionStartMode = resolveDaemonSessionStartMode({
|
|
702
799
|
adapter,
|
|
703
800
|
explicitSessionStartMode: options.sessionStartMode,
|
|
@@ -707,9 +804,10 @@ class WechatDaemon {
|
|
|
707
804
|
reuseExistingVisible: options.reuseExistingVisible !== false,
|
|
708
805
|
});
|
|
709
806
|
if (!created &&
|
|
710
|
-
options.reuseExistingVisible === false
|
|
807
|
+
(options.reuseExistingVisible === false ||
|
|
808
|
+
options.sessionStartMode === "new") &&
|
|
711
809
|
sessionStartMode === "new" &&
|
|
712
|
-
(adapter === "claude" || adapter === "opencode") &&
|
|
810
|
+
(adapter === "claude" || adapter === "opencode" || adapter === "pi") &&
|
|
713
811
|
visibleConnected) {
|
|
714
812
|
await this.startFreshSlotSession(slot);
|
|
715
813
|
appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
|
|
@@ -740,16 +838,36 @@ class WechatDaemon {
|
|
|
740
838
|
appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS} cleaned_launcher=${cleanedLauncher}`);
|
|
741
839
|
}
|
|
742
840
|
}
|
|
743
|
-
|
|
841
|
+
let visibleReady = visibleConnected;
|
|
842
|
+
if (adapter === "codex" && visibleConnected && !sharedSessionBeforeVisible) {
|
|
843
|
+
const visibleThreadId = await waitForCodexVisibleThread({
|
|
844
|
+
getThreadId: () => {
|
|
845
|
+
const state = slot.runtime.getState();
|
|
846
|
+
if (state.lastThreadSwitchSource !== "local") {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
return state.sharedThreadId ?? state.sharedSessionId;
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
visibleReady = Boolean(visibleThreadId);
|
|
853
|
+
if (visibleThreadId) {
|
|
854
|
+
appendDaemonLog(`visible_codex_thread_ready: thread=${visibleThreadId} cwd=${this.cwd}`);
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
appendDaemonLog(`visible_codex_thread_timeout: cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const activated = options.openVisible === false || (visibleConnected && visibleReady);
|
|
744
861
|
if (activated) {
|
|
745
862
|
this.activeAdapter = adapter;
|
|
746
863
|
}
|
|
747
|
-
appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected} activated=${activated} previous_active=${previousActiveAdapter ?? "(none)"} session_start_mode=${sessionStartMode}`);
|
|
864
|
+
appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected} visible_ready=${visibleReady} activated=${activated} previous_active=${previousActiveAdapter ?? "(none)"} session_start_mode=${sessionStartMode}`);
|
|
748
865
|
return {
|
|
749
866
|
activeAdapter: adapter,
|
|
750
867
|
created,
|
|
751
868
|
openedVisible,
|
|
752
869
|
visibleConnected,
|
|
870
|
+
visibleReady,
|
|
753
871
|
activated,
|
|
754
872
|
previousActiveAdapter,
|
|
755
873
|
};
|
|
@@ -792,12 +910,12 @@ class WechatDaemon {
|
|
|
792
910
|
slot.pendingConfirmations = [];
|
|
793
911
|
slot.pendingUserInput = null;
|
|
794
912
|
slot.activeTask = null;
|
|
795
|
-
if (slot.adapter === "claude") {
|
|
913
|
+
if (slot.adapter === "codex" || slot.adapter === "claude") {
|
|
796
914
|
await slot.runtime.reset();
|
|
797
915
|
}
|
|
798
|
-
else if (slot.adapter === "opencode") {
|
|
916
|
+
else if (slot.adapter === "opencode" || slot.adapter === "pi") {
|
|
799
917
|
if (!slot.runtime.createSession) {
|
|
800
|
-
throw new Error(
|
|
918
|
+
throw new Error(`/new is not available in ${slot.adapter} mode.`);
|
|
801
919
|
}
|
|
802
920
|
await slot.runtime.createSession();
|
|
803
921
|
}
|
|
@@ -874,14 +992,19 @@ class WechatDaemon {
|
|
|
874
992
|
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required");
|
|
875
993
|
}));
|
|
876
994
|
break;
|
|
877
|
-
case "user_input_required":
|
|
995
|
+
case "user_input_required": {
|
|
996
|
+
// Mirror the pending request synchronously, before the async WeChat
|
|
997
|
+
// send (which retries with backoff). Otherwise a fast user reply can
|
|
998
|
+
// race past the still-unwritten mirror and be dispatched as plain
|
|
999
|
+
// input into a CLI that is waiting for a structured form answer.
|
|
1000
|
+
const pending = toPendingUserInput(event.request);
|
|
1001
|
+
slot.pendingUserInput = pending;
|
|
1002
|
+
appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
|
|
878
1003
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
879
|
-
const pending = toPendingUserInput(event.request);
|
|
880
|
-
slot.pendingUserInput = pending;
|
|
881
|
-
appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
|
|
882
1004
|
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required");
|
|
883
1005
|
}));
|
|
884
1006
|
break;
|
|
1007
|
+
}
|
|
885
1008
|
case "mirrored_user_input":
|
|
886
1009
|
appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
|
|
887
1010
|
if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
|
|
@@ -937,6 +1060,7 @@ class WechatDaemon {
|
|
|
937
1060
|
slot.pendingConfirmations = [];
|
|
938
1061
|
slot.pendingUserInput = null;
|
|
939
1062
|
slot.activeTask = null;
|
|
1063
|
+
this.disposeDeadSlot(slot);
|
|
940
1064
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
941
1065
|
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(event.message)), "fatal_error");
|
|
942
1066
|
}));
|
|
@@ -975,18 +1099,24 @@ class WechatDaemon {
|
|
|
975
1099
|
message = { ...message, text: rewritten };
|
|
976
1100
|
}
|
|
977
1101
|
}
|
|
978
|
-
const
|
|
979
|
-
if (
|
|
980
|
-
const result = await this.ensureSlot(
|
|
1102
|
+
const switchDirective = parseDaemonSwitchDirective(message.text);
|
|
1103
|
+
if (switchDirective) {
|
|
1104
|
+
const result = await this.ensureSlot(switchDirective.adapter, {
|
|
981
1105
|
openVisible: true,
|
|
982
1106
|
reuseExistingVisible: true,
|
|
983
1107
|
});
|
|
984
1108
|
const detail = formatDaemonSwitchResultDetail(result);
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1109
|
+
if (!result.activated) {
|
|
1110
|
+
await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchDirective.adapter}.\n${detail}`);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (switchDirective.remainder) {
|
|
1114
|
+
message = { ...message, text: switchDirective.remainder };
|
|
1115
|
+
}
|
|
1116
|
+
else {
|
|
1117
|
+
await this.queueWechatMessage(message.senderId, `Active terminal: ${switchDirective.adapter}.\n${detail}`);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
990
1120
|
}
|
|
991
1121
|
if (message.text.trim().toLowerCase() === "/daemon-stop") {
|
|
992
1122
|
await this.queueWechatMessage(message.senderId, "Stopping wechat-daemon...");
|
|
@@ -1004,7 +1134,7 @@ class WechatDaemon {
|
|
|
1004
1134
|
await this.queueWechatMessage(message.senderId, formatBindCommandUsage());
|
|
1005
1135
|
return;
|
|
1006
1136
|
}
|
|
1007
|
-
|
|
1137
|
+
let slot = this.getActiveSlot();
|
|
1008
1138
|
if (!slot) {
|
|
1009
1139
|
await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
|
|
1010
1140
|
return;
|
|
@@ -1026,7 +1156,25 @@ class WechatDaemon {
|
|
|
1026
1156
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingUserInputReminder(slot.pendingUserInput)));
|
|
1027
1157
|
return;
|
|
1028
1158
|
}
|
|
1159
|
+
const visibleResult = await this.ensureSlot(slot.adapter, {
|
|
1160
|
+
openVisible: true,
|
|
1161
|
+
reuseExistingVisible: true,
|
|
1162
|
+
});
|
|
1163
|
+
if (!visibleResult.activated) {
|
|
1164
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatDaemonSwitchResultDetail(visibleResult)));
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
slot = this.getActiveSlot() ?? slot;
|
|
1029
1168
|
const adapterState = slot.runtime.getState();
|
|
1169
|
+
if (adapterState.status === "awaiting_input") {
|
|
1170
|
+
// Belt-and-braces with the pendingUserInput mirror above: the adapter
|
|
1171
|
+
// state is authoritative even when the mirror was cleared or the WeChat
|
|
1172
|
+
// notification is still queued.
|
|
1173
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, slot.pendingUserInput
|
|
1174
|
+
? formatPendingUserInputReminder(slot.pendingUserInput)
|
|
1175
|
+
: `${slot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1030
1178
|
if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
|
|
1031
1179
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
|
|
1032
1180
|
return;
|
|
@@ -1058,9 +1206,33 @@ class WechatDaemon {
|
|
|
1058
1206
|
case "status":
|
|
1059
1207
|
await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
|
|
1060
1208
|
return;
|
|
1061
|
-
case "resume":
|
|
1062
|
-
|
|
1209
|
+
case "resume": {
|
|
1210
|
+
if (activeSlot.adapter !== "opencode") {
|
|
1211
|
+
await this.queueWechatMessage(message.senderId, `WeChat /resume is disabled for ${activeSlot.adapter} in daemon mode. Use /resume directly inside the visible terminal; WeChat will follow that local session.`);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (command.type !== "resume") {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
if (command.target) {
|
|
1219
|
+
await activeSlot.runtime.resumeSession(command.target);
|
|
1220
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, `Resumed OpenCode session ${command.target}.`));
|
|
1221
|
+
}
|
|
1222
|
+
else {
|
|
1223
|
+
const candidates = await activeSlot.runtime.listResumeSessions(8);
|
|
1224
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, formatResumeSessionList({
|
|
1225
|
+
adapter: activeSlot.adapter,
|
|
1226
|
+
candidates,
|
|
1227
|
+
currentSessionId: activeSlot.runtime.getState().sharedSessionId,
|
|
1228
|
+
})));
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
catch (error) {
|
|
1232
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, `Failed to resume OpenCode session: ${error instanceof Error ? error.message : String(error)}`));
|
|
1233
|
+
}
|
|
1063
1234
|
return;
|
|
1235
|
+
}
|
|
1064
1236
|
case "new_session":
|
|
1065
1237
|
if (!activeSlot.runtime.createSession) {
|
|
1066
1238
|
await this.queueWechatMessage(message.senderId, `/new is not available in ${activeSlot.adapter} mode.`);
|
|
@@ -1138,28 +1310,42 @@ class WechatDaemon {
|
|
|
1138
1310
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1 ? `${count} approvals denied.` : "Approval denied."));
|
|
1139
1311
|
}
|
|
1140
1312
|
async answerPendingUserInput(message, activeSlot, raw) {
|
|
1141
|
-
|
|
1313
|
+
// Mirror the /confirm and /deny behavior: search every slot for a pending
|
|
1314
|
+
// user-input request so the answer reaches the adapter that asked, even
|
|
1315
|
+
// when the user has since switched the active adapter.
|
|
1316
|
+
const slot = this.resolvePendingUserInputSlot(activeSlot);
|
|
1317
|
+
if (!slot) {
|
|
1318
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
const pending = slot.pendingUserInput;
|
|
1142
1322
|
if (!pending) {
|
|
1143
1323
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
|
|
1144
1324
|
return;
|
|
1145
1325
|
}
|
|
1146
1326
|
const parsed = parsePendingUserInputAnswerCommand(raw, pending);
|
|
1147
1327
|
if ("error" in parsed) {
|
|
1148
|
-
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(
|
|
1328
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, parsed.error));
|
|
1149
1329
|
return;
|
|
1150
1330
|
}
|
|
1151
|
-
const submitted = await
|
|
1331
|
+
const submitted = await slot.runtime.submitUserInput(parsed.answers);
|
|
1152
1332
|
if (!submitted) {
|
|
1153
|
-
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(
|
|
1333
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "The worker could not apply this answer."));
|
|
1154
1334
|
return;
|
|
1155
1335
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1336
|
+
slot.pendingUserInput = null;
|
|
1337
|
+
slot.activeTask = {
|
|
1158
1338
|
startedAt: Date.now(),
|
|
1159
1339
|
inputPreview: parsed.preview,
|
|
1160
1340
|
};
|
|
1161
|
-
appendDaemonLog(`user_input_answered: adapter=${
|
|
1162
|
-
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(
|
|
1341
|
+
appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
|
|
1342
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
|
|
1343
|
+
}
|
|
1344
|
+
resolvePendingUserInputSlot(activeSlot) {
|
|
1345
|
+
if (activeSlot.pendingUserInput) {
|
|
1346
|
+
return activeSlot;
|
|
1347
|
+
}
|
|
1348
|
+
return (Array.from(this.slots.values()).find((slot) => slot.pendingUserInput !== null) ?? null);
|
|
1163
1349
|
}
|
|
1164
1350
|
resolvePendingApprovalSlot(activeSlot) {
|
|
1165
1351
|
if (activeSlot.pendingConfirmations.length > 0) {
|
|
@@ -1167,13 +1353,6 @@ class WechatDaemon {
|
|
|
1167
1353
|
}
|
|
1168
1354
|
return (Array.from(this.slots.values()).find((slot) => slot.pendingConfirmations.length > 0) ?? null);
|
|
1169
1355
|
}
|
|
1170
|
-
countPendingApprovals() {
|
|
1171
|
-
let count = 0;
|
|
1172
|
-
for (const slot of this.slots.values()) {
|
|
1173
|
-
count += slot.pendingConfirmations.length;
|
|
1174
|
-
}
|
|
1175
|
-
return count;
|
|
1176
|
-
}
|
|
1177
1356
|
getActiveSlot() {
|
|
1178
1357
|
if (!this.activeAdapter) {
|
|
1179
1358
|
return null;
|
|
@@ -1565,27 +1744,35 @@ export async function runDaemon(options) {
|
|
|
1565
1744
|
daemon.takenOverAdapter = cleanupResult.lock.adapter;
|
|
1566
1745
|
}
|
|
1567
1746
|
await daemon.startIpcServer();
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
process.
|
|
1747
|
+
try {
|
|
1748
|
+
await daemon.runInitialAdapter(options);
|
|
1749
|
+
let shutdownInProgress = false;
|
|
1750
|
+
const handleSignal = (signal) => {
|
|
1751
|
+
if (shutdownInProgress) {
|
|
1752
|
+
log(`Received ${signal} during shutdown, forcing exit.`);
|
|
1753
|
+
process.exit(1);
|
|
1754
|
+
}
|
|
1755
|
+
shutdownInProgress = true;
|
|
1756
|
+
log(`Received ${signal}. Stopping daemon.`);
|
|
1757
|
+
void daemon.shutdown().finally(() => process.exit(0));
|
|
1758
|
+
};
|
|
1759
|
+
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
1760
|
+
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
1761
|
+
process.on("SIGHUP", () => handleSignal("SIGHUP"));
|
|
1762
|
+
if (process.platform === "win32") {
|
|
1763
|
+
process.on("SIGBREAK", () => handleSignal("SIGBREAK"));
|
|
1764
|
+
}
|
|
1765
|
+
process.on("exit", () => {
|
|
1766
|
+
clearDaemonEndpoint();
|
|
1767
|
+
});
|
|
1768
|
+
await daemon.runPollLoop();
|
|
1769
|
+
}
|
|
1770
|
+
catch (error) {
|
|
1771
|
+
// Abnormal exit (poll loop failure, initial adapter failure): dispose the
|
|
1772
|
+
// slot runtimes so adapter child processes are not stranded as orphans.
|
|
1773
|
+
await daemon.shutdown().catch(() => undefined);
|
|
1774
|
+
throw error;
|
|
1584
1775
|
}
|
|
1585
|
-
process.on("exit", () => {
|
|
1586
|
-
clearDaemonEndpoint();
|
|
1587
|
-
});
|
|
1588
|
-
await daemon.runPollLoop();
|
|
1589
1776
|
}
|
|
1590
1777
|
export async function main(argv = process.argv.slice(2)) {
|
|
1591
1778
|
if (argv.includes("--doctor")) {
|
package/dist/i18n/index.js
CHANGED
package/dist/i18n/messages-en.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const messages = {
|
|
2
2
|
// === Bridge welcome & control ===
|
|
3
|
-
"bridge.welcome": "WeChat Bridge ready ({adapter}).\nCWD: {cwd}
|
|
3
|
+
"bridge.welcome": "WeChat Bridge ready ({adapter}).\nCWD: {cwd}",
|
|
4
4
|
"bridge.stopped": "Bridge stopped.",
|
|
5
5
|
"bridge.interrupt.sent": "Interrupt signal sent.",
|
|
6
6
|
"bridge.interrupt.notBusy": "No active task to interrupt.",
|
|
@@ -11,8 +11,8 @@ export const messages = {
|
|
|
11
11
|
"bridge.fatalError": "Bridge error: {message}",
|
|
12
12
|
"bridge.backlogIgnored": "Ignored {count} backlog message(s) sent before the bridge started (they will not be processed).\nIf you actually sent them after the bridge started, your system clock is likely skewed by more than {graceSeconds}s — sync the clock and restart the bridge.",
|
|
13
13
|
// === Daemon welcome & control ===
|
|
14
|
-
"daemon.welcome": "WeChat Daemon ready.\nCWD: {cwd}\nActive: {adapter}\n\nCommands: /claude, /codex, /opencode, /stop, /confirm, /deny, /status\n{bindings}\n\nManage: /bind [emoji] cmd, /unbind [emoji], /bindings",
|
|
15
|
-
"daemon.noActiveAdapter": "No active terminal is selected. Send /codex, /claude, or /
|
|
14
|
+
"daemon.welcome": "WeChat Daemon ready.\nCWD: {cwd}\nActive: {adapter}\n\nCommands: /claude, /codex, /opencode, /pi, /stop, /confirm, /deny, /status\n{bindings}\n\nManage: /bind [emoji] cmd, /unbind [emoji], /bindings",
|
|
15
|
+
"daemon.noActiveAdapter": "No active terminal is selected. Send /codex, /claude, /opencode, or /pi to start one.",
|
|
16
16
|
"daemon.switchResult.new": "Started a new visible CLI.",
|
|
17
17
|
"daemon.switchResult.reused": "Reused the existing visible CLI.",
|
|
18
18
|
// === Approval ===
|
|
@@ -66,7 +66,6 @@ export const messages = {
|
|
|
66
66
|
"doctor.clock.ok": "~{skewSeconds}s skew vs server time",
|
|
67
67
|
"doctor.clock.skewWarning": "~{skewSeconds}s skew vs server time (large skew makes the bridge treat fresh messages as pre-start backlog — sync the system clock)",
|
|
68
68
|
"doctor.label.nodePty": "node-pty",
|
|
69
|
-
"doctor.label.shell": "shell",
|
|
70
69
|
"doctor.label.dataDir": "Data dir",
|
|
71
70
|
"doctor.label.credentials": "Credentials",
|
|
72
71
|
"doctor.label.mode": "Mode",
|
|
@@ -79,7 +78,6 @@ export const messages = {
|
|
|
79
78
|
"doctor.winBuildConpty": "(ConPTY requires build 18309+)",
|
|
80
79
|
"doctor.nodePty.loaded": "loaded",
|
|
81
80
|
"doctor.nodePty.fix": "Fix: {fix}",
|
|
82
|
-
"doctor.adapterCli.shellNative": "uses the local shell; no other adapter CLI is checked.",
|
|
83
81
|
"doctor.adapterCli.notFound": "not found{optional}",
|
|
84
82
|
"doctor.dataDirMissing": "(will be created on first run)",
|
|
85
83
|
"doctor.credentialsFound": "found",
|
|
@@ -88,6 +86,7 @@ export const messages = {
|
|
|
88
86
|
"doctor.mode.generic": "generic",
|
|
89
87
|
"doctor.notSpecified": "(not specified)",
|
|
90
88
|
"doctor.none": "none",
|
|
89
|
+
"doctor.endpoint.corrupted": "endpoint file exists but cannot be parsed; it will be replaced on the next successful write",
|
|
91
90
|
"doctor.unknown": "unknown",
|
|
92
91
|
"doctor.yes": "yes",
|
|
93
92
|
"doctor.no": "no",
|