cli-wechat-bridge 1.1.4 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -7
- package/bin/_run-entry.mjs +0 -12
- package/dist/bridge/bridge-adapters.claude.js +427 -10
- package/dist/bridge/bridge-adapters.codex.js +235 -7
- package/dist/bridge/bridge-adapters.opencode.js +392 -53
- package/dist/bridge/bridge-adapters.pi.js +32 -6
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-utils.js +26 -5
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +37 -29
- package/dist/companion/codex-remote-client.js +291 -32
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +26 -0
- package/dist/companion/local-companion-start.js +0 -1
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/wechat-daemon.js +35 -17
- package/package.json +1 -5
- package/bin/wechat-claude-start.mjs +0 -10
- package/bin/wechat-codex-start.mjs +0 -10
- package/bin/wechat-opencode-start.mjs +0 -10
- package/bin/wechat-pi-start.mjs +0 -10
package/README.md
CHANGED
|
@@ -157,9 +157,7 @@ cd D:\work\your-project
|
|
|
157
157
|
- 如果检测到可见端仍在运行但 worker 状态异常(如 `stopped` / `error`),会自动重启 bridge 再重新打开可见端;
|
|
158
158
|
- 在其他目录执行会显式切换活动工作区。
|
|
159
159
|
|
|
160
|
-
|
|
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`
|
|
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
|
-
| `/
|
|
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`
|
|
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
|
|
package/bin/_run-entry.mjs
CHANGED
|
@@ -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(
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
1214
|
+
const isStartupRestore = !previousRuntimeSessionId &&
|
|
867
1215
|
(payload.source === "resume" ||
|
|
868
1216
|
(nextResumeConversationId !== null &&
|
|
869
1217
|
nextResumeConversationId === previousResumeConversationId));
|
|
870
|
-
const source =
|
|
871
|
-
|
|
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));
|