@unlinearity/cli-wechat-bridge 1.2.0 → 1.2.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 (50) hide show
  1. package/README.md +25 -10
  2. package/bin/wechat-check-update.mjs +0 -0
  3. package/bin/wechat-claude.mjs +0 -0
  4. package/bin/wechat-codex.mjs +0 -0
  5. package/bin/wechat-daemon.mjs +0 -0
  6. package/bin/wechat-opencode.mjs +0 -0
  7. package/bin/wechat-pi.mjs +0 -0
  8. package/bin/wechat-setup.mjs +0 -0
  9. package/bin/wecom-check-update.mjs +0 -0
  10. package/bin/wecom-claude.mjs +0 -0
  11. package/bin/wecom-codex.mjs +0 -0
  12. package/bin/wecom-daemon.mjs +0 -0
  13. package/bin/wecom-opencode.mjs +0 -0
  14. package/bin/wecom-pi.mjs +0 -0
  15. package/bin/wecom-setup.mjs +0 -0
  16. package/dist/bridge/adapter-control.js +19 -12
  17. package/dist/bridge/bridge-adapters.claude.js +219 -115
  18. package/dist/bridge/bridge-adapters.codex.js +295 -90
  19. package/dist/bridge/bridge-adapters.core.js +84 -7
  20. package/dist/bridge/bridge-adapters.opencode.js +344 -74
  21. package/dist/bridge/bridge-adapters.pi.js +23 -18
  22. package/dist/bridge/bridge-adapters.shared.js +9 -9
  23. package/dist/bridge/bridge-session-resume.js +13 -6
  24. package/dist/bridge/bridge-utils.js +94 -144
  25. package/dist/bridge/claude-hooks.js +60 -3
  26. package/dist/bridge/claude-native-controls.js +32 -13
  27. package/dist/bridge/codex-visible-thread-proxy.js +140 -0
  28. package/dist/bridge/language-control.js +33 -0
  29. package/dist/bridge/native-terminal-control.js +38 -10
  30. package/dist/bridge/opencode-v2-compat.js +458 -0
  31. package/dist/bridge/wechat-bridge.js +57 -42
  32. package/dist/channels/channel-driver-factory.js +44 -0
  33. package/dist/channels/local/local-channel-port.js +40 -0
  34. package/dist/channels/local/local-driver.js +217 -0
  35. package/dist/channels/wechat/wechat-driver.js +11 -2
  36. package/dist/channels/wecom/wecom-driver.js +8 -1
  37. package/dist/companion/local-companion.js +27 -7
  38. package/dist/companion/opencode-v2-tui-bridge-plugin.js +93 -0
  39. package/dist/companion/pi-tui-bridge-extension.js +10 -4
  40. package/dist/core/bridge-control.js +3 -0
  41. package/dist/core/bridge-defer.js +3 -2
  42. package/dist/core/bridge-event-forwarder.js +3 -0
  43. package/dist/core/channel-types.js +5 -1
  44. package/dist/daemon/wechat-daemon.js +202 -124
  45. package/dist/i18n/index.js +25 -11
  46. package/dist/i18n/messages-en.js +244 -1
  47. package/dist/i18n/messages-zh.js +244 -1
  48. package/dist/runtime/legacy-adapter-runtime.js +7 -4
  49. package/dist/wechat/channel-config.js +3 -2
  50. package/package.json +4 -2
@@ -12,6 +12,9 @@ import { execFileSync, spawn } from "node:child_process";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { resolveDefaultAdapterCommand, } from "../bridge/bridge-adapters.js";
14
14
  import { t } from "../i18n/index.js";
15
+ import { handleLanguageControl } from "../bridge/language-control.js";
16
+ import { parseBridgeControlCommand } from "../core/bridge-control.js";
17
+ import { LANGUAGE_FILE } from "../wechat/channel-config.js";
15
18
  import { delay, getSharedSessionIdFromAdapterState, quoteWindowsCommandArg, } from "../bridge/bridge-adapters.shared.js";
16
19
  import { BridgeController } from "../bridge/bridge-controller.js";
17
20
  import { readBridgeLockFile, } from "../bridge/bridge-state.js";
@@ -38,8 +41,9 @@ import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
38
41
  import { ensureWecomAccount } from "../channels/wecom/setup.js";
39
42
  import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
40
43
  import { WecomTransport } from "../channels/wecom/wecom-transport.js";
41
- import { WecomChannelDriver } from "../channels/wecom/wecom-driver.js";
42
- import { WechatChannelDriver } from "../channels/wechat/wechat-driver.js";
44
+ import { createChannelDriver } from "../channels/channel-driver-factory.js";
45
+ import { LocalChannelPort } from "../channels/local/local-channel-port.js";
46
+ import { getLocalChannelFiles, LOCAL_CHANNEL_OPERATOR_ID, } from "../channels/local/local-driver.js";
43
47
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
44
48
  import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
45
49
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -60,7 +64,12 @@ const MAX_DAEMON_DEFERRED_INPUTS = 32;
60
64
  const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
61
65
  function getCliChannelId(argv = process.argv.slice(2)) {
62
66
  const index = argv.indexOf("--channel");
63
- return index >= 0 && argv[index + 1] === "wecom" ? "wecom" : "wechat";
67
+ const value = index >= 0 ? argv[index + 1] : undefined;
68
+ if (value === "wecom")
69
+ return "wecom";
70
+ if (value === "local")
71
+ return "local";
72
+ return "wechat";
64
73
  }
65
74
  function log(message) {
66
75
  process.stderr.write(`[${getCliChannelId()}-daemon] ${message}\n`);
@@ -132,11 +141,12 @@ export function parseDaemonCliArgs(argv) {
132
141
  }
133
142
  const next = argv[i + 1];
134
143
  if (arg === "--help" || arg === "-h") {
144
+ const channelDisplayName = channelId === "wecom" ? "WeCom" : channelId === "local" ? "Local" : "WeChat";
135
145
  process.stdout.write([
136
146
  `Usage: ${channelId}-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]`,
137
147
  "",
138
- `Keeps one ${channelId === "wecom" ? "WeCom" : "WeChat"} connection alive and switches between Codex, Claude Code, OpenCode, and Pi.`,
139
- `Send /codex, /claude, /opencode, or /pi in ${channelId === "wecom" ? "WeCom" : "WeChat"} to switch the active terminal.`,
148
+ `Keeps one ${channelDisplayName} connection alive and switches between Codex, Claude Code, OpenCode, and Pi.`,
149
+ `Send /codex, /claude, /opencode, or /pi in ${channelDisplayName} to switch the active terminal.`,
140
150
  "",
141
151
  ].join("\n"));
142
152
  process.exit(0);
@@ -150,7 +160,7 @@ export function parseDaemonCliArgs(argv) {
150
160
  continue;
151
161
  }
152
162
  if (arg === "--channel") {
153
- if (!next || (next !== "wechat" && next !== "wecom")) {
163
+ if (!next || (next !== "wechat" && next !== "wecom" && next !== "local")) {
154
164
  throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
155
165
  }
156
166
  channelId = next;
@@ -184,7 +194,7 @@ export function parseDaemonCliArgs(argv) {
184
194
  profile,
185
195
  initialAdapter,
186
196
  openVisible,
187
- ...(channelId === "wecom" ? { channelId } : {}),
197
+ ...(channelId === "wechat" ? {} : { channelId }),
188
198
  };
189
199
  }
190
200
  export function parseDaemonSwitchCommand(text) {
@@ -323,8 +333,29 @@ function shellQuotePosix(s) {
323
333
  function formatLaunchPreview(launch) {
324
334
  return [launch.command, ...launch.args].join(" ");
325
335
  }
326
- function openVisibleClient(params) {
336
+ export function openVisibleClient(params) {
327
337
  const args = buildVisibleClientLaunchArgs(params);
338
+ // E2E harness override: host the visible client in a PTY owned by an
339
+ // external launcher script instead of a terminal window, so automated
340
+ // runs can type and read the TUI while every other step stays identical.
341
+ const launcherOverride = process.env.CLI_BRIDGE_VISIBLE_LAUNCHER;
342
+ if (launcherOverride) {
343
+ const child = spawn(process.execPath, [launcherOverride, ...args], {
344
+ cwd: params.cwd,
345
+ env: process.env,
346
+ detached: true,
347
+ stdio: "ignore",
348
+ });
349
+ child.once("error", (error) => {
350
+ params.onError?.(error instanceof Error ? error : new Error(String(error)));
351
+ });
352
+ child.unref();
353
+ return {
354
+ command: process.execPath,
355
+ args: [launcherOverride, ...args],
356
+ pid: child.pid,
357
+ };
358
+ }
328
359
  if (process.platform === "win32") {
329
360
  const command = process.env.ComSpec || "cmd.exe";
330
361
  const launchArgs = [
@@ -490,57 +521,44 @@ function formatInboundMessagePreview(message) {
490
521
  return "(empty)";
491
522
  }
492
523
  function formatNoActiveAdapterMessage() {
493
- return [
494
- "No active terminal is selected.",
495
- "Send /codex, /claude, /opencode, or /pi to choose one.",
496
- ].join("\n");
524
+ return t("daemon.noActiveAdapter");
497
525
  }
498
526
  export function formatDaemonSwitchResultDetail(result) {
499
- if (result.activated === false) {
500
- const previous = result.previousActiveAdapter
501
- ? ` Active terminal remains ${result.previousActiveAdapter}.`
502
- : " No terminal is active yet.";
503
- if (result.visibleConnected && result.visibleReady === false) {
504
- return `The visible Codex CLI connected, but its active thread is not ready yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
505
- }
506
- if (result.openedVisible) {
507
- return result.created
508
- ? `Started the bridge slot and tried to open the visible CLI, but it has not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`
509
- : `Tried to open a visible CLI for the existing slot, but it has not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
510
- }
511
- return `The visible CLI is not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
512
- }
513
- if (result.openedVisible && result.visibleConnected) {
514
- return result.created
515
- ? "Started a new visible CLI."
516
- : "Opened a visible CLI for the existing slot.";
517
- }
518
- if (result.openedVisible) {
519
- return result.created
520
- ? `Started the bridge slot and tried to open the visible CLI, but it has not connected yet. Check ${BRIDGE_LOG_FILE}.`
521
- : `Tried to open a visible CLI for the existing slot, but it has not connected yet. Check ${BRIDGE_LOG_FILE}.`;
522
- }
523
- if (result.visibleConnected) {
524
- return "Reused the existing visible CLI.";
525
- }
526
- return result.created ? "Started the bridge slot." : "Reused the bridge slot.";
527
+ const previous = result.activated === false
528
+ ? result.previousActiveAdapter
529
+ ? t("daemon.switch.previous", { adapter: result.previousActiveAdapter })
530
+ : t("daemon.switch.none")
531
+ : "";
532
+ if (result.activated === false && result.visibleConnected && result.visibleReady === false) {
533
+ return t("daemon.switch.threadNotReady", { previous, log: BRIDGE_LOG_FILE });
534
+ }
535
+ if (result.openedVisible && (result.activated === false || !result.visibleConnected)) {
536
+ return t(result.created ? "daemon.switch.newNotConnected" : "daemon.switch.existingNotConnected", { previous, log: BRIDGE_LOG_FILE });
537
+ }
538
+ if (result.activated === false)
539
+ return t("daemon.switch.notConnected", { previous, log: BRIDGE_LOG_FILE });
540
+ if (result.openedVisible && result.visibleConnected)
541
+ return t(result.created ? "daemon.switchResult.new" : "daemon.switch.opened");
542
+ if (result.visibleConnected)
543
+ return t("daemon.switchResult.reused");
544
+ return t(result.created ? "daemon.switch.slotStarted" : "daemon.switch.slotReused");
527
545
  }
528
546
  export function formatDaemonStatus(status) {
529
547
  const lines = [
530
- "wechat-daemon status",
531
- `cwd: ${status.cwd}`,
532
- `active: ${status.activeAdapter ?? "(none)"}`,
533
- `started_at: ${status.startedAt}`,
548
+ t("status.daemon", { channel: status.channelId ?? "wechat" }),
549
+ `${t("status.label.cwd")}: ${status.cwd}`,
550
+ `${t("status.label.active")}: ${status.activeAdapter ?? t("status.none")}`,
551
+ `${t("status.label.started_at")}: ${status.startedAt}`,
534
552
  ];
535
553
  for (const adapter of DAEMON_ADAPTERS) {
536
554
  const slot = status.slots.find((entry) => entry.adapter === adapter);
537
555
  if (!slot) {
538
- lines.push(`${adapter}: not started`);
556
+ lines.push(`${adapter}: ${t("status.notStarted")}`);
539
557
  continue;
540
558
  }
541
559
  const flags = [
542
- slot.pendingApproval ? "pending_approval" : "",
543
- slot.pendingUserInput ? "pending_input" : "",
560
+ slot.pendingApproval ? t("status.pendingApproval") : "",
561
+ slot.pendingUserInput ? t("status.pendingInput") : "",
544
562
  slot.companionPid ? `companion_pid=${slot.companionPid}` : "",
545
563
  ].filter(Boolean);
546
564
  lines.push(`${adapter}: ${slot.status}${flags.length ? ` (${flags.join(", ")})` : ""}`);
@@ -555,6 +573,7 @@ export class WechatDaemon {
555
573
  channelDriver;
556
574
  channelId;
557
575
  wecomTransport;
576
+ localFiles;
558
577
  inboundConversationContext = new InboundConversationContext();
559
578
  fallbackConversation;
560
579
  slots = new Map();
@@ -582,19 +601,19 @@ export class WechatDaemon {
582
601
  this.transport = params.transport;
583
602
  this.channelId = params.channelId ?? "wechat";
584
603
  this.wecomTransport = params.wecomTransport ?? null;
604
+ this.localFiles = params.localFiles ?? null;
585
605
  this.deps = params.deps ?? {};
586
- this.channelDriver = this.wecomTransport
587
- ? new WecomChannelDriver({
588
- transport: this.wecomTransport,
589
- accountId: params.accountId,
590
- operatorId: params.authorizedUserId,
591
- logError,
592
- })
593
- : new WechatChannelDriver({
594
- transport: this.transport,
595
- logError,
596
- buildInboundPrompt: (text, attachments) => buildWechatInboundPrompt(text, attachments.filter((attachment) => attachment.kind === "image" || attachment.kind === "file")),
597
- });
606
+ this.channelDriver = createChannelDriver({
607
+ channelId: this.channelId,
608
+ authorizedUserId: params.authorizedUserId,
609
+ accountId: params.accountId,
610
+ wechatTransport: this.transport,
611
+ wecomTransport: this.wecomTransport,
612
+ localFiles: this.localFiles,
613
+ buildWechatInboundPrompt: (text, attachments) => buildWechatInboundPrompt(text, attachments.filter((attachment) => attachment.kind === "image" || attachment.kind === "file")),
614
+ log: (message) => this.daemonLog(message),
615
+ logError,
616
+ });
598
617
  this.fallbackConversation = this.channelDriver.defaultConversation()
599
618
  ?? this.channelDriver.directConversation(params.authorizedUserId);
600
619
  this.pendingWechatMessages = this.deps.pendingWechatMessages ??
@@ -716,7 +735,7 @@ export class WechatDaemon {
716
735
  if (chatType === "direct") {
717
736
  const result = await this.channelDriver.sendText({
718
737
  target: this.channelDriver.directConversation(senderId),
719
- text: "Unauthorized.",
738
+ text: t("channel.unauthorized"),
720
739
  context: "notice",
721
740
  log: appendDaemonLog,
722
741
  });
@@ -743,6 +762,7 @@ export class WechatDaemon {
743
762
  appendDaemonLog(`started: channel=${this.channelId} cwd=${this.cwd}`);
744
763
  const activeSlot = this.getActiveSlot();
745
764
  const welcomeText = t("daemon.welcome", {
765
+ channel: this.channelDriver.displayName,
746
766
  cwd: this.cwd,
747
767
  adapter: activeSlot?.adapter ?? "none",
748
768
  bindings: formatBindingsListMessage(listBindings()),
@@ -756,6 +776,9 @@ export class WechatDaemon {
756
776
  return;
757
777
  }
758
778
  while (!this.shutdownPromise) {
779
+ if (!this.transport) {
780
+ throw new Error("The wechat poll loop requires a WeChat transport.");
781
+ }
759
782
  let pollResult;
760
783
  try {
761
784
  pollResult = await this.transport.pollMessages({
@@ -853,7 +876,7 @@ export class WechatDaemon {
853
876
  // the process alive until the 35-second poll timeout and retain the daemon
854
877
  // endpoint for the next startup to clean up.
855
878
  try {
856
- this.transport.stop();
879
+ this.transport?.stop();
857
880
  }
858
881
  catch {
859
882
  // Best effort shutdown.
@@ -1081,12 +1104,12 @@ export class WechatDaemon {
1081
1104
  remindPendingUserInput: async () => {
1082
1105
  routeMessage = prefixDaemonAdapterMessage(slot.adapter, slot.pendingUserInput
1083
1106
  ? formatPendingUserInputReminder(slot.pendingUserInput)
1084
- : `${slot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`);
1107
+ : t("bridge.input.waiting", { adapter: slot.adapter }));
1085
1108
  reason = "pending_user_input";
1086
1109
  await sendReminder(routeMessage, "user_input_required");
1087
1110
  },
1088
1111
  remindBusy: async () => {
1089
- routeMessage = prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`);
1112
+ routeMessage = prefixDaemonAdapterMessage(slot.adapter, t("bridge.busy.remote", { adapter: slot.adapter }));
1090
1113
  reason = "busy";
1091
1114
  await sendReminder(routeMessage, "notice");
1092
1115
  },
@@ -1362,7 +1385,7 @@ export class WechatDaemon {
1362
1385
  }
1363
1386
  else if (slot.adapter === "opencode" || slot.adapter === "pi") {
1364
1387
  if (!slot.runtime.createSession) {
1365
- throw new Error(`/new is not available in ${slot.adapter} mode.`);
1388
+ throw new Error(t("bridge.newSession.unavailable", { adapter: slot.adapter }));
1366
1389
  }
1367
1390
  await slot.runtime.createSession();
1368
1391
  }
@@ -1379,12 +1402,28 @@ export class WechatDaemon {
1379
1402
  },
1380
1403
  });
1381
1404
  }
1405
+ if (this.channelId === "local") {
1406
+ return new LocalChannelPort({
1407
+ sendText: (recipientId, text, context) => this.queueWechatMessage(recipientId, text, context),
1408
+ prefixText: (currentAdapter, text) => prefixDaemonAdapterMessage(currentAdapter ?? adapter, text),
1409
+ onEmptyVisibleReply: (currentAdapter, rawText) => {
1410
+ appendDaemonLog(`empty_visible_final_reply: adapter=${currentAdapter ?? adapter} raw=${truncatePreview(rawText)}`);
1411
+ },
1412
+ onTextSent: (currentAdapter, text) => {
1413
+ appendDaemonLog(`final_reply_sent: adapter=${currentAdapter ?? adapter} chars=${Array.from(text).length}`);
1414
+ },
1415
+ });
1416
+ }
1417
+ if (!this.transport) {
1418
+ throw new Error("The wechat channel port requires a WeChat transport.");
1419
+ }
1420
+ const transport = this.transport;
1382
1421
  return new WechatChannelPort({
1383
1422
  sendText: (recipientId, text, context) => this.queueWechatMessage(recipientId, text, context),
1384
- sendImage: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(filePath, { recipientId })),
1385
- sendFile: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendFile(filePath, { recipientId })),
1386
- sendVoice: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendVoice(filePath, recipientId)),
1387
- sendVideo: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendVideo(filePath, { recipientId })),
1423
+ sendImage: (recipientId, filePath) => this.queueWechatAttachmentAction(() => transport.sendImage(filePath, { recipientId })),
1424
+ sendFile: (recipientId, filePath) => this.queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId })),
1425
+ sendVoice: (recipientId, filePath) => this.queueWechatAttachmentAction(() => transport.sendVoice(filePath, recipientId)),
1426
+ sendVideo: (recipientId, filePath) => this.queueWechatAttachmentAction(() => transport.sendVideo(filePath, { recipientId })),
1388
1427
  prefixText: (currentAdapter, text) => prefixDaemonAdapterMessage(currentAdapter ?? adapter, text),
1389
1428
  onEmptyVisibleReply: (currentAdapter, rawText) => {
1390
1429
  appendDaemonLog(`empty_visible_final_reply: adapter=${currentAdapter ?? adapter} raw=${truncatePreview(rawText)}`);
@@ -1402,10 +1441,18 @@ export class WechatDaemon {
1402
1441
  ? this.resolveSlotOutputTarget(slot)
1403
1442
  : this.fallbackConversation;
1404
1443
  const eventTask = slot.turns.activeTask;
1444
+ if (adapterState.pendingApprovals !== undefined) {
1445
+ const previous = new Map(slot.pendingConfirmations.map((pending) => [pending.requestId, pending]));
1446
+ slot.pendingConfirmations = adapterState.pendingApprovals.map((request) => {
1447
+ const existing = previous.get(request.requestId);
1448
+ return existing ? { ...existing, ...request } : toPendingApproval({ type: "approval_required", request, timestamp: nowIso() });
1449
+ });
1450
+ }
1405
1451
  if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
1406
1452
  slot.pendingConfirmations = [];
1407
1453
  }
1408
- if (slot.pendingUserInput && !adapterState.pendingUserInput) {
1454
+ if (slot.pendingUserInput && (!adapterState.pendingUserInput
1455
+ || (slot.pendingUserInput.requestId !== undefined && slot.pendingUserInput.requestId !== adapterState.pendingUserInput.requestId))) {
1409
1456
  slot.pendingUserInput = null;
1410
1457
  }
1411
1458
  slot.eventForwardChain = slot.eventForwardChain
@@ -1452,17 +1499,29 @@ export class WechatDaemon {
1452
1499
  },
1453
1500
  approvalRequired: (next) => {
1454
1501
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1502
+ const liveState = slot.runtime.getState();
1503
+ if (liveState.pendingApprovals !== undefined && liveState.pendingApproval?.requestId !== next.request.requestId)
1504
+ return;
1455
1505
  const pending = toPendingApproval(next);
1456
- slot.pendingConfirmations.push(pending);
1506
+ if (liveState.pendingApprovals === undefined) {
1507
+ slot.pendingConfirmations = [pending];
1508
+ }
1457
1509
  appendDaemonLog(`approval_required: adapter=${slot.adapter} source=${pending.source} command=${truncatePreview(pending.commandPreview)}`);
1458
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required", eventTarget);
1510
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, liveState)), "approval_required", eventTarget);
1511
+ if (slot.runtime.getState().pendingApproval?.requestId === pending.requestId) {
1512
+ slot.displayedApprovalRequestId = pending.requestId;
1513
+ }
1459
1514
  }));
1460
1515
  },
1461
1516
  userInputRequired: (next) => {
1517
+ if (next.request.requestId !== undefined && slot.runtime.getState().pendingUserInput?.requestId !== next.request.requestId)
1518
+ return;
1462
1519
  const pending = toPendingUserInput(next.request);
1463
1520
  slot.pendingUserInput = pending;
1464
1521
  appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
1465
1522
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1523
+ if (pending.requestId !== undefined && slot.runtime.getState().pendingUserInput?.requestId !== pending.requestId)
1524
+ return;
1466
1525
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required", eventTarget);
1467
1526
  }));
1468
1527
  },
@@ -1561,7 +1620,12 @@ export class WechatDaemon {
1561
1620
  }
1562
1621
  async handleInboundMessage(message) {
1563
1622
  if (message.senderId !== this.authorizedUserId) {
1564
- await this.queueWechatMessage(message.senderId, `Unauthorized. This daemon only accepts messages from ${this.channelDriver.operatorDescription}.`);
1623
+ await this.queueWechatMessage(message.senderId, t("bridge.unauthorized", { mode: t("bridge.mode.daemon"), operator: this.channelDriver.operatorDescription }));
1624
+ return;
1625
+ }
1626
+ const languageCommand = parseBridgeControlCommand(message.text);
1627
+ if (languageCommand?.type === "language") {
1628
+ await this.handleLanguageCommand(message.senderId, languageCommand.target);
1565
1629
  return;
1566
1630
  }
1567
1631
  const emojiMatch = resolveEmojiCommand(message.text);
@@ -1578,7 +1642,7 @@ export class WechatDaemon {
1578
1642
  }
1579
1643
  else {
1580
1644
  const detail = formatDaemonSwitchResultDetail(result);
1581
- await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchTarget}.\n${detail}`);
1645
+ await this.queueWechatMessage(message.senderId, t("daemon.activateFailed", { adapter: switchTarget, detail }));
1582
1646
  return;
1583
1647
  }
1584
1648
  }
@@ -1597,7 +1661,7 @@ export class WechatDaemon {
1597
1661
  });
1598
1662
  const detail = formatDaemonSwitchResultDetail(result);
1599
1663
  if (!result.activated) {
1600
- await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchDirective.adapter}.\n${detail}`);
1664
+ await this.queueWechatMessage(message.senderId, t("daemon.activateFailed", { adapter: switchDirective.adapter, detail }));
1601
1665
  return;
1602
1666
  }
1603
1667
  this.bindCurrentWecomConversation(this.getActiveSlot());
@@ -1605,12 +1669,12 @@ export class WechatDaemon {
1605
1669
  message = { ...message, text: switchDirective.remainder };
1606
1670
  }
1607
1671
  else {
1608
- await this.queueWechatMessage(message.senderId, `Active terminal: ${switchDirective.adapter}.\n${detail}`);
1672
+ await this.queueWechatMessage(message.senderId, t("daemon.activeTerminal", { adapter: switchDirective.adapter, detail }));
1609
1673
  return;
1610
1674
  }
1611
1675
  }
1612
1676
  if (message.text.trim().toLowerCase() === "/daemon-stop") {
1613
- await this.queueWechatMessage(message.senderId, `Stopping ${this.channelId}-daemon...`);
1677
+ await this.queueWechatMessage(message.senderId, t("daemon.stopping", { channel: this.channelId }));
1614
1678
  setTimeout(() => {
1615
1679
  void this.shutdown().finally(() => process.exit(0));
1616
1680
  }, 0);
@@ -1651,10 +1715,10 @@ export class WechatDaemon {
1651
1715
  remindPendingUserInput: async () => {
1652
1716
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, currentSlot.pendingUserInput
1653
1717
  ? formatPendingUserInputReminder(currentSlot.pendingUserInput)
1654
- : `${currentSlot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
1718
+ : t("bridge.input.waiting", { adapter: currentSlot.adapter })));
1655
1719
  },
1656
1720
  remindBusy: async () => {
1657
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, `${currentSlot.adapter} is still working. Wait for the current reply or use /stop.`));
1721
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, t("bridge.busy.remote", { adapter: currentSlot.adapter })));
1658
1722
  },
1659
1723
  defer: async () => undefined,
1660
1724
  dispatch: async () => {
@@ -1702,20 +1766,23 @@ export class WechatDaemon {
1702
1766
  }
1703
1767
  case "bind": {
1704
1768
  setBinding(cmd.emoji, cmd.command);
1705
- await this.queueWechatMessage(senderId, `Bound ${cmd.emoji} → ${cmd.command}`);
1769
+ await this.queueWechatMessage(senderId, t("binding.bound", { emoji: cmd.emoji, command: cmd.command }));
1706
1770
  return;
1707
1771
  }
1708
1772
  case "unbind": {
1709
1773
  const removed = removeBinding(cmd.emoji);
1710
1774
  await this.queueWechatMessage(senderId, removed
1711
- ? `Unbound ${cmd.emoji}`
1712
- : `No binding found for ${cmd.emoji}`);
1775
+ ? t("binding.unbound", { emoji: cmd.emoji })
1776
+ : t("binding.notFound", { emoji: cmd.emoji }));
1713
1777
  return;
1714
1778
  }
1715
1779
  }
1716
1780
  }
1717
1781
  async handleSystemCommand(message, activeSlot, command) {
1718
1782
  switch (command.type) {
1783
+ case "language":
1784
+ await this.handleLanguageCommand(message.senderId, command.target);
1785
+ return;
1719
1786
  case "model":
1720
1787
  case "plan":
1721
1788
  await this.queueWechatMessage(message.senderId, await handleAdapterControl(activeSlot.runtime, message.senderId, command));
@@ -1725,7 +1792,7 @@ export class WechatDaemon {
1725
1792
  return;
1726
1793
  case "resume": {
1727
1794
  if (!isWechatResumeEnabled(activeSlot.adapter)) {
1728
- await this.queueWechatMessage(message.senderId, `${this.channelDriver.displayName} /resume is disabled for ${activeSlot.adapter} in daemon mode. Use /resume directly inside the visible terminal; the remote channel will follow that local session.`);
1795
+ await this.queueWechatMessage(message.senderId, t("daemon.resume.disabled", { channel: this.channelDriver.displayName, adapter: activeSlot.adapter }));
1729
1796
  return;
1730
1797
  }
1731
1798
  try {
@@ -1757,7 +1824,7 @@ export class WechatDaemon {
1757
1824
  }
1758
1825
  case "new_session":
1759
1826
  if (!activeSlot.runtime.createSession) {
1760
- await this.queueWechatMessage(message.senderId, `/new is not available in ${activeSlot.adapter} mode.`);
1827
+ await this.queueWechatMessage(message.senderId, t("bridge.newSession.unavailable", { adapter: activeSlot.adapter }));
1761
1828
  return;
1762
1829
  }
1763
1830
  await activeSlot.outputBatcher.flushNow();
@@ -1774,8 +1841,8 @@ export class WechatDaemon {
1774
1841
  case "stop": {
1775
1842
  const interrupted = await activeSlot.runtime.interrupt();
1776
1843
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
1777
- ? "Interrupt signal sent to the active worker."
1778
- : "No running worker was available to interrupt."));
1844
+ ? t("bridge.interrupt.workerSent")
1845
+ : t("bridge.interrupt.workerIdle")));
1779
1846
  return;
1780
1847
  }
1781
1848
  case "reset":
@@ -1786,7 +1853,7 @@ export class WechatDaemon {
1786
1853
  activeSlot.resumeCoordinator.clear();
1787
1854
  await activeSlot.runtime.reset();
1788
1855
  appendDaemonLog(`reset: adapter=${activeSlot.adapter}`);
1789
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "Worker session has been reset."));
1856
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, t("bridge.reset.done")));
1790
1857
  return;
1791
1858
  case "confirm":
1792
1859
  await this.confirmPendingApproval(message, activeSlot);
@@ -1799,42 +1866,49 @@ export class WechatDaemon {
1799
1866
  return;
1800
1867
  }
1801
1868
  }
1869
+ async handleLanguageCommand(senderId, target) {
1870
+ await this.queueWechatMessage(senderId, await handleLanguageControl({
1871
+ target,
1872
+ settingsFile: this.deps.localeSettingsFile ?? LANGUAGE_FILE,
1873
+ adapters: [...this.slots.values()].map((slot) => slot.runtime),
1874
+ }));
1875
+ }
1802
1876
  async confirmPendingApproval(message, activeSlot) {
1803
1877
  const slot = this.resolvePendingApprovalSlot(activeSlot);
1804
1878
  if (!slot || slot.pendingConfirmations.length === 0) {
1805
- await this.queueWechatMessage(message.senderId, "No pending approval request.");
1879
+ await this.queueWechatMessage(message.senderId, t("approval.noPending"));
1806
1880
  return;
1807
1881
  }
1808
- const count = await slot.runtime.resolveAllApprovals("confirm");
1882
+ const pending = slot.pendingConfirmations[0];
1883
+ const count = await slot.runtime.resolveApproval("confirm", slot.runtime.getState().pendingApprovals !== undefined ? slot.displayedApprovalRequestId ?? "" : pending.requestId) ? 1 : 0;
1809
1884
  if (!count) {
1810
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "The worker could not apply this approval request."));
1885
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, t("bridge.approval.applyFailed")));
1811
1886
  return;
1812
1887
  }
1813
- const preview = slot.pendingConfirmations[0]?.commandPreview ?? "";
1814
- slot.pendingConfirmations = [];
1815
- slot.turns.setActiveTask({
1816
- startedAt: Date.now(),
1817
- inputPreview: preview,
1818
- });
1888
+ const preview = pending.commandPreview;
1889
+ const adapterState = slot.runtime.getState();
1890
+ slot.pendingConfirmations = (adapterState.pendingApprovals ?? (adapterState.pendingApproval ? [adapterState.pendingApproval] : []))
1891
+ .map((request) => toPendingApproval({ type: "approval_required", request, timestamp: nowIso() }));
1819
1892
  appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} count=${count} command=${truncatePreview(preview)}`);
1820
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1
1821
- ? `${count} approvals confirmed. Continuing...`
1822
- : "Approval confirmed. Continuing..."));
1893
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, t("approval.confirmed")));
1823
1894
  }
1824
1895
  async denyPendingApproval(message, activeSlot) {
1825
1896
  const slot = this.resolvePendingApprovalSlot(activeSlot);
1826
1897
  if (!slot || slot.pendingConfirmations.length === 0) {
1827
- await this.queueWechatMessage(message.senderId, "No pending approval request.");
1898
+ await this.queueWechatMessage(message.senderId, t("approval.noPending"));
1828
1899
  return;
1829
1900
  }
1830
- const count = await slot.runtime.resolveAllApprovals("deny");
1901
+ const pending = slot.pendingConfirmations[0];
1902
+ const count = await slot.runtime.resolveApproval("deny", slot.runtime.getState().pendingApprovals !== undefined ? slot.displayedApprovalRequestId ?? "" : pending.requestId) ? 1 : 0;
1831
1903
  if (!count) {
1832
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "The worker could not deny this approval request cleanly."));
1904
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, t("bridge.approval.denyFailed")));
1833
1905
  return;
1834
1906
  }
1835
- slot.pendingConfirmations = [];
1907
+ const adapterState = slot.runtime.getState();
1908
+ slot.pendingConfirmations = (adapterState.pendingApprovals ?? (adapterState.pendingApproval ? [adapterState.pendingApproval] : []))
1909
+ .map((request) => toPendingApproval({ type: "approval_required", request, timestamp: nowIso() }));
1836
1910
  appendDaemonLog(`approval_denied: adapter=${slot.adapter} count=${count}`);
1837
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1 ? `${count} approvals denied.` : "Approval denied."));
1911
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, t("approval.denied")));
1838
1912
  }
1839
1913
  async answerPendingUserInput(message, activeSlot, raw) {
1840
1914
  // Mirror the /confirm and /deny behavior: search every slot for a pending
@@ -1842,12 +1916,12 @@ export class WechatDaemon {
1842
1916
  // when the user has since switched the active adapter.
1843
1917
  const slot = this.resolvePendingUserInputSlot(activeSlot);
1844
1918
  if (!slot) {
1845
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
1919
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, t("bridge.answer.none")));
1846
1920
  return;
1847
1921
  }
1848
1922
  const pending = slot.pendingUserInput;
1849
1923
  if (!pending) {
1850
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
1924
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, t("bridge.answer.none")));
1851
1925
  return;
1852
1926
  }
1853
1927
  const parsed = parsePendingUserInputAnswerCommand(raw, pending);
@@ -1855,18 +1929,15 @@ export class WechatDaemon {
1855
1929
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, parsed.error));
1856
1930
  return;
1857
1931
  }
1858
- const submitted = await slot.runtime.submitUserInput(parsed.answers);
1932
+ const submitted = await slot.runtime.submitUserInput(parsed.answers, pending.requestId);
1859
1933
  if (!submitted) {
1860
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "The worker could not apply this answer."));
1934
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, t("bridge.answer.failed")));
1861
1935
  return;
1862
1936
  }
1863
- slot.pendingUserInput = null;
1864
- slot.turns.setActiveTask({
1865
- startedAt: Date.now(),
1866
- inputPreview: parsed.preview,
1867
- });
1937
+ if (slot.pendingUserInput?.requestId === pending.requestId)
1938
+ slot.pendingUserInput = null;
1868
1939
  appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
1869
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
1940
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, t("bridge.answer.sent")));
1870
1941
  }
1871
1942
  resolvePendingUserInputSlot(activeSlot) {
1872
1943
  if (activeSlot.pendingUserInput) {
@@ -1904,14 +1975,14 @@ export class WechatDaemon {
1904
1975
  .map((adapter) => this.slots.get(adapter))
1905
1976
  .filter((slot) => Boolean(slot));
1906
1977
  if (startedSlots.length === 0) {
1907
- await this.queueWechatMessage(message.senderId, "/all needs at least one running adapter. Open one first with /codex, /claude, /opencode, or /pi.");
1978
+ await this.queueWechatMessage(message.senderId, t("daemon.broadcast.empty"));
1908
1979
  return;
1909
1980
  }
1910
1981
  const busySlots = startedSlots.filter((slot) => this.isSlotBusyForBroadcast(slot));
1911
1982
  if (busySlots.length > 0) {
1912
1983
  const names = busySlots.map((slot) => slot.adapter).join(", ");
1913
1984
  appendDaemonLog(`broadcast_canceled_busy: busy=[${names}]`);
1914
- await this.queueWechatMessage(message.senderId, `/all canceled: ${busySlots.length > 1 ? "these workers are" : "this worker is"} still busy — ${names}. Wait for the current replies or use /stop first.`);
1985
+ await this.queueWechatMessage(message.senderId, t("daemon.broadcast.busy", { workers: t(busySlots.length > 1 ? "daemon.broadcast.multipleBusy" : "daemon.broadcast.singleBusy"), names }));
1915
1986
  return;
1916
1987
  }
1917
1988
  const broadcastMessage = { ...message, text };
@@ -1928,10 +1999,10 @@ export class WechatDaemon {
1928
1999
  dispatched.push(adapter);
1929
2000
  }
1930
2001
  const lines = [
1931
- `Broadcast dispatched to ${dispatched.length} ${dispatched.length > 1 ? "workers" : "worker"}: ${dispatched.join(", ")}. Each reply arrives prefixed with its adapter name.`,
2002
+ t("daemon.broadcast.sent", { count: dispatched.length, workers: t(dispatched.length > 1 ? "daemon.broadcast.multipleWorkers" : "daemon.broadcast.singleWorker"), names: dispatched.join(", ") }),
1932
2003
  ];
1933
2004
  if (skipped.length > 0) {
1934
- lines.push(`Not started (skipped): ${skipped.join(", ")}. Start them with their /command to include them next time.`);
2005
+ lines.push(t("daemon.broadcast.skipped", { names: skipped.join(", ") }));
1935
2006
  }
1936
2007
  await this.queueWechatMessage(message.senderId, lines.join("\n"));
1937
2008
  }
@@ -1948,7 +2019,7 @@ export class WechatDaemon {
1948
2019
  task: nextTask,
1949
2020
  conversation: inboundConversation,
1950
2021
  onBusy: async () => {
1951
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`), "notice", conversationOverride);
2022
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, t("bridge.busy.remote", { adapter: slot.adapter })), "notice", conversationOverride);
1952
2023
  },
1953
2024
  forward: async () => {
1954
2025
  appendDaemonLog(`forwarded_input: adapter=${slot.adapter} text=${truncatePreview(preview)}`);
@@ -2346,11 +2417,13 @@ export async function runDaemon(options) {
2346
2417
  : null;
2347
2418
  const credentials = wecomAccount
2348
2419
  ? { userId: wecomAccount.operatorUserId }
2349
- : await ensureWechatCredentials({
2350
- requireUserId: true,
2351
- validateExisting: true,
2352
- log,
2353
- });
2420
+ : options.channelId === "local"
2421
+ ? { userId: LOCAL_CHANNEL_OPERATOR_ID }
2422
+ : await ensureWechatCredentials({
2423
+ requireUserId: true,
2424
+ validateExisting: true,
2425
+ log,
2426
+ });
2354
2427
  if (!credentials.userId) {
2355
2428
  throw new Error(options.channelId === "wecom"
2356
2429
  ? "Saved WeCom credentials are missing operatorUserId."
@@ -2360,7 +2433,9 @@ export async function runDaemon(options) {
2360
2433
  cwd: options.cwd,
2361
2434
  profile: options.profile,
2362
2435
  authorizedUserId: credentials.userId,
2363
- transport: new WeChatTransport({ log, logError }),
2436
+ transport: (options.channelId ?? "wechat") === "wechat"
2437
+ ? new WeChatTransport({ log, logError })
2438
+ : null,
2364
2439
  channelId: options.channelId,
2365
2440
  accountId: wecomAccount?.botId,
2366
2441
  wecomTransport: wecomAccount
@@ -2372,6 +2447,9 @@ export async function runDaemon(options) {
2372
2447
  },
2373
2448
  })
2374
2449
  : null,
2450
+ localFiles: options.channelId === "local"
2451
+ ? getLocalChannelFiles(options.cwd)
2452
+ : null,
2375
2453
  });
2376
2454
  if (cleanupResult.action === "stopped" && isDaemonAdapterKind(cleanupResult.lock.adapter)) {
2377
2455
  daemon.takenOverAdapter = cleanupResult.lock.adapter;