cli-wechat-bridge 1.2.1 → 1.2.3

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/README.md +146 -73
  2. package/README_EN.md +544 -0
  3. package/bin/telegram-check-update.mjs +10 -0
  4. package/bin/telegram-claude.mjs +10 -0
  5. package/bin/telegram-codex.mjs +10 -0
  6. package/bin/telegram-daemon.mjs +10 -0
  7. package/bin/telegram-opencode.mjs +10 -0
  8. package/bin/telegram-pi.mjs +10 -0
  9. package/bin/telegram-setup.mjs +10 -0
  10. package/dist/bridge/adapter-control.js +19 -12
  11. package/dist/bridge/bridge-adapters.claude.js +219 -115
  12. package/dist/bridge/bridge-adapters.codex.js +532 -99
  13. package/dist/bridge/bridge-adapters.core.js +84 -7
  14. package/dist/bridge/bridge-adapters.opencode.js +202 -55
  15. package/dist/bridge/bridge-adapters.pi.js +23 -18
  16. package/dist/bridge/bridge-adapters.shared.js +9 -9
  17. package/dist/bridge/bridge-session-resume.js +13 -6
  18. package/dist/bridge/bridge-state.js +3 -1
  19. package/dist/bridge/bridge-utils.js +94 -144
  20. package/dist/bridge/claude-hooks.js +60 -3
  21. package/dist/bridge/claude-native-controls.js +19 -12
  22. package/dist/bridge/language-control.js +33 -0
  23. package/dist/bridge/native-terminal-control.js +26 -10
  24. package/dist/bridge/opencode-v2-compat.js +11 -3
  25. package/dist/bridge/wechat-bridge.js +154 -72
  26. package/dist/channels/channel-driver-factory.js +56 -0
  27. package/dist/channels/local/local-channel-port.js +40 -0
  28. package/dist/channels/local/local-driver.js +217 -0
  29. package/dist/channels/telegram/setup-cli.js +12 -0
  30. package/dist/channels/telegram/setup.js +163 -0
  31. package/dist/channels/telegram/telegram-channel-port.js +47 -0
  32. package/dist/channels/telegram/telegram-config.js +66 -0
  33. package/dist/channels/telegram/telegram-driver.js +106 -0
  34. package/dist/channels/telegram/telegram-message.js +41 -0
  35. package/dist/channels/telegram/telegram-transport.js +378 -0
  36. package/dist/channels/wechat/wechat-driver.js +11 -2
  37. package/dist/channels/wecom/wecom-driver.js +8 -1
  38. package/dist/companion/local-companion-start.js +180 -61
  39. package/dist/companion/local-companion.js +27 -7
  40. package/dist/companion/pi-tui-bridge-extension.js +10 -4
  41. package/dist/core/bridge-control.js +3 -0
  42. package/dist/core/bridge-defer.js +3 -2
  43. package/dist/core/bridge-event-forwarder.js +3 -0
  44. package/dist/core/channel-types.js +13 -1
  45. package/dist/daemon/wechat-daemon.js +239 -126
  46. package/dist/i18n/index.js +25 -11
  47. package/dist/i18n/messages-en.js +245 -1
  48. package/dist/i18n/messages-zh.js +245 -1
  49. package/dist/runtime/legacy-adapter-runtime.js +7 -4
  50. package/dist/wechat/channel-config.js +3 -2
  51. package/package.json +17 -5
  52. package/skills/codex-bridge/SKILL.md +35 -0
  53. package/skills/codex-bridge/agents/openai.yaml +4 -0
  54. package/skills/codex-bridge/scripts/install.mjs +28 -0
@@ -1,3 +1,10 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ import { t } from "../i18n/index.js";
1
8
  const snapshots = new WeakMap();
2
9
  export const MODEL_SNAPSHOT_TTL_MS = 5 * 60_000;
3
10
  export function invalidateModelSnapshot(adapter) {
@@ -17,40 +24,40 @@ export async function handleAdapterControl(adapter, senderId, command) {
17
24
  try {
18
25
  if (command.type === "plan") {
19
26
  if (!adapter.setPlanMode)
20
- throw new Error(`Plan mode is not available for ${name}.`);
27
+ throw new Error(t("control.plan.unavailable", { name }));
21
28
  const enabled = await adapter.setPlanMode(command.enabled);
22
29
  return enabled
23
- ? `${name} plan mode enabled. Send /plan off to restore the previous mode.`
24
- : `${name} plan mode disabled.`;
30
+ ? t("control.plan.enabled", { name })
31
+ : t("control.plan.disabled", { name });
25
32
  }
26
33
  if (!adapter.listModels || !adapter.selectModel)
27
- throw new Error(`Model selection is not available for ${name}.`);
34
+ throw new Error(t("control.model.unavailable", { name }));
28
35
  const owner = snapshotOwner(adapter, senderId);
29
36
  if (!command.target) {
30
37
  snapshots.delete(adapter);
31
38
  const models = await adapter.listModels();
32
39
  if (snapshotOwner(adapter, senderId) !== owner)
33
- throw new Error("The active session changed. Send /model again.");
40
+ throw new Error(t("control.model.sessionChanged"));
34
41
  if (!models.length)
35
- return `${name} did not return any available models.`;
42
+ return t("control.model.empty", { name });
36
43
  snapshots.set(adapter, { owner, createdAtMs: Date.now(), models });
37
44
  return [
38
- `Available ${name} models:`, "",
39
- ...models.map((model, index) => `${index + 1}. ${model.displayName}${model.isCurrent ? " (current)" : ""}`),
40
- "", "Reply with /model <number> to switch. The list is valid for 5 minutes.",
45
+ t("control.model.header", { name }), "",
46
+ ...models.map((model, index) => `${index + 1}. ${model.displayName}${model.isCurrent ? t("control.model.current") : ""}`),
47
+ "", t("control.model.instructions"),
41
48
  ].join("\n");
42
49
  }
43
50
  const snapshot = snapshots.get(adapter);
44
51
  if (!snapshot || snapshot.owner !== owner || Date.now() - snapshot.createdAtMs > MODEL_SNAPSHOT_TTL_MS) {
45
- throw new Error("The model list has expired or belongs to another session. Send /model again.");
52
+ throw new Error(t("control.model.expired"));
46
53
  }
47
54
  const index = Number(command.target);
48
55
  if (!/^\d+$/.test(command.target) || !Number.isSafeInteger(index) || index < 1 || index > snapshot.models.length) {
49
- throw new Error("Model selection is outside the displayed range. Send /model again to refresh the list.");
56
+ throw new Error(t("control.model.range"));
50
57
  }
51
58
  const model = await adapter.selectModel(snapshot.models[index - 1].id);
52
59
  snapshots.delete(adapter);
53
- return `${name} model switched to ${model.displayName}.`;
60
+ return t("control.model.changed", { name, model: model.displayName });
54
61
  }
55
62
  catch (error) {
56
63
  return error instanceof Error ? error.message : String(error);
@@ -14,7 +14,7 @@ import { spawnSync } from "node:child_process";
14
14
  import { buildLocalCompanionToken } from "../companion/local-companion-link.js";
15
15
  import { t } from "../i18n/index.js";
16
16
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
17
- import { buildClaudeFailureMessage, buildClaudeHookScript, buildClaudeHookSettings, buildClaudePermissionDecisionHookOutput, buildClaudePermissionApprovalRequest, extractClaudeAssistantMessageText, extractClaudeResumeConversationId, extractClaudeTranscriptFinalReply, findInjectedClaudePromptIndex, getClaudePermissionAutoResponse, getClaudeWechatOutboundAttachmentDenyMessage, normalizeClaudeAssistantMessage, parseClaudeHookPayload, } from "./claude-hooks.js";
17
+ import { buildClaudeFailureMessage, buildClaudeHookScript, buildClaudeHookSettings, buildClaudeUserInputRequest, buildClaudeUserInputHookOutput, buildClaudePermissionDecisionHookOutput, buildClaudePermissionApprovalRequest, extractClaudeAssistantMessageText, extractClaudeResumeConversationId, extractClaudeTranscriptFinalReply, findInjectedClaudePromptIndex, getClaudePermissionAutoResponse, getClaudeWechatOutboundAttachmentDenyMessage, normalizeClaudeAssistantMessage, parseClaudeHookPayload, } from "./claude-hooks.js";
18
18
  import { detectCliApproval, isThinkingForwardEnabled, } from "./bridge-utils.js";
19
19
  import { normalizeOutput, nowIso, truncatePreview } from "../core/text-utils.js";
20
20
  import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
@@ -333,6 +333,9 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
333
333
  hookErrorLogPath = null;
334
334
  hookReceivedCount = 0;
335
335
  hookHealthCheckTimer = null;
336
+ resolvingHookApprovals = new Set();
337
+ pendingHookUserInputs = new Map();
338
+ resolvingHookUserInputs = new Set();
336
339
  pendingHookApprovals = new Map();
337
340
  recoveringInvalidResume = false;
338
341
  workingNoticeTimer = null;
@@ -341,7 +344,6 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
341
344
  lastCompactCompletionAtMs = 0;
342
345
  startupOutputBuffer = "";
343
346
  hasAutoConfirmedWorkspaceTrustPrompt = false;
344
- lastAutoApprovedPayload = null;
345
347
  transcriptPollTimer = null;
346
348
  transcriptTailOffset = 0;
347
349
  polledTranscriptSessionId = null;
@@ -359,7 +361,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
359
361
  isPromptEmpty: () => readClaudePermissionMode(this.terminalControl.text) !== null,
360
362
  assertReady: () => {
361
363
  if (!this.pty || this.state.status !== "idle" || this.pendingApproval || this.pendingResume) {
362
- throw new Error("Claude must be idle with no pending approval before using remote controls.");
364
+ throw new Error(t("adapter.claude.controlsIdle"));
363
365
  }
364
366
  },
365
367
  });
@@ -369,6 +371,11 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
369
371
  async setPlanMode(enabled) { return this.nativeControls.setPlanMode(enabled); }
370
372
  constructor(options) {
371
373
  super(options);
374
+ this.state.supportsApprovalRequestIds = true;
375
+ this.state.supportsUserInputRequestIds = true;
376
+ this.state.pendingUserInput = null;
377
+ this.state.pendingApprovals = [];
378
+ this.state.pendingApproval = null;
372
379
  const shouldRestoreInitialSession = options.sessionStartMode !== "new";
373
380
  this.runtimeSessionId = shouldRestoreInitialSession
374
381
  ? options.initialSharedSessionId ?? options.initialSharedThreadId ?? null
@@ -431,26 +438,30 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
431
438
  }
432
439
  async sendInput(text) {
433
440
  if (this.terminalControl.active)
434
- throw new Error("A Claude control operation is in progress.");
441
+ throw new Error(t("adapter.claude.controlActive"));
435
442
  if (!this.pty) {
436
- throw new Error("claude adapter is not running.");
443
+ throw new Error(t("adapter.claude.notRunning"));
437
444
  }
438
445
  if (this.state.status === "busy") {
439
- throw new Error("claude is still working. Wait for the current reply or use /stop.");
446
+ throw new Error(t("adapter.claude.busy"));
440
447
  }
441
448
  if (this.pendingApproval) {
442
- throw new Error("A Claude approval request is pending. Reply with /confirm or /deny.");
449
+ throw new Error(t("adapter.claude.approvalPending"));
443
450
  }
451
+ if (this.state.status === "awaiting_input")
452
+ throw new Error(t("adapter.claude.inputPending"));
444
453
  await this.waitForInteractiveReadiness();
445
454
  if (!this.pty) {
446
- throw new Error("claude adapter is not running.");
455
+ throw new Error(t("adapter.claude.notRunning"));
447
456
  }
448
457
  if (this.getState().status === "busy") {
449
- throw new Error("claude is still working. Wait for the current reply or use /stop.");
458
+ throw new Error(t("adapter.claude.busy"));
450
459
  }
451
460
  if (this.pendingApproval) {
452
- throw new Error("A Claude approval request is pending. Reply with /confirm or /deny.");
461
+ throw new Error(t("adapter.claude.approvalPending"));
453
462
  }
463
+ if (this.getState().status === "awaiting_input")
464
+ throw new Error(t("adapter.claude.inputPending"));
454
465
  const normalizedText = normalizeOutput(text).trim();
455
466
  this.clearRemoteSubmitRetry();
456
467
  this.pendingInjectedInputs.push({
@@ -477,18 +488,18 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
477
488
  }
478
489
  async resumeSession(sessionId) {
479
490
  if (this.terminalControl.active)
480
- throw new Error("A CLI control operation is in progress. Wait for it to finish before switching sessions.");
491
+ throw new Error(t("adapter.claude.sessionControlActive"));
481
492
  if (!this.pty) {
482
- throw new Error("Claude is not running yet.");
493
+ throw new Error(t("adapter.claude.notReady"));
483
494
  }
484
495
  if (this.pendingResume) {
485
- throw new Error("Claude is already switching sessions.");
496
+ throw new Error(t("adapter.claude.switching"));
486
497
  }
487
498
  if (this.pendingApproval) {
488
- throw new Error("A Claude approval request is pending. Reply with /confirm or /deny.");
499
+ throw new Error(t("adapter.claude.approvalPending"));
489
500
  }
490
501
  if (this.state.status === "busy" || this.state.status === "awaiting_approval") {
491
- throw new Error("Claude is still working. Wait for the current reply or use /stop.");
502
+ throw new Error(t("adapter.claude.switchBusy"));
492
503
  }
493
504
  if (this.state.status !== "idle") {
494
505
  throw new Error(`Claude cannot switch sessions while its status is ${this.state.status}.`);
@@ -606,7 +617,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
606
617
  }
607
618
  async reset() {
608
619
  if (this.terminalControl.active)
609
- throw new Error("A CLI control operation is in progress. Wait for it to finish before switching sessions.");
620
+ throw new Error(t("adapter.claude.sessionControlActive"));
610
621
  this.rejectPendingResume(new Error("Claude was reset during session switching."));
611
622
  this.clearWechatWorkingNotice(true);
612
623
  this.pendingCliApprovalHints = null;
@@ -624,56 +635,42 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
624
635
  await this.dispose();
625
636
  await this.start();
626
637
  }
627
- async resolveApproval(action) {
628
- if (!this.pendingApproval) {
629
- return false;
630
- }
631
- if (this.pendingApproval.requestId) {
632
- const handled = this.respondToClaudeHookApproval(this.pendingApproval.requestId, action);
638
+ async resolveApproval(action, requestId) {
639
+ const id = requestId ?? this.pendingApproval?.requestId;
640
+ if (id && this.pendingHookApprovals.has(id)) {
641
+ const handled = await this.respondToClaudeHookApproval(id, action);
633
642
  if (handled) {
634
643
  this.clearWechatWorkingNotice();
635
- this.pendingCliApprovalHints = null;
636
- this.pendingApproval = null;
637
- this.state.pendingApproval = null;
638
- this.state.pendingApprovalOrigin = undefined;
639
- this.setStatus("busy");
640
- return true;
644
+ this.syncPendingHookApprovals();
641
645
  }
646
+ return handled;
642
647
  }
648
+ if (!this.pendingApproval || (requestId !== undefined && requestId !== this.pendingApproval.requestId))
649
+ return false;
650
+ // Hook requests never fall through to keystrokes after their socket expires.
651
+ if (this.pendingApproval.requestId)
652
+ return false;
643
653
  const input = action === "confirm" ? this.pendingApproval.confirmInput : this.pendingApproval.denyInput;
644
654
  if (!input) {
645
- throw new Error("Remote approval is not safely available for this Claude prompt. Approve it in the local Claude terminal.");
655
+ throw new Error(t("adapter.claude.approvalLocal"));
646
656
  }
647
- this.clearWechatWorkingNotice();
657
+ this.writeToPty(input);
648
658
  this.pendingCliApprovalHints = null;
649
659
  this.pendingApproval = null;
650
660
  this.state.pendingApproval = null;
651
661
  this.state.pendingApprovalOrigin = undefined;
652
662
  this.setStatus("busy");
653
- this.writeToPty(input);
654
663
  return true;
655
664
  }
656
665
  async resolveAllApprovals(action) {
666
+ if (!this.pendingHookApprovals.size)
667
+ return await this.resolveApproval(action) ? 1 : 0;
657
668
  let count = 0;
658
- for (const requestId of Array.from(this.pendingHookApprovals.keys())) {
659
- const pending = this.pendingHookApprovals.get(requestId);
660
- if (pending) {
661
- this.pendingHookApprovals.delete(requestId);
662
- this.respondToClaudeHook(pending.socket, requestId, buildClaudePermissionDecisionHookOutput(action));
669
+ for (const id of Array.from(this.pendingHookApprovals.keys())) {
670
+ if (await this.resolveApproval(action, id))
663
671
  count++;
664
- }
665
672
  }
666
- if (count > 0) {
667
- this.clearWechatWorkingNotice();
668
- this.pendingCliApprovalHints = null;
669
- this.pendingApproval = null;
670
- this.state.pendingApproval = null;
671
- this.state.pendingApprovalOrigin = undefined;
672
- this.setStatus("busy");
673
- return count;
674
- }
675
- const ok = await this.resolveApproval(action);
676
- return ok ? 1 : 0;
673
+ return count;
677
674
  }
678
675
  async dispose() {
679
676
  this.terminalControl.cancel("Claude is shutting down.");
@@ -846,9 +843,12 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
846
843
  }
847
844
  });
848
845
  const cleanupPendingRequestsForSocket = () => {
846
+ for (const [requestId, pending] of this.pendingHookUserInputs.entries()) {
847
+ if (pending.socket === socket)
848
+ this.handleClosedClaudeHookUserInput(requestId);
849
+ }
849
850
  for (const [requestId, pending] of this.pendingHookApprovals.entries()) {
850
851
  if (pending.socket === socket) {
851
- this.pendingHookApprovals.delete(requestId);
852
852
  this.handleClosedClaudeHookApproval(requestId);
853
853
  }
854
854
  }
@@ -1271,35 +1271,42 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1271
1271
  this.handleClaudeUserPromptSubmit(payload);
1272
1272
  this.respondToClaudeHook(params.socket, params.requestId);
1273
1273
  return;
1274
+ case "PreToolUse":
1275
+ if (payload.tool_name === "AskUserQuestion") {
1276
+ this.handleClaudeUserInputRequest(params.requestId, payload, params.socket);
1277
+ }
1278
+ else {
1279
+ this.respondToClaudeHook(params.socket, params.requestId);
1280
+ }
1281
+ return;
1282
+ case "PostToolUse":
1283
+ if (payload.tool_name === "AskUserQuestion" && payload.tool_use_id) {
1284
+ let removed = false;
1285
+ for (const [id, pending] of this.pendingHookUserInputs) {
1286
+ if (pending.payload.tool_use_id === payload.tool_use_id) {
1287
+ this.pendingHookUserInputs.delete(id);
1288
+ removed = true;
1289
+ this.respondToClaudeHook(pending.socket, id);
1290
+ }
1291
+ }
1292
+ if (removed)
1293
+ this.syncPendingHookUserInputs();
1294
+ }
1295
+ this.respondToClaudeHook(params.socket, params.requestId);
1296
+ return;
1274
1297
  case "PermissionRequest":
1275
1298
  this.handleClaudePermissionRequest(params.requestId, payload, params.socket);
1276
1299
  return;
1277
1300
  case "Notification":
1278
1301
  if (payload.notification_type === "permission_prompt") {
1279
- if (this.pendingApproval) {
1302
+ if (this.state.pendingUserInput) {
1303
+ this.setStatus("awaiting_input", "Claude is waiting for your answers.");
1304
+ }
1305
+ else if (this.pendingApproval) {
1280
1306
  this.setStatus("awaiting_approval", "Claude approval is required.");
1281
1307
  }
1282
- else if (this.lastAutoApprovedPayload) {
1283
- // Auto-approval response failed to reach Claude Code (socket closed before delivery).
1284
- // Fall back to forwarding the approval request to WeChat.
1285
- const fallbackPayload = this.lastAutoApprovedPayload;
1286
- this.lastAutoApprovedPayload = null;
1287
- const request = buildClaudePermissionApprovalRequest(fallbackPayload);
1288
- this.pendingApproval = {
1289
- ...request,
1290
- requestId: undefined,
1291
- confirmInput: this.pendingCliApprovalHints?.confirmInput,
1292
- denyInput: this.pendingCliApprovalHints?.denyInput,
1293
- };
1294
- this.pendingCliApprovalHints = null;
1295
- this.state.pendingApproval = this.pendingApproval;
1296
- this.state.pendingApprovalOrigin = this.state.activeTurnOrigin;
1297
- this.setStatus("awaiting_approval", "Claude auto-approval failed, forwarding to WeChat.");
1298
- this.emit({
1299
- type: "approval_required",
1300
- request: this.pendingApproval,
1301
- timestamp: nowIso(),
1302
- });
1308
+ else {
1309
+ this.emitClaudeNotice("Claude is waiting for a local permission prompt that has no remote approval hook. Resolve it in the local terminal.", "warning");
1303
1310
  }
1304
1311
  }
1305
1312
  this.respondToClaudeHook(params.socket, params.requestId);
@@ -1408,6 +1415,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1408
1415
  }
1409
1416
  }
1410
1417
  handleClaudeSessionEnd(payload) {
1418
+ this.flushPendingClaudeHookApprovals();
1411
1419
  if (payload.reason !== "resume") {
1412
1420
  return;
1413
1421
  }
@@ -1506,65 +1514,59 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1506
1514
  }
1507
1515
  handleClaudePermissionRequest(requestId, payload, socket) {
1508
1516
  this.clearWechatWorkingNotice();
1509
- this.lastAutoApprovedPayload = null;
1517
+ if (payload.tool_name === "AskUserQuestion") {
1518
+ this.handleClaudeUserInputRequest(requestId, { ...payload, hook_event_name: "PermissionRequest" }, socket);
1519
+ return;
1520
+ }
1510
1521
  const denyMessage = getClaudeWechatOutboundAttachmentDenyMessage(payload);
1511
1522
  if (denyMessage) {
1512
- this.pendingApproval = null;
1513
- this.state.pendingApproval = null;
1514
- this.state.pendingApprovalOrigin = undefined;
1515
- if (this.state.status === "awaiting_approval") {
1516
- this.setStatus("busy");
1517
- }
1518
1523
  this.respondToClaudeHook(socket, requestId, buildClaudePermissionDecisionHookOutput("deny", denyMessage));
1519
1524
  return;
1520
1525
  }
1521
1526
  const autoResponse = getClaudePermissionAutoResponse(payload);
1522
1527
  if (autoResponse) {
1523
- if (!this.pendingApproval && !this.state.pendingApproval) {
1528
+ if (!this.pendingApproval && !this.state.pendingApproval && !this.state.pendingUserInput) {
1524
1529
  this.setStatus("busy", `Claude approval auto-approved: ${truncatePreview(autoResponse.reason, 180)}`);
1525
1530
  }
1526
- this.lastAutoApprovedPayload = payload;
1527
1531
  this.respondToClaudeHook(socket, requestId, buildClaudePermissionDecisionHookOutput(autoResponse.action));
1528
1532
  return;
1529
1533
  }
1530
- this.flushPendingClaudeHookApprovals();
1534
+ if (this.pendingHookApprovals.has(requestId))
1535
+ return;
1536
+ const request = {
1537
+ ...buildClaudePermissionApprovalRequest(payload),
1538
+ requestId,
1539
+ confirmInput: this.pendingCliApprovalHints?.confirmInput,
1540
+ denyInput: this.pendingCliApprovalHints?.denyInput,
1541
+ };
1531
1542
  this.pendingHookApprovals.set(requestId, {
1532
1543
  requestId,
1533
1544
  socket,
1545
+ request,
1546
+ origin: this.state.activeTurnOrigin,
1534
1547
  });
1535
- const request = buildClaudePermissionApprovalRequest(payload);
1536
- this.pendingApproval = {
1537
- ...request,
1538
- requestId,
1539
- confirmInput: this.pendingApproval?.confirmInput ?? this.pendingCliApprovalHints?.confirmInput,
1540
- denyInput: this.pendingApproval?.denyInput ?? this.pendingCliApprovalHints?.denyInput,
1541
- };
1542
1548
  this.pendingCliApprovalHints = null;
1549
+ this.syncPendingHookApprovals();
1550
+ }
1551
+ syncPendingHookApprovals() {
1552
+ const previousId = this.pendingApproval?.requestId;
1553
+ const entries = Array.from(this.pendingHookApprovals.values());
1554
+ const requests = entries.map((pending) => pending.request);
1555
+ this.pendingApproval = requests[0] ?? null;
1543
1556
  this.state.pendingApproval = this.pendingApproval;
1544
- this.state.pendingApprovalOrigin = this.state.activeTurnOrigin;
1545
- this.setStatus("awaiting_approval", "Claude approval is required.");
1546
- this.emit({
1547
- type: "approval_required",
1548
- request: this.pendingApproval,
1549
- timestamp: nowIso(),
1550
- });
1557
+ this.state.pendingApprovals = requests;
1558
+ this.state.pendingApprovalOrigin = entries[0]?.origin;
1559
+ this.setStatus(requests.length ? "awaiting_approval" : this.state.pendingUserInput ? "awaiting_input" : "busy");
1560
+ this.emit({ type: "approval_state_changed", requests, timestamp: nowIso() });
1561
+ if (requests[0] && requests[0].requestId !== previousId) {
1562
+ this.emit({ type: "approval_required", request: requests[0], timestamp: nowIso() });
1563
+ }
1551
1564
  }
1552
1565
  handleClosedClaudeHookApproval(requestId) {
1553
- if (this.pendingApproval?.requestId !== requestId) {
1566
+ if (!this.pendingHookApprovals.delete(requestId))
1554
1567
  return;
1555
- }
1556
- if (this.pendingApproval.confirmInput || this.pendingApproval.denyInput) {
1557
- this.pendingApproval = {
1558
- ...this.pendingApproval,
1559
- requestId: undefined,
1560
- };
1561
- this.state.pendingApproval = this.pendingApproval;
1562
- return;
1563
- }
1564
- this.pendingApproval = null;
1565
- this.state.pendingApproval = null;
1566
- this.state.pendingApprovalOrigin = undefined;
1567
- if (this.state.status === "awaiting_approval") {
1568
+ this.syncPendingHookApprovals();
1569
+ if (!this.pendingHookApprovals.size) {
1568
1570
  this.setStatus("awaiting_approval", "Claude approval must be resolved in the local terminal.");
1569
1571
  }
1570
1572
  this.emitClaudeNotice("Claude approval can no longer be resolved from WeChat. Approve it in the local Claude terminal.", "warning");
@@ -1600,7 +1602,6 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1600
1602
  this.clearRemoteSubmitRetry();
1601
1603
  this.clearWechatWorkingNotice(true);
1602
1604
  this.pendingCliApprovalHints = null;
1603
- this.lastAutoApprovedPayload = null;
1604
1605
  this.flushPendingClaudeHookApprovals();
1605
1606
  this.pendingApproval = null;
1606
1607
  this.state.pendingApproval = null;
@@ -1627,7 +1628,6 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1627
1628
  payload.session_id !== this.runtimeSessionId) {
1628
1629
  return;
1629
1630
  }
1630
- this.lastAutoApprovedPayload = null;
1631
1631
  this.stopTranscriptThinkingWatch();
1632
1632
  this.failClaudeTurn(buildClaudeFailureMessage(payload));
1633
1633
  }
@@ -1727,14 +1727,102 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1727
1727
  }
1728
1728
  }
1729
1729
  }
1730
- respondToClaudeHookApproval(requestId, action) {
1730
+ async respondToClaudeHookApproval(requestId, action) {
1731
1731
  const pending = this.pendingHookApprovals.get(requestId);
1732
1732
  if (!pending) {
1733
1733
  return false;
1734
1734
  }
1735
- this.pendingHookApprovals.delete(requestId);
1736
- this.respondToClaudeHook(pending.socket, requestId, buildClaudePermissionDecisionHookOutput(action));
1737
- return true;
1735
+ if (pending.socket.destroyed || pending.socket.writableEnded || this.resolvingHookApprovals.has(requestId))
1736
+ return false;
1737
+ this.resolvingHookApprovals.add(requestId);
1738
+ try {
1739
+ await this.writeClaudeHookResponse(pending.socket, requestId, buildClaudePermissionDecisionHookOutput(action));
1740
+ this.pendingHookApprovals.delete(requestId);
1741
+ return true;
1742
+ }
1743
+ catch {
1744
+ return false;
1745
+ }
1746
+ finally {
1747
+ this.resolvingHookApprovals.delete(requestId);
1748
+ }
1749
+ }
1750
+ async writeClaudeHookResponse(socket, requestId, stdout) {
1751
+ const response = `${JSON.stringify({ requestId, stdout })}\n`;
1752
+ const candidate = socket;
1753
+ if (!(candidate instanceof net.Socket)) {
1754
+ socket.end(response);
1755
+ return;
1756
+ }
1757
+ await new Promise((resolve, reject) => {
1758
+ const cleanup = () => { socket.off("error", onError); socket.off("close", onClose); };
1759
+ const onError = (error) => { cleanup(); reject(error); };
1760
+ const onClose = () => { cleanup(); reject(new Error("Claude hook closed before its reply was written.")); };
1761
+ socket.once("error", onError);
1762
+ socket.once("close", onClose);
1763
+ socket.end(response, () => { cleanup(); resolve(); });
1764
+ });
1765
+ }
1766
+ handleClaudeUserInputRequest(requestId, payload, socket) {
1767
+ const request = buildClaudeUserInputRequest(payload, requestId);
1768
+ const existingAnswers = payload.tool_input?.answers;
1769
+ if (!request || (existingAnswers && typeof existingAnswers === "object"
1770
+ && request.questions.every((question) => {
1771
+ const answer = existingAnswers[question.question];
1772
+ return typeof answer === "string" && answer.trim();
1773
+ }))) {
1774
+ this.respondToClaudeHook(socket, requestId);
1775
+ return;
1776
+ }
1777
+ if (this.pendingHookUserInputs.has(requestId))
1778
+ return;
1779
+ this.clearWechatWorkingNotice();
1780
+ this.pendingHookUserInputs.set(requestId, { socket, payload, request, origin: this.state.activeTurnOrigin });
1781
+ this.syncPendingHookUserInputs();
1782
+ }
1783
+ syncPendingHookUserInputs() {
1784
+ const previousId = this.state.pendingUserInput?.requestId;
1785
+ const head = this.pendingHookUserInputs.values().next().value;
1786
+ this.state.pendingUserInput = head?.request ?? null;
1787
+ this.state.pendingUserInputOrigin = head?.origin;
1788
+ this.setStatus(head ? "awaiting_input" : this.pendingApproval ? "awaiting_approval" : "busy");
1789
+ if (head && head.request.requestId !== previousId) {
1790
+ this.emit({ type: "user_input_required", request: head.request, timestamp: nowIso() });
1791
+ }
1792
+ }
1793
+ handleClosedClaudeHookUserInput(requestId) {
1794
+ if (!this.pendingHookUserInputs.delete(requestId))
1795
+ return;
1796
+ this.syncPendingHookUserInputs();
1797
+ if (!this.pendingHookUserInputs.size && !this.pendingApproval)
1798
+ this.setStatus("awaiting_input");
1799
+ this.emitClaudeNotice("Claude question hook expired or disconnected. Answer in the local Claude terminal, or use /stop.", "warning");
1800
+ }
1801
+ async submitUserInput(answers, requestId) {
1802
+ const id = requestId ?? this.state.pendingUserInput?.requestId;
1803
+ if (!id || id !== this.state.pendingUserInput?.requestId || this.resolvingHookUserInputs.has(id))
1804
+ return false;
1805
+ const pending = this.pendingHookUserInputs.get(id);
1806
+ if (!pending || pending.socket.destroyed || pending.socket.writableEnded)
1807
+ return false;
1808
+ const stdout = buildClaudeUserInputHookOutput(pending.payload, pending.request, answers);
1809
+ if (!stdout)
1810
+ return false;
1811
+ this.resolvingHookUserInputs.add(id);
1812
+ try {
1813
+ await this.writeClaudeHookResponse(pending.socket, id, stdout);
1814
+ if (this.pendingHookUserInputs.get(id) !== pending)
1815
+ return false;
1816
+ this.pendingHookUserInputs.delete(id);
1817
+ this.syncPendingHookUserInputs();
1818
+ return true;
1819
+ }
1820
+ catch {
1821
+ return false;
1822
+ }
1823
+ finally {
1824
+ this.resolvingHookUserInputs.delete(id);
1825
+ }
1738
1826
  }
1739
1827
  cancelPendingClaudeHookApproval(requestId) {
1740
1828
  const pending = this.pendingHookApprovals.get(requestId);
@@ -1745,8 +1833,24 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
1745
1833
  this.pendingHookApprovals.delete(requestId);
1746
1834
  }
1747
1835
  flushPendingClaudeHookApprovals() {
1836
+ const userInputs = Array.from(this.pendingHookUserInputs.entries());
1837
+ this.pendingHookUserInputs.clear();
1838
+ this.state.pendingUserInput = null;
1839
+ this.state.pendingUserInputOrigin = undefined;
1840
+ for (const [id, pending] of userInputs)
1841
+ this.respondToClaudeHook(pending.socket, id);
1842
+ const hadPending = this.pendingHookApprovals.size > 0;
1843
+ if (userInputs.length && !hadPending)
1844
+ this.setStatus(this.state.status);
1748
1845
  for (const requestId of Array.from(this.pendingHookApprovals.keys())) {
1749
1846
  this.cancelPendingClaudeHookApproval(requestId);
1750
1847
  }
1848
+ this.state.pendingApprovals = [];
1849
+ if (hadPending) {
1850
+ this.pendingApproval = null;
1851
+ this.state.pendingApproval = null;
1852
+ this.state.pendingApprovalOrigin = undefined;
1853
+ this.emit({ type: "approval_state_changed", requests: [], timestamp: nowIso() });
1854
+ }
1751
1855
  }
1752
1856
  }