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.
Files changed (50) hide show
  1. package/README.md +110 -140
  2. package/bin/wechat-claude.mjs +1 -1
  3. package/bin/wechat-codex.mjs +1 -1
  4. package/bin/wechat-opencode.mjs +1 -1
  5. package/bin/wechat-pi.mjs +1 -1
  6. package/dist/bridge/bridge-adapters.claude.js +448 -11
  7. package/dist/bridge/bridge-adapters.codex.js +262 -11
  8. package/dist/bridge/bridge-adapters.core.js +76 -4
  9. package/dist/bridge/bridge-adapters.js +0 -3
  10. package/dist/bridge/bridge-adapters.opencode.js +497 -49
  11. package/dist/bridge/bridge-adapters.pi.js +44 -8
  12. package/dist/bridge/bridge-adapters.shared.js +13 -100
  13. package/dist/bridge/bridge-controller.js +3 -0
  14. package/dist/bridge/bridge-final-reply.js +17 -2
  15. package/dist/bridge/bridge-process-reaper.js +14 -4
  16. package/dist/bridge/bridge-session-resume.js +151 -0
  17. package/dist/bridge/bridge-state.js +41 -6
  18. package/dist/bridge/bridge-utils.js +63 -229
  19. package/dist/bridge/claude-hooks.js +1 -0
  20. package/dist/bridge/wechat-bridge.js +93 -140
  21. package/dist/bridge/wechat-forwarding.js +58 -0
  22. package/dist/companion/codex-remote-client.js +309 -36
  23. package/dist/companion/codex-visible-client-link.js +122 -0
  24. package/dist/companion/local-companion-link.js +33 -2
  25. package/dist/companion/local-companion-start.js +44 -13
  26. package/dist/companion/local-companion.js +6 -3
  27. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  28. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  29. package/dist/daemon/daemon-link.js +25 -3
  30. package/dist/daemon/wechat-daemon.js +168 -56
  31. package/dist/i18n/index.js +0 -3
  32. package/dist/i18n/messages-en.js +2 -3
  33. package/dist/i18n/messages-zh.js +2 -3
  34. package/dist/utils/atomic-file.js +15 -0
  35. package/dist/utils/doctor.js +23 -11
  36. package/dist/wechat/channel-config.js +0 -2
  37. package/dist/wechat/setup.js +5 -11
  38. package/dist/wechat/wechat-transport.js +14 -8
  39. package/package.json +2 -13
  40. package/bin/wechat-bridge-claude.mjs +0 -5
  41. package/bin/wechat-bridge-codex.mjs +0 -5
  42. package/bin/wechat-bridge-opencode.mjs +0 -5
  43. package/bin/wechat-bridge-pi.mjs +0 -5
  44. package/bin/wechat-bridge-shell.mjs +0 -5
  45. package/bin/wechat-bridge.mjs +0 -5
  46. package/bin/wechat-claude-start.mjs +0 -5
  47. package/bin/wechat-codex-start.mjs +0 -5
  48. package/bin/wechat-opencode-start.mjs +0 -5
  49. package/bin/wechat-pi-start.mjs +0 -5
  50. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -2,6 +2,7 @@ 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";
@@ -17,6 +18,10 @@ const CLAUDE_BRACKETED_PASTE_START = "\u001b[200~";
17
18
  const CLAUDE_BRACKETED_PASTE_END = "\u001b[201~";
18
19
  const CLAUDE_REMOTE_ENTER_DELAY_MS = 40;
19
20
  const CLAUDE_STARTUP_OUTPUT_BUFFER_LIMIT = 4_000;
21
+ const CLAUDE_SESSION_METADATA_READ_BYTES = 64 * 1024;
22
+ const CLAUDE_MAX_SANITIZED_PROJECT_PATH = 200;
23
+ const CLAUDE_RESUME_TIMEOUT_MS = 30_000;
24
+ 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
25
  function isClaudeWorkspaceTrustPrompt(text) {
21
26
  const compact = text.replace(/\s+/g, " ").trim();
22
27
  return (/Accessing workspace:/i.test(compact) &&
@@ -27,6 +32,229 @@ function isClaudeWorkspaceTrustPrompt(text) {
27
32
  function isRecord(value) {
28
33
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
29
34
  }
35
+ function expandHomePath(value) {
36
+ if (value === "~") {
37
+ return os.homedir();
38
+ }
39
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
40
+ return path.join(os.homedir(), value.slice(2));
41
+ }
42
+ return value;
43
+ }
44
+ function normalizeComparablePath(value) {
45
+ const resolved = path.resolve(value);
46
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
47
+ }
48
+ function isSameClaudePath(left, right) {
49
+ return normalizeComparablePath(left) === normalizeComparablePath(right);
50
+ }
51
+ function getClaudeConfigDirectory(env) {
52
+ const custom = env.CLAUDE_CONFIG_DIR;
53
+ return custom
54
+ ? path.resolve(expandHomePath(custom))
55
+ : path.join(env.USERPROFILE || env.HOME || os.homedir(), ".claude");
56
+ }
57
+ function sanitizeClaudeProjectPath(cwd) {
58
+ return path.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-");
59
+ }
60
+ export function buildClaudeProjectSessionDirectory(cwd, env = process.env) {
61
+ return path.join(getClaudeConfigDirectory(env), "projects", sanitizeClaudeProjectPath(cwd));
62
+ }
63
+ function readClaudeSessionSegments(filePath) {
64
+ const stat = fs.statSync(filePath);
65
+ const fd = fs.openSync(filePath, "r");
66
+ try {
67
+ const headLength = Math.min(stat.size, CLAUDE_SESSION_METADATA_READ_BYTES);
68
+ const headBuffer = Buffer.alloc(headLength);
69
+ fs.readSync(fd, headBuffer, 0, headLength, 0);
70
+ const tailStart = Math.max(0, stat.size - CLAUDE_SESSION_METADATA_READ_BYTES);
71
+ const tailLength = stat.size - tailStart;
72
+ const tailBuffer = Buffer.alloc(tailLength);
73
+ fs.readSync(fd, tailBuffer, 0, tailLength, tailStart);
74
+ return [headBuffer.toString("utf8"), tailBuffer.toString("utf8")];
75
+ }
76
+ finally {
77
+ fs.closeSync(fd);
78
+ }
79
+ }
80
+ function extractClaudeUserMessageText(value) {
81
+ if (typeof value === "string") {
82
+ return normalizeOutput(value).trim();
83
+ }
84
+ if (!Array.isArray(value)) {
85
+ return "";
86
+ }
87
+ return normalizeOutput(value
88
+ .filter((item) => isRecord(item))
89
+ .filter((item) => item.type === "text" && typeof item.text === "string")
90
+ .map((item) => item.text)
91
+ .join("\n")).trim();
92
+ }
93
+ function readClaudeSessionMetadata(filePath) {
94
+ const metadata = { lastTimestampMs: 0 };
95
+ const seenLines = new Set();
96
+ for (const segment of readClaudeSessionSegments(filePath)) {
97
+ for (const rawLine of segment.split(/\r?\n/)) {
98
+ const line = rawLine.trim();
99
+ if (!line || seenLines.has(line)) {
100
+ continue;
101
+ }
102
+ seenLines.add(line);
103
+ try {
104
+ const entry = JSON.parse(line);
105
+ if (!isRecord(entry)) {
106
+ continue;
107
+ }
108
+ if (typeof entry.cwd === "string") {
109
+ metadata.cwd = entry.cwd;
110
+ }
111
+ if (entry.isSidechain === false) {
112
+ metadata.isSidechain = false;
113
+ }
114
+ else if (entry.isSidechain === true && metadata.isSidechain === undefined) {
115
+ metadata.isSidechain = true;
116
+ }
117
+ if (entry.type === "custom-title" && typeof entry.customTitle === "string") {
118
+ metadata.customTitle = normalizeOutput(entry.customTitle).trim();
119
+ }
120
+ else if (entry.type === "ai-title" && typeof entry.aiTitle === "string") {
121
+ metadata.aiTitle = normalizeOutput(entry.aiTitle).trim();
122
+ }
123
+ else if (entry.type === "last-prompt" && typeof entry.lastPrompt === "string") {
124
+ metadata.lastPrompt = normalizeOutput(entry.lastPrompt).trim();
125
+ }
126
+ else if (entry.type === "summary" && typeof entry.summary === "string") {
127
+ metadata.summary = normalizeOutput(entry.summary).trim();
128
+ }
129
+ else if (!metadata.firstPrompt &&
130
+ entry.type === "user" &&
131
+ isRecord(entry.message)) {
132
+ metadata.firstPrompt = extractClaudeUserMessageText(entry.message.content);
133
+ }
134
+ if (typeof entry.timestamp === "string") {
135
+ const timestampMs = Date.parse(entry.timestamp);
136
+ if (Number.isFinite(timestampMs)) {
137
+ metadata.lastTimestampMs = Math.max(metadata.lastTimestampMs, timestampMs);
138
+ }
139
+ }
140
+ }
141
+ catch {
142
+ // Head/tail segments can begin or end with a partial JSONL line.
143
+ }
144
+ }
145
+ }
146
+ return metadata;
147
+ }
148
+ function readClaudeSessionIndex(projectDir, cwd) {
149
+ const indexPath = path.join(projectDir, "sessions-index.json");
150
+ if (!fs.existsSync(indexPath)) {
151
+ return new Map();
152
+ }
153
+ try {
154
+ const parsed = JSON.parse(fs.readFileSync(indexPath, "utf8"));
155
+ if (!isRecord(parsed)) {
156
+ return new Map();
157
+ }
158
+ if (typeof parsed.originalPath === "string" && !isSameClaudePath(parsed.originalPath, cwd)) {
159
+ return new Map();
160
+ }
161
+ const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
162
+ return new Map(entries
163
+ .filter((entry) => isRecord(entry))
164
+ .filter((entry) => typeof entry.sessionId === "string")
165
+ .map((entry) => [entry.sessionId, entry]));
166
+ }
167
+ catch {
168
+ return new Map();
169
+ }
170
+ }
171
+ function findClaudeProjectSessionDirectories(cwd, env) {
172
+ const projectsDir = path.join(getClaudeConfigDirectory(env), "projects");
173
+ const direct = buildClaudeProjectSessionDirectory(cwd, env);
174
+ const results = new Set();
175
+ if (fs.existsSync(direct)) {
176
+ results.add(direct);
177
+ }
178
+ const sanitized = sanitizeClaudeProjectPath(cwd);
179
+ if (sanitized.length <= CLAUDE_MAX_SANITIZED_PROJECT_PATH || !fs.existsSync(projectsDir)) {
180
+ return [...results];
181
+ }
182
+ const prefix = sanitized.slice(0, CLAUDE_MAX_SANITIZED_PROJECT_PATH);
183
+ for (const entry of fs.readdirSync(projectsDir, { withFileTypes: true })) {
184
+ if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
185
+ results.add(path.join(projectsDir, entry.name));
186
+ }
187
+ }
188
+ return [...results];
189
+ }
190
+ function summarizeClaudeSessionFile(filePath, cwd, indexEntry) {
191
+ const sessionId = path.basename(filePath, ".jsonl");
192
+ if (!CLAUDE_SESSION_ID_RE.test(sessionId)) {
193
+ return null;
194
+ }
195
+ try {
196
+ const metadata = readClaudeSessionMetadata(filePath);
197
+ const belongsToCwd = (typeof indexEntry?.projectPath === "string" &&
198
+ isSameClaudePath(indexEntry.projectPath, cwd)) ||
199
+ (typeof metadata.cwd === "string" && isSameClaudePath(metadata.cwd, cwd));
200
+ if (!belongsToCwd || indexEntry?.isSidechain === true || metadata.isSidechain === true) {
201
+ return null;
202
+ }
203
+ const indexedModifiedMs = typeof indexEntry?.modified === "string" ? Date.parse(indexEntry.modified) : 0;
204
+ const updatedMs = metadata.lastTimestampMs ||
205
+ (Number.isFinite(indexedModifiedMs) ? indexedModifiedMs : 0) ||
206
+ fs.statSync(filePath).mtimeMs;
207
+ const title = metadata.customTitle ||
208
+ indexEntry?.customTitle ||
209
+ metadata.aiTitle ||
210
+ metadata.lastPrompt ||
211
+ indexEntry?.summary ||
212
+ metadata.summary ||
213
+ indexEntry?.firstPrompt ||
214
+ metadata.firstPrompt;
215
+ if (!title) {
216
+ return null;
217
+ }
218
+ return {
219
+ sessionId,
220
+ title: truncatePreview(title, 120),
221
+ lastUpdatedAt: new Date(updatedMs).toISOString(),
222
+ source: "claude",
223
+ };
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }
229
+ export function listClaudeResumeSessions(cwd, limit = 10, env = process.env) {
230
+ const candidates = [];
231
+ for (const projectDir of findClaudeProjectSessionDirectories(cwd, env)) {
232
+ const index = readClaudeSessionIndex(projectDir, cwd);
233
+ for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) {
234
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".jsonl")) {
235
+ continue;
236
+ }
237
+ const sessionId = path.basename(entry.name, ".jsonl");
238
+ const candidate = summarizeClaudeSessionFile(path.join(projectDir, entry.name), cwd, index.get(sessionId));
239
+ if (candidate) {
240
+ candidates.push(candidate);
241
+ }
242
+ }
243
+ }
244
+ return candidates
245
+ .sort((left, right) => Date.parse(right.lastUpdatedAt) - Date.parse(left.lastUpdatedAt))
246
+ .slice(0, Math.max(1, limit));
247
+ }
248
+ function findClaudeResumeSessionFile(cwd, sessionId, env = process.env) {
249
+ for (const projectDir of findClaudeProjectSessionDirectories(cwd, env)) {
250
+ const filePath = path.join(projectDir, `${sessionId}.jsonl`);
251
+ if (fs.existsSync(filePath) &&
252
+ summarizeClaudeSessionFile(filePath, cwd, readClaudeSessionIndex(projectDir, cwd).get(sessionId))) {
253
+ return filePath;
254
+ }
255
+ }
256
+ return null;
257
+ }
30
258
  export function normalizeClaudeProjectConfigKey(cwd) {
31
259
  return path.resolve(cwd).replace(/\\/g, "/");
32
260
  }
@@ -106,6 +334,8 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
106
334
  transcriptPollTimer = null;
107
335
  transcriptTailOffset = 0;
108
336
  polledTranscriptSessionId = null;
337
+ pendingResume = null;
338
+ localResumeSessionEndObserved = false;
109
339
  constructor(options) {
110
340
  super(options);
111
341
  const shouldRestoreInitialSession = options.sessionStartMode !== "new";
@@ -192,11 +422,104 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
192
422
  this.writeToPty("\r");
193
423
  this.armWechatWorkingNotice();
194
424
  }
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.');
425
+ async listResumeSessions(limit = 10) {
426
+ return listClaudeResumeSessions(this.options.cwd, limit);
427
+ }
428
+ async resumeSession(sessionId) {
429
+ if (!this.pty) {
430
+ throw new Error("Claude is not running yet.");
431
+ }
432
+ if (this.pendingResume) {
433
+ throw new Error("Claude is already switching sessions.");
434
+ }
435
+ if (this.pendingApproval) {
436
+ throw new Error("A Claude approval request is pending. Reply with /confirm or /deny.");
437
+ }
438
+ if (this.state.status === "busy" || this.state.status === "awaiting_approval") {
439
+ throw new Error("Claude is still working. Wait for the current reply or use /stop.");
440
+ }
441
+ if (this.state.status !== "idle") {
442
+ throw new Error(`Claude cannot switch sessions while its status is ${this.state.status}.`);
443
+ }
444
+ const targetConversationId = sessionId.trim();
445
+ if (!CLAUDE_SESSION_ID_RE.test(targetConversationId)) {
446
+ throw new Error(`Invalid Claude session ID: ${targetConversationId}`);
447
+ }
448
+ if (!this.resolveClaudeResumeSessionFile(targetConversationId)) {
449
+ throw new Error(`No Claude session ${targetConversationId} was found for ${this.options.cwd}.`);
450
+ }
451
+ if (this.isClaudeSessionActiveElsewhere(targetConversationId)) {
452
+ throw new Error(`Claude session ${targetConversationId} is active in another Claude process. Stop it before resuming from WeChat.`);
453
+ }
454
+ this.setStatus("starting", `Switching Claude to session ${targetConversationId.slice(0, 12)}...`);
455
+ const completion = new Promise((resolve, reject) => {
456
+ const timer = setTimeout(() => {
457
+ if (this.pendingResume?.targetConversationId !== targetConversationId) {
458
+ return;
459
+ }
460
+ const phase = this.pendingResume.sessionEndObserved
461
+ ? " after the previous session ended"
462
+ : "";
463
+ this.pendingResume = null;
464
+ this.setStatus("idle");
465
+ reject(new Error(`Timed out waiting for Claude to resume session ${targetConversationId}${phase}.`));
466
+ }, CLAUDE_RESUME_TIMEOUT_MS);
467
+ this.pendingResume = {
468
+ targetConversationId,
469
+ resolve,
470
+ reject,
471
+ timer,
472
+ sessionEndObserved: false,
473
+ };
474
+ });
475
+ this.writeToPty(`/resume ${targetConversationId}`);
476
+ await delay(CLAUDE_REMOTE_ENTER_DELAY_MS);
477
+ this.writeToPty("\r");
478
+ await completion;
479
+ }
480
+ isClaudeSessionActiveElsewhere(sessionId) {
481
+ if (sessionId === this.resumeConversationId) {
482
+ return false;
483
+ }
484
+ try {
485
+ const env = shared.buildCliEnvironment("claude");
486
+ const target = shared.resolveSpawnTarget(this.options.command, "claude", { env });
487
+ const result = spawnSync(target.file, [...target.args, "agents", "--json", "--cwd", this.options.cwd], {
488
+ cwd: this.options.cwd,
489
+ env,
490
+ encoding: "utf8",
491
+ windowsHide: true,
492
+ timeout: 5_000,
493
+ });
494
+ if (result.status !== 0 || !result.stdout.trim()) {
495
+ return false;
496
+ }
497
+ const parsed = JSON.parse(result.stdout);
498
+ const sessions = Array.isArray(parsed)
499
+ ? parsed
500
+ : isRecord(parsed) && Array.isArray(parsed.sessions)
501
+ ? parsed.sessions
502
+ : [];
503
+ return sessions.some((session) => {
504
+ if (!isRecord(session)) {
505
+ return false;
506
+ }
507
+ const candidateId = typeof session.id === "string"
508
+ ? session.id
509
+ : typeof session.sessionId === "string"
510
+ ? session.sessionId
511
+ : typeof session.session_id === "string"
512
+ ? session.session_id
513
+ : "";
514
+ return candidateId === sessionId;
515
+ });
516
+ }
517
+ catch {
518
+ return false;
519
+ }
197
520
  }
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.');
521
+ resolveClaudeResumeSessionFile(sessionId) {
522
+ return findClaudeResumeSessionFile(this.options.cwd, sessionId);
200
523
  }
201
524
  async interrupt() {
202
525
  if (!this.pty) {
@@ -208,11 +531,27 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
208
531
  this.clearWechatWorkingNotice(true);
209
532
  this.pendingCliApprovalHints = null;
210
533
  this.flushPendingClaudeHookApprovals();
534
+ if (this.pendingApproval) {
535
+ // The flush above answered the pending hook approval with an empty
536
+ // response, so the mirrored request is dead. Drop it — otherwise a
537
+ // later /confirm targets a deleted requestId and every new message is
538
+ // rejected by a stale "approval pending" state. Mirrors what the
539
+ // socket-close path does via handleClosedClaudeHookApproval.
540
+ this.pendingApproval = null;
541
+ this.state.pendingApproval = null;
542
+ this.state.pendingApprovalOrigin = undefined;
543
+ }
544
+ if (this.state.status === "awaiting_approval") {
545
+ // Return to busy so the interrupt settle path (guarded on busy) can
546
+ // complete the turn normally.
547
+ this.setStatus("busy");
548
+ }
211
549
  this.writeToPty("\u0003");
212
550
  this.scheduleTaskComplete(shared.INTERRUPT_SETTLE_DELAY_MS);
213
551
  return true;
214
552
  }
215
553
  async reset() {
554
+ this.rejectPendingResume(new Error("Claude was reset during session switching."));
216
555
  this.clearWechatWorkingNotice(true);
217
556
  this.pendingCliApprovalHints = null;
218
557
  this.runtimeSessionId = null;
@@ -280,6 +619,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
280
619
  return ok ? 1 : 0;
281
620
  }
282
621
  async dispose() {
622
+ this.rejectPendingResume(new Error("Claude is shutting down during session switching."));
283
623
  this.detachLocalTerminal();
284
624
  this.clearWechatWorkingNotice(true);
285
625
  this.pendingCliApprovalHints = null;
@@ -311,6 +651,16 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
311
651
  if (!text) {
312
652
  return;
313
653
  }
654
+ if (this.pendingResume &&
655
+ (isClaudeInvalidResumeError(text) ||
656
+ /Session .+ was not found|Found \d+ sessions matching|Failed to resume/i.test(text))) {
657
+ const failureLine = text
658
+ .split("\n")
659
+ .map((line) => line.trim())
660
+ .find((line) => /not found|matching|failed to resume/i.test(line));
661
+ this.rejectPendingResume(new Error(failureLine || "Claude could not resume the requested session."));
662
+ return;
663
+ }
314
664
  if (this.resumeConversationId &&
315
665
  !this.hasAcceptedInput &&
316
666
  !this.recoveringInvalidResume &&
@@ -370,6 +720,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
370
720
  this.detachLocalTerminal();
371
721
  this.clearWechatWorkingNotice(true);
372
722
  this.pendingCliApprovalHints = null;
723
+ this.rejectPendingResume(new Error("Claude exited before the requested session switch completed."));
373
724
  void this.stopHookServer();
374
725
  if (this.recoveringInvalidResume && !this.shuttingDown) {
375
726
  this.clearCompletionTimer();
@@ -480,7 +831,12 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
480
831
  throw new Error("Claude hook server is not ready.");
481
832
  }
482
833
  const { workspaceDir } = ensureWorkspaceChannelDir(this.options.cwd);
483
- const runtimeDir = path.join(workspaceDir, "claude-runtime");
834
+ // Scope the runtime directory to this adapter instance: two bridges in the
835
+ // same cwd would otherwise overwrite each other's hook script, settings,
836
+ // and error log, routing approvals and final replies to the wrong chat.
837
+ // The hook token is unique per hook server (per instance), and the same
838
+ // token regenerates the same directory across hook restarts.
839
+ const runtimeDir = path.join(workspaceDir, `claude-runtime-${this.hookToken.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12)}`);
484
840
  fs.mkdirSync(runtimeDir, { recursive: true });
485
841
  const hookScriptPath = path.join(runtimeDir, process.platform === "win32" ? "hook.cmd" : "hook.sh");
486
842
  const settingsFilePath = path.join(runtimeDir, "settings.json");
@@ -752,6 +1108,10 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
752
1108
  this.handleClaudeSessionStart(payload);
753
1109
  this.respondToClaudeHook(params.socket, params.requestId);
754
1110
  return;
1111
+ case "SessionEnd":
1112
+ this.handleClaudeSessionEnd(payload);
1113
+ this.respondToClaudeHook(params.socket, params.requestId);
1114
+ return;
755
1115
  case "UserPromptSubmit":
756
1116
  this.handleClaudeUserPromptSubmit(payload);
757
1117
  this.respondToClaudeHook(params.socket, params.requestId);
@@ -816,6 +1176,17 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
816
1176
  ? payload.transcript_path.trim()
817
1177
  : null;
818
1178
  const nextResumeConversationId = extractClaudeResumeConversationId(nextTranscriptPath ?? undefined);
1179
+ const resumedConversationId = nextResumeConversationId ?? payload.session_id;
1180
+ const pendingResume = this.pendingResume;
1181
+ const pendingResumeMatches = payload.source === "resume" &&
1182
+ pendingResume !== null &&
1183
+ pendingResume.targetConversationId === resumedConversationId;
1184
+ const isInteractiveResume = payload.source === "resume" && Boolean(previousRuntimeSessionId);
1185
+ if (isInteractiveResume &&
1186
+ !pendingResumeMatches &&
1187
+ !this.localResumeSessionEndObserved) {
1188
+ this.failClaudeTurn("The active WeChat task was interrupted because the local Claude terminal switched sessions.");
1189
+ }
819
1190
  const compactedByTranscriptRotation = Boolean(this.transcriptPath) &&
820
1191
  Boolean(nextTranscriptPath) &&
821
1192
  this.transcriptPath !== nextTranscriptPath &&
@@ -839,16 +1210,33 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
839
1210
  this.transcriptPath = nextTranscriptPath;
840
1211
  this.state.transcriptPath = nextTranscriptPath ?? undefined;
841
1212
  this.startTranscriptThinkingWatch();
842
- if (previousRuntimeSessionId === payload.session_id) {
843
- return;
844
- }
845
1213
  const timestamp = nowIso();
846
- const isRestore = !previousRuntimeSessionId &&
1214
+ const isStartupRestore = !previousRuntimeSessionId &&
847
1215
  (payload.source === "resume" ||
848
1216
  (nextResumeConversationId !== null &&
849
1217
  nextResumeConversationId === previousResumeConversationId));
850
- const source = isRestore ? "restore" : "local";
851
- const reason = isRestore ? "startup_restore" : "local_follow";
1218
+ const source = pendingResumeMatches
1219
+ ? "wechat"
1220
+ : isStartupRestore
1221
+ ? "restore"
1222
+ : "local";
1223
+ const reason = pendingResumeMatches
1224
+ ? "wechat_resume"
1225
+ : isStartupRestore
1226
+ ? "startup_restore"
1227
+ : "local_follow";
1228
+ this.localResumeSessionEndObserved = false;
1229
+ if (pendingResume &&
1230
+ payload.source === "resume" &&
1231
+ !pendingResumeMatches) {
1232
+ this.rejectPendingResume(new Error(`Claude resumed unexpected session ${resumedConversationId}; expected ${pendingResume.targetConversationId}.`));
1233
+ }
1234
+ if (previousRuntimeSessionId === payload.session_id) {
1235
+ if (pendingResumeMatches) {
1236
+ this.resolvePendingResume();
1237
+ }
1238
+ return;
1239
+ }
852
1240
  this.state.lastSessionSwitchAt = timestamp;
853
1241
  this.state.lastSessionSwitchSource = source;
854
1242
  this.state.lastSessionSwitchReason = reason;
@@ -859,6 +1247,40 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
859
1247
  reason,
860
1248
  timestamp,
861
1249
  });
1250
+ if (pendingResumeMatches) {
1251
+ this.resolvePendingResume();
1252
+ }
1253
+ }
1254
+ handleClaudeSessionEnd(payload) {
1255
+ if (payload.reason !== "resume") {
1256
+ return;
1257
+ }
1258
+ if (this.pendingResume) {
1259
+ this.pendingResume.sessionEndObserved = true;
1260
+ return;
1261
+ }
1262
+ this.localResumeSessionEndObserved = true;
1263
+ this.failClaudeTurn("The active WeChat task was interrupted because the local Claude terminal switched sessions.");
1264
+ }
1265
+ resolvePendingResume() {
1266
+ const pending = this.pendingResume;
1267
+ if (!pending) {
1268
+ return;
1269
+ }
1270
+ this.pendingResume = null;
1271
+ clearTimeout(pending.timer);
1272
+ this.setStatus("idle");
1273
+ pending.resolve();
1274
+ }
1275
+ rejectPendingResume(error) {
1276
+ const pending = this.pendingResume;
1277
+ if (!pending) {
1278
+ return;
1279
+ }
1280
+ this.pendingResume = null;
1281
+ clearTimeout(pending.timer);
1282
+ this.setStatus("idle");
1283
+ pending.reject(error);
862
1284
  }
863
1285
  handleClaudeUserPromptSubmit(payload) {
864
1286
  const prompt = typeof payload.prompt === "string" ? normalizeOutput(payload.prompt).trim() : "";
@@ -1008,6 +1430,16 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1008
1430
  normalizeClaudeAssistantMessage(payload));
1009
1431
  }
1010
1432
  handleClaudeStop(payload) {
1433
+ if (payload.session_id &&
1434
+ this.runtimeSessionId &&
1435
+ payload.session_id !== this.runtimeSessionId) {
1436
+ return;
1437
+ }
1438
+ if (!this.hasAcceptedInput &&
1439
+ this.state.activeTurnOrigin === undefined &&
1440
+ this.currentPreview === "(idle)") {
1441
+ return;
1442
+ }
1011
1443
  this.clearWechatWorkingNotice(true);
1012
1444
  this.pendingCliApprovalHints = null;
1013
1445
  this.lastAutoApprovedPayload = null;
@@ -1032,6 +1464,11 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1032
1464
  this.currentPreview = "(idle)";
1033
1465
  }
1034
1466
  handleClaudeStopFailure(payload) {
1467
+ if (payload.session_id &&
1468
+ this.runtimeSessionId &&
1469
+ payload.session_id !== this.runtimeSessionId) {
1470
+ return;
1471
+ }
1035
1472
  this.lastAutoApprovedPayload = null;
1036
1473
  this.stopTranscriptThinkingWatch();
1037
1474
  this.failClaudeTurn(buildClaudeFailureMessage(payload));