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
|
@@ -16,7 +16,7 @@ const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".
|
|
|
16
16
|
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
|
17
17
|
const DEFAULT_ADAPTER = "codex";
|
|
18
18
|
function log(adapter, message) {
|
|
19
|
-
process.stderr.write(`[wechat-${adapter}
|
|
19
|
+
process.stderr.write(`[wechat-${adapter}] ${message}\n`);
|
|
20
20
|
}
|
|
21
21
|
export function normalizeComparablePath(cwd) {
|
|
22
22
|
const normalized = path.resolve(cwd);
|
|
@@ -61,7 +61,8 @@ export function decideLaunchAction(input) {
|
|
|
61
61
|
if (input.sessionStartMode === "new" &&
|
|
62
62
|
(input.requestedAdapter === "claude" ||
|
|
63
63
|
input.requestedAdapter === "opencode" ||
|
|
64
|
-
input.requestedAdapter === "pi"
|
|
64
|
+
input.requestedAdapter === "pi" ||
|
|
65
|
+
input.requestedAdapter === "codex")) {
|
|
65
66
|
return {
|
|
66
67
|
kind: "start_bridge",
|
|
67
68
|
message: `Starting a fresh ${input.requestedAdapter} session for ${input.requestedCwd}...`,
|
|
@@ -106,22 +107,36 @@ export function parseCliArgs(argv) {
|
|
|
106
107
|
}
|
|
107
108
|
const next = argv[i + 1];
|
|
108
109
|
if (arg === "--help" || arg === "-h") {
|
|
110
|
+
const helpAdapter = adapter;
|
|
111
|
+
const commandName = `wechat-${helpAdapter}`;
|
|
112
|
+
const adapterLabel = {
|
|
113
|
+
codex: "Codex",
|
|
114
|
+
claude: "Claude Code",
|
|
115
|
+
opencode: "OpenCode",
|
|
116
|
+
pi: "Pi",
|
|
117
|
+
}[helpAdapter];
|
|
109
118
|
process.stdout.write([
|
|
110
|
-
|
|
111
|
-
" wechat-claude-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...claude args]",
|
|
112
|
-
" wechat-opencode-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...opencode args]",
|
|
113
|
-
" wechat-pi-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...pi args]",
|
|
114
|
-
" local-companion-start [--adapter <codex|claude|opencode|pi>] [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...cli args]",
|
|
119
|
+
`Usage: ${commandName} [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [--session-start-mode <restore|new>] [...${helpAdapter} args]`,
|
|
115
120
|
"",
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
"Ensures the WeChat runtime for the current directory, then opens or reuses the visible CLI.",
|
|
122
|
+
"If a same-workspace daemon is running, the command delegates to that daemon.",
|
|
123
|
+
helpAdapter === "codex"
|
|
124
|
+
? "Codex restores the current session by default."
|
|
125
|
+
: `${adapterLabel} starts a fresh session by default.`,
|
|
126
|
+
helpAdapter === "codex"
|
|
127
|
+
? "Use --session-start-mode new to explicitly start a fresh session."
|
|
128
|
+
: "Use --session-start-mode restore to explicitly restore the previous session.",
|
|
129
|
+
"Use --doctor to check the selected adapter and workspace without starting it.",
|
|
130
|
+
"Without a daemon, the transient runtime is companion-bound and stops when the visible CLI closes.",
|
|
120
131
|
"Unknown arguments are forwarded to the visible CLI client.",
|
|
132
|
+
`Deprecated alias: ${commandName}-start.`,
|
|
121
133
|
"",
|
|
122
134
|
].join("\n"));
|
|
123
135
|
process.exit(0);
|
|
124
136
|
}
|
|
137
|
+
if (arg === "--doctor") {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
125
140
|
if (arg === "--adapter") {
|
|
126
141
|
if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
|
|
127
142
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
@@ -375,6 +390,12 @@ async function ensureBridgeReady(options) {
|
|
|
375
390
|
await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
|
|
376
391
|
return { shouldOpenVisibleClient: true };
|
|
377
392
|
}
|
|
393
|
+
// The daemon's ensure_slot can legitimately take a while: opening a visible
|
|
394
|
+
// client waits up to 15s for it to connect, and codex waits another 15s for
|
|
395
|
+
// the visible thread — all serialized per adapter. The default 2s IPC timeout
|
|
396
|
+
// would expire on nearly every cold start, so delegation uses a generous
|
|
397
|
+
// deadline and falls back to a local bridge when the daemon cannot deliver.
|
|
398
|
+
const ENSURE_SLOT_DELEGATION_TIMEOUT_MS = 45_000;
|
|
378
399
|
export async function tryDelegateToDaemon(options, deps = {}) {
|
|
379
400
|
const readEndpoint = deps.readEndpoint ?? readDaemonEndpoint;
|
|
380
401
|
const isEndpointAlive = deps.isEndpointAlive ?? isDaemonEndpointAlive;
|
|
@@ -400,9 +421,14 @@ export async function tryDelegateToDaemon(options, deps = {}) {
|
|
|
400
421
|
openVisible: true,
|
|
401
422
|
sessionStartMode: options.sessionStartMode,
|
|
402
423
|
reuseExistingVisible: options.adapter !== "pi" || options.sessionStartMode !== "new",
|
|
403
|
-
});
|
|
424
|
+
}, { timeoutMs: ENSURE_SLOT_DELEGATION_TIMEOUT_MS });
|
|
404
425
|
if (!response.ok) {
|
|
405
|
-
|
|
426
|
+
// A daemon that cannot answer in time (or at all) must not abort the
|
|
427
|
+
// launch: fall back to a local companion-bound bridge so the user still
|
|
428
|
+
// gets a working terminal instead of an error for work the daemon may
|
|
429
|
+
// still complete.
|
|
430
|
+
log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
|
|
431
|
+
return false;
|
|
406
432
|
}
|
|
407
433
|
log(options.adapter, `Delegated to running wechat-daemon for ${options.cwd}.`);
|
|
408
434
|
return true;
|
|
@@ -414,6 +440,7 @@ export async function runVisibleClient(options, runners = {}) {
|
|
|
414
440
|
return await (runners.codexRemoteClient ?? runCodexRemoteClient)({
|
|
415
441
|
cwd: options.cwd,
|
|
416
442
|
cliArgs: options.cliArgs,
|
|
443
|
+
sessionStartMode: options.sessionStartMode,
|
|
417
444
|
});
|
|
418
445
|
}
|
|
419
446
|
return await (runners.localCompanion ?? runLocalCompanion)({
|
|
@@ -431,6 +458,11 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
|
|
|
431
458
|
});
|
|
432
459
|
}
|
|
433
460
|
export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
|
|
461
|
+
if (argv.includes("--doctor")) {
|
|
462
|
+
const { runDoctorCheck } = await import("../utils/doctor.js");
|
|
463
|
+
await runDoctorCheck(argv, { mode: "bridge" });
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
434
466
|
const options = parseCliArgs(argv);
|
|
435
467
|
await ensureCompanionStartWechatCredentials(options.adapter);
|
|
436
468
|
if (await tryDelegateToDaemon(options)) {
|
|
@@ -23,9 +23,9 @@ function parseCliArgs(argv) {
|
|
|
23
23
|
const next = argv[i + 1];
|
|
24
24
|
if (arg === "--help" || arg === "-h") {
|
|
25
25
|
process.stdout.write([
|
|
26
|
-
"
|
|
26
|
+
"Internal companion usage: local-companion --adapter <codex|claude|opencode|pi> [--cwd <path>] [...cli args]",
|
|
27
27
|
"",
|
|
28
|
-
|
|
28
|
+
"Connects a visible local CLI to the matching bridge runtime for the current directory.",
|
|
29
29
|
"Unknown arguments are forwarded to the visible CLI client.",
|
|
30
30
|
"",
|
|
31
31
|
].join("\n"));
|
|
@@ -73,7 +73,7 @@ function readMatchingEndpoint(options) {
|
|
|
73
73
|
adapter: options.adapter,
|
|
74
74
|
});
|
|
75
75
|
if (!endpoint || endpoint.kind !== options.adapter) {
|
|
76
|
-
throw new Error(`No active ${options.adapter}
|
|
76
|
+
throw new Error(`No active ${options.adapter} runtime was found for ${options.cwd}. Run "wechat-${options.adapter}" in that directory to recreate it.`);
|
|
77
77
|
}
|
|
78
78
|
return endpoint;
|
|
79
79
|
}
|
|
@@ -241,6 +241,9 @@ export async function runLocalCompanion(options) {
|
|
|
241
241
|
case "resolve_approval":
|
|
242
242
|
sendResponse(socket, message.id, true, await adapter.resolveApproval(message.payload.action));
|
|
243
243
|
break;
|
|
244
|
+
case "submit_user_input":
|
|
245
|
+
sendResponse(socket, message.id, true, await adapter.submitUserInput(message.payload.answers));
|
|
246
|
+
break;
|
|
244
247
|
case "dispose":
|
|
245
248
|
sendResponse(socket, message.id, true);
|
|
246
249
|
await closeCompanion(0, "bridge_dispose");
|
|
@@ -65,8 +65,12 @@ export function isPidAlive(pid) {
|
|
|
65
65
|
process.kill(pid, 0);
|
|
66
66
|
return true;
|
|
67
67
|
}
|
|
68
|
-
catch {
|
|
69
|
-
|
|
68
|
+
catch (error) {
|
|
69
|
+
// EPERM means the OS found the pid but denied access (e.g. a Windows
|
|
70
|
+
// process running under another privilege level). The process exists, so
|
|
71
|
+
// it must be treated as alive — returning false here would let callers
|
|
72
|
+
// delete a live daemon's endpoint file and break its singleton.
|
|
73
|
+
return error instanceof Error && error.code === "EPERM";
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
export function sendDaemonResponse(socket, id, response) {
|
|
@@ -170,6 +174,11 @@ export async function sendDaemonRequest(endpoint, payload, options = {}) {
|
|
|
170
174
|
socket.once("error", () => {
|
|
171
175
|
finish({ ok: false, error: "Daemon endpoint is not reachable." });
|
|
172
176
|
});
|
|
177
|
+
socket.once("close", () => {
|
|
178
|
+
// The daemon may die or be taken over mid-request; without this the
|
|
179
|
+
// caller waits out the full timeout for a socket that is already gone.
|
|
180
|
+
finish({ ok: false, error: "Daemon connection closed before a response." });
|
|
181
|
+
});
|
|
173
182
|
});
|
|
174
183
|
}
|
|
175
184
|
export async function isDaemonEndpointAlive(endpoint, options = {}) {
|
|
@@ -177,5 +186,18 @@ export async function isDaemonEndpointAlive(endpoint, options = {}) {
|
|
|
177
186
|
return false;
|
|
178
187
|
}
|
|
179
188
|
const response = await sendDaemonRequest(endpoint, { command: "status" }, options);
|
|
180
|
-
|
|
189
|
+
if (response.ok) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
// A momentarily busy daemon (e.g. a synchronous session-directory scan
|
|
193
|
+
// blocking its event loop) can miss the short status deadline. Retry once
|
|
194
|
+
// before declaring it dead: a false negative makes callers delete a live
|
|
195
|
+
// daemon's endpoint file and break its singleton.
|
|
196
|
+
const timedOut = (response.error ?? "").includes("Timed out");
|
|
197
|
+
if (!timedOut) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
201
|
+
const retry = await sendDaemonRequest(endpoint, { command: "status" }, options);
|
|
202
|
+
return retry.ok;
|
|
181
203
|
}
|
|
@@ -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;
|
|
@@ -52,9 +51,6 @@ 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
55
|
return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
|
|
60
56
|
}
|
|
@@ -155,12 +151,16 @@ export function defaultDaemonSessionStartMode(adapter) {
|
|
|
155
151
|
return adapter === "codex" ? "restore" : "new";
|
|
156
152
|
}
|
|
157
153
|
export function resolveDaemonSessionStartMode(params) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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.
|
|
161
158
|
if (params.explicitSessionStartMode) {
|
|
162
159
|
return params.explicitSessionStartMode;
|
|
163
160
|
}
|
|
161
|
+
if (params.reuseExistingVisible && params.visibleConnected) {
|
|
162
|
+
return "restore";
|
|
163
|
+
}
|
|
164
164
|
if (params.adapter === "codex") {
|
|
165
165
|
return "restore";
|
|
166
166
|
}
|
|
@@ -483,6 +483,8 @@ class WechatDaemon {
|
|
|
483
483
|
authorizedUserId;
|
|
484
484
|
transport;
|
|
485
485
|
slots = new Map();
|
|
486
|
+
// Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
|
|
487
|
+
slotEnsureChains = new Map();
|
|
486
488
|
startedAt = new Date().toISOString();
|
|
487
489
|
bridgeStartedAtMs = Date.now();
|
|
488
490
|
backlogNoticeSent = false;
|
|
@@ -638,14 +640,12 @@ class WechatDaemon {
|
|
|
638
640
|
}
|
|
639
641
|
catch (error) {
|
|
640
642
|
const errorText = error instanceof Error ? error.message : String(error);
|
|
641
|
-
const isUserFacingShellRejection = error instanceof Error && error.name === "ShellCommandRejectedError";
|
|
642
643
|
logError(errorText);
|
|
643
|
-
appendDaemonLog(
|
|
644
|
+
appendDaemonLog(`inbound_error: ${errorText}`);
|
|
644
645
|
await this.queueWechatMessage(message.senderId, formatUserFacingInboundError({
|
|
645
646
|
adapter: this.activeAdapter ?? "codex",
|
|
646
647
|
cwd: this.cwd,
|
|
647
648
|
errorText,
|
|
648
|
-
isUserFacingShellRejection,
|
|
649
649
|
}), "inbound_error");
|
|
650
650
|
}
|
|
651
651
|
}
|
|
@@ -657,6 +657,32 @@ class WechatDaemon {
|
|
|
657
657
|
}
|
|
658
658
|
await this.shutdownPromise;
|
|
659
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
|
+
}
|
|
660
686
|
async cleanup() {
|
|
661
687
|
appendDaemonLog("shutdown_started");
|
|
662
688
|
for (const slot of this.slots.values()) {
|
|
@@ -721,6 +747,19 @@ class WechatDaemon {
|
|
|
721
747
|
}
|
|
722
748
|
}
|
|
723
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 = {}) {
|
|
724
763
|
const previousActiveAdapter = this.activeAdapter ?? undefined;
|
|
725
764
|
let slot = this.slots.get(adapter);
|
|
726
765
|
let created = false;
|
|
@@ -765,7 +804,8 @@ class WechatDaemon {
|
|
|
765
804
|
reuseExistingVisible: options.reuseExistingVisible !== false,
|
|
766
805
|
});
|
|
767
806
|
if (!created &&
|
|
768
|
-
options.reuseExistingVisible === false
|
|
807
|
+
(options.reuseExistingVisible === false ||
|
|
808
|
+
options.sessionStartMode === "new") &&
|
|
769
809
|
sessionStartMode === "new" &&
|
|
770
810
|
(adapter === "claude" || adapter === "opencode" || adapter === "pi") &&
|
|
771
811
|
visibleConnected) {
|
|
@@ -952,14 +992,19 @@ class WechatDaemon {
|
|
|
952
992
|
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required");
|
|
953
993
|
}));
|
|
954
994
|
break;
|
|
955
|
-
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}`);
|
|
956
1003
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
957
|
-
const pending = toPendingUserInput(event.request);
|
|
958
|
-
slot.pendingUserInput = pending;
|
|
959
|
-
appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
|
|
960
1004
|
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required");
|
|
961
1005
|
}));
|
|
962
1006
|
break;
|
|
1007
|
+
}
|
|
963
1008
|
case "mirrored_user_input":
|
|
964
1009
|
appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
|
|
965
1010
|
if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
|
|
@@ -1015,6 +1060,7 @@ class WechatDaemon {
|
|
|
1015
1060
|
slot.pendingConfirmations = [];
|
|
1016
1061
|
slot.pendingUserInput = null;
|
|
1017
1062
|
slot.activeTask = null;
|
|
1063
|
+
this.disposeDeadSlot(slot);
|
|
1018
1064
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1019
1065
|
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(event.message)), "fatal_error");
|
|
1020
1066
|
}));
|
|
@@ -1120,6 +1166,15 @@ class WechatDaemon {
|
|
|
1120
1166
|
}
|
|
1121
1167
|
slot = this.getActiveSlot() ?? slot;
|
|
1122
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
|
+
}
|
|
1123
1178
|
if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
|
|
1124
1179
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
|
|
1125
1180
|
return;
|
|
@@ -1151,9 +1206,33 @@ class WechatDaemon {
|
|
|
1151
1206
|
case "status":
|
|
1152
1207
|
await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
|
|
1153
1208
|
return;
|
|
1154
|
-
case "resume":
|
|
1155
|
-
|
|
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
|
+
}
|
|
1156
1234
|
return;
|
|
1235
|
+
}
|
|
1157
1236
|
case "new_session":
|
|
1158
1237
|
if (!activeSlot.runtime.createSession) {
|
|
1159
1238
|
await this.queueWechatMessage(message.senderId, `/new is not available in ${activeSlot.adapter} mode.`);
|
|
@@ -1231,28 +1310,42 @@ class WechatDaemon {
|
|
|
1231
1310
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1 ? `${count} approvals denied.` : "Approval denied."));
|
|
1232
1311
|
}
|
|
1233
1312
|
async answerPendingUserInput(message, activeSlot, raw) {
|
|
1234
|
-
|
|
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;
|
|
1235
1322
|
if (!pending) {
|
|
1236
1323
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
|
|
1237
1324
|
return;
|
|
1238
1325
|
}
|
|
1239
1326
|
const parsed = parsePendingUserInputAnswerCommand(raw, pending);
|
|
1240
1327
|
if ("error" in parsed) {
|
|
1241
|
-
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(
|
|
1328
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, parsed.error));
|
|
1242
1329
|
return;
|
|
1243
1330
|
}
|
|
1244
|
-
const submitted = await
|
|
1331
|
+
const submitted = await slot.runtime.submitUserInput(parsed.answers);
|
|
1245
1332
|
if (!submitted) {
|
|
1246
|
-
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(
|
|
1333
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "The worker could not apply this answer."));
|
|
1247
1334
|
return;
|
|
1248
1335
|
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1336
|
+
slot.pendingUserInput = null;
|
|
1337
|
+
slot.activeTask = {
|
|
1251
1338
|
startedAt: Date.now(),
|
|
1252
1339
|
inputPreview: parsed.preview,
|
|
1253
1340
|
};
|
|
1254
|
-
appendDaemonLog(`user_input_answered: adapter=${
|
|
1255
|
-
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);
|
|
1256
1349
|
}
|
|
1257
1350
|
resolvePendingApprovalSlot(activeSlot) {
|
|
1258
1351
|
if (activeSlot.pendingConfirmations.length > 0) {
|
|
@@ -1260,13 +1353,6 @@ class WechatDaemon {
|
|
|
1260
1353
|
}
|
|
1261
1354
|
return (Array.from(this.slots.values()).find((slot) => slot.pendingConfirmations.length > 0) ?? null);
|
|
1262
1355
|
}
|
|
1263
|
-
countPendingApprovals() {
|
|
1264
|
-
let count = 0;
|
|
1265
|
-
for (const slot of this.slots.values()) {
|
|
1266
|
-
count += slot.pendingConfirmations.length;
|
|
1267
|
-
}
|
|
1268
|
-
return count;
|
|
1269
|
-
}
|
|
1270
1356
|
getActiveSlot() {
|
|
1271
1357
|
if (!this.activeAdapter) {
|
|
1272
1358
|
return null;
|
|
@@ -1658,27 +1744,35 @@ export async function runDaemon(options) {
|
|
|
1658
1744
|
daemon.takenOverAdapter = cleanupResult.lock.adapter;
|
|
1659
1745
|
}
|
|
1660
1746
|
await daemon.startIpcServer();
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
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;
|
|
1677
1775
|
}
|
|
1678
|
-
process.on("exit", () => {
|
|
1679
|
-
clearDaemonEndpoint();
|
|
1680
|
-
});
|
|
1681
|
-
await daemon.runPollLoop();
|
|
1682
1776
|
}
|
|
1683
1777
|
export async function main(argv = process.argv.slice(2)) {
|
|
1684
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.",
|
|
@@ -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",
|
package/dist/i18n/messages-zh.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const messages = {
|
|
2
2
|
// === 桥接欢迎 & 控制 ===
|
|
3
|
-
"bridge.welcome": "微信桥接就绪 ({adapter})。\n工作目录: {cwd}
|
|
3
|
+
"bridge.welcome": "微信桥接就绪 ({adapter})。\n工作目录: {cwd}",
|
|
4
4
|
"bridge.stopped": "桥接已停止。",
|
|
5
5
|
"bridge.interrupt.sent": "已发送中断信号。",
|
|
6
6
|
"bridge.interrupt.notBusy": "没有正在执行的任务可中断。",
|
|
@@ -66,7 +66,6 @@ export const messages = {
|
|
|
66
66
|
"doctor.clock.ok": "与服务器时间偏差约 {skewSeconds}s",
|
|
67
67
|
"doctor.clock.skewWarning": "与服务器时间偏差约 {skewSeconds}s(偏差过大会导致桥接启动后的消息被当作积压忽略,请校准系统时间)",
|
|
68
68
|
"doctor.label.nodePty": "node-pty",
|
|
69
|
-
"doctor.label.shell": "shell",
|
|
70
69
|
"doctor.label.dataDir": "数据目录",
|
|
71
70
|
"doctor.label.credentials": "凭据",
|
|
72
71
|
"doctor.label.mode": "模式",
|
|
@@ -79,7 +78,6 @@ export const messages = {
|
|
|
79
78
|
"doctor.winBuildConpty": "(ConPTY 需要 build 18309+)",
|
|
80
79
|
"doctor.nodePty.loaded": "已加载",
|
|
81
80
|
"doctor.nodePty.fix": "修复: {fix}",
|
|
82
|
-
"doctor.adapterCli.shellNative": "使用本机 shell,无需检查其它适配器 CLI。",
|
|
83
81
|
"doctor.adapterCli.notFound": "未找到{optional}",
|
|
84
82
|
"doctor.dataDirMissing": "(首次运行会创建)",
|
|
85
83
|
"doctor.credentialsFound": "已找到",
|
|
@@ -88,6 +86,7 @@ export const messages = {
|
|
|
88
86
|
"doctor.mode.generic": "通用",
|
|
89
87
|
"doctor.notSpecified": "(未指定)",
|
|
90
88
|
"doctor.none": "无",
|
|
89
|
+
"doctor.endpoint.corrupted": "endpoint 文件存在但无法解析;下次成功写入时将被替换",
|
|
91
90
|
"doctor.unknown": "未知",
|
|
92
91
|
"doctor.yes": "是",
|
|
93
92
|
"doctor.no": "否",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { ensureChannelDataDir } from "../wechat/channel-config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Write JSON atomically via temp file + rename, so a crash mid-write cannot
|
|
5
|
+
* leave a truncated or half-written file behind. Cross-process readers (the
|
|
6
|
+
* daemon, launchers, companions) either see the previous complete file or the
|
|
7
|
+
* new complete file — never a torn write.
|
|
8
|
+
*/
|
|
9
|
+
export function writeJsonFileAtomic(filePath, value) {
|
|
10
|
+
ensureChannelDataDir();
|
|
11
|
+
const data = JSON.stringify(value, null, 2);
|
|
12
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
13
|
+
fs.writeFileSync(tempPath, data, "utf-8");
|
|
14
|
+
fs.renameSync(tempPath, filePath);
|
|
15
|
+
}
|