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.
- package/README.md +110 -140
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi.mjs +1 -1
- package/dist/bridge/bridge-adapters.claude.js +448 -11
- package/dist/bridge/bridge-adapters.codex.js +262 -11
- package/dist/bridge/bridge-adapters.core.js +76 -4
- package/dist/bridge/bridge-adapters.js +0 -3
- package/dist/bridge/bridge-adapters.opencode.js +497 -49
- package/dist/bridge/bridge-adapters.pi.js +44 -8
- package/dist/bridge/bridge-adapters.shared.js +13 -100
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-state.js +41 -6
- package/dist/bridge/bridge-utils.js +63 -229
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +93 -140
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +309 -36
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +33 -2
- package/dist/companion/local-companion-start.js +44 -13
- package/dist/companion/local-companion.js +6 -3
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +168 -56
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +2 -3
- package/dist/i18n/messages-zh.js +2 -3
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +23 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -11
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +2 -13
- package/bin/wechat-bridge-claude.mjs +0 -5
- package/bin/wechat-bridge-codex.mjs +0 -5
- package/bin/wechat-bridge-opencode.mjs +0 -5
- package/bin/wechat-bridge-pi.mjs +0 -5
- package/bin/wechat-bridge-shell.mjs +0 -5
- package/bin/wechat-bridge.mjs +0 -5
- package/bin/wechat-claude-start.mjs +0 -5
- package/bin/wechat-codex-start.mjs +0 -5
- package/bin/wechat-opencode-start.mjs +0 -5
- package/bin/wechat-pi-start.mjs +0 -5
- 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(
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
|
1214
|
+
const isStartupRestore = !previousRuntimeSessionId &&
|
|
847
1215
|
(payload.source === "resume" ||
|
|
848
1216
|
(nextResumeConversationId !== null &&
|
|
849
1217
|
nextResumeConversationId === previousResumeConversationId));
|
|
850
|
-
const source =
|
|
851
|
-
|
|
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));
|