@unlinearity/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 CHANGED
@@ -157,9 +157,7 @@ cd D:\work\your-project
157
157
  - 如果检测到可见端仍在运行但 worker 状态异常(如 `stopped` / `error`),会自动重启 bridge 再重新打开可见端;
158
158
  - 在其他目录执行会显式切换活动工作区。
159
159
 
160
- `wechat-codex-start`、`wechat-claude-start`、`wechat-opencode-start`、`wechat-pi-start` 暂时保留为过渡别名,功能与直接命令一致,但会输出弃用提示,并将在下一版本移除。
161
-
162
- ### 6. 常驻 daemon 模式(支持多 CLI 切换)
160
+ ### 6. 常驻 daemon 模式(支持多 CLI 切换)
163
161
 
164
162
  如果你希望微信连接长期保持在线,并在 Codex / Claude Code / OpenCode / Pi 之间来回切换,可以在项目目录启动统一 daemon:
165
163
 
@@ -253,7 +251,7 @@ OpenCode 模式下,微信侧支持 `/new` 或 `/new-session` 创建新 session
253
251
  | OpenCode | `wechat-opencode` |
254
252
  | Pi | `wechat-pi` |
255
253
 
256
- `wechat-*-start` 暂时作为弃用别名保留一个版本。公开的 `wechat-bridge*` 命令和 Shell adapter 已移除;内部 bridge runtime 仍由直接命令和 daemon 自动管理。
254
+ 1.1.5 已移除完成弃用周期的 `wechat-*-start` 别名;请直接使用 `wechat-codex`、`wechat-claude`、`wechat-opencode` 或 `wechat-pi`。公开的 `wechat-bridge*` 命令和 Shell adapter 也已移除;内部 bridge runtime 仍由直接命令和 daemon 自动管理。
257
255
 
258
256
  ### Daemon CLI 参数
259
257
 
@@ -311,15 +309,24 @@ wechat-claude --model sonnet --dangerously-skip-permissions
311
309
  | `/codex [prompt]` / `/claude [prompt]` / `/opencode [prompt]` / `/pi [prompt]` | daemon 模式下切换活动 CLI;已有 CLI 会复用,没有则自动打开;可选 prompt 会在切换成功后立即转发 |
312
310
  | `/status` | 查看 bridge 当前状态 |
313
311
  | `/stop` | 中断当前任务 |
314
- | `/reset` | 重建当前本地会话 |
312
+ | `/reset` | 重建当前本地会话 |
315
313
  | `/new` 或 `/new-session` | OpenCode 或 Pi 模式下新建 session |
316
- | `/confirm` / `/deny` | 处理 CLI 权限请求;需要一次性 code 的请求会在消息中提示具体确认格式 |
314
+ | `/resume` / `/resume <编号或 ID 前缀>` | Codex、Claude Code、OpenCode 或 Pi 模式下列出并恢复当前工作目录的最近 thread/session;编号在列表显示后 5 分钟内有效 |
315
+ | `/confirm` / `/deny` | 处理 CLI 权限请求;需要一次性 code 的请求会在消息中提示具体确认格式 |
317
316
  | `/daemon-stop` | daemon 模式下停止常驻进程 |
318
317
  | `/bindings` | 查看当前所有表情绑定 |
319
318
  | `/bind [表情] /命令` | 绑定表情到指定命令 |
320
319
  | `/unbind [表情]` | 解除指定表情的绑定 |
321
320
 
322
- 说明:微信侧 `/resume` 目前暂时保持禁用;需要切换 Codex / Claude / OpenCode / Pi 会话时,优先在本地 companion 中使用 `/new` 或对应 CLI 的会话命令,微信会跟随本地活动会话。
321
+ 说明:微信侧 `/resume` 当前支持 Codex、Claude Code、OpenCode 和 Pi,并且只列出 daemon/bridge 启动目录内的最近 root thread/session。实际恢复会等待当前任务进入 idle;有任务、审批或待回答问题时请先处理,或使用 `/stop`。仍处于 active 状态的 Codex thread,以及仍在运行的 Claude background session,不允许从微信接管。
322
+
323
+ OpenCode 和 Pi 的本地可见 TUI 会反向同步 session:在电脑端通过 picker、`/resume` 或新建会话切换后,微信自动跟随新的 session,并使之前显示的 `/resume` 编号列表失效。如果本地切换发生在微信任务仍在执行时,bridge 会中断旧任务、报告中断原因,再确认已经跟随新的本地 session,避免回复继续落入不可见的旧会话。
324
+
325
+ Codex 通过 bridge 持有的持久 app-server 恢复 thread:`/resume` 使用 `thread/list` 获取同 cwd 的 CLI/VS Code 根 thread,选择后依次执行 `thread/read`、`thread/resume` 预检,再通过带随机 token 的 localhost supervisor 只替换可见 TUI 子进程。只有目标 `codex resume <thread-id> --remote ...` 客户端稳定启动后才提交 shared thread;失败时保持或恢复原可见 thread。电脑端 Codex picker 或 `/resume` 仍由 app-server 事件反向同步微信。
326
+
327
+ Claude Code 使用官方 Hook 完成同样的双向确认:微信恢复会向当前 PTY提交精确 `/resume <UUID>`,并等待 `SessionEnd(reason=resume)` 与目标 `SessionStart(source=resume)`;电脑端 picker 或 `/resume` 产生相同 Hook 序列,bridge 会跟随新的 transcript。Claude 的本地输入和最终回答继续同步微信。
328
+
329
+ Pi 的电脑端输入和最终回答都会同步到微信:微信先显示本地输入提示,再在 Pi `agent_settled` 后收到该 turn 的最终结果;微信发起的 Pi turn 仍只发送一次最终回复。
323
330
 
324
331
  ### 表情绑定
325
332
 
@@ -57,15 +57,3 @@ export function runJsEntry(relativeEntryPath, extraArgs = []) {
57
57
  process.exit(code ?? 0);
58
58
  });
59
59
  }
60
-
61
- export function runDeprecatedJsEntry(
62
- commandName,
63
- replacementCommand,
64
- relativeEntryPath,
65
- extraArgs = [],
66
- ) {
67
- process.stderr.write(
68
- `[cli-wechat-bridge] ${commandName} is deprecated; use ${replacementCommand}. This alias will be removed in the next release.\n`,
69
- );
70
- runJsEntry(relativeEntryPath, extraArgs);
71
- }
@@ -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) {
@@ -228,6 +551,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
228
551
  return true;
229
552
  }
230
553
  async reset() {
554
+ this.rejectPendingResume(new Error("Claude was reset during session switching."));
231
555
  this.clearWechatWorkingNotice(true);
232
556
  this.pendingCliApprovalHints = null;
233
557
  this.runtimeSessionId = null;
@@ -295,6 +619,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
295
619
  return ok ? 1 : 0;
296
620
  }
297
621
  async dispose() {
622
+ this.rejectPendingResume(new Error("Claude is shutting down during session switching."));
298
623
  this.detachLocalTerminal();
299
624
  this.clearWechatWorkingNotice(true);
300
625
  this.pendingCliApprovalHints = null;
@@ -326,6 +651,16 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
326
651
  if (!text) {
327
652
  return;
328
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
+ }
329
664
  if (this.resumeConversationId &&
330
665
  !this.hasAcceptedInput &&
331
666
  !this.recoveringInvalidResume &&
@@ -385,6 +720,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
385
720
  this.detachLocalTerminal();
386
721
  this.clearWechatWorkingNotice(true);
387
722
  this.pendingCliApprovalHints = null;
723
+ this.rejectPendingResume(new Error("Claude exited before the requested session switch completed."));
388
724
  void this.stopHookServer();
389
725
  if (this.recoveringInvalidResume && !this.shuttingDown) {
390
726
  this.clearCompletionTimer();
@@ -772,6 +1108,10 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
772
1108
  this.handleClaudeSessionStart(payload);
773
1109
  this.respondToClaudeHook(params.socket, params.requestId);
774
1110
  return;
1111
+ case "SessionEnd":
1112
+ this.handleClaudeSessionEnd(payload);
1113
+ this.respondToClaudeHook(params.socket, params.requestId);
1114
+ return;
775
1115
  case "UserPromptSubmit":
776
1116
  this.handleClaudeUserPromptSubmit(payload);
777
1117
  this.respondToClaudeHook(params.socket, params.requestId);
@@ -836,6 +1176,17 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
836
1176
  ? payload.transcript_path.trim()
837
1177
  : null;
838
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
+ }
839
1190
  const compactedByTranscriptRotation = Boolean(this.transcriptPath) &&
840
1191
  Boolean(nextTranscriptPath) &&
841
1192
  this.transcriptPath !== nextTranscriptPath &&
@@ -859,16 +1210,33 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
859
1210
  this.transcriptPath = nextTranscriptPath;
860
1211
  this.state.transcriptPath = nextTranscriptPath ?? undefined;
861
1212
  this.startTranscriptThinkingWatch();
862
- if (previousRuntimeSessionId === payload.session_id) {
863
- return;
864
- }
865
1213
  const timestamp = nowIso();
866
- const isRestore = !previousRuntimeSessionId &&
1214
+ const isStartupRestore = !previousRuntimeSessionId &&
867
1215
  (payload.source === "resume" ||
868
1216
  (nextResumeConversationId !== null &&
869
1217
  nextResumeConversationId === previousResumeConversationId));
870
- const source = isRestore ? "restore" : "local";
871
- 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
+ }
872
1240
  this.state.lastSessionSwitchAt = timestamp;
873
1241
  this.state.lastSessionSwitchSource = source;
874
1242
  this.state.lastSessionSwitchReason = reason;
@@ -879,6 +1247,40 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
879
1247
  reason,
880
1248
  timestamp,
881
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);
882
1284
  }
883
1285
  handleClaudeUserPromptSubmit(payload) {
884
1286
  const prompt = typeof payload.prompt === "string" ? normalizeOutput(payload.prompt).trim() : "";
@@ -1028,6 +1430,16 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1028
1430
  normalizeClaudeAssistantMessage(payload));
1029
1431
  }
1030
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
+ }
1031
1443
  this.clearWechatWorkingNotice(true);
1032
1444
  this.pendingCliApprovalHints = null;
1033
1445
  this.lastAutoApprovedPayload = null;
@@ -1052,6 +1464,11 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1052
1464
  this.currentPreview = "(idle)";
1053
1465
  }
1054
1466
  handleClaudeStopFailure(payload) {
1467
+ if (payload.session_id &&
1468
+ this.runtimeSessionId &&
1469
+ payload.session_id !== this.runtimeSessionId) {
1470
+ return;
1471
+ }
1055
1472
  this.lastAutoApprovedPayload = null;
1056
1473
  this.stopTranscriptThinkingWatch();
1057
1474
  this.failClaudeTurn(buildClaudeFailureMessage(payload));