cli-wechat-bridge 1.1.4 → 1.1.6

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.
Files changed (42) hide show
  1. package/README.md +153 -131
  2. package/bin/_run-entry.mjs +20 -17
  3. package/dist/bridge/bridge-adapters.claude.js +429 -11
  4. package/dist/bridge/bridge-adapters.codex.js +441 -22
  5. package/dist/bridge/bridge-adapters.core.js +2 -1
  6. package/dist/bridge/bridge-adapters.opencode.js +392 -53
  7. package/dist/bridge/bridge-adapters.pi.js +33 -7
  8. package/dist/bridge/bridge-adapters.shared.js +2 -1
  9. package/dist/bridge/bridge-controller.js +3 -0
  10. package/dist/bridge/bridge-final-reply.js +2 -65
  11. package/dist/bridge/bridge-session-resume.js +151 -0
  12. package/dist/bridge/bridge-state.js +1 -1
  13. package/dist/bridge/bridge-utils.js +30 -101
  14. package/dist/bridge/claude-hooks.js +1 -0
  15. package/dist/bridge/wechat-bridge.js +256 -211
  16. package/dist/bridge/wechat-forwarding.js +2 -58
  17. package/dist/bridge/wechat-outbound-queue.js +2 -0
  18. package/dist/channels/wechat/bridge-final-reply.js +65 -0
  19. package/dist/channels/wechat/channel-message.js +24 -0
  20. package/dist/channels/wechat/index.js +5 -0
  21. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  22. package/dist/channels/wechat/wechat-forwarding.js +79 -0
  23. package/dist/channels/wechat/wechat-outbound-queue.js +72 -0
  24. package/dist/companion/codex-remote-client.js +291 -32
  25. package/dist/companion/codex-visible-client-link.js +122 -0
  26. package/dist/companion/local-companion-link.js +26 -0
  27. package/dist/companion/local-companion-start.js +0 -1
  28. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  29. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  30. package/dist/core/bridge-control.js +32 -0
  31. package/dist/core/bridge-event-forwarder.js +50 -0
  32. package/dist/core/bridge-message-router.js +32 -0
  33. package/dist/core/channel-types.js +1 -0
  34. package/dist/core/text-utils.js +63 -0
  35. package/dist/daemon/wechat-daemon.js +242 -159
  36. package/dist/i18n/messages-en.js +2 -2
  37. package/dist/i18n/messages-zh.js +2 -2
  38. package/package.json +105 -109
  39. package/bin/wechat-claude-start.mjs +0 -10
  40. package/bin/wechat-codex-start.mjs +0 -10
  41. package/bin/wechat-opencode-start.mjs +0 -10
  42. package/bin/wechat-pi-start.mjs +0 -10
@@ -2,11 +2,13 @@ import fs from "node:fs";
2
2
  import net from "node:net";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
5
6
  import { buildLocalCompanionToken } from "../companion/local-companion-link.js";
6
7
  import { t } from "../i18n/index.js";
7
8
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
8
9
  import { buildClaudeFailureMessage, buildClaudeHookScript, buildClaudeHookSettings, buildClaudePermissionDecisionHookOutput, buildClaudePermissionApprovalRequest, extractClaudeAssistantMessageText, extractClaudeResumeConversationId, extractClaudeTranscriptFinalReply, findInjectedClaudePromptIndex, getClaudePermissionAutoResponse, getClaudeWechatOutboundAttachmentDenyMessage, normalizeClaudeAssistantMessage, parseClaudeHookPayload, } from "./claude-hooks.js";
9
- import { detectCliApproval, isThinkingForwardEnabled, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
10
+ import { detectCliApproval, isThinkingForwardEnabled, } from "./bridge-utils.js";
11
+ import { normalizeOutput, nowIso, truncatePreview } from "../core/text-utils.js";
10
12
  import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
11
13
  import * as shared from "./bridge-adapters.shared.js";
12
14
  const { CLAUDE_HOOK_LISTEN_HOST, CLAUDE_WECHAT_WORKING_NOTICE_DELAY_MS, DEFAULT_COLS, DEFAULT_ROWS, MODULE_DIR, buildClaudeCliArgs, delay, isClaudeInvalidResumeError, quotePosixCommandArg, quoteWindowsCommandArg, shouldIncludeClaudeNoAltScreen, } = shared;
@@ -17,6 +19,10 @@ const CLAUDE_BRACKETED_PASTE_START = "\u001b[200~";
17
19
  const CLAUDE_BRACKETED_PASTE_END = "\u001b[201~";
18
20
  const CLAUDE_REMOTE_ENTER_DELAY_MS = 40;
19
21
  const CLAUDE_STARTUP_OUTPUT_BUFFER_LIMIT = 4_000;
22
+ const CLAUDE_SESSION_METADATA_READ_BYTES = 64 * 1024;
23
+ const CLAUDE_MAX_SANITIZED_PROJECT_PATH = 200;
24
+ const CLAUDE_RESUME_TIMEOUT_MS = 30_000;
25
+ const CLAUDE_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
20
26
  function isClaudeWorkspaceTrustPrompt(text) {
21
27
  const compact = text.replace(/\s+/g, " ").trim();
22
28
  return (/Accessing workspace:/i.test(compact) &&
@@ -27,6 +33,229 @@ function isClaudeWorkspaceTrustPrompt(text) {
27
33
  function isRecord(value) {
28
34
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
29
35
  }
36
+ function expandHomePath(value) {
37
+ if (value === "~") {
38
+ return os.homedir();
39
+ }
40
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
41
+ return path.join(os.homedir(), value.slice(2));
42
+ }
43
+ return value;
44
+ }
45
+ function normalizeComparablePath(value) {
46
+ const resolved = path.resolve(value);
47
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
48
+ }
49
+ function isSameClaudePath(left, right) {
50
+ return normalizeComparablePath(left) === normalizeComparablePath(right);
51
+ }
52
+ function getClaudeConfigDirectory(env) {
53
+ const custom = env.CLAUDE_CONFIG_DIR;
54
+ return custom
55
+ ? path.resolve(expandHomePath(custom))
56
+ : path.join(env.USERPROFILE || env.HOME || os.homedir(), ".claude");
57
+ }
58
+ function sanitizeClaudeProjectPath(cwd) {
59
+ return path.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-");
60
+ }
61
+ export function buildClaudeProjectSessionDirectory(cwd, env = process.env) {
62
+ return path.join(getClaudeConfigDirectory(env), "projects", sanitizeClaudeProjectPath(cwd));
63
+ }
64
+ function readClaudeSessionSegments(filePath) {
65
+ const stat = fs.statSync(filePath);
66
+ const fd = fs.openSync(filePath, "r");
67
+ try {
68
+ const headLength = Math.min(stat.size, CLAUDE_SESSION_METADATA_READ_BYTES);
69
+ const headBuffer = Buffer.alloc(headLength);
70
+ fs.readSync(fd, headBuffer, 0, headLength, 0);
71
+ const tailStart = Math.max(0, stat.size - CLAUDE_SESSION_METADATA_READ_BYTES);
72
+ const tailLength = stat.size - tailStart;
73
+ const tailBuffer = Buffer.alloc(tailLength);
74
+ fs.readSync(fd, tailBuffer, 0, tailLength, tailStart);
75
+ return [headBuffer.toString("utf8"), tailBuffer.toString("utf8")];
76
+ }
77
+ finally {
78
+ fs.closeSync(fd);
79
+ }
80
+ }
81
+ function extractClaudeUserMessageText(value) {
82
+ if (typeof value === "string") {
83
+ return normalizeOutput(value).trim();
84
+ }
85
+ if (!Array.isArray(value)) {
86
+ return "";
87
+ }
88
+ return normalizeOutput(value
89
+ .filter((item) => isRecord(item))
90
+ .filter((item) => item.type === "text" && typeof item.text === "string")
91
+ .map((item) => item.text)
92
+ .join("\n")).trim();
93
+ }
94
+ function readClaudeSessionMetadata(filePath) {
95
+ const metadata = { lastTimestampMs: 0 };
96
+ const seenLines = new Set();
97
+ for (const segment of readClaudeSessionSegments(filePath)) {
98
+ for (const rawLine of segment.split(/\r?\n/)) {
99
+ const line = rawLine.trim();
100
+ if (!line || seenLines.has(line)) {
101
+ continue;
102
+ }
103
+ seenLines.add(line);
104
+ try {
105
+ const entry = JSON.parse(line);
106
+ if (!isRecord(entry)) {
107
+ continue;
108
+ }
109
+ if (typeof entry.cwd === "string") {
110
+ metadata.cwd = entry.cwd;
111
+ }
112
+ if (entry.isSidechain === false) {
113
+ metadata.isSidechain = false;
114
+ }
115
+ else if (entry.isSidechain === true && metadata.isSidechain === undefined) {
116
+ metadata.isSidechain = true;
117
+ }
118
+ if (entry.type === "custom-title" && typeof entry.customTitle === "string") {
119
+ metadata.customTitle = normalizeOutput(entry.customTitle).trim();
120
+ }
121
+ else if (entry.type === "ai-title" && typeof entry.aiTitle === "string") {
122
+ metadata.aiTitle = normalizeOutput(entry.aiTitle).trim();
123
+ }
124
+ else if (entry.type === "last-prompt" && typeof entry.lastPrompt === "string") {
125
+ metadata.lastPrompt = normalizeOutput(entry.lastPrompt).trim();
126
+ }
127
+ else if (entry.type === "summary" && typeof entry.summary === "string") {
128
+ metadata.summary = normalizeOutput(entry.summary).trim();
129
+ }
130
+ else if (!metadata.firstPrompt &&
131
+ entry.type === "user" &&
132
+ isRecord(entry.message)) {
133
+ metadata.firstPrompt = extractClaudeUserMessageText(entry.message.content);
134
+ }
135
+ if (typeof entry.timestamp === "string") {
136
+ const timestampMs = Date.parse(entry.timestamp);
137
+ if (Number.isFinite(timestampMs)) {
138
+ metadata.lastTimestampMs = Math.max(metadata.lastTimestampMs, timestampMs);
139
+ }
140
+ }
141
+ }
142
+ catch {
143
+ // Head/tail segments can begin or end with a partial JSONL line.
144
+ }
145
+ }
146
+ }
147
+ return metadata;
148
+ }
149
+ function readClaudeSessionIndex(projectDir, cwd) {
150
+ const indexPath = path.join(projectDir, "sessions-index.json");
151
+ if (!fs.existsSync(indexPath)) {
152
+ return new Map();
153
+ }
154
+ try {
155
+ const parsed = JSON.parse(fs.readFileSync(indexPath, "utf8"));
156
+ if (!isRecord(parsed)) {
157
+ return new Map();
158
+ }
159
+ if (typeof parsed.originalPath === "string" && !isSameClaudePath(parsed.originalPath, cwd)) {
160
+ return new Map();
161
+ }
162
+ const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
163
+ return new Map(entries
164
+ .filter((entry) => isRecord(entry))
165
+ .filter((entry) => typeof entry.sessionId === "string")
166
+ .map((entry) => [entry.sessionId, entry]));
167
+ }
168
+ catch {
169
+ return new Map();
170
+ }
171
+ }
172
+ function findClaudeProjectSessionDirectories(cwd, env) {
173
+ const projectsDir = path.join(getClaudeConfigDirectory(env), "projects");
174
+ const direct = buildClaudeProjectSessionDirectory(cwd, env);
175
+ const results = new Set();
176
+ if (fs.existsSync(direct)) {
177
+ results.add(direct);
178
+ }
179
+ const sanitized = sanitizeClaudeProjectPath(cwd);
180
+ if (sanitized.length <= CLAUDE_MAX_SANITIZED_PROJECT_PATH || !fs.existsSync(projectsDir)) {
181
+ return [...results];
182
+ }
183
+ const prefix = sanitized.slice(0, CLAUDE_MAX_SANITIZED_PROJECT_PATH);
184
+ for (const entry of fs.readdirSync(projectsDir, { withFileTypes: true })) {
185
+ if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
186
+ results.add(path.join(projectsDir, entry.name));
187
+ }
188
+ }
189
+ return [...results];
190
+ }
191
+ function summarizeClaudeSessionFile(filePath, cwd, indexEntry) {
192
+ const sessionId = path.basename(filePath, ".jsonl");
193
+ if (!CLAUDE_SESSION_ID_RE.test(sessionId)) {
194
+ return null;
195
+ }
196
+ try {
197
+ const metadata = readClaudeSessionMetadata(filePath);
198
+ const belongsToCwd = (typeof indexEntry?.projectPath === "string" &&
199
+ isSameClaudePath(indexEntry.projectPath, cwd)) ||
200
+ (typeof metadata.cwd === "string" && isSameClaudePath(metadata.cwd, cwd));
201
+ if (!belongsToCwd || indexEntry?.isSidechain === true || metadata.isSidechain === true) {
202
+ return null;
203
+ }
204
+ const indexedModifiedMs = typeof indexEntry?.modified === "string" ? Date.parse(indexEntry.modified) : 0;
205
+ const updatedMs = metadata.lastTimestampMs ||
206
+ (Number.isFinite(indexedModifiedMs) ? indexedModifiedMs : 0) ||
207
+ fs.statSync(filePath).mtimeMs;
208
+ const title = metadata.customTitle ||
209
+ indexEntry?.customTitle ||
210
+ metadata.aiTitle ||
211
+ metadata.lastPrompt ||
212
+ indexEntry?.summary ||
213
+ metadata.summary ||
214
+ indexEntry?.firstPrompt ||
215
+ metadata.firstPrompt;
216
+ if (!title) {
217
+ return null;
218
+ }
219
+ return {
220
+ sessionId,
221
+ title: truncatePreview(title, 120),
222
+ lastUpdatedAt: new Date(updatedMs).toISOString(),
223
+ source: "claude",
224
+ };
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ }
230
+ export function listClaudeResumeSessions(cwd, limit = 10, env = process.env) {
231
+ const candidates = [];
232
+ for (const projectDir of findClaudeProjectSessionDirectories(cwd, env)) {
233
+ const index = readClaudeSessionIndex(projectDir, cwd);
234
+ for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {
235
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".jsonl")) {
236
+ continue;
237
+ }
238
+ const sessionId = path.basename(entry.name, ".jsonl");
239
+ const candidate = summarizeClaudeSessionFile(path.join(projectDir, entry.name), cwd, index.get(sessionId));
240
+ if (candidate) {
241
+ candidates.push(candidate);
242
+ }
243
+ }
244
+ }
245
+ return candidates
246
+ .sort((left, right) => Date.parse(right.lastUpdatedAt) - Date.parse(left.lastUpdatedAt))
247
+ .slice(0, Math.max(1, limit));
248
+ }
249
+ function findClaudeResumeSessionFile(cwd, sessionId, env = process.env) {
250
+ for (const projectDir of findClaudeProjectSessionDirectories(cwd, env)) {
251
+ const filePath = path.join(projectDir, `${sessionId}.jsonl`);
252
+ if (fs.existsSync(filePath) &&
253
+ summarizeClaudeSessionFile(filePath, cwd, readClaudeSessionIndex(projectDir, cwd).get(sessionId))) {
254
+ return filePath;
255
+ }
256
+ }
257
+ return null;
258
+ }
30
259
  export function normalizeClaudeProjectConfigKey(cwd) {
31
260
  return path.resolve(cwd).replace(/\\/g, "/");
32
261
  }
@@ -106,6 +335,8 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
106
335
  transcriptPollTimer = null;
107
336
  transcriptTailOffset = 0;
108
337
  polledTranscriptSessionId = null;
338
+ pendingResume = null;
339
+ localResumeSessionEndObserved = false;
109
340
  constructor(options) {
110
341
  super(options);
111
342
  const shouldRestoreInitialSession = options.sessionStartMode !== "new";
@@ -192,11 +423,104 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
192
423
  this.writeToPty("\r");
193
424
  this.armWechatWorkingNotice();
194
425
  }
195
- async listResumeSessions(_limit = 10) {
196
- throw new Error('WeChat /resume is disabled in claude mode. Use /resume directly inside "wechat-claude"; WeChat will follow the active local session.');
426
+ async listResumeSessions(limit = 10) {
427
+ return listClaudeResumeSessions(this.options.cwd, limit);
428
+ }
429
+ async resumeSession(sessionId) {
430
+ if (!this.pty) {
431
+ throw new Error("Claude is not running yet.");
432
+ }
433
+ if (this.pendingResume) {
434
+ throw new Error("Claude is already switching sessions.");
435
+ }
436
+ if (this.pendingApproval) {
437
+ throw new Error("A Claude approval request is pending. Reply with /confirm or /deny.");
438
+ }
439
+ if (this.state.status === "busy" || this.state.status === "awaiting_approval") {
440
+ throw new Error("Claude is still working. Wait for the current reply or use /stop.");
441
+ }
442
+ if (this.state.status !== "idle") {
443
+ throw new Error(`Claude cannot switch sessions while its status is ${this.state.status}.`);
444
+ }
445
+ const targetConversationId = sessionId.trim();
446
+ if (!CLAUDE_SESSION_ID_RE.test(targetConversationId)) {
447
+ throw new Error(`Invalid Claude session ID: ${targetConversationId}`);
448
+ }
449
+ if (!this.resolveClaudeResumeSessionFile(targetConversationId)) {
450
+ throw new Error(`No Claude session ${targetConversationId} was found for ${this.options.cwd}.`);
451
+ }
452
+ if (this.isClaudeSessionActiveElsewhere(targetConversationId)) {
453
+ throw new Error(`Claude session ${targetConversationId} is active in another Claude process. Stop it before resuming from WeChat.`);
454
+ }
455
+ this.setStatus("starting", `Switching Claude to session ${targetConversationId.slice(0, 12)}...`);
456
+ const completion = new Promise((resolve, reject) => {
457
+ const timer = setTimeout(() => {
458
+ if (this.pendingResume?.targetConversationId !== targetConversationId) {
459
+ return;
460
+ }
461
+ const phase = this.pendingResume.sessionEndObserved
462
+ ? " after the previous session ended"
463
+ : "";
464
+ this.pendingResume = null;
465
+ this.setStatus("idle");
466
+ reject(new Error(`Timed out waiting for Claude to resume session ${targetConversationId}${phase}.`));
467
+ }, CLAUDE_RESUME_TIMEOUT_MS);
468
+ this.pendingResume = {
469
+ targetConversationId,
470
+ resolve,
471
+ reject,
472
+ timer,
473
+ sessionEndObserved: false,
474
+ };
475
+ });
476
+ this.writeToPty(`/resume ${targetConversationId}`);
477
+ await delay(CLAUDE_REMOTE_ENTER_DELAY_MS);
478
+ this.writeToPty("\r");
479
+ await completion;
480
+ }
481
+ isClaudeSessionActiveElsewhere(sessionId) {
482
+ if (sessionId === this.resumeConversationId) {
483
+ return false;
484
+ }
485
+ try {
486
+ const env = shared.buildCliEnvironment("claude");
487
+ const target = shared.resolveSpawnTarget(this.options.command, "claude", { env });
488
+ const result = spawnSync(target.file, [...target.args, "agents", "--json", "--cwd", this.options.cwd], {
489
+ cwd: this.options.cwd,
490
+ env,
491
+ encoding: "utf8",
492
+ windowsHide: true,
493
+ timeout: 5_000,
494
+ });
495
+ if (result.status !== 0 || !result.stdout.trim()) {
496
+ return false;
497
+ }
498
+ const parsed = JSON.parse(result.stdout);
499
+ const sessions = Array.isArray(parsed)
500
+ ? parsed
501
+ : isRecord(parsed) && Array.isArray(parsed.sessions)
502
+ ? parsed.sessions
503
+ : [];
504
+ return sessions.some((session) => {
505
+ if (!isRecord(session)) {
506
+ return false;
507
+ }
508
+ const candidateId = typeof session.id === "string"
509
+ ? session.id
510
+ : typeof session.sessionId === "string"
511
+ ? session.sessionId
512
+ : typeof session.session_id === "string"
513
+ ? session.session_id
514
+ : "";
515
+ return candidateId === sessionId;
516
+ });
517
+ }
518
+ catch {
519
+ return false;
520
+ }
197
521
  }
198
- async resumeSession(_threadId) {
199
- throw new Error('WeChat /resume is disabled in claude mode. Use /resume directly inside "wechat-claude"; WeChat will follow the active local session.');
522
+ resolveClaudeResumeSessionFile(sessionId) {
523
+ return findClaudeResumeSessionFile(this.options.cwd, sessionId);
200
524
  }
201
525
  async interrupt() {
202
526
  if (!this.pty) {
@@ -228,6 +552,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
228
552
  return true;
229
553
  }
230
554
  async reset() {
555
+ this.rejectPendingResume(new Error("Claude was reset during session switching."));
231
556
  this.clearWechatWorkingNotice(true);
232
557
  this.pendingCliApprovalHints = null;
233
558
  this.runtimeSessionId = null;
@@ -295,6 +620,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
295
620
  return ok ? 1 : 0;
296
621
  }
297
622
  async dispose() {
623
+ this.rejectPendingResume(new Error("Claude is shutting down during session switching."));
298
624
  this.detachLocalTerminal();
299
625
  this.clearWechatWorkingNotice(true);
300
626
  this.pendingCliApprovalHints = null;
@@ -326,6 +652,16 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
326
652
  if (!text) {
327
653
  return;
328
654
  }
655
+ if (this.pendingResume &&
656
+ (isClaudeInvalidResumeError(text) ||
657
+ /Session .+ was not found|Found \d+ sessions matching|Failed to resume/i.test(text))) {
658
+ const failureLine = text
659
+ .split("\n")
660
+ .map((line) => line.trim())
661
+ .find((line) => /not found|matching|failed to resume/i.test(line));
662
+ this.rejectPendingResume(new Error(failureLine || "Claude could not resume the requested session."));
663
+ return;
664
+ }
329
665
  if (this.resumeConversationId &&
330
666
  !this.hasAcceptedInput &&
331
667
  !this.recoveringInvalidResume &&
@@ -385,6 +721,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
385
721
  this.detachLocalTerminal();
386
722
  this.clearWechatWorkingNotice(true);
387
723
  this.pendingCliApprovalHints = null;
724
+ this.rejectPendingResume(new Error("Claude exited before the requested session switch completed."));
388
725
  void this.stopHookServer();
389
726
  if (this.recoveringInvalidResume && !this.shuttingDown) {
390
727
  this.clearCompletionTimer();
@@ -772,6 +1109,10 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
772
1109
  this.handleClaudeSessionStart(payload);
773
1110
  this.respondToClaudeHook(params.socket, params.requestId);
774
1111
  return;
1112
+ case "SessionEnd":
1113
+ this.handleClaudeSessionEnd(payload);
1114
+ this.respondToClaudeHook(params.socket, params.requestId);
1115
+ return;
775
1116
  case "UserPromptSubmit":
776
1117
  this.handleClaudeUserPromptSubmit(payload);
777
1118
  this.respondToClaudeHook(params.socket, params.requestId);
@@ -836,6 +1177,17 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
836
1177
  ? payload.transcript_path.trim()
837
1178
  : null;
838
1179
  const nextResumeConversationId = extractClaudeResumeConversationId(nextTranscriptPath ?? undefined);
1180
+ const resumedConversationId = nextResumeConversationId ?? payload.session_id;
1181
+ const pendingResume = this.pendingResume;
1182
+ const pendingResumeMatches = payload.source === "resume" &&
1183
+ pendingResume !== null &&
1184
+ pendingResume.targetConversationId === resumedConversationId;
1185
+ const isInteractiveResume = payload.source === "resume" && Boolean(previousRuntimeSessionId);
1186
+ if (isInteractiveResume &&
1187
+ !pendingResumeMatches &&
1188
+ !this.localResumeSessionEndObserved) {
1189
+ this.failClaudeTurn("The active WeChat task was interrupted because the local Claude terminal switched sessions.");
1190
+ }
839
1191
  const compactedByTranscriptRotation = Boolean(this.transcriptPath) &&
840
1192
  Boolean(nextTranscriptPath) &&
841
1193
  this.transcriptPath !== nextTranscriptPath &&
@@ -859,16 +1211,33 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
859
1211
  this.transcriptPath = nextTranscriptPath;
860
1212
  this.state.transcriptPath = nextTranscriptPath ?? undefined;
861
1213
  this.startTranscriptThinkingWatch();
862
- if (previousRuntimeSessionId === payload.session_id) {
863
- return;
864
- }
865
1214
  const timestamp = nowIso();
866
- const isRestore = !previousRuntimeSessionId &&
1215
+ const isStartupRestore = !previousRuntimeSessionId &&
867
1216
  (payload.source === "resume" ||
868
1217
  (nextResumeConversationId !== null &&
869
1218
  nextResumeConversationId === previousResumeConversationId));
870
- const source = isRestore ? "restore" : "local";
871
- const reason = isRestore ? "startup_restore" : "local_follow";
1219
+ const source = pendingResumeMatches
1220
+ ? "wechat"
1221
+ : isStartupRestore
1222
+ ? "restore"
1223
+ : "local";
1224
+ const reason = pendingResumeMatches
1225
+ ? "wechat_resume"
1226
+ : isStartupRestore
1227
+ ? "startup_restore"
1228
+ : "local_follow";
1229
+ this.localResumeSessionEndObserved = false;
1230
+ if (pendingResume &&
1231
+ payload.source === "resume" &&
1232
+ !pendingResumeMatches) {
1233
+ this.rejectPendingResume(new Error(`Claude resumed unexpected session ${resumedConversationId}; expected ${pendingResume.targetConversationId}.`));
1234
+ }
1235
+ if (previousRuntimeSessionId === payload.session_id) {
1236
+ if (pendingResumeMatches) {
1237
+ this.resolvePendingResume();
1238
+ }
1239
+ return;
1240
+ }
872
1241
  this.state.lastSessionSwitchAt = timestamp;
873
1242
  this.state.lastSessionSwitchSource = source;
874
1243
  this.state.lastSessionSwitchReason = reason;
@@ -879,6 +1248,40 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
879
1248
  reason,
880
1249
  timestamp,
881
1250
  });
1251
+ if (pendingResumeMatches) {
1252
+ this.resolvePendingResume();
1253
+ }
1254
+ }
1255
+ handleClaudeSessionEnd(payload) {
1256
+ if (payload.reason !== "resume") {
1257
+ return;
1258
+ }
1259
+ if (this.pendingResume) {
1260
+ this.pendingResume.sessionEndObserved = true;
1261
+ return;
1262
+ }
1263
+ this.localResumeSessionEndObserved = true;
1264
+ this.failClaudeTurn("The active WeChat task was interrupted because the local Claude terminal switched sessions.");
1265
+ }
1266
+ resolvePendingResume() {
1267
+ const pending = this.pendingResume;
1268
+ if (!pending) {
1269
+ return;
1270
+ }
1271
+ this.pendingResume = null;
1272
+ clearTimeout(pending.timer);
1273
+ this.setStatus("idle");
1274
+ pending.resolve();
1275
+ }
1276
+ rejectPendingResume(error) {
1277
+ const pending = this.pendingResume;
1278
+ if (!pending) {
1279
+ return;
1280
+ }
1281
+ this.pendingResume = null;
1282
+ clearTimeout(pending.timer);
1283
+ this.setStatus("idle");
1284
+ pending.reject(error);
882
1285
  }
883
1286
  handleClaudeUserPromptSubmit(payload) {
884
1287
  const prompt = typeof payload.prompt === "string" ? normalizeOutput(payload.prompt).trim() : "";
@@ -1028,6 +1431,16 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1028
1431
  normalizeClaudeAssistantMessage(payload));
1029
1432
  }
1030
1433
  handleClaudeStop(payload) {
1434
+ if (payload.session_id &&
1435
+ this.runtimeSessionId &&
1436
+ payload.session_id !== this.runtimeSessionId) {
1437
+ return;
1438
+ }
1439
+ if (!this.hasAcceptedInput &&
1440
+ this.state.activeTurnOrigin === undefined &&
1441
+ this.currentPreview === "(idle)") {
1442
+ return;
1443
+ }
1031
1444
  this.clearWechatWorkingNotice(true);
1032
1445
  this.pendingCliApprovalHints = null;
1033
1446
  this.lastAutoApprovedPayload = null;
@@ -1052,6 +1465,11 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1052
1465
  this.currentPreview = "(idle)";
1053
1466
  }
1054
1467
  handleClaudeStopFailure(payload) {
1468
+ if (payload.session_id &&
1469
+ this.runtimeSessionId &&
1470
+ payload.session_id !== this.runtimeSessionId) {
1471
+ return;
1472
+ }
1055
1473
  this.lastAutoApprovedPayload = null;
1056
1474
  this.stopTranscriptThinkingWatch();
1057
1475
  this.failClaudeTurn(buildClaudeFailureMessage(payload));