@unlinearity/cli-wechat-bridge 1.0.0 → 1.0.2

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