cli-wechat-bridge 1.1.4 → 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 +14 -7
- package/bin/_run-entry.mjs +0 -12
- package/dist/bridge/bridge-adapters.claude.js +427 -10
- package/dist/bridge/bridge-adapters.codex.js +235 -7
- package/dist/bridge/bridge-adapters.opencode.js +392 -53
- package/dist/bridge/bridge-adapters.pi.js +32 -6
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-utils.js +26 -5
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +37 -29
- package/dist/companion/codex-remote-client.js +291 -32
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +26 -0
- package/dist/companion/local-companion-start.js +0 -1
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/wechat-daemon.js +35 -17
- package/package.json +1 -5
- package/bin/wechat-claude-start.mjs +0 -10
- package/bin/wechat-codex-start.mjs +0 -10
- package/bin/wechat-opencode-start.mjs +0 -10
- package/bin/wechat-pi-start.mjs +0 -10
|
@@ -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
|
+
}
|
|
@@ -568,6 +568,20 @@ export function formatSessionSwitchMessage(params) {
|
|
|
568
568
|
return `OpenCode session switched to ${shortSessionId}.`;
|
|
569
569
|
}
|
|
570
570
|
}
|
|
571
|
+
if (params.adapter === "pi") {
|
|
572
|
+
switch (params.reason) {
|
|
573
|
+
case "local_follow":
|
|
574
|
+
case "local_session_fallback":
|
|
575
|
+
case "local_turn":
|
|
576
|
+
return `Pi session switched to ${shortSessionId} from the local terminal.`;
|
|
577
|
+
case "wechat_resume":
|
|
578
|
+
return `Pi session switched to ${shortSessionId} from WeChat.`;
|
|
579
|
+
case "startup_restore":
|
|
580
|
+
return `Pi restored shared session ${shortSessionId} on startup.`;
|
|
581
|
+
default:
|
|
582
|
+
return `Pi session switched to ${shortSessionId}.`;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
571
585
|
switch (params.reason) {
|
|
572
586
|
case "local_follow":
|
|
573
587
|
case "local_session_fallback":
|
|
@@ -596,17 +610,24 @@ export function formatResumeSessionList(params) {
|
|
|
596
610
|
? "No saved Codex threads were found for this working directory."
|
|
597
611
|
: adapter === "opencode"
|
|
598
612
|
? "No saved OpenCode sessions were found for this working directory."
|
|
599
|
-
:
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
613
|
+
: adapter === "pi"
|
|
614
|
+
? "No saved Pi sessions were found for this working directory."
|
|
615
|
+
: "No saved Claude sessions were found for this working directory.";
|
|
616
|
+
}
|
|
617
|
+
const title = adapter === "codex"
|
|
618
|
+
? "Recent Codex threads:"
|
|
619
|
+
: adapter === "opencode"
|
|
620
|
+
? "Recent OpenCode sessions:"
|
|
621
|
+
: adapter === "pi"
|
|
622
|
+
? "Recent Pi sessions:"
|
|
623
|
+
: "Recent Claude sessions:";
|
|
603
624
|
return [
|
|
604
625
|
title,
|
|
605
626
|
...candidates.map((candidate, index) => {
|
|
606
627
|
const marker = currentSessionId && candidate.sessionId === currentSessionId ? " [current]" : "";
|
|
607
628
|
return `${index + 1}. ${candidate.title} (${candidate.lastUpdatedAt}, ${candidate.sessionId.slice(0, 12)})${marker}`;
|
|
608
629
|
}),
|
|
609
|
-
|
|
630
|
+
"Reply with /resume <number> or /resume <unique-id-prefix>.",
|
|
610
631
|
].join("\n");
|
|
611
632
|
}
|
|
612
633
|
export function formatResumeThreadList(candidates, currentThreadId) {
|
|
@@ -5,12 +5,13 @@ import { delay } from "./bridge-adapters.shared.js";
|
|
|
5
5
|
import { t } from "../i18n/index.js";
|
|
6
6
|
import { BridgeController } from "./bridge-controller.js";
|
|
7
7
|
import { forwardWechatFinalReply } from "./bridge-final-reply.js";
|
|
8
|
+
import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "./bridge-session-resume.js";
|
|
8
9
|
import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "./wechat-forwarding.js";
|
|
9
10
|
import { ensureWechatCredentials } from "../wechat/setup.js";
|
|
10
11
|
import { BridgeStateStore } from "./bridge-state.js";
|
|
11
12
|
import { getProcessRecordByPid, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "./bridge-process-reaper.js";
|
|
12
13
|
import { createRuntimeHost } from "../runtime/create-runtime-host.js";
|
|
13
|
-
import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage,
|
|
14
|
+
import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
|
|
14
15
|
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
|
|
15
16
|
import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
|
|
16
17
|
import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
|
|
@@ -269,6 +270,10 @@ async function main() {
|
|
|
269
270
|
initialResumeConversationId: stateStore.getState().resumeConversationId,
|
|
270
271
|
initialTranscriptPath: stateStore.getState().transcriptPath,
|
|
271
272
|
});
|
|
273
|
+
const resumeCoordinator = new ResumeSessionCoordinator({
|
|
274
|
+
adapter: options.adapter,
|
|
275
|
+
runtime: adapter,
|
|
276
|
+
});
|
|
272
277
|
const controller = new BridgeController(adapter, options.cwd);
|
|
273
278
|
controller.clearLocalClientEndpoint();
|
|
274
279
|
stateStore.appendLog(`Cleared stale companion endpoint for ${options.cwd} before adapter start.`);
|
|
@@ -533,6 +538,7 @@ async function main() {
|
|
|
533
538
|
try {
|
|
534
539
|
wireAdapterEvents({
|
|
535
540
|
adapter,
|
|
541
|
+
resumeCoordinator,
|
|
536
542
|
options,
|
|
537
543
|
transport,
|
|
538
544
|
stateStore,
|
|
@@ -651,8 +657,12 @@ async function main() {
|
|
|
651
657
|
options,
|
|
652
658
|
stateStore,
|
|
653
659
|
adapter,
|
|
660
|
+
resumeCoordinator,
|
|
654
661
|
queueWechatMessage,
|
|
655
662
|
outputBatcher,
|
|
663
|
+
clearActiveTask: () => {
|
|
664
|
+
activeTask = null;
|
|
665
|
+
},
|
|
656
666
|
deferInboundMessage: async (nextMessage) => {
|
|
657
667
|
deferredInboundMessages.push({
|
|
658
668
|
message: nextMessage,
|
|
@@ -709,7 +719,7 @@ function syncSharedSessionState(stateStore, adapter) {
|
|
|
709
719
|
}
|
|
710
720
|
}
|
|
711
721
|
function wireAdapterEvents(params) {
|
|
712
|
-
const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
|
|
722
|
+
const { adapter, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
|
|
713
723
|
adapter.setEventSink((event) => {
|
|
714
724
|
syncSharedSessionState();
|
|
715
725
|
syncLocalClientEndpoint();
|
|
@@ -805,8 +815,12 @@ function wireAdapterEvents(params) {
|
|
|
805
815
|
}
|
|
806
816
|
break;
|
|
807
817
|
case "session_switched":
|
|
818
|
+
if (event.source === "local") {
|
|
819
|
+
resumeCoordinator.clear();
|
|
820
|
+
}
|
|
808
821
|
stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
|
|
809
|
-
if (
|
|
822
|
+
if (shouldForwardSessionSwitchEvent(event.reason) &&
|
|
823
|
+
shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
|
|
810
824
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
811
825
|
await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
|
|
812
826
|
adapter: options.adapter,
|
|
@@ -818,8 +832,12 @@ function wireAdapterEvents(params) {
|
|
|
818
832
|
}
|
|
819
833
|
break;
|
|
820
834
|
case "thread_switched":
|
|
835
|
+
if (event.source === "local") {
|
|
836
|
+
resumeCoordinator.clear();
|
|
837
|
+
}
|
|
821
838
|
stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
|
|
822
|
-
if (
|
|
839
|
+
if (shouldForwardSessionSwitchEvent(event.reason) &&
|
|
840
|
+
shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
|
|
823
841
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
824
842
|
await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
|
|
825
843
|
adapter: options.adapter,
|
|
@@ -879,7 +897,7 @@ function formatInboundMessagePreview(message) {
|
|
|
879
897
|
}
|
|
880
898
|
async function handleInboundMessage(params) {
|
|
881
899
|
let { message, } = params;
|
|
882
|
-
const { options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
|
|
900
|
+
const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, deferInboundMessage, } = params;
|
|
883
901
|
const state = stateStore.getState();
|
|
884
902
|
if (message.senderId !== state.authorizedUserId) {
|
|
885
903
|
await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
|
|
@@ -926,35 +944,23 @@ async function handleInboundMessage(params) {
|
|
|
926
944
|
await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
|
|
927
945
|
return null;
|
|
928
946
|
case "resume": {
|
|
929
|
-
if (options.adapter
|
|
930
|
-
await queueWechatMessage(message.senderId,
|
|
931
|
-
return null;
|
|
932
|
-
}
|
|
933
|
-
if (options.adapter === "claude") {
|
|
934
|
-
await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in claude mode. Use /resume directly inside "wechat-claude"; WeChat will follow the active local session.');
|
|
947
|
+
if (!isWechatResumeEnabled(options.adapter)) {
|
|
948
|
+
await queueWechatMessage(message.senderId, `WeChat /resume is disabled in ${options.adapter} mode. Use /resume directly inside "wechat-${options.adapter}"; WeChat will follow the active local session.`);
|
|
935
949
|
return null;
|
|
936
950
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
await adapter.resumeSession(systemCommand.target);
|
|
941
|
-
await queueWechatMessage(message.senderId, `Resumed OpenCode session ${systemCommand.target}.`);
|
|
942
|
-
}
|
|
943
|
-
else {
|
|
944
|
-
const candidates = await adapter.listResumeSessions(8);
|
|
945
|
-
await queueWechatMessage(message.senderId, formatResumeSessionList({
|
|
946
|
-
adapter: options.adapter,
|
|
947
|
-
candidates,
|
|
948
|
-
currentSessionId: adapter.getState().sharedSessionId,
|
|
949
|
-
}));
|
|
950
|
-
}
|
|
951
|
+
try {
|
|
952
|
+
if (systemCommand.target) {
|
|
953
|
+
await outputBatcher.flushNow();
|
|
951
954
|
}
|
|
952
|
-
|
|
953
|
-
|
|
955
|
+
const result = await resumeCoordinator.execute(systemCommand.target);
|
|
956
|
+
if (result.kind === "resumed") {
|
|
957
|
+
clearActiveTask();
|
|
954
958
|
}
|
|
955
|
-
|
|
959
|
+
await queueWechatMessage(message.senderId, result.message);
|
|
960
|
+
}
|
|
961
|
+
catch (error) {
|
|
962
|
+
await queueWechatMessage(message.senderId, error instanceof Error ? error.message : String(error));
|
|
956
963
|
}
|
|
957
|
-
await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
|
|
958
964
|
return null;
|
|
959
965
|
}
|
|
960
966
|
case "new_session": {
|
|
@@ -967,6 +973,7 @@ async function handleInboundMessage(params) {
|
|
|
967
973
|
stateStore.clearPendingConfirmation();
|
|
968
974
|
stateStore.clearPendingUserInput();
|
|
969
975
|
stateStore.clearSharedSessionId();
|
|
976
|
+
resumeCoordinator.clear();
|
|
970
977
|
await adapter.createSession();
|
|
971
978
|
stateStore.appendLog(`New ${options.adapter} session requested by owner.`);
|
|
972
979
|
return null;
|
|
@@ -984,6 +991,7 @@ async function handleInboundMessage(params) {
|
|
|
984
991
|
stateStore.clearPendingConfirmation();
|
|
985
992
|
stateStore.clearPendingUserInput();
|
|
986
993
|
stateStore.clearSharedSessionId();
|
|
994
|
+
resumeCoordinator.clear();
|
|
987
995
|
await adapter.reset();
|
|
988
996
|
stateStore.appendLog("Worker reset by owner.");
|
|
989
997
|
await queueWechatMessage(message.senderId, "Worker session has been reset.");
|