cli-wechat-bridge 1.1.3 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -140
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi.mjs +1 -1
- package/dist/bridge/bridge-adapters.claude.js +448 -11
- package/dist/bridge/bridge-adapters.codex.js +262 -11
- package/dist/bridge/bridge-adapters.core.js +76 -4
- package/dist/bridge/bridge-adapters.js +0 -3
- package/dist/bridge/bridge-adapters.opencode.js +497 -49
- package/dist/bridge/bridge-adapters.pi.js +44 -8
- package/dist/bridge/bridge-adapters.shared.js +13 -100
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-state.js +41 -6
- package/dist/bridge/bridge-utils.js +63 -229
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +93 -140
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +309 -36
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +33 -2
- package/dist/companion/local-companion-start.js +44 -13
- package/dist/companion/local-companion.js +6 -3
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +168 -56
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +2 -3
- package/dist/i18n/messages-zh.js +2 -3
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +23 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -11
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +2 -13
- package/bin/wechat-bridge-claude.mjs +0 -5
- package/bin/wechat-bridge-codex.mjs +0 -5
- package/bin/wechat-bridge-opencode.mjs +0 -5
- package/bin/wechat-bridge-pi.mjs +0 -5
- package/bin/wechat-bridge-shell.mjs +0 -5
- package/bin/wechat-bridge.mjs +0 -5
- package/bin/wechat-claude-start.mjs +0 -5
- package/bin/wechat-codex-start.mjs +0 -5
- package/bin/wechat-opencode-start.mjs +0 -5
- package/bin/wechat-pi-start.mjs +0 -5
- package/dist/bridge/bridge-adapters.shell.js +0 -363
|
@@ -319,9 +319,17 @@ export class PiTuiAdapter {
|
|
|
319
319
|
if (!sessionPath) {
|
|
320
320
|
throw new Error(`Pi session not found for this workspace: ${sessionId}`);
|
|
321
321
|
}
|
|
322
|
+
const verifiedSession = summarizePiSessionFile(sessionPath, this.options.cwd);
|
|
323
|
+
if (!verifiedSession || verifiedSession.sessionId !== sessionId) {
|
|
324
|
+
throw new Error(`Pi session changed or became unavailable before it could be resumed: ${sessionId}`);
|
|
325
|
+
}
|
|
322
326
|
this.pendingSessionSwitch = { source: "wechat", reason: "wechat_resume" };
|
|
323
327
|
try {
|
|
324
|
-
const response = await this.sendCommand("switch_session", {
|
|
328
|
+
const response = await this.sendCommand("switch_session", {
|
|
329
|
+
sessionPath,
|
|
330
|
+
sessionId,
|
|
331
|
+
cwd: this.options.cwd,
|
|
332
|
+
});
|
|
325
333
|
this.applySessionState(response.data, "wechat", "wechat_resume", true);
|
|
326
334
|
if (isRecord(response.data) && response.data.cancelled === true) {
|
|
327
335
|
throw new Error("Pi session switch was cancelled by an extension.");
|
|
@@ -482,6 +490,14 @@ export class PiTuiAdapter {
|
|
|
482
490
|
switch (frame.type) {
|
|
483
491
|
case "session_state": {
|
|
484
492
|
const pending = this.pendingSessionSwitch;
|
|
493
|
+
const nextSessionId = typeof frame.sessionId === "string" ? frame.sessionId : undefined;
|
|
494
|
+
const isLocalSessionSwitch = !pending &&
|
|
495
|
+
Boolean(nextSessionId) &&
|
|
496
|
+
Boolean(this.state.sharedSessionId) &&
|
|
497
|
+
nextSessionId !== this.state.sharedSessionId;
|
|
498
|
+
if (isLocalSessionSwitch) {
|
|
499
|
+
this.prepareForLocalSessionSwitch();
|
|
500
|
+
}
|
|
485
501
|
this.applySessionState(frame, pending?.source ?? "local", pending?.reason ?? "local_follow", Boolean(this.state.sharedSessionId));
|
|
486
502
|
this.setStatus(this.state.status);
|
|
487
503
|
break;
|
|
@@ -520,6 +536,19 @@ export class PiTuiAdapter {
|
|
|
520
536
|
timestamp: nowIso(),
|
|
521
537
|
});
|
|
522
538
|
}
|
|
539
|
+
prepareForLocalSessionSwitch() {
|
|
540
|
+
if (this.state.activeTurnOrigin === "wechat") {
|
|
541
|
+
this.emit({
|
|
542
|
+
type: "task_failed",
|
|
543
|
+
message: "The active WeChat task was interrupted because the local Pi terminal switched sessions.",
|
|
544
|
+
timestamp: nowIso(),
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
this.currentAssistantText = "";
|
|
548
|
+
this.currentTurnError = "";
|
|
549
|
+
this.clearActiveTurn();
|
|
550
|
+
this.setStatus("idle");
|
|
551
|
+
}
|
|
523
552
|
beginTurn(origin) {
|
|
524
553
|
this.currentAssistantText = "";
|
|
525
554
|
this.currentTurnError = "";
|
|
@@ -533,14 +562,11 @@ export class PiTuiAdapter {
|
|
|
533
562
|
return;
|
|
534
563
|
}
|
|
535
564
|
this.finalizingTurn = true;
|
|
536
|
-
const origin = this.state.activeTurnOrigin;
|
|
537
565
|
try {
|
|
538
566
|
if (this.currentTurnError) {
|
|
539
|
-
|
|
540
|
-
this.emit({ type: "task_failed", message: this.currentTurnError, timestamp: nowIso() });
|
|
541
|
-
}
|
|
567
|
+
this.emit({ type: "task_failed", message: this.currentTurnError, timestamp: nowIso() });
|
|
542
568
|
}
|
|
543
|
-
else if (
|
|
569
|
+
else if (this.currentAssistantText) {
|
|
544
570
|
this.emit({ type: "final_reply", text: this.currentAssistantText, timestamp: nowIso() });
|
|
545
571
|
}
|
|
546
572
|
this.state.lastOutputAt = nowIso();
|
|
@@ -636,6 +662,12 @@ export class PiTuiAdapter {
|
|
|
636
662
|
this.reconnectTimer = setTimeout(() => {
|
|
637
663
|
this.reconnectTimer = null;
|
|
638
664
|
if (!this.socket && !this.disposing) {
|
|
665
|
+
// Reset the turn state alongside the fatal error: leaving status at
|
|
666
|
+
// "busy" would keep rejecting every message with "still working"
|
|
667
|
+
// while /stop stays unavailable (no socket) and /reset is the only
|
|
668
|
+
// way out.
|
|
669
|
+
this.clearActiveTurn();
|
|
670
|
+
this.setStatus("idle", "The Pi TUI extension disconnected; the bridge is waiting for a reconnect.");
|
|
639
671
|
this.emit({
|
|
640
672
|
type: "fatal_error",
|
|
641
673
|
message: "The native Pi TUI bridge extension disconnected and did not reconnect.",
|
|
@@ -680,9 +712,13 @@ export class PiTuiAdapter {
|
|
|
680
712
|
this.extensionReady = false;
|
|
681
713
|
this.clearActiveTurn();
|
|
682
714
|
if (!this.disposing) {
|
|
715
|
+
// Report unexpected exits as fatal errors, matching the other adapters:
|
|
716
|
+
// a bare "stopped" status is only logged by the bridge, so WeChat users
|
|
717
|
+
// would get no signal that the TUI is gone until their next message
|
|
718
|
+
// fails with a cryptic not-connected error.
|
|
683
719
|
this.emit({
|
|
684
|
-
type: "
|
|
685
|
-
|
|
720
|
+
type: "fatal_error",
|
|
721
|
+
message: `Pi TUI exited unexpectedly (code=${code ?? "none"}, signal=${signal ?? "none"}). Restart the bridge or use /reset to recover.`,
|
|
686
722
|
timestamp: nowIso(),
|
|
687
723
|
});
|
|
688
724
|
}
|
|
@@ -32,8 +32,6 @@ export const LOCAL_COMPANION_RECONNECT_GRACE_MS = 15_000;
|
|
|
32
32
|
export const CLAUDE_HOOK_LISTEN_HOST = "127.0.0.1";
|
|
33
33
|
export const CLAUDE_HELP_PROBE_TIMEOUT_MS = 5_000;
|
|
34
34
|
export const CLAUDE_WECHAT_WORKING_NOTICE_DELAY_MS = 12_000;
|
|
35
|
-
export const DEFAULT_UNIX_SHELL_CANDIDATES = ["pwsh", "bash", "zsh", "sh"];
|
|
36
|
-
export const POSIX_SHELL_NAMES = new Set(["bash", "zsh", "sh", "dash", "ksh"]);
|
|
37
35
|
export const CLAUDE_FLAG_SUPPORT_CACHE = new Map();
|
|
38
36
|
export const OPENCODE_SERVER_HOST = "127.0.0.1";
|
|
39
37
|
export const OPENCODE_SERVER_READY_TIMEOUT_MS = 10_000;
|
|
@@ -678,44 +676,25 @@ function applyLoopbackNoProxy(env) {
|
|
|
678
676
|
env.no_proxy = mergeNoProxyValue(env.no_proxy);
|
|
679
677
|
return env;
|
|
680
678
|
}
|
|
681
|
-
export function resolveDefaultAdapterCommand(kind,
|
|
682
|
-
|
|
683
|
-
if (kind !== "shell") {
|
|
684
|
-
return kind;
|
|
685
|
-
}
|
|
686
|
-
if (platform === "win32") {
|
|
687
|
-
return "powershell.exe";
|
|
688
|
-
}
|
|
689
|
-
const env = options.env ?? process.env;
|
|
690
|
-
for (const candidate of DEFAULT_UNIX_SHELL_CANDIDATES) {
|
|
691
|
-
if (resolveCommandPath(candidate, platform, env)) {
|
|
692
|
-
return candidate;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
throw new Error(`No default shell executable was found on ${platform}. Tried: ${DEFAULT_UNIX_SHELL_CANDIDATES.join(", ")}. Use --cmd <executable>.`);
|
|
679
|
+
export function resolveDefaultAdapterCommand(kind, _options = {}) {
|
|
680
|
+
return kind;
|
|
696
681
|
}
|
|
697
|
-
export function buildCliEnvironment(
|
|
682
|
+
export function buildCliEnvironment(_kind, options = {}) {
|
|
698
683
|
const sourceEnv = options.env ?? process.env;
|
|
699
684
|
const platform = options.platform ?? process.platform;
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const env = {
|
|
707
|
-
...copyDefinedEnv(sourceEnv),
|
|
708
|
-
TERM: sourceEnv.TERM || "xterm-256color",
|
|
709
|
-
};
|
|
710
|
-
if (platform === "win32" && !env.HOME && env.USERPROFILE) {
|
|
711
|
-
env.HOME = env.USERPROFILE;
|
|
712
|
-
}
|
|
713
|
-
return applyLoopbackNoProxy(env);
|
|
714
|
-
}
|
|
715
|
-
return {
|
|
685
|
+
// Pass the full environment through on every platform. A previous
|
|
686
|
+
// Windows-only allowlist silently dropped user-configured variables such
|
|
687
|
+
// as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY, which
|
|
688
|
+
// broke CLIs that authenticate via environment variables even though the
|
|
689
|
+
// same CLI worked when launched manually from the user's shell.
|
|
690
|
+
const env = {
|
|
716
691
|
...copyDefinedEnv(sourceEnv),
|
|
717
692
|
TERM: sourceEnv.TERM || "xterm-256color",
|
|
718
693
|
};
|
|
694
|
+
if (platform === "win32" && !env.HOME && env.USERPROFILE) {
|
|
695
|
+
env.HOME = env.USERPROFILE;
|
|
696
|
+
}
|
|
697
|
+
return applyLoopbackNoProxy(env);
|
|
719
698
|
}
|
|
720
699
|
// ConPTY shipped in Windows 10 build 18309; on older builds node-pty must be
|
|
721
700
|
// left to auto-select its winpty fallback instead of being forced onto ConPTY.
|
|
@@ -738,72 +717,6 @@ export function buildPtySpawnOptions(params) {
|
|
|
738
717
|
}
|
|
739
718
|
return options;
|
|
740
719
|
}
|
|
741
|
-
export function normalizeShellCommandName(command) {
|
|
742
|
-
return path.parse(path.basename(command)).name.toLowerCase();
|
|
743
|
-
}
|
|
744
|
-
export function resolveShellRuntime(command, options = {}) {
|
|
745
|
-
const platform = options.platform ?? process.platform;
|
|
746
|
-
const name = normalizeShellCommandName(command);
|
|
747
|
-
if (name === "powershell" || name === "pwsh") {
|
|
748
|
-
return {
|
|
749
|
-
family: "powershell",
|
|
750
|
-
launchArgs: platform === "win32"
|
|
751
|
-
? ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-NoExit"]
|
|
752
|
-
: ["-NoLogo", "-NoProfile", "-NoExit"],
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
if (POSIX_SHELL_NAMES.has(name)) {
|
|
756
|
-
return {
|
|
757
|
-
family: "posix",
|
|
758
|
-
launchArgs: ["-i"],
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
throw new Error(`Unsupported shell executable for shell adapter: ${command}. Supported shells: powershell, pwsh, bash, zsh, sh, dash, ksh.`);
|
|
762
|
-
}
|
|
763
|
-
export function escapePowerShellString(text) {
|
|
764
|
-
return text.replace(/`/g, "``").replace(/"/g, '`"');
|
|
765
|
-
}
|
|
766
|
-
export function escapePosixShellString(text) {
|
|
767
|
-
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
768
|
-
}
|
|
769
|
-
export function buildShellProfileCommand(profilePath, family) {
|
|
770
|
-
const resolved = path.resolve(profilePath);
|
|
771
|
-
if (family === "powershell") {
|
|
772
|
-
return `. "${escapePowerShellString(resolved)}"`;
|
|
773
|
-
}
|
|
774
|
-
return `. ${escapePosixShellString(resolved)}`;
|
|
775
|
-
}
|
|
776
|
-
export function buildShellInputPayload(text, family, completionMarker = "__WECHAT_BRIDGE_DONE__") {
|
|
777
|
-
if (family === "powershell") {
|
|
778
|
-
const encodedCommand = Buffer.from(text, "utf8").toString("base64");
|
|
779
|
-
const script = [
|
|
780
|
-
"$__wechatBridgePreviousErrorActionPreference = $ErrorActionPreference",
|
|
781
|
-
"$ErrorActionPreference = 'Continue'",
|
|
782
|
-
"$global:LASTEXITCODE = 0",
|
|
783
|
-
"try {",
|
|
784
|
-
` $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${escapePowerShellString(encodedCommand)}"))`,
|
|
785
|
-
" $scriptBlock = [scriptblock]::Create($decoded)",
|
|
786
|
-
" & $scriptBlock",
|
|
787
|
-
"} catch {",
|
|
788
|
-
" Write-Error $_",
|
|
789
|
-
" $global:LASTEXITCODE = 1",
|
|
790
|
-
"} finally {",
|
|
791
|
-
" if (-not ($global:LASTEXITCODE -is [int])) { $global:LASTEXITCODE = 0 }",
|
|
792
|
-
` Write-Output "${escapePowerShellString(completionMarker)}:$global:LASTEXITCODE"`,
|
|
793
|
-
" $ErrorActionPreference = $__wechatBridgePreviousErrorActionPreference",
|
|
794
|
-
"}",
|
|
795
|
-
"",
|
|
796
|
-
];
|
|
797
|
-
return `${script.join("\r")}\r`;
|
|
798
|
-
}
|
|
799
|
-
const script = [
|
|
800
|
-
text,
|
|
801
|
-
"__wechat_bridge_status=$?",
|
|
802
|
-
`printf '%s:%s\\n' ${escapePosixShellString(completionMarker)} "$__wechat_bridge_status"`,
|
|
803
|
-
"",
|
|
804
|
-
];
|
|
805
|
-
return `${script.join("\r")}\r`;
|
|
806
|
-
}
|
|
807
720
|
export async function reserveLocalPort() {
|
|
808
721
|
return await new Promise((resolve, reject) => {
|
|
809
722
|
const server = net.createServer();
|
|
@@ -29,6 +29,9 @@ export class BridgeController {
|
|
|
29
29
|
companionStatus: adapterState.status,
|
|
30
30
|
companionLastStateAt: new Date().toISOString(),
|
|
31
31
|
companionWorkerPid: adapterState.pid,
|
|
32
|
+
codexControlPort: endpoint.codexControlPort ?? existing.codexControlPort,
|
|
33
|
+
codexControlToken: endpoint.codexControlToken ?? existing.codexControlToken,
|
|
34
|
+
codexVisibleThreadId: endpoint.codexVisibleThreadId ?? existing.codexVisibleThreadId,
|
|
32
35
|
}
|
|
33
36
|
: {
|
|
34
37
|
...endpoint,
|
|
@@ -8,9 +8,24 @@ export async function forwardWechatFinalReply(params) {
|
|
|
8
8
|
if (visibleText) {
|
|
9
9
|
// Send long replies in bounded chunks: a single oversized sendmessage call
|
|
10
10
|
// can be rejected by the WeChat API, silently losing the whole reply.
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const chunks = splitWechatTextIntoChunks(visibleText);
|
|
12
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
13
|
+
const sent = await sender.sendText(chunks[index]);
|
|
13
14
|
if (sent === false) {
|
|
15
|
+
// The send channel is failing (e.g. an expired context token).
|
|
16
|
+
// Report what was dropped instead of ending mid-reply silently;
|
|
17
|
+
// this notice itself is best effort and may also fail.
|
|
18
|
+
const remainingChunks = chunks.length - index - 1;
|
|
19
|
+
if (remainingChunks > 0 || parsed.attachments.length > 0) {
|
|
20
|
+
const parts = [];
|
|
21
|
+
if (remainingChunks > 0) {
|
|
22
|
+
parts.push(`${remainingChunks} reply chunk(s)`);
|
|
23
|
+
}
|
|
24
|
+
if (parsed.attachments.length > 0) {
|
|
25
|
+
parts.push(`${parsed.attachments.length} attachment(s)`);
|
|
26
|
+
}
|
|
27
|
+
await sender.sendText(`[bridge] Reply delivery was interrupted; ${parts.join(" and ")} could not be sent. Send a new message to retry.`);
|
|
28
|
+
}
|
|
14
29
|
return;
|
|
15
30
|
}
|
|
16
31
|
}
|
|
@@ -56,9 +56,14 @@ export function isWechatDaemonCommandLineForCwd(commandLine, cwd) {
|
|
|
56
56
|
* opencode serve --port 12345 --hostname 127.0.0.1
|
|
57
57
|
* opencode.exe serve --port 12345
|
|
58
58
|
* Also matches wrapper invocations (opencode.cmd / opencode.bat) on Windows.
|
|
59
|
+
*
|
|
60
|
+
* `serve` must be the first subcommand token after the executable so that
|
|
61
|
+
* unrelated command lines merely containing both words (e.g.
|
|
62
|
+
* `node opencode-lint/serve.js` or `mytool --theme opencode --mode serve`)
|
|
63
|
+
* are not reaped as orphans.
|
|
59
64
|
*/
|
|
60
65
|
export function isOpencodeServeCommandLine(commandLine) {
|
|
61
|
-
return /\bopencode(?:\.exe|\.cmd|\.bat)?\
|
|
66
|
+
return /\bopencode(?:\.exe|\.cmd|\.bat)?["']?\s+serve\b/i.test(commandLine);
|
|
62
67
|
}
|
|
63
68
|
/**
|
|
64
69
|
* Detects `opencode attach` command lines spawned by the local OpenCode companion.
|
|
@@ -67,7 +72,7 @@ export function isOpencodeServeCommandLine(commandLine) {
|
|
|
67
72
|
* opencode.exe attach http://127.0.0.1:12345 --session ses_123
|
|
68
73
|
*/
|
|
69
74
|
export function isOpencodeAttachCommandLine(commandLine) {
|
|
70
|
-
return /\bopencode(?:\.exe|\.cmd|\.bat)?\
|
|
75
|
+
return /\bopencode(?:\.exe|\.cmd|\.bat)?["']?\s+attach\b/i.test(commandLine);
|
|
71
76
|
}
|
|
72
77
|
function normalizeBridgeProcessRecord(value) {
|
|
73
78
|
if (!isRecord(value)) {
|
|
@@ -158,7 +163,8 @@ function listWindowsBridgeProcesses(currentPid = process.pid) {
|
|
|
158
163
|
"Bypass",
|
|
159
164
|
"-Command",
|
|
160
165
|
[
|
|
161
|
-
|
|
166
|
+
// Trailing semicolon required — see the note in listAllProcessesRaw.
|
|
167
|
+
"$ErrorActionPreference='Stop';",
|
|
162
168
|
"Get-CimInstance Win32_Process",
|
|
163
169
|
"| Where-Object { $_.CommandLine }",
|
|
164
170
|
"| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
|
|
@@ -236,7 +242,11 @@ function listAllProcessesRaw(currentPid = process.pid) {
|
|
|
236
242
|
"Bypass",
|
|
237
243
|
"-Command",
|
|
238
244
|
[
|
|
239
|
-
|
|
245
|
+
// Statement separator required: without the trailing semicolon
|
|
246
|
+
// PowerShell parses the preference assignment and the first command
|
|
247
|
+
// as one statement and fails with a ParserError, silently
|
|
248
|
+
// returning an empty process list on every probe.
|
|
249
|
+
"$ErrorActionPreference='Stop';",
|
|
240
250
|
"Get-CimInstance Win32_Process",
|
|
241
251
|
"| Where-Object { $_.CommandLine }",
|
|
242
252
|
"| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { formatResumeSessionList, formatSessionSwitchMessage, } from "./bridge-utils.js";
|
|
2
|
+
export const RESUME_SESSION_LIST_LIMIT = 8;
|
|
3
|
+
export const RESUME_SESSION_SNAPSHOT_TTL_MS = 5 * 60 * 1_000;
|
|
4
|
+
const RESUME_SESSION_PREFIX_SCAN_LIMIT = 100;
|
|
5
|
+
function describeError(error) {
|
|
6
|
+
return error instanceof Error ? error.message : String(error);
|
|
7
|
+
}
|
|
8
|
+
function adapterDisplayName(adapter) {
|
|
9
|
+
switch (adapter) {
|
|
10
|
+
case "codex":
|
|
11
|
+
return "Codex";
|
|
12
|
+
case "claude":
|
|
13
|
+
return "Claude";
|
|
14
|
+
case "opencode":
|
|
15
|
+
return "OpenCode";
|
|
16
|
+
case "pi":
|
|
17
|
+
return "Pi";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function adapterSessionNoun(adapter) {
|
|
21
|
+
return adapter === "codex" ? "thread" : "session";
|
|
22
|
+
}
|
|
23
|
+
export function isWechatResumeEnabled(adapter) {
|
|
24
|
+
return (adapter === "codex" ||
|
|
25
|
+
adapter === "claude" ||
|
|
26
|
+
adapter === "opencode" ||
|
|
27
|
+
adapter === "pi");
|
|
28
|
+
}
|
|
29
|
+
export function shouldForwardSessionSwitchEvent(reason) {
|
|
30
|
+
return reason !== "wechat_resume";
|
|
31
|
+
}
|
|
32
|
+
export class ResumeSessionCoordinator {
|
|
33
|
+
adapter;
|
|
34
|
+
runtime;
|
|
35
|
+
now;
|
|
36
|
+
snapshot = null;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.adapter = options.adapter;
|
|
39
|
+
this.runtime = options.runtime;
|
|
40
|
+
this.now = options.now ?? Date.now;
|
|
41
|
+
}
|
|
42
|
+
clear() {
|
|
43
|
+
this.snapshot = null;
|
|
44
|
+
}
|
|
45
|
+
async execute(target) {
|
|
46
|
+
const normalizedTarget = target?.trim() ?? "";
|
|
47
|
+
if (!normalizedTarget) {
|
|
48
|
+
const candidates = await this.listCandidates(RESUME_SESSION_LIST_LIMIT);
|
|
49
|
+
this.snapshot = {
|
|
50
|
+
createdAtMs: this.now(),
|
|
51
|
+
candidates,
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
kind: "list",
|
|
55
|
+
message: formatResumeSessionList({
|
|
56
|
+
adapter: this.adapter,
|
|
57
|
+
candidates,
|
|
58
|
+
currentSessionId: this.runtime.getState().sharedSessionId ??
|
|
59
|
+
this.runtime.getState().sharedThreadId,
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const candidate = await this.resolveCandidate(normalizedTarget);
|
|
64
|
+
const currentSessionId = this.runtime.getState().sharedSessionId ??
|
|
65
|
+
this.runtime.getState().sharedThreadId;
|
|
66
|
+
if (candidate.sessionId === currentSessionId) {
|
|
67
|
+
return {
|
|
68
|
+
kind: "already_active",
|
|
69
|
+
sessionId: candidate.sessionId,
|
|
70
|
+
message: `${adapterDisplayName(this.adapter)} ${adapterSessionNoun(this.adapter)} ${candidate.sessionId.slice(0, 12)} is already active.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
await this.runtime.resumeSession(candidate.sessionId);
|
|
74
|
+
return {
|
|
75
|
+
kind: "resumed",
|
|
76
|
+
sessionId: candidate.sessionId,
|
|
77
|
+
message: formatSessionSwitchMessage({
|
|
78
|
+
adapter: this.adapter,
|
|
79
|
+
sessionId: candidate.sessionId,
|
|
80
|
+
source: "wechat",
|
|
81
|
+
reason: "wechat_resume",
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async resolveCandidate(target) {
|
|
86
|
+
if (/^\d+$/.test(target)) {
|
|
87
|
+
const snapshot = this.getCurrentSnapshot();
|
|
88
|
+
if (!snapshot) {
|
|
89
|
+
throw new Error("The recent session list has expired. Send /resume again before choosing a number.");
|
|
90
|
+
}
|
|
91
|
+
const index = Number(target) - 1;
|
|
92
|
+
const candidate = snapshot.candidates[index];
|
|
93
|
+
if (!candidate) {
|
|
94
|
+
throw new Error(`Resume selection ${target} is outside the displayed range. Send /resume again to refresh the list.`);
|
|
95
|
+
}
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
const snapshot = this.getCurrentSnapshot();
|
|
99
|
+
const snapshotMatch = this.findIdMatch(snapshot?.candidates ?? [], target);
|
|
100
|
+
if (snapshotMatch) {
|
|
101
|
+
return snapshotMatch;
|
|
102
|
+
}
|
|
103
|
+
const candidates = await this.listCandidates(RESUME_SESSION_PREFIX_SCAN_LIMIT);
|
|
104
|
+
const candidate = this.findIdMatch(candidates, target);
|
|
105
|
+
if (candidate) {
|
|
106
|
+
return candidate;
|
|
107
|
+
}
|
|
108
|
+
// A complete ID may be older than the recent candidate window. The
|
|
109
|
+
// adapter remains responsible for validating its existence and cwd.
|
|
110
|
+
return {
|
|
111
|
+
sessionId: target,
|
|
112
|
+
title: target,
|
|
113
|
+
lastUpdatedAt: new Date(0).toISOString(),
|
|
114
|
+
source: this.adapter,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
findIdMatch(candidates, target) {
|
|
118
|
+
const normalizedTarget = target.toLowerCase();
|
|
119
|
+
const exact = candidates.find((candidate) => candidate.sessionId.toLowerCase() === normalizedTarget);
|
|
120
|
+
if (exact) {
|
|
121
|
+
return exact;
|
|
122
|
+
}
|
|
123
|
+
const prefixMatches = candidates.filter((candidate) => candidate.sessionId.toLowerCase().startsWith(normalizedTarget));
|
|
124
|
+
if (prefixMatches.length > 1) {
|
|
125
|
+
throw new Error(`Session ID prefix ${target} is ambiguous. Send /resume again and use a longer ID prefix.`);
|
|
126
|
+
}
|
|
127
|
+
return prefixMatches[0] ?? null;
|
|
128
|
+
}
|
|
129
|
+
getCurrentSnapshot() {
|
|
130
|
+
if (!this.snapshot) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (this.now() - this.snapshot.createdAtMs > RESUME_SESSION_SNAPSHOT_TTL_MS) {
|
|
134
|
+
this.snapshot = null;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return this.snapshot;
|
|
138
|
+
}
|
|
139
|
+
async listCandidates(limit) {
|
|
140
|
+
try {
|
|
141
|
+
return await this.runtime.listResumeSessions(limit);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
const message = describeError(error);
|
|
145
|
+
if (/^Failed to list\s/i.test(message)) {
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`Failed to list ${adapterDisplayName(this.adapter)} ${adapterSessionNoun(this.adapter)}s: ${message}`, { cause: error });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
|
|
3
3
|
import { buildInstanceId } from "./bridge-utils.js";
|
|
4
|
+
import { writeJsonFileAtomic } from "../utils/atomic-file.js";
|
|
5
|
+
import { getProcessRecordByPid, isWechatBridgeCommandLine, } from "./bridge-process-reaper.js";
|
|
4
6
|
const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
|
|
5
7
|
const ORPHAN_LOCK_RECLAIM_POLL_MS = 100;
|
|
6
8
|
export function resolveRestorableSharedSessionId(persisted, options) {
|
|
@@ -137,7 +139,37 @@ export function evaluateBridgeRuntimeOwnership(params) {
|
|
|
137
139
|
function buildLockConflictError(lock) {
|
|
138
140
|
return new Error(`Another bridge instance is already running (pid=${lock.pid}, instanceId=${lock.instanceId}, adapter=${lock.adapter}, cwd=${lock.cwd}, startedAt=${lock.startedAt}, lifecycle=${lock.lifecycle}). Stop it before starting a new bridge.`);
|
|
139
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Check whether the process currently holding a stale lock is still a
|
|
144
|
+
* wechat-bridge process. OS pid reuse can hand the recorded pid to an
|
|
145
|
+
* unrelated process; terminating that pid would kill an innocent process.
|
|
146
|
+
*
|
|
147
|
+
* Returns "bridge" when the command line matches a bridge, "foreign" when the
|
|
148
|
+
* pid is alive but belongs to something else, and "unknown" when the command
|
|
149
|
+
* line could not be resolved (probe failure, restricted permissions).
|
|
150
|
+
*/
|
|
151
|
+
export function classifyLockHolderProcess(pid) {
|
|
152
|
+
if (!isPidAlive(pid)) {
|
|
153
|
+
return "unknown";
|
|
154
|
+
}
|
|
155
|
+
const record = getProcessRecordByPid(pid);
|
|
156
|
+
if (!record) {
|
|
157
|
+
return "unknown";
|
|
158
|
+
}
|
|
159
|
+
return isWechatBridgeCommandLine(record.commandLine) ? "bridge" : "foreign";
|
|
160
|
+
}
|
|
140
161
|
function tryTerminateOrphanedBridge(lock) {
|
|
162
|
+
const holderKind = classifyLockHolderProcess(lock.pid);
|
|
163
|
+
if (holderKind === "foreign") {
|
|
164
|
+
// The lock-holding bridge is gone and its pid was reused by an unrelated
|
|
165
|
+
// process. Reclaim the lock without killing that process.
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (holderKind === "unknown") {
|
|
169
|
+
// Could not confirm the pid still belongs to a bridge. Do not risk
|
|
170
|
+
// killing an innocent process; keep the conflict error instead.
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
141
173
|
try {
|
|
142
174
|
process.kill(lock.pid);
|
|
143
175
|
}
|
|
@@ -295,13 +327,9 @@ export class BridgeStateStore {
|
|
|
295
327
|
}
|
|
296
328
|
}
|
|
297
329
|
writeAtomicJsonFile(filePath, value) {
|
|
298
|
-
ensureChannelDataDir();
|
|
299
|
-
const data = JSON.stringify(value, null, 2);
|
|
300
330
|
// temp + rename so a crash mid-write cannot leave a truncated/half-written
|
|
301
331
|
// state or lock file that would break the next launch or look like a stale lock.
|
|
302
|
-
|
|
303
|
-
fs.writeFileSync(tempPath, data, "utf-8");
|
|
304
|
-
fs.renameSync(tempPath, filePath);
|
|
332
|
+
writeJsonFileAtomic(filePath, value);
|
|
305
333
|
}
|
|
306
334
|
save() {
|
|
307
335
|
this.writeAtomicJsonFile(this.stateFilePath, this.state);
|
|
@@ -311,7 +339,14 @@ export class BridgeStateStore {
|
|
|
311
339
|
if (existing &&
|
|
312
340
|
existing.pid !== process.pid &&
|
|
313
341
|
isPidAlive(existing.pid)) {
|
|
314
|
-
|
|
342
|
+
const holderKind = classifyLockHolderProcess(existing.pid);
|
|
343
|
+
if (holderKind === "foreign") {
|
|
344
|
+
// The recorded bridge exited and the OS reused its pid for an
|
|
345
|
+
// unrelated process. The lock is stale: reclaim it instead of
|
|
346
|
+
// dead-locking new bridges on a phantom holder.
|
|
347
|
+
this.appendLog(`stale_lock_pid_reuse: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|
|
348
|
+
}
|
|
349
|
+
else if (shouldAutoReclaimBridgeLock(existing)) {
|
|
315
350
|
this.appendLog(`lock_reclaim_attempt: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|
|
316
351
|
if (tryTerminateOrphanedBridge(existing)) {
|
|
317
352
|
this.appendLog(`lock_reclaimed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|