cli-wechat-bridge 1.0.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 (54) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +637 -0
  3. package/bin/_run-entry.mjs +35 -0
  4. package/bin/wechat-bridge-claude.mjs +5 -0
  5. package/bin/wechat-bridge-codex.mjs +5 -0
  6. package/bin/wechat-bridge-opencode.mjs +5 -0
  7. package/bin/wechat-bridge-shell.mjs +5 -0
  8. package/bin/wechat-bridge.mjs +5 -0
  9. package/bin/wechat-check-update.mjs +5 -0
  10. package/bin/wechat-claude-start.mjs +5 -0
  11. package/bin/wechat-claude.mjs +5 -0
  12. package/bin/wechat-codex-start.mjs +5 -0
  13. package/bin/wechat-codex.mjs +5 -0
  14. package/bin/wechat-daemon.mjs +5 -0
  15. package/bin/wechat-opencode-start.mjs +5 -0
  16. package/bin/wechat-opencode.mjs +5 -0
  17. package/bin/wechat-setup.mjs +5 -0
  18. package/dist/bridge/bridge-adapter-common.js +95 -0
  19. package/dist/bridge/bridge-adapters.claude.js +829 -0
  20. package/dist/bridge/bridge-adapters.codex.js +2228 -0
  21. package/dist/bridge/bridge-adapters.core.js +717 -0
  22. package/dist/bridge/bridge-adapters.js +26 -0
  23. package/dist/bridge/bridge-adapters.opencode.js +2129 -0
  24. package/dist/bridge/bridge-adapters.shared.js +1005 -0
  25. package/dist/bridge/bridge-adapters.shell.js +363 -0
  26. package/dist/bridge/bridge-controller.js +48 -0
  27. package/dist/bridge/bridge-final-reply.js +46 -0
  28. package/dist/bridge/bridge-process-reaper.js +348 -0
  29. package/dist/bridge/bridge-state.js +362 -0
  30. package/dist/bridge/bridge-types.js +1 -0
  31. package/dist/bridge/bridge-utils.js +1240 -0
  32. package/dist/bridge/claude-hook.js +82 -0
  33. package/dist/bridge/claude-hooks.js +267 -0
  34. package/dist/bridge/wechat-bridge.js +1026 -0
  35. package/dist/commands/check-update.js +30 -0
  36. package/dist/companion/codex-panel-link.js +72 -0
  37. package/dist/companion/codex-panel.js +179 -0
  38. package/dist/companion/codex-remote-client.js +124 -0
  39. package/dist/companion/local-companion-link.js +240 -0
  40. package/dist/companion/local-companion-start.js +420 -0
  41. package/dist/companion/local-companion.js +424 -0
  42. package/dist/daemon/daemon-link.js +175 -0
  43. package/dist/daemon/wechat-daemon.js +1202 -0
  44. package/dist/media/media-types.js +1 -0
  45. package/dist/runtime/create-runtime-host.js +12 -0
  46. package/dist/runtime/legacy-adapter-runtime.js +46 -0
  47. package/dist/runtime/runtime-types.js +5 -0
  48. package/dist/utils/version-checker.js +161 -0
  49. package/dist/wechat/channel-config.js +196 -0
  50. package/dist/wechat/setup.js +283 -0
  51. package/dist/wechat/standalone-bot.js +355 -0
  52. package/dist/wechat/wechat-channel.js +492 -0
  53. package/dist/wechat/wechat-transport.js +1213 -0
  54. package/package.json +101 -0
@@ -0,0 +1,829 @@
1
+ import fs from "node:fs";
2
+ import net from "node:net";
3
+ import path from "node:path";
4
+ import { buildLocalCompanionToken } from "../companion/local-companion-link.js";
5
+ import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
6
+ import { buildClaudeFailureMessage, buildClaudeHookScript, buildClaudeHookSettings, buildClaudePermissionDecisionHookOutput, buildClaudePermissionApprovalRequest, extractClaudeAssistantMessageText, extractClaudeResumeConversationId, extractClaudeTranscriptFinalReply, findInjectedClaudePromptIndex, normalizeClaudeAssistantMessage, parseClaudeHookPayload, } from "./claude-hooks.js";
7
+ import { detectCliApproval, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
8
+ import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
9
+ import * as shared from "./bridge-adapters.shared.js";
10
+ const { CLAUDE_HOOK_LISTEN_HOST, CLAUDE_WECHAT_WORKING_NOTICE_DELAY_MS, DEFAULT_COLS, DEFAULT_ROWS, MODULE_DIR, buildClaudeCliArgs, isClaudeInvalidResumeError, quotePosixCommandArg, quoteWindowsCommandArg, shouldIncludeClaudeNoAltScreen, } = shared;
11
+ const CLAUDE_COMPACT_OUTPUT_LINE_RE = /^Compacted(?:\s*\(.*full summary.*\))?$/i;
12
+ const CLAUDE_COMPACT_FAILURE_RE = /Error:\s*Error during compaction:|(?:^|\b)API Error:|\b(?:compact|compaction)\s+failed\b|^Error:/i;
13
+ const CLAUDE_COMPACT_DEDUP_MS = 2_000;
14
+ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
15
+ hookServer = null;
16
+ hookPort = null;
17
+ hookToken = null;
18
+ runtimeSessionId;
19
+ resumeConversationId;
20
+ transcriptPath;
21
+ pendingCliApprovalHints = null;
22
+ pendingInjectedInputs = [];
23
+ localTerminalInputListener = null;
24
+ resizeListener = null;
25
+ settingsFilePath = null;
26
+ pendingHookApprovals = new Map();
27
+ recoveringInvalidResume = false;
28
+ workingNoticeTimer = null;
29
+ workingNoticeSent = false;
30
+ workingNoticeDelayMs = CLAUDE_WECHAT_WORKING_NOTICE_DELAY_MS;
31
+ lastCompactCompletionAtMs = 0;
32
+ constructor(options) {
33
+ super(options);
34
+ this.runtimeSessionId = options.initialSharedSessionId ?? options.initialSharedThreadId ?? null;
35
+ this.resumeConversationId = options.initialResumeConversationId ?? null;
36
+ this.transcriptPath = options.initialTranscriptPath ?? null;
37
+ if (this.runtimeSessionId) {
38
+ this.state.sharedSessionId = this.runtimeSessionId;
39
+ this.state.activeRuntimeSessionId = this.runtimeSessionId;
40
+ }
41
+ if (this.resumeConversationId) {
42
+ this.state.resumeConversationId = this.resumeConversationId;
43
+ }
44
+ if (this.transcriptPath) {
45
+ this.state.transcriptPath = this.transcriptPath;
46
+ }
47
+ }
48
+ async start() {
49
+ if (this.pty) {
50
+ return;
51
+ }
52
+ // Validate transcript file exists before launching Claude CLI.
53
+ // After a compact, the old transcript is deleted and the persisted
54
+ // resumeConversationId becomes invalid, causing --resume to crash.
55
+ if (this.transcriptPath) {
56
+ try {
57
+ fs.accessSync(this.transcriptPath);
58
+ }
59
+ catch {
60
+ this.emitClaudeNotice(`Conversation transcript "${this.transcriptPath}" no longer exists (likely after compact). Starting fresh session.`, "warning");
61
+ this.transcriptPath = null;
62
+ this.resumeConversationId = null;
63
+ this.runtimeSessionId = null;
64
+ this.state.transcriptPath = undefined;
65
+ this.state.resumeConversationId = undefined;
66
+ this.state.sharedSessionId = undefined;
67
+ this.state.activeRuntimeSessionId = undefined;
68
+ }
69
+ }
70
+ await this.startHookServer();
71
+ try {
72
+ await super.start();
73
+ }
74
+ catch (error) {
75
+ await this.stopHookServer();
76
+ throw error;
77
+ }
78
+ }
79
+ async sendInput(text) {
80
+ if (!this.pty) {
81
+ throw new Error("claude adapter is not running.");
82
+ }
83
+ if (this.state.status === "busy") {
84
+ throw new Error("claude is still working. Wait for the current reply or use /stop.");
85
+ }
86
+ if (this.pendingApproval) {
87
+ throw new Error("A Claude approval request is pending. Reply with /confirm <code> or /deny.");
88
+ }
89
+ const normalizedText = normalizeOutput(text).trim();
90
+ this.pendingInjectedInputs.push({
91
+ normalizedText,
92
+ createdAtMs: Date.now(),
93
+ });
94
+ this.pendingInjectedInputs = this.pendingInjectedInputs.slice(-8);
95
+ this.hasAcceptedInput = true;
96
+ this.currentPreview = truncatePreview(text);
97
+ this.state.lastInputAt = nowIso();
98
+ this.state.activeTurnOrigin = "wechat";
99
+ this.pendingCliApprovalHints = null;
100
+ this.clearWechatWorkingNotice(true);
101
+ this.setStatus("busy");
102
+ this.writeToPty(text.replace(/\r?\n/g, "\r"));
103
+ this.writeToPty("\r");
104
+ this.armWechatWorkingNotice();
105
+ }
106
+ async listResumeSessions(_limit = 10) {
107
+ throw new Error('WeChat /resume is disabled in claude mode. Use /resume directly inside "wechat-claude"; WeChat will follow the active local session.');
108
+ }
109
+ async resumeSession(_threadId) {
110
+ throw new Error('WeChat /resume is disabled in claude mode. Use /resume directly inside "wechat-claude"; WeChat will follow the active local session.');
111
+ }
112
+ async interrupt() {
113
+ if (!this.pty) {
114
+ return false;
115
+ }
116
+ if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
117
+ return false;
118
+ }
119
+ this.clearWechatWorkingNotice(true);
120
+ this.pendingCliApprovalHints = null;
121
+ this.flushPendingClaudeHookApprovals();
122
+ this.writeToPty("\u0003");
123
+ return true;
124
+ }
125
+ async reset() {
126
+ this.clearWechatWorkingNotice(true);
127
+ this.pendingCliApprovalHints = null;
128
+ this.runtimeSessionId = null;
129
+ this.resumeConversationId = null;
130
+ this.transcriptPath = null;
131
+ this.state.sharedSessionId = undefined;
132
+ this.state.sharedThreadId = undefined;
133
+ this.state.activeRuntimeSessionId = undefined;
134
+ this.state.resumeConversationId = undefined;
135
+ this.state.transcriptPath = undefined;
136
+ this.state.lastSessionSwitchAt = undefined;
137
+ this.state.lastSessionSwitchSource = undefined;
138
+ this.state.lastSessionSwitchReason = undefined;
139
+ await super.reset();
140
+ }
141
+ async resolveApproval(action) {
142
+ if (!this.pendingApproval) {
143
+ return false;
144
+ }
145
+ if (this.pendingApproval.requestId) {
146
+ const handled = this.respondToClaudeHookApproval(this.pendingApproval.requestId, action);
147
+ if (handled) {
148
+ this.clearWechatWorkingNotice();
149
+ this.pendingCliApprovalHints = null;
150
+ this.pendingApproval = null;
151
+ this.state.pendingApproval = null;
152
+ this.state.pendingApprovalOrigin = undefined;
153
+ this.setStatus("busy");
154
+ return true;
155
+ }
156
+ }
157
+ const input = action === "confirm" ? this.pendingApproval.confirmInput : this.pendingApproval.denyInput;
158
+ if (!input) {
159
+ throw new Error("Remote approval is not safely available for this Claude prompt. Approve it in the local Claude terminal.");
160
+ }
161
+ this.clearWechatWorkingNotice();
162
+ this.pendingCliApprovalHints = null;
163
+ this.pendingApproval = null;
164
+ this.state.pendingApproval = null;
165
+ this.state.pendingApprovalOrigin = undefined;
166
+ this.setStatus("busy");
167
+ this.writeToPty(input);
168
+ return true;
169
+ }
170
+ async dispose() {
171
+ this.detachLocalTerminal();
172
+ this.clearWechatWorkingNotice(true);
173
+ this.pendingCliApprovalHints = null;
174
+ this.flushPendingClaudeHookApprovals();
175
+ await super.dispose();
176
+ await this.stopHookServer();
177
+ }
178
+ buildSpawnArgs() {
179
+ if (!this.settingsFilePath) {
180
+ throw new Error("Claude companion settings are not ready.");
181
+ }
182
+ return buildClaudeCliArgs({
183
+ settingsFilePath: this.settingsFilePath,
184
+ resumeConversationId: this.resumeConversationId,
185
+ profile: this.options.profile,
186
+ includeNoAltScreen: shouldIncludeClaudeNoAltScreen(this.options.command),
187
+ extraCliArgs: this.options.extraCliArgs,
188
+ });
189
+ }
190
+ afterStart() {
191
+ this.attachLocalTerminal();
192
+ this.resizePtyToTerminal();
193
+ }
194
+ handleData(rawText) {
195
+ this.renderLocalOutput(rawText);
196
+ const text = normalizeOutput(rawText);
197
+ if (!text) {
198
+ return;
199
+ }
200
+ if (this.resumeConversationId &&
201
+ !this.hasAcceptedInput &&
202
+ !this.recoveringInvalidResume &&
203
+ isClaudeInvalidResumeError(text)) {
204
+ void this.recoverFromInvalidResume(this.resumeConversationId);
205
+ return;
206
+ }
207
+ this.state.lastOutputAt = nowIso();
208
+ if (this.shouldTreatClaudeOutputAsCompactCompletion(text)) {
209
+ this.completeClaudeCompact();
210
+ return;
211
+ }
212
+ const compactFailure = this.extractClaudeCompactFailure(text);
213
+ if (compactFailure) {
214
+ this.failClaudeTurn(compactFailure);
215
+ return;
216
+ }
217
+ const approval = detectCliApproval(text);
218
+ if (approval) {
219
+ this.clearWechatWorkingNotice();
220
+ if (this.pendingApproval) {
221
+ this.pendingApproval = {
222
+ ...this.pendingApproval,
223
+ confirmInput: this.pendingApproval.confirmInput ?? approval.confirmInput,
224
+ denyInput: this.pendingApproval.denyInput ?? approval.denyInput,
225
+ };
226
+ this.state.pendingApproval = this.pendingApproval;
227
+ }
228
+ else {
229
+ this.pendingCliApprovalHints = {
230
+ confirmInput: approval.confirmInput,
231
+ denyInput: approval.denyInput,
232
+ };
233
+ }
234
+ return;
235
+ }
236
+ if (!this.hasAcceptedInput) {
237
+ return;
238
+ }
239
+ }
240
+ handleExit(exitCode) {
241
+ this.detachLocalTerminal();
242
+ this.clearWechatWorkingNotice(true);
243
+ this.pendingCliApprovalHints = null;
244
+ void this.stopHookServer();
245
+ if (this.recoveringInvalidResume && !this.shuttingDown) {
246
+ this.clearCompletionTimer();
247
+ this.pty = null;
248
+ this.state.status = "stopped";
249
+ this.state.pid = undefined;
250
+ this.pendingApproval = null;
251
+ this.state.pendingApproval = null;
252
+ return;
253
+ }
254
+ super.handleExit(exitCode);
255
+ }
256
+ async startHookServer() {
257
+ if (this.hookServer) {
258
+ return;
259
+ }
260
+ this.hookToken = buildLocalCompanionToken();
261
+ await new Promise((resolve, reject) => {
262
+ const server = net.createServer((socket) => {
263
+ let buffer = "";
264
+ socket.setEncoding("utf8");
265
+ socket.on("data", (chunk) => {
266
+ buffer += chunk;
267
+ while (true) {
268
+ const newlineIndex = buffer.indexOf("\n");
269
+ if (newlineIndex < 0) {
270
+ break;
271
+ }
272
+ const line = buffer.slice(0, newlineIndex).trim();
273
+ buffer = buffer.slice(newlineIndex + 1);
274
+ if (!line) {
275
+ continue;
276
+ }
277
+ try {
278
+ const envelope = JSON.parse(line);
279
+ if (envelope.token === this.hookToken &&
280
+ typeof envelope.requestId === "string" &&
281
+ typeof envelope.payload === "string") {
282
+ this.handleClaudeHookEnvelope({
283
+ requestId: envelope.requestId,
284
+ rawPayload: envelope.payload,
285
+ socket,
286
+ });
287
+ }
288
+ }
289
+ catch {
290
+ // Ignore malformed hook payloads.
291
+ }
292
+ }
293
+ });
294
+ const cleanupPendingRequestsForSocket = () => {
295
+ for (const [requestId, pending] of this.pendingHookApprovals.entries()) {
296
+ if (pending.socket === socket) {
297
+ this.pendingHookApprovals.delete(requestId);
298
+ this.handleClosedClaudeHookApproval(requestId);
299
+ }
300
+ }
301
+ };
302
+ socket.once("close", cleanupPendingRequestsForSocket);
303
+ socket.once("error", cleanupPendingRequestsForSocket);
304
+ });
305
+ this.hookServer = server;
306
+ server.once("error", (error) => {
307
+ reject(error);
308
+ });
309
+ server.listen(0, CLAUDE_HOOK_LISTEN_HOST, () => {
310
+ const address = server.address();
311
+ if (!address || typeof address === "string") {
312
+ reject(new Error("Failed to allocate a local Claude hook port."));
313
+ return;
314
+ }
315
+ this.hookPort = address.port;
316
+ try {
317
+ this.writeClaudeRuntimeFiles();
318
+ resolve();
319
+ }
320
+ catch (error) {
321
+ reject(error);
322
+ }
323
+ });
324
+ });
325
+ }
326
+ async stopHookServer() {
327
+ this.flushPendingClaudeHookApprovals();
328
+ if (!this.hookServer) {
329
+ this.hookPort = null;
330
+ this.settingsFilePath = null;
331
+ return;
332
+ }
333
+ const server = this.hookServer;
334
+ this.hookServer = null;
335
+ this.hookPort = null;
336
+ this.settingsFilePath = null;
337
+ await new Promise((resolve) => {
338
+ server.close(() => resolve());
339
+ });
340
+ }
341
+ writeClaudeRuntimeFiles() {
342
+ if (!this.hookPort || !this.hookToken) {
343
+ throw new Error("Claude hook server is not ready.");
344
+ }
345
+ const { workspaceDir } = ensureWorkspaceChannelDir(this.options.cwd);
346
+ const runtimeDir = path.join(workspaceDir, "claude-runtime");
347
+ fs.mkdirSync(runtimeDir, { recursive: true });
348
+ const hookScriptPath = path.join(runtimeDir, process.platform === "win32" ? "hook.cmd" : "hook.sh");
349
+ const settingsFilePath = path.join(runtimeDir, "settings.json");
350
+ const sourceHookEntryPath = path.join(MODULE_DIR, "claude-hook.ts");
351
+ const hookEntryPath = fs.existsSync(sourceHookEntryPath)
352
+ ? sourceHookEntryPath
353
+ : path.join(MODULE_DIR, "claude-hook.js");
354
+ fs.writeFileSync(hookScriptPath, buildClaudeHookScript({
355
+ platform: process.platform,
356
+ runtimeExecPath: process.execPath,
357
+ hookEntryPath,
358
+ hookPort: this.hookPort,
359
+ hookToken: this.hookToken,
360
+ }), "utf8");
361
+ if (process.platform !== "win32") {
362
+ fs.chmodSync(hookScriptPath, 0o755);
363
+ }
364
+ const hookCommand = process.platform === "win32"
365
+ ? quoteWindowsCommandArg(hookScriptPath)
366
+ : quotePosixCommandArg(hookScriptPath);
367
+ fs.writeFileSync(settingsFilePath, JSON.stringify(buildClaudeHookSettings(hookCommand), null, 2), "utf8");
368
+ this.settingsFilePath = settingsFilePath;
369
+ }
370
+ attachLocalTerminal() {
371
+ if (this.localTerminalInputListener || !this.pty) {
372
+ return;
373
+ }
374
+ this.localTerminalInputListener = (chunk) => {
375
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
376
+ this.writeToPty(text);
377
+ };
378
+ process.stdin.on("data", this.localTerminalInputListener);
379
+ process.stdin.resume();
380
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
381
+ process.stdin.setRawMode(true);
382
+ }
383
+ this.resizeListener = () => {
384
+ this.resizePtyToTerminal();
385
+ };
386
+ if (process.stdout.isTTY) {
387
+ process.stdout.on("resize", this.resizeListener);
388
+ }
389
+ }
390
+ detachLocalTerminal() {
391
+ if (this.localTerminalInputListener) {
392
+ process.stdin.off("data", this.localTerminalInputListener);
393
+ this.localTerminalInputListener = null;
394
+ }
395
+ if (this.resizeListener) {
396
+ process.stdout.off("resize", this.resizeListener);
397
+ this.resizeListener = null;
398
+ }
399
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
400
+ process.stdin.setRawMode(false);
401
+ }
402
+ }
403
+ resizePtyToTerminal() {
404
+ if (!this.pty || !process.stdout.isTTY) {
405
+ return;
406
+ }
407
+ try {
408
+ this.pty.resize(process.stdout.columns || DEFAULT_COLS, process.stdout.rows || DEFAULT_ROWS);
409
+ }
410
+ catch {
411
+ // Best effort resize sync.
412
+ }
413
+ }
414
+ renderLocalOutput(rawText) {
415
+ try {
416
+ process.stdout.write(rawText);
417
+ }
418
+ catch {
419
+ // Best effort local mirroring for the visible Claude companion.
420
+ }
421
+ }
422
+ armWechatWorkingNotice() {
423
+ this.clearWechatWorkingNotice();
424
+ if (this.workingNoticeSent ||
425
+ !this.hasAcceptedInput ||
426
+ this.state.status !== "busy" ||
427
+ this.pendingApproval ||
428
+ this.state.activeTurnOrigin !== "wechat") {
429
+ return;
430
+ }
431
+ this.workingNoticeTimer = setTimeout(() => {
432
+ this.workingNoticeTimer = null;
433
+ if (this.workingNoticeSent ||
434
+ !this.hasAcceptedInput ||
435
+ this.state.status !== "busy" ||
436
+ this.pendingApproval ||
437
+ this.state.activeTurnOrigin !== "wechat") {
438
+ return;
439
+ }
440
+ this.workingNoticeSent = true;
441
+ this.emitClaudeNotice(`Claude is still working on:\n${this.currentPreview}`);
442
+ }, this.workingNoticeDelayMs);
443
+ this.workingNoticeTimer.unref?.();
444
+ }
445
+ clearWechatWorkingNotice(resetSent = false) {
446
+ if (this.workingNoticeTimer) {
447
+ clearTimeout(this.workingNoticeTimer);
448
+ this.workingNoticeTimer = null;
449
+ }
450
+ if (resetSent) {
451
+ this.workingNoticeSent = false;
452
+ }
453
+ }
454
+ emitClaudeNotice(text, level = "info") {
455
+ const normalized = normalizeOutput(text).trim();
456
+ if (!normalized) {
457
+ return;
458
+ }
459
+ this.state.lastOutputAt = nowIso();
460
+ this.emit({
461
+ type: "notice",
462
+ text: normalized,
463
+ level,
464
+ timestamp: nowIso(),
465
+ });
466
+ }
467
+ shouldTreatClaudeOutputAsCompactCompletion(text) {
468
+ if (this.state.status !== "busy" &&
469
+ this.state.status !== "awaiting_approval" &&
470
+ !this.hasAcceptedInput) {
471
+ return false;
472
+ }
473
+ return normalizeOutput(text)
474
+ .split("\n")
475
+ .some((line) => CLAUDE_COMPACT_OUTPUT_LINE_RE.test(line.trim()));
476
+ }
477
+ isCompactCommandActive() {
478
+ const preview = normalizeOutput(this.currentPreview).trim().toLowerCase();
479
+ return preview === "/compact" || preview.startsWith("/compact ");
480
+ }
481
+ extractClaudeCompactFailure(text) {
482
+ if (!this.isCompactCommandActive()) {
483
+ return null;
484
+ }
485
+ const matchedLine = normalizeOutput(text)
486
+ .split("\n")
487
+ .map((line) => line.trim())
488
+ .filter(Boolean)
489
+ .find((line) => CLAUDE_COMPACT_FAILURE_RE.test(line));
490
+ if (!matchedLine) {
491
+ return null;
492
+ }
493
+ const detail = matchedLine
494
+ .replace(/^Error:\s*Error during compaction:\s*/i, "")
495
+ .replace(/^Error:\s*/i, "")
496
+ .replace(/^(?:compact|compaction)\s+failed:\s*/i, "")
497
+ .trim();
498
+ return truncatePreview(`Compact failed: ${detail || "Claude reported an unknown compaction error."}`, 500);
499
+ }
500
+ failClaudeTurn(message) {
501
+ const hasActiveTurn = this.state.status === "busy" ||
502
+ this.state.status === "awaiting_approval" ||
503
+ this.hasAcceptedInput ||
504
+ this.pendingApproval !== null ||
505
+ this.state.activeTurnOrigin !== undefined ||
506
+ this.currentPreview !== "(idle)";
507
+ if (!hasActiveTurn) {
508
+ return;
509
+ }
510
+ this.clearCompletionTimer();
511
+ this.clearWechatWorkingNotice(true);
512
+ this.pendingCliApprovalHints = null;
513
+ this.flushPendingClaudeHookApprovals();
514
+ this.pendingApproval = null;
515
+ this.state.pendingApproval = null;
516
+ this.state.pendingApprovalOrigin = undefined;
517
+ this.state.activeTurnOrigin = undefined;
518
+ this.hasAcceptedInput = false;
519
+ this.setStatus("idle");
520
+ this.emit({
521
+ type: "task_failed",
522
+ message,
523
+ timestamp: nowIso(),
524
+ });
525
+ this.currentPreview = "(idle)";
526
+ }
527
+ completeClaudeCompact(params) {
528
+ const compactedAtMs = Date.now();
529
+ const shouldEmitNotice = compactedAtMs - this.lastCompactCompletionAtMs > CLAUDE_COMPACT_DEDUP_MS;
530
+ this.lastCompactCompletionAtMs = compactedAtMs;
531
+ if (shouldEmitNotice) {
532
+ const previousResumeConversationId = this.resumeConversationId;
533
+ const nextResumeConversationId = params?.nextResumeConversationId ?? previousResumeConversationId;
534
+ const detail = previousResumeConversationId &&
535
+ nextResumeConversationId &&
536
+ previousResumeConversationId !== nextResumeConversationId
537
+ ? ` Old ID: ${previousResumeConversationId} → New ID: ${nextResumeConversationId}.`
538
+ : "";
539
+ this.emitClaudeNotice(`Conversation was compacted.${detail} Bridge is ready for new WeChat messages.`, "info");
540
+ }
541
+ const shouldEmitTaskComplete = this.state.status === "busy" ||
542
+ this.state.status === "awaiting_approval" ||
543
+ this.hasAcceptedInput;
544
+ const completedPreview = this.currentPreview;
545
+ this.clearCompletionTimer();
546
+ this.clearWechatWorkingNotice(true);
547
+ this.pendingCliApprovalHints = null;
548
+ this.flushPendingClaudeHookApprovals();
549
+ this.pendingApproval = null;
550
+ this.state.pendingApproval = null;
551
+ this.state.pendingApprovalOrigin = undefined;
552
+ this.state.activeTurnOrigin = undefined;
553
+ this.hasAcceptedInput = false;
554
+ this.setStatus("idle");
555
+ if (shouldEmitTaskComplete) {
556
+ this.emit({
557
+ type: "task_complete",
558
+ summary: completedPreview,
559
+ timestamp: nowIso(),
560
+ });
561
+ }
562
+ this.currentPreview = "(idle)";
563
+ }
564
+ handleClaudeHookEnvelope(params) {
565
+ const payload = parseClaudeHookPayload(params.rawPayload);
566
+ if (!payload?.hook_event_name) {
567
+ this.respondToClaudeHook(params.socket, params.requestId);
568
+ return;
569
+ }
570
+ switch (payload.hook_event_name) {
571
+ case "SessionStart":
572
+ this.handleClaudeSessionStart(payload);
573
+ this.respondToClaudeHook(params.socket, params.requestId);
574
+ return;
575
+ case "UserPromptSubmit":
576
+ this.handleClaudeUserPromptSubmit(payload);
577
+ this.respondToClaudeHook(params.socket, params.requestId);
578
+ return;
579
+ case "PermissionRequest":
580
+ this.handleClaudePermissionRequest(params.requestId, payload, params.socket);
581
+ return;
582
+ case "Notification":
583
+ if (payload.notification_type === "permission_prompt" && this.pendingApproval) {
584
+ this.setStatus("awaiting_approval", "Claude approval is required.");
585
+ }
586
+ this.respondToClaudeHook(params.socket, params.requestId);
587
+ return;
588
+ case "Stop":
589
+ this.handleClaudeStop(payload);
590
+ this.respondToClaudeHook(params.socket, params.requestId);
591
+ return;
592
+ case "StopFailure":
593
+ this.handleClaudeStopFailure(payload);
594
+ this.respondToClaudeHook(params.socket, params.requestId);
595
+ return;
596
+ default:
597
+ this.respondToClaudeHook(params.socket, params.requestId);
598
+ return;
599
+ }
600
+ }
601
+ handleClaudeSessionStart(payload) {
602
+ if (!payload.session_id) {
603
+ return;
604
+ }
605
+ const previousRuntimeSessionId = this.runtimeSessionId;
606
+ const previousResumeConversationId = this.resumeConversationId;
607
+ const nextTranscriptPath = typeof payload.transcript_path === "string" && payload.transcript_path.trim()
608
+ ? payload.transcript_path.trim()
609
+ : null;
610
+ const nextResumeConversationId = extractClaudeResumeConversationId(nextTranscriptPath ?? undefined);
611
+ const compactedByTranscriptRotation = Boolean(this.transcriptPath) &&
612
+ Boolean(nextTranscriptPath) &&
613
+ this.transcriptPath !== nextTranscriptPath &&
614
+ (this.state.status === "busy" ||
615
+ this.state.status === "awaiting_approval" ||
616
+ this.hasAcceptedInput);
617
+ // Compact may keep the same runtime session id, so rely on the structured
618
+ // source when available and fall back to transcript rotation while a turn is active.
619
+ if (payload.source === "compact" ||
620
+ compactedByTranscriptRotation) {
621
+ this.completeClaudeCompact({
622
+ nextResumeConversationId,
623
+ });
624
+ }
625
+ this.runtimeSessionId = payload.session_id;
626
+ this.state.sharedSessionId = payload.session_id;
627
+ this.state.activeRuntimeSessionId = payload.session_id;
628
+ this.state.sharedThreadId = undefined;
629
+ this.resumeConversationId = nextResumeConversationId;
630
+ this.state.resumeConversationId = nextResumeConversationId ?? undefined;
631
+ this.transcriptPath = nextTranscriptPath;
632
+ this.state.transcriptPath = nextTranscriptPath ?? undefined;
633
+ if (previousRuntimeSessionId === payload.session_id) {
634
+ return;
635
+ }
636
+ const timestamp = nowIso();
637
+ const isRestore = !previousRuntimeSessionId &&
638
+ (payload.source === "resume" ||
639
+ (nextResumeConversationId !== null &&
640
+ nextResumeConversationId === previousResumeConversationId));
641
+ const source = isRestore ? "restore" : "local";
642
+ const reason = isRestore ? "startup_restore" : "local_follow";
643
+ this.state.lastSessionSwitchAt = timestamp;
644
+ this.state.lastSessionSwitchSource = source;
645
+ this.state.lastSessionSwitchReason = reason;
646
+ this.emit({
647
+ type: "session_switched",
648
+ sessionId: payload.session_id,
649
+ source,
650
+ reason,
651
+ timestamp,
652
+ });
653
+ }
654
+ handleClaudeUserPromptSubmit(payload) {
655
+ const prompt = typeof payload.prompt === "string" ? normalizeOutput(payload.prompt).trim() : "";
656
+ if (!prompt) {
657
+ return;
658
+ }
659
+ const injectedIndex = findInjectedClaudePromptIndex(prompt, this.pendingInjectedInputs);
660
+ if (injectedIndex >= 0) {
661
+ this.pendingInjectedInputs.splice(injectedIndex, 1);
662
+ return;
663
+ }
664
+ this.hasAcceptedInput = true;
665
+ this.currentPreview = truncatePreview(prompt);
666
+ this.state.lastInputAt = nowIso();
667
+ this.state.activeTurnOrigin = "local";
668
+ this.pendingCliApprovalHints = null;
669
+ this.clearWechatWorkingNotice(true);
670
+ this.setStatus("busy");
671
+ this.emit({
672
+ type: "mirrored_user_input",
673
+ text: prompt,
674
+ origin: "local",
675
+ timestamp: nowIso(),
676
+ });
677
+ }
678
+ async recoverFromInvalidResume(failedResumeConversationId) {
679
+ if (this.recoveringInvalidResume) {
680
+ return;
681
+ }
682
+ this.recoveringInvalidResume = true;
683
+ this.clearWechatWorkingNotice(true);
684
+ this.pendingCliApprovalHints = null;
685
+ this.flushPendingClaudeHookApprovals();
686
+ this.pendingApproval = null;
687
+ this.state.pendingApproval = null;
688
+ this.state.pendingApprovalOrigin = undefined;
689
+ this.runtimeSessionId = null;
690
+ this.resumeConversationId = null;
691
+ this.transcriptPath = null;
692
+ this.state.sharedSessionId = undefined;
693
+ this.state.sharedThreadId = undefined;
694
+ this.state.activeRuntimeSessionId = undefined;
695
+ this.state.resumeConversationId = undefined;
696
+ this.state.transcriptPath = undefined;
697
+ this.state.lastSessionSwitchAt = undefined;
698
+ this.state.lastSessionSwitchSource = undefined;
699
+ this.state.lastSessionSwitchReason = undefined;
700
+ this.emitClaudeNotice(`Saved Claude conversation ${failedResumeConversationId} is no longer available. Starting a fresh Claude session.`, "warning");
701
+ try {
702
+ await super.reset();
703
+ }
704
+ finally {
705
+ this.recoveringInvalidResume = false;
706
+ }
707
+ }
708
+ handleClaudePermissionRequest(requestId, payload, socket) {
709
+ this.clearWechatWorkingNotice();
710
+ this.flushPendingClaudeHookApprovals();
711
+ this.pendingHookApprovals.set(requestId, {
712
+ requestId,
713
+ socket,
714
+ });
715
+ const request = buildClaudePermissionApprovalRequest(payload);
716
+ this.pendingApproval = {
717
+ ...request,
718
+ requestId,
719
+ confirmInput: this.pendingApproval?.confirmInput ?? this.pendingCliApprovalHints?.confirmInput,
720
+ denyInput: this.pendingApproval?.denyInput ?? this.pendingCliApprovalHints?.denyInput,
721
+ };
722
+ this.pendingCliApprovalHints = null;
723
+ this.state.pendingApproval = this.pendingApproval;
724
+ this.state.pendingApprovalOrigin = this.state.activeTurnOrigin;
725
+ this.setStatus("awaiting_approval", "Claude approval is required.");
726
+ this.emit({
727
+ type: "approval_required",
728
+ request: this.pendingApproval,
729
+ timestamp: nowIso(),
730
+ });
731
+ }
732
+ handleClosedClaudeHookApproval(requestId) {
733
+ if (this.pendingApproval?.requestId !== requestId) {
734
+ return;
735
+ }
736
+ if (this.pendingApproval.confirmInput || this.pendingApproval.denyInput) {
737
+ this.pendingApproval = {
738
+ ...this.pendingApproval,
739
+ requestId: undefined,
740
+ };
741
+ this.state.pendingApproval = this.pendingApproval;
742
+ return;
743
+ }
744
+ this.pendingApproval = null;
745
+ this.state.pendingApproval = null;
746
+ this.state.pendingApprovalOrigin = undefined;
747
+ if (this.state.status === "awaiting_approval") {
748
+ this.setStatus("awaiting_approval", "Claude approval must be resolved in the local terminal.");
749
+ }
750
+ this.emitClaudeNotice("Claude approval can no longer be resolved from WeChat. Approve it in the local Claude terminal.", "warning");
751
+ }
752
+ readClaudeTranscriptFinalReply() {
753
+ if (!this.transcriptPath) {
754
+ return null;
755
+ }
756
+ try {
757
+ const rawTranscript = fs.readFileSync(this.transcriptPath, "utf8");
758
+ return extractClaudeTranscriptFinalReply(rawTranscript);
759
+ }
760
+ catch {
761
+ return null;
762
+ }
763
+ }
764
+ resolveClaudeFinalReplyText(payload) {
765
+ return (extractClaudeAssistantMessageText(payload) ||
766
+ this.readClaudeTranscriptFinalReply() ||
767
+ normalizeClaudeAssistantMessage(payload));
768
+ }
769
+ handleClaudeStop(payload) {
770
+ this.clearWechatWorkingNotice(true);
771
+ this.pendingCliApprovalHints = null;
772
+ this.flushPendingClaudeHookApprovals();
773
+ this.pendingApproval = null;
774
+ this.state.pendingApproval = null;
775
+ this.state.pendingApprovalOrigin = undefined;
776
+ this.state.activeTurnOrigin = undefined;
777
+ this.hasAcceptedInput = false;
778
+ this.setStatus("idle");
779
+ this.emit({
780
+ type: "final_reply",
781
+ text: this.resolveClaudeFinalReplyText(payload),
782
+ timestamp: nowIso(),
783
+ });
784
+ this.emit({
785
+ type: "task_complete",
786
+ summary: this.currentPreview,
787
+ timestamp: nowIso(),
788
+ });
789
+ this.currentPreview = "(idle)";
790
+ }
791
+ handleClaudeStopFailure(payload) {
792
+ this.failClaudeTurn(buildClaudeFailureMessage(payload));
793
+ }
794
+ respondToClaudeHook(socket, requestId, stdout) {
795
+ try {
796
+ socket.end(`${JSON.stringify({ requestId, stdout })}\n`);
797
+ }
798
+ catch {
799
+ try {
800
+ socket.destroy();
801
+ }
802
+ catch {
803
+ // Best effort cleanup.
804
+ }
805
+ }
806
+ }
807
+ respondToClaudeHookApproval(requestId, action) {
808
+ const pending = this.pendingHookApprovals.get(requestId);
809
+ if (!pending) {
810
+ return false;
811
+ }
812
+ this.pendingHookApprovals.delete(requestId);
813
+ this.respondToClaudeHook(pending.socket, requestId, buildClaudePermissionDecisionHookOutput(action));
814
+ return true;
815
+ }
816
+ cancelPendingClaudeHookApproval(requestId) {
817
+ const pending = this.pendingHookApprovals.get(requestId);
818
+ if (!pending) {
819
+ return;
820
+ }
821
+ this.respondToClaudeHook(pending.socket, requestId);
822
+ this.pendingHookApprovals.delete(requestId);
823
+ }
824
+ flushPendingClaudeHookApprovals() {
825
+ for (const requestId of Array.from(this.pendingHookApprovals.keys())) {
826
+ this.cancelPendingClaudeHookApproval(requestId);
827
+ }
828
+ }
829
+ }