@unlinearity/cli-wechat-bridge 1.2.1 → 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 (34) hide show
  1. package/README.md +23 -9
  2. package/dist/bridge/adapter-control.js +19 -12
  3. package/dist/bridge/bridge-adapters.claude.js +219 -115
  4. package/dist/bridge/bridge-adapters.codex.js +207 -83
  5. package/dist/bridge/bridge-adapters.core.js +84 -7
  6. package/dist/bridge/bridge-adapters.opencode.js +202 -55
  7. package/dist/bridge/bridge-adapters.pi.js +23 -18
  8. package/dist/bridge/bridge-adapters.shared.js +9 -9
  9. package/dist/bridge/bridge-session-resume.js +13 -6
  10. package/dist/bridge/bridge-utils.js +94 -144
  11. package/dist/bridge/claude-hooks.js +60 -3
  12. package/dist/bridge/claude-native-controls.js +19 -12
  13. package/dist/bridge/language-control.js +33 -0
  14. package/dist/bridge/native-terminal-control.js +26 -10
  15. package/dist/bridge/opencode-v2-compat.js +11 -3
  16. package/dist/bridge/wechat-bridge.js +57 -42
  17. package/dist/channels/channel-driver-factory.js +44 -0
  18. package/dist/channels/local/local-channel-port.js +40 -0
  19. package/dist/channels/local/local-driver.js +217 -0
  20. package/dist/channels/wechat/wechat-driver.js +11 -2
  21. package/dist/channels/wecom/wecom-driver.js +8 -1
  22. package/dist/companion/local-companion.js +27 -7
  23. package/dist/companion/pi-tui-bridge-extension.js +10 -4
  24. package/dist/core/bridge-control.js +3 -0
  25. package/dist/core/bridge-defer.js +3 -2
  26. package/dist/core/bridge-event-forwarder.js +3 -0
  27. package/dist/core/channel-types.js +5 -1
  28. package/dist/daemon/wechat-daemon.js +202 -124
  29. package/dist/i18n/index.js +25 -11
  30. package/dist/i18n/messages-en.js +244 -1
  31. package/dist/i18n/messages-zh.js +244 -1
  32. package/dist/runtime/legacy-adapter-runtime.js +7 -4
  33. package/dist/wechat/channel-config.js +3 -2
  34. package/package.json +1 -1
@@ -0,0 +1,33 @@
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 { getLocale, parseLocale, setLocale, t } from "../i18n/index.js";
8
+ import { writeJsonFileAtomic } from "../utils/atomic-file.js";
9
+ let languageChangeChain = Promise.resolve();
10
+ /** Language changes never touch turn ownership or pending interactions. */
11
+ export function handleLanguageControl(params) {
12
+ const run = languageChangeChain.then(() => applyLanguageControl(params));
13
+ languageChangeChain = run.catch(() => undefined);
14
+ return run;
15
+ }
16
+ async function applyLanguageControl(params) {
17
+ if (!params.target)
18
+ return t("language.current", { locale: getLocale() });
19
+ const locale = parseLocale(params.target);
20
+ if (!locale)
21
+ return t("language.usage");
22
+ try {
23
+ writeJsonFileAtomic(params.settingsFile, { locale });
24
+ }
25
+ catch (error) {
26
+ return t("language.saveFailed", { error: error instanceof Error ? error.message : String(error) });
27
+ }
28
+ setLocale(locale);
29
+ process.env.CLI_BRIDGE_LANG = locale;
30
+ const results = await Promise.allSettled((params.adapters ?? []).map((adapter) => adapter.updateLocale?.(locale) ?? true));
31
+ const needsRestart = results.some((result) => result.status === "rejected" || result.value === false);
32
+ return t("language.changed", { locale }) + (needsRestart ? `\n${t("language.companionRestart")}` : "");
33
+ }
@@ -5,6 +5,7 @@
5
5
  // must be released under AGPL-3.0-or-later with full source code; see
6
6
  // LICENSE.txt. Network services built on it must offer source to users.
7
7
  import xterm from "@xterm/headless";
8
+ import { t } from "../i18n/index.js";
8
9
  const { Terminal } = xterm;
9
10
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
11
  /** A screen mirror, never a second input source: only explicit control operations write keys. */
@@ -117,20 +118,20 @@ export class NativeTerminalControl {
117
118
  check() {
118
119
  const operation = this.operation;
119
120
  if (!operation)
120
- throw new Error("No terminal control operation is active.");
121
+ throw new Error(t("native.control.inactive"));
121
122
  if (operation.error)
122
123
  throw operation.error;
123
124
  if (operation.identity !== this.options.identity()) {
124
- operation.error = new Error("The active CLI session changed during control. Try again.");
125
+ operation.error = new Error(t("native.control.sessionChanged"));
125
126
  throw operation.error;
126
127
  }
127
128
  this.options.assertReady();
128
129
  if (Date.now() > operation.deadline)
129
- throw new Error("Native CLI control timed out; the result could not be confirmed.");
130
+ throw new Error(t("native.control.timeout"));
130
131
  }
131
132
  async run(action) {
132
133
  if (this.operation)
133
- throw new Error("Another CLI control operation is in progress.");
134
+ throw new Error(t("native.control.busy"));
134
135
  this.options.assertReady();
135
136
  this.operation = { identity: this.options.identity(), deadline: Date.now() + 25_000 };
136
137
  try {
@@ -143,7 +144,7 @@ export class NativeTerminalControl {
143
144
  else if (this.draft && this.options.isPromptEmpty?.())
144
145
  this.draft = false;
145
146
  if (this.draft)
146
- throw new Error("The local terminal has an input draft. Submit or clear it before using remote controls.");
147
+ throw new Error(t("native.control.draft"));
147
148
  const result = await action();
148
149
  this.check();
149
150
  return result;
@@ -183,7 +184,7 @@ export class NativeTerminalControl {
183
184
  }
184
185
  async command(command) {
185
186
  if (!/^\/[a-z]+$/.test(command))
186
- throw new Error("Invalid native control command.");
187
+ throw new Error(t("native.control.invalid"));
187
188
  await this.key(`\u001b[200~${command}\u001b[201~`);
188
189
  await this.key("\r");
189
190
  // Claude can consume the first Enter to accept a slash completion while its
@@ -211,7 +212,7 @@ export class NativeTerminalControl {
211
212
  }
212
213
  async openMenu(command, title) {
213
214
  if (this.ownedMenu || /Select model|Select agent|Select variant/.test(this.text)) {
214
- throw new Error("A native CLI menu is already open. Close it before using remote controls.");
215
+ throw new Error(t("native.control.menuOpen"));
215
216
  }
216
217
  await this.command(command);
217
218
  await this.waitFor(() => this.hasMenu(title));
@@ -220,7 +221,7 @@ export class NativeTerminalControl {
220
221
  async closeMenu() {
221
222
  const title = this.ownedMenu;
222
223
  if (!title || !this.hasMenu(title))
223
- throw new Error("The native selector changed unexpectedly.");
224
+ throw new Error(t("native.control.selectorChanged"));
224
225
  await this.key("\u001b");
225
226
  await this.waitFor(() => !this.hasMenu(title));
226
227
  this.ownedMenu = null;
@@ -228,7 +229,7 @@ export class NativeTerminalControl {
228
229
  async submitMenu(nextTitle, key = "\r") {
229
230
  const title = this.ownedMenu;
230
231
  if (!title || !this.hasMenu(title))
231
- throw new Error("The native selector changed unexpectedly.");
232
+ throw new Error(t("native.control.selectorChanged"));
232
233
  await this.key(key);
233
234
  await this.waitFor(() => !this.hasMenu(title));
234
235
  this.ownedMenu = nextTitle && this.hasMenu(nextTitle) ? nextTitle : null;
@@ -260,7 +261,7 @@ export function parseClaudeModelSwitchConfirmation(text) {
260
261
  export function readClaudePermissionMode(text) {
261
262
  const lines = text.split("\n");
262
263
  const prompt = lines.findLastIndex((line) => /^\s*❯/.test(line));
263
- if (prompt < 0 || !/^\s*❯\s*$/.test(lines[prompt]))
264
+ if (prompt < 0 || !isClaudePromptVisuallyEmpty(lines[prompt]))
264
265
  return null;
265
266
  const footer = lines.slice(prompt + 1).join("\n");
266
267
  for (const [label, mode] of [["plan mode", "plan"], ["accept edits", "acceptEdits"], ["bypass permissions", "bypassPermissions"], ["auto", "auto"], ["don't ask", "dontAsk"]]) {
@@ -269,3 +270,18 @@ export function readClaudePermissionMode(text) {
269
270
  }
270
271
  return "default";
271
272
  }
273
+ /**
274
+ * Claude renders idle decorations inside the input box: an automatic context
275
+ * chip like `[⧉ In file.ts]` and a suggestion placeholder like
276
+ * Try "how does <filepath> work?". Both appear on a freshly started session
277
+ * before any user input; neither is a draft. Real typed input still fails
278
+ * this check (and the local-input mirror blocks controls independently).
279
+ */
280
+ function isClaudePromptVisuallyEmpty(line) {
281
+ const body = line
282
+ .replace(/^\s*❯\s*/, "")
283
+ .replace(/^\[⧉ [^\]]*\]\s*/, "")
284
+ .replace(/^Try "[^"]*"\s*$/, "")
285
+ .replace(/\s+$/, "");
286
+ return body.length === 0;
287
+ }
@@ -110,9 +110,14 @@ function normalizeFormQuestions(form) {
110
110
  });
111
111
  }
112
112
  function normalizeFormAnswer(field, values) {
113
+ const options = Array.isArray(field.options) ? field.options.filter(isRecord) : [];
114
+ const normalizedValues = values.map((value) => {
115
+ const option = options.find((option) => option.label === value);
116
+ return typeof option?.value === "string" ? option.value : value;
117
+ });
113
118
  if (field.type === "multiselect")
114
- return values;
115
- const value = values[0] ?? "";
119
+ return normalizedValues;
120
+ const value = normalizedValues[0] ?? "";
116
121
  if (field.type === "boolean") {
117
122
  return /^(1|true|yes|y)$/i.test(String(value));
118
123
  }
@@ -407,7 +412,10 @@ export async function createOpenCodeV2CompatClient(options) {
407
412
  sessionID: input.sessionID ?? permissionSessions.get(String(input.requestID)),
408
413
  requestID: input.requestID,
409
414
  decision: input.reply === "reject" ? "reject" : input.reply,
410
- }, requestOptions).finally(() => permissionSessions.delete(String(input.requestID)))),
415
+ }, requestOptions).then((response) => {
416
+ permissionSessions.delete(String(input.requestID));
417
+ return response;
418
+ })),
411
419
  },
412
420
  question: {
413
421
  reply: (input, requestOptions) => result(async () => {
@@ -22,6 +22,8 @@ import { canDrainDeferredCodexInboundQueue, formatDeferredCodexInboundQueueMessa
22
22
  import { TurnCoordinator } from "../core/turn-coordinator.js";
23
23
  import { resolveOutboundConversationTarget } from "../core/outbound-target.js";
24
24
  import { handleAdapterControl } from "./adapter-control.js";
25
+ import { handleLanguageControl } from "./language-control.js";
26
+ import { LANGUAGE_FILE } from "../wechat/channel-config.js";
25
27
  import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
26
28
  import { isDirectModuleRun } from "../core/direct-run.js";
27
29
  import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
@@ -706,7 +708,7 @@ async function main() {
706
708
  task: nextActiveTask,
707
709
  conversation: channelMessage.conversation,
708
710
  onBusy: async () => {
709
- await queueInboundReply(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
711
+ await queueInboundReply(message.senderId, t("bridge.busy.remote", { adapter: options.adapter }));
710
712
  },
711
713
  forward: async () => {
712
714
  await dispatchInboundWechatText({
@@ -752,7 +754,7 @@ async function main() {
752
754
  if (chatType === "direct") {
753
755
  const result = await channelDriver.sendText({
754
756
  target: channelDriver.directConversation(senderId),
755
- text: "Unauthorized.",
757
+ text: t("channel.unauthorized"),
756
758
  context: "notice",
757
759
  log: (entry) => stateStore.appendLog(entry),
758
760
  });
@@ -884,7 +886,7 @@ async function main() {
884
886
  const result = await turns.dispatch({
885
887
  task: nextActiveTask,
886
888
  onBusy: async () => {
887
- await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
889
+ await queueWechatMessage(message.senderId, t("bridge.busy.remote", { adapter: options.adapter }));
888
890
  },
889
891
  forward: async () => {
890
892
  await dispatchInboundWechatText({
@@ -954,7 +956,7 @@ function syncSharedSessionState(stateStore, adapter) {
954
956
  }
955
957
  }
956
958
  }
957
- function wireAdapterEvents(params) {
959
+ export function wireAdapterEvents(params) {
958
960
  const { adapter, channelDriver, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, wecomTransport, getWecomTarget, } = params;
959
961
  const channelPort = options.channelId === "wecom"
960
962
  ? new WecomChannelPort({
@@ -984,10 +986,12 @@ function wireAdapterEvents(params) {
984
986
  syncLocalClientEndpoint();
985
987
  const adapterState = adapter.getState();
986
988
  const bridgeState = stateStore.getState();
987
- if (bridgeState.pendingConfirmation && !adapterState.pendingApproval) {
989
+ if (bridgeState.pendingConfirmation && (!adapterState.pendingApproval
990
+ || (adapterState.pendingApprovals !== undefined && bridgeState.pendingConfirmation.requestId !== adapterState.pendingApproval.requestId))) {
988
991
  stateStore.clearPendingConfirmation();
989
992
  }
990
- if (bridgeState.pendingUserInput && !adapterState.pendingUserInput) {
993
+ if (bridgeState.pendingUserInput && (!adapterState.pendingUserInput
994
+ || (bridgeState.pendingUserInput.requestId !== undefined && bridgeState.pendingUserInput.requestId !== adapterState.pendingUserInput.requestId))) {
991
995
  stateStore.clearPendingUserInput();
992
996
  }
993
997
  const authorizedUserId = stateStore.getState().authorizedUserId;
@@ -1050,14 +1054,21 @@ function wireAdapterEvents(params) {
1050
1054
  },
1051
1055
  approvalRequired: (next) => {
1052
1056
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
1057
+ const liveState = adapter.getState();
1058
+ if (liveState.pendingApprovals !== undefined && liveState.pendingApproval?.requestId !== next.request.requestId)
1059
+ return;
1053
1060
  const pending = toPendingApproval(next.request);
1054
- stateStore.setPendingConfirmation(pending);
1055
1061
  stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
1056
- await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required", eventTarget);
1062
+ await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, liveState), "approval_required", eventTarget);
1063
+ if (adapter.getState().pendingApproval?.requestId === pending.requestId) {
1064
+ stateStore.setPendingConfirmation(pending);
1065
+ }
1057
1066
  }));
1058
1067
  },
1059
1068
  userInputRequired: (next) => {
1060
1069
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
1070
+ if (next.request.requestId !== undefined && adapter.getState().pendingUserInput?.requestId !== next.request.requestId)
1071
+ return;
1061
1072
  const pending = toPendingUserInput(next.request);
1062
1073
  stateStore.setPendingUserInput(pending);
1063
1074
  stateStore.appendLog(`User input requested: questions=${pending.questions.length}`);
@@ -1155,12 +1166,12 @@ function formatInboundMessagePreview(message) {
1155
1166
  }
1156
1167
  return "(empty)";
1157
1168
  }
1158
- async function handleInboundMessage(params) {
1169
+ export async function handleInboundMessage(params) {
1159
1170
  const { message, channelMessage, channelDriver, } = params;
1160
1171
  const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, dispatchInboundText, deferInboundMessage, } = params;
1161
1172
  const state = stateStore.getState();
1162
1173
  if (message.senderId !== state.authorizedUserId) {
1163
- await queueWechatMessage(message.senderId, `Unauthorized. This bridge only accepts messages from ${channelDriver.operatorDescription}.`);
1174
+ await queueWechatMessage(message.senderId, t("bridge.unauthorized", { mode: t("bridge.mode.bridge"), operator: channelDriver.operatorDescription }));
1164
1175
  return null;
1165
1176
  }
1166
1177
  const systemCommand = parseWechatControlCommand(message.text, {
@@ -1169,19 +1180,24 @@ async function handleInboundMessage(params) {
1169
1180
  hasPendingUserInput: Boolean(state.pendingUserInput),
1170
1181
  });
1171
1182
  switch (systemCommand?.type) {
1183
+ case "language":
1184
+ await queueWechatMessage(message.senderId, await handleLanguageControl({
1185
+ target: systemCommand.target, settingsFile: params.localeSettingsFile ?? LANGUAGE_FILE, adapters: [adapter],
1186
+ }));
1187
+ return null;
1172
1188
  case "model":
1173
1189
  case "plan":
1174
1190
  await queueWechatMessage(message.senderId, await handleAdapterControl(adapter, message.senderId, systemCommand));
1175
1191
  return null;
1176
1192
  case "broadcast":
1177
- await queueWechatMessage(message.senderId, "/all is only available in daemon mode. Start the daemon with wechat-daemon or wecom-daemon to broadcast to all workers.");
1193
+ await queueWechatMessage(message.senderId, t("bridge.broadcast.daemonOnly"));
1178
1194
  return null;
1179
1195
  case "status":
1180
1196
  await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
1181
1197
  return null;
1182
1198
  case "resume": {
1183
1199
  if (!isWechatResumeEnabled(options.adapter)) {
1184
- await queueWechatMessage(message.senderId, `${channelDriver.displayName} /resume is disabled in ${options.adapter} mode. Use /resume directly inside "${channelDriver.id}-${options.adapter}"; the remote channel will follow the active local session.`);
1200
+ await queueWechatMessage(message.senderId, t("bridge.resume.disabled", { channel: channelDriver.displayName, adapter: options.adapter, command: `${channelDriver.id}-${options.adapter}` }));
1185
1201
  return null;
1186
1202
  }
1187
1203
  try {
@@ -1201,7 +1217,7 @@ async function handleInboundMessage(params) {
1201
1217
  }
1202
1218
  case "new_session": {
1203
1219
  if (!adapter.createSession) {
1204
- await queueWechatMessage(message.senderId, `/new is not available in ${options.adapter} mode.`);
1220
+ await queueWechatMessage(message.senderId, t("bridge.newSession.unavailable", { adapter: options.adapter }));
1205
1221
  return null;
1206
1222
  }
1207
1223
  await outputBatcher.flushNow();
@@ -1217,8 +1233,8 @@ async function handleInboundMessage(params) {
1217
1233
  case "stop": {
1218
1234
  const interrupted = await adapter.interrupt();
1219
1235
  await queueWechatMessage(message.senderId, interrupted
1220
- ? "Interrupt signal sent to the active worker."
1221
- : "No running worker was available to interrupt.");
1236
+ ? t("bridge.interrupt.workerSent")
1237
+ : t("bridge.interrupt.workerIdle"));
1222
1238
  return null;
1223
1239
  }
1224
1240
  case "reset":
@@ -1230,47 +1246,48 @@ async function handleInboundMessage(params) {
1230
1246
  resumeCoordinator.clear();
1231
1247
  await adapter.reset();
1232
1248
  stateStore.appendLog("Worker reset by owner.");
1233
- await queueWechatMessage(message.senderId, "Worker session has been reset.");
1249
+ await queueWechatMessage(message.senderId, t("bridge.reset.done"));
1234
1250
  return null;
1235
1251
  case "confirm": {
1236
1252
  const pending = state.pendingConfirmation;
1237
1253
  if (!pending) {
1238
- await queueWechatMessage(message.senderId, "No pending approval request.");
1254
+ await queueWechatMessage(message.senderId, t("approval.noPending"));
1239
1255
  return null;
1240
1256
  }
1241
- const confirmed = await adapter.resolveApproval("confirm");
1257
+ const confirmed = await adapter.resolveApproval("confirm", pending.requestId);
1242
1258
  if (!confirmed) {
1243
- await queueWechatMessage(message.senderId, "The worker could not apply this approval request.");
1259
+ await queueWechatMessage(message.senderId, t("bridge.approval.applyFailed"));
1244
1260
  return null;
1245
1261
  }
1246
- stateStore.clearPendingConfirmation();
1262
+ if (stateStore.getState().pendingConfirmation?.requestId === pending.requestId) {
1263
+ stateStore.clearPendingConfirmation();
1264
+ }
1247
1265
  stateStore.appendLog(`Approval confirmed: ${pending.commandPreview}`);
1248
- await queueWechatMessage(message.senderId, "Approval confirmed. Continuing...");
1249
- return {
1250
- startedAt: Date.now(),
1251
- inputPreview: pending.commandPreview,
1252
- };
1266
+ await queueWechatMessage(message.senderId, t("approval.confirmed"));
1267
+ return null;
1253
1268
  }
1254
1269
  case "deny": {
1255
1270
  const pending = state.pendingConfirmation;
1256
1271
  if (!pending) {
1257
- await queueWechatMessage(message.senderId, "No pending approval request.");
1272
+ await queueWechatMessage(message.senderId, t("approval.noPending"));
1258
1273
  return null;
1259
1274
  }
1260
- const denied = await adapter.resolveApproval("deny");
1275
+ const denied = await adapter.resolveApproval("deny", pending.requestId);
1261
1276
  if (!denied) {
1262
- await queueWechatMessage(message.senderId, "The worker could not deny this approval request cleanly.");
1277
+ await queueWechatMessage(message.senderId, t("bridge.approval.denyFailed"));
1263
1278
  return null;
1264
1279
  }
1265
- stateStore.clearPendingConfirmation();
1280
+ if (stateStore.getState().pendingConfirmation?.requestId === pending.requestId) {
1281
+ stateStore.clearPendingConfirmation();
1282
+ }
1266
1283
  stateStore.appendLog(`Approval denied: ${pending.commandPreview}`);
1267
- await queueWechatMessage(message.senderId, "Approval denied.");
1284
+ await queueWechatMessage(message.senderId, t("approval.denied"));
1268
1285
  return null;
1269
1286
  }
1270
1287
  case "answer": {
1271
1288
  const pending = state.pendingUserInput;
1272
1289
  if (!pending) {
1273
- await queueWechatMessage(message.senderId, "No pending user input request.");
1290
+ await queueWechatMessage(message.senderId, t("bridge.answer.none"));
1274
1291
  return null;
1275
1292
  }
1276
1293
  const parsed = parsePendingUserInputAnswerCommand(systemCommand.raw, pending);
@@ -1278,18 +1295,16 @@ async function handleInboundMessage(params) {
1278
1295
  await queueWechatMessage(message.senderId, parsed.error);
1279
1296
  return null;
1280
1297
  }
1281
- const submitted = await adapter.submitUserInput(parsed.answers);
1298
+ const submitted = await adapter.submitUserInput(parsed.answers, pending.requestId);
1282
1299
  if (!submitted) {
1283
- await queueWechatMessage(message.senderId, "The worker could not apply this answer.");
1300
+ await queueWechatMessage(message.senderId, t("bridge.answer.failed"));
1284
1301
  return null;
1285
1302
  }
1286
- stateStore.clearPendingUserInput();
1303
+ if (stateStore.getState().pendingUserInput?.requestId === pending.requestId)
1304
+ stateStore.clearPendingUserInput();
1287
1305
  stateStore.appendLog(`User input answered: ${parsed.preview}`);
1288
- await queueWechatMessage(message.senderId, "Answer submitted. Continuing...");
1289
- return {
1290
- startedAt: Date.now(),
1291
- inputPreview: parsed.preview,
1292
- };
1306
+ await queueWechatMessage(message.senderId, t("bridge.answer.sent"));
1307
+ return null;
1293
1308
  }
1294
1309
  }
1295
1310
  const adapterState = adapter.getState();
@@ -1316,16 +1331,16 @@ async function handleInboundMessage(params) {
1316
1331
  const pendingUserInput = stateStore.getState().pendingUserInput;
1317
1332
  await queueWechatMessage(message.senderId, pendingUserInput
1318
1333
  ? formatPendingUserInputReminder(pendingUserInput)
1319
- : `${options.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`);
1334
+ : t("bridge.input.waiting", { adapter: options.adapter }));
1320
1335
  },
1321
1336
  remindBusy: async () => {
1322
1337
  const currentState = adapter.getState();
1323
1338
  if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1324
1339
  currentState.activeTurnOrigin === "local") {
1325
- await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
1340
+ await queueWechatMessage(message.senderId, t("bridge.busy.local", { adapter: options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex" }));
1326
1341
  return;
1327
1342
  }
1328
- await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
1343
+ await queueWechatMessage(message.senderId, t("bridge.busy.remote", { adapter: options.adapter }));
1329
1344
  },
1330
1345
  defer: async () => {
1331
1346
  await deferInboundMessage(message);
@@ -0,0 +1,44 @@
1
+ import { WecomChannelDriver } from "./wecom/wecom-driver.js";
2
+ import { WechatChannelDriver } from "./wechat/wechat-driver.js";
3
+ import { LocalChannelDriver } from "./local/local-driver.js";
4
+ /**
5
+ * Single construction point for channel drivers. Adding a channel means
6
+ * adding a driver implementation and one branch here; orchestration layers
7
+ * stay channel-neutral.
8
+ */
9
+ export function createChannelDriver(input) {
10
+ if (input.channelId === "wecom") {
11
+ if (!input.wecomTransport) {
12
+ throw new Error("The wecom channel requires a WeCom transport.");
13
+ }
14
+ return new WecomChannelDriver({
15
+ transport: input.wecomTransport,
16
+ accountId: input.accountId,
17
+ operatorId: input.authorizedUserId,
18
+ logError: input.logError,
19
+ });
20
+ }
21
+ if (input.channelId === "local") {
22
+ const files = input.localFiles ?? {
23
+ inboxFile: "",
24
+ transcriptFile: "",
25
+ };
26
+ if (!files.inboxFile || !files.transcriptFile) {
27
+ throw new Error("The local channel requires inbox and transcript file paths.");
28
+ }
29
+ return new LocalChannelDriver({
30
+ operatorId: input.authorizedUserId,
31
+ inboxFile: files.inboxFile,
32
+ transcriptFile: files.transcriptFile,
33
+ log: input.log,
34
+ });
35
+ }
36
+ if (!input.wechatTransport) {
37
+ throw new Error("The wechat channel requires a WeChat transport.");
38
+ }
39
+ return new WechatChannelDriver({
40
+ transport: input.wechatTransport,
41
+ logError: input.logError,
42
+ buildInboundPrompt: input.buildWechatInboundPrompt,
43
+ });
44
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Channel boundary for local loopback output. Unlike WeChat there is no
3
+ * plain-text conversion: the harness consumes the raw final reply.
4
+ */
5
+ export class LocalChannelPort {
6
+ channelId = "local";
7
+ deps;
8
+ constructor(deps) {
9
+ this.deps = deps;
10
+ }
11
+ async send(output) {
12
+ if (output.target.channelId !== "local") {
13
+ throw new Error(`Local channel cannot send to ${output.target.channelId}.`);
14
+ }
15
+ const recipientId = output.target.recipientId;
16
+ const prefixText = (text) => this.deps.prefixText?.(output.adapter, text) ?? text;
17
+ const context = typeof output.metadata?.sendContext === "string"
18
+ ? output.metadata.sendContext
19
+ : output.kind;
20
+ if (output.kind === "final_reply") {
21
+ const rawText = output.text ?? "";
22
+ if (!rawText.trim()) {
23
+ this.deps.onEmptyVisibleReply?.(output.adapter, rawText);
24
+ return true;
25
+ }
26
+ const sent = await this.deps.sendText(recipientId, prefixText(rawText), "final_reply");
27
+ if (sent) {
28
+ this.deps.onTextSent?.(output.adapter, rawText);
29
+ }
30
+ return true;
31
+ }
32
+ if (output.attachment) {
33
+ await this.deps.sendAttachment?.(recipientId, output.attachment);
34
+ }
35
+ if (output.text?.trim()) {
36
+ return Boolean(await this.deps.sendText(recipientId, prefixText(output.text), context));
37
+ }
38
+ return true;
39
+ }
40
+ }