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.
@@ -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
- : "No saved sessions were found for this working directory.";
600
- }
601
- const title = adapter === "codex" ? "Recent Codex threads:" : adapter === "opencode" ? "Recent OpenCode sessions:" : "Recent sessions:";
602
- const resumeTargetLabel = adapter === "codex" ? "threadId" : "sessionId";
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
- `Reply with /resume <number> or /resume <${resumeTargetLabel}>.`,
630
+ "Reply with /resume <number> or /resume <unique-id-prefix>.",
610
631
  ].join("\n");
611
632
  }
612
633
  export function formatResumeThreadList(candidates, currentThreadId) {
@@ -53,6 +53,7 @@ export function buildClaudeHookSettings(command) {
53
53
  return {
54
54
  hooks: {
55
55
  SessionStart: [hook],
56
+ SessionEnd: [hook],
56
57
  UserPromptSubmit: [hook],
57
58
  PermissionRequest: [hook],
58
59
  Notification: [
@@ -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, formatResumeSessionList, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
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 (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
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 (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
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 === "codex") {
930
- await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in codex mode. Use /resume directly inside "wechat-codex"; WeChat will follow the active local thread.');
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
- if (options.adapter === "opencode") {
938
- try {
939
- if (systemCommand.target) {
940
- await adapter.resumeSession(systemCommand.target);
941
- await queueWechatMessage(message.senderId, `Resumed OpenCode session ${systemCommand.target}.`);
942
- }
943
- else {
944
- const candidates = await adapter.listResumeSessions(8);
945
- await queueWechatMessage(message.senderId, formatResumeSessionList({
946
- adapter: options.adapter,
947
- candidates,
948
- currentSessionId: adapter.getState().sharedSessionId,
949
- }));
950
- }
951
+ try {
952
+ if (systemCommand.target) {
953
+ await outputBatcher.flushNow();
951
954
  }
952
- catch (error) {
953
- await queueWechatMessage(message.senderId, `Failed to resume OpenCode session: ${error instanceof Error ? error.message : String(error)}`);
955
+ const result = await resumeCoordinator.execute(systemCommand.target);
956
+ if (result.kind === "resumed") {
957
+ clearActiveTask();
954
958
  }
955
- return null;
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.");