cli-wechat-bridge 1.1.7 → 1.2.0

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 (82) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +461 -434
  3. package/bin/_run-entry.mjs +29 -6
  4. package/bin/wechat-check-update.mjs +6 -1
  5. package/bin/wechat-claude.mjs +6 -1
  6. package/bin/wechat-codex.mjs +6 -1
  7. package/bin/wechat-daemon.mjs +6 -1
  8. package/bin/wechat-opencode.mjs +6 -1
  9. package/bin/wechat-pi.mjs +6 -1
  10. package/bin/wechat-setup.mjs +6 -1
  11. package/bin/wecom-check-update.mjs +6 -1
  12. package/bin/wecom-claude.mjs +6 -1
  13. package/bin/wecom-codex.mjs +6 -1
  14. package/bin/wecom-daemon.mjs +6 -1
  15. package/bin/wecom-opencode.mjs +6 -1
  16. package/bin/wecom-pi.mjs +6 -1
  17. package/bin/wecom-setup.mjs +6 -1
  18. package/dist/bridge/adapter-control.js +58 -0
  19. package/dist/bridge/bridge-adapters.claude.js +35 -1
  20. package/dist/bridge/bridge-adapters.codex.js +118 -7
  21. package/dist/bridge/bridge-adapters.core.js +25 -1
  22. package/dist/bridge/bridge-adapters.opencode.js +156 -1
  23. package/dist/bridge/bridge-adapters.pi.js +45 -0
  24. package/dist/bridge/bridge-adapters.shared.js +6 -0
  25. package/dist/bridge/bridge-controller.js +6 -0
  26. package/dist/bridge/bridge-final-reply.js +6 -0
  27. package/dist/bridge/bridge-process-reaper.js +6 -0
  28. package/dist/bridge/bridge-state.js +6 -0
  29. package/dist/bridge/bridge-utils.js +6 -31
  30. package/dist/bridge/claude-hook.js +6 -0
  31. package/dist/bridge/claude-native-controls.js +133 -0
  32. package/dist/bridge/native-terminal-control.js +259 -0
  33. package/dist/bridge/wechat-bridge.js +250 -274
  34. package/dist/bridge/wechat-forwarding.js +6 -0
  35. package/dist/bridge/wechat-outbound-queue.js +6 -0
  36. package/dist/channels/wechat/bridge-final-reply.js +13 -22
  37. package/dist/channels/wechat/index.js +6 -0
  38. package/dist/channels/wechat/wechat-driver.js +191 -0
  39. package/dist/channels/wechat/wechat-forwarding.js +18 -0
  40. package/dist/channels/wechat/wechat-outbound-queue.js +39 -0
  41. package/dist/channels/wecom/bridge-final-reply.js +6 -0
  42. package/dist/channels/wecom/setup-cli.js +6 -0
  43. package/dist/channels/wecom/setup.js +6 -0
  44. package/dist/channels/wecom/wecom-config.js +6 -0
  45. package/dist/channels/wecom/wecom-driver.js +106 -0
  46. package/dist/channels/wecom/wecom-media.js +6 -0
  47. package/dist/channels/wecom/wecom-transport.js +6 -0
  48. package/dist/commands/check-update.js +6 -0
  49. package/dist/companion/codex-remote-client.js +28 -2
  50. package/dist/companion/codex-visible-client-link.js +107 -0
  51. package/dist/companion/local-companion-link.js +6 -0
  52. package/dist/companion/local-companion-start.js +13 -2
  53. package/dist/companion/local-companion.js +23 -0
  54. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  55. package/dist/companion/pi-tui-bridge-extension.js +62 -3
  56. package/dist/core/bridge-control.js +10 -0
  57. package/dist/core/bridge-defer.js +25 -0
  58. package/dist/core/channel-driver.js +1 -0
  59. package/dist/core/conversation-routing.js +58 -0
  60. package/dist/core/direct-run.js +6 -0
  61. package/dist/core/inbound-message-claims.js +6 -0
  62. package/dist/core/outbound-target.js +32 -0
  63. package/dist/core/text-utils.js +6 -0
  64. package/dist/core/turn-coordinator.js +85 -0
  65. package/dist/daemon/daemon-link.js +6 -0
  66. package/dist/daemon/emoji-bindings.js +6 -0
  67. package/dist/daemon/wechat-daemon.js +529 -205
  68. package/dist/i18n/index.js +6 -0
  69. package/dist/i18n/messages-en.js +7 -1
  70. package/dist/i18n/messages-zh.js +7 -1
  71. package/dist/runtime/create-runtime-host.js +6 -0
  72. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  73. package/dist/runtime/runtime-types.js +3 -0
  74. package/dist/utils/atomic-file.js +6 -0
  75. package/dist/utils/doctor.js +6 -0
  76. package/dist/utils/version-checker.js +17 -11
  77. package/dist/wechat/channel-config.js +6 -0
  78. package/dist/wechat/setup.js +6 -0
  79. package/dist/wechat/wechat-channel.js +6 -0
  80. package/dist/wechat/wechat-transport.js +169 -58
  81. package/package.json +108 -106
  82. package/scripts/ensure-node-pty-permissions.mjs +73 -73
@@ -1,3 +1,9 @@
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.
1
7
  import crypto from "node:crypto";
2
8
  import net from "node:net";
3
9
  import { readLocalCompanionEndpoint, } from "./local-companion-link.js";
@@ -23,12 +29,37 @@ export function parseCodexVisibleSwitchRequest(value) {
23
29
  threadId: value.threadId,
24
30
  };
25
31
  }
32
+ export function parseCodexVisibleControlRequest(value) {
33
+ const switchRequest = parseCodexVisibleSwitchRequest(value);
34
+ if (switchRequest) {
35
+ return switchRequest;
36
+ }
37
+ if (!isRecord(value)) {
38
+ return null;
39
+ }
40
+ if (value.type !== "shutdown" ||
41
+ typeof value.id !== "string" ||
42
+ typeof value.token !== "string") {
43
+ return null;
44
+ }
45
+ return {
46
+ type: "shutdown",
47
+ id: value.id,
48
+ token: value.token,
49
+ };
50
+ }
26
51
  export function sendCodexVisibleSwitchResponse(socket, response) {
27
52
  if (socket.destroyed || socket.writableEnded) {
28
53
  return;
29
54
  }
30
55
  socket.end(`${JSON.stringify(response)}\n`);
31
56
  }
57
+ export function sendCodexVisibleShutdownResponse(socket, response) {
58
+ if (socket.destroyed || socket.writableEnded) {
59
+ return;
60
+ }
61
+ socket.end(`${JSON.stringify(response)}\n`);
62
+ }
32
63
  function resolveCodexControlEndpoint(cwd, instanceId) {
33
64
  const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
34
65
  if (!endpoint || endpoint.kind !== "codex" || endpoint.instanceId !== instanceId) {
@@ -120,3 +151,79 @@ export async function requestCodexVisibleThreadSwitch(params) {
120
151
  });
121
152
  });
122
153
  }
154
+ export async function requestCodexVisibleClientShutdown(params) {
155
+ const endpoint = resolveCodexControlEndpoint(params.cwd, params.instanceId);
156
+ const requestId = crypto.randomUUID();
157
+ const timeoutMs = params.timeoutMs ?? 5_000;
158
+ await new Promise((resolve, reject) => {
159
+ const socket = net.connect({
160
+ host: CODEX_VISIBLE_CONTROL_HOST,
161
+ port: endpoint.codexControlPort,
162
+ });
163
+ let buffer = "";
164
+ let settled = false;
165
+ const finish = (error) => {
166
+ if (settled) {
167
+ return;
168
+ }
169
+ settled = true;
170
+ socket.destroy();
171
+ if (error) {
172
+ reject(error);
173
+ }
174
+ else {
175
+ resolve();
176
+ }
177
+ };
178
+ socket.setEncoding("utf8");
179
+ socket.setTimeout(timeoutMs);
180
+ socket.once("connect", () => {
181
+ const request = {
182
+ type: "shutdown",
183
+ id: requestId,
184
+ token: endpoint.codexControlToken,
185
+ };
186
+ socket.write(`${JSON.stringify(request)}\n`);
187
+ });
188
+ socket.on("data", (chunk) => {
189
+ buffer += chunk;
190
+ if (buffer.length > 65_536) {
191
+ finish(new Error("The visible Codex client returned an oversized shutdown response."));
192
+ return;
193
+ }
194
+ const newlineIndex = buffer.indexOf("\n");
195
+ if (newlineIndex < 0) {
196
+ return;
197
+ }
198
+ const line = buffer.slice(0, newlineIndex).trim();
199
+ let parsed;
200
+ try {
201
+ parsed = JSON.parse(line);
202
+ }
203
+ catch {
204
+ finish(new Error("The visible Codex client returned an invalid shutdown response."));
205
+ return;
206
+ }
207
+ if (!isRecord(parsed) || parsed.type !== "shutdown_result" || parsed.id !== requestId) {
208
+ finish(new Error("The visible Codex client returned an unexpected shutdown response."));
209
+ return;
210
+ }
211
+ if (parsed.ok !== true) {
212
+ finish(new Error(typeof parsed.error === "string"
213
+ ? parsed.error
214
+ : "The visible Codex client could not shut down."));
215
+ return;
216
+ }
217
+ finish();
218
+ });
219
+ socket.once("timeout", () => {
220
+ finish(new Error("Timed out waiting for the visible Codex client to shut down."));
221
+ });
222
+ socket.once("error", (error) => finish(error));
223
+ socket.once("close", () => {
224
+ if (!settled) {
225
+ finish(new Error("The visible Codex client closed the shutdown connection."));
226
+ }
227
+ });
228
+ });
229
+ }
@@ -1,3 +1,9 @@
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.
1
7
  import crypto from "node:crypto";
2
8
  import fs from "node:fs";
3
9
  import { ensureWorkspaceChannelDir, getWorkspaceAdapterEndpointFile, getWorkspaceChannelPaths, } from "../wechat/channel-config.js";
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
4
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
5
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
+ // must be released under AGPL-3.0-or-later with full source code; see
7
+ // LICENSE.txt. Network services built on it must offer source to users.
2
8
  import net from "node:net";
3
9
  import path from "node:path";
4
10
  import { spawn } from "node:child_process";
@@ -39,8 +45,13 @@ export function formatSwitchFailureMessage(cwd) {
39
45
  export function formatRestartUnhealthyMessage(cwd) {
40
46
  return `Detected unhealthy companion state for ${cwd}. Restarting bridge...`;
41
47
  }
42
- export function defaultSessionStartMode(adapter) {
43
- return adapter === "codex" ? "restore" : "new";
48
+ export function defaultSessionStartMode(_adapter) {
49
+ // Codex 0.155+ persists app-server threads lazily, so launching the visible
50
+ // client with a freshly prepared (not yet persisted) thread id crashes its
51
+ // bootstrap with "no rollout found". Start every adapter fresh by default;
52
+ // explicit --session-start-mode restore remains available for deliberate
53
+ // restores of already persisted threads.
54
+ return "new";
44
55
  }
45
56
  export function decideLaunchAction(input) {
46
57
  if (input.lockShouldAutoReclaim) {
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env bun
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
4
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
5
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
+ // must be released under AGPL-3.0-or-later with full source code; see
7
+ // LICENSE.txt. Network services built on it must offer source to users.
2
8
  import net from "node:net";
3
9
  import path from "node:path";
4
10
  import { createBridgeAdapter } from "../bridge/bridge-adapters.js";
@@ -231,6 +237,23 @@ export async function runLocalCompanion(options) {
231
237
  publishState();
232
238
  sendResponse(socket, message.id, true);
233
239
  break;
240
+ case "list_models":
241
+ if (!adapter.listModels)
242
+ throw new Error("Model listing is not supported by this companion.");
243
+ sendResponse(socket, message.id, true, await adapter.listModels());
244
+ break;
245
+ case "select_model":
246
+ if (!adapter.selectModel)
247
+ throw new Error("Model selection is not supported by this companion.");
248
+ sendResponse(socket, message.id, true, await adapter.selectModel(message.payload.modelId));
249
+ publishState();
250
+ break;
251
+ case "set_plan_mode":
252
+ if (!adapter.setPlanMode)
253
+ throw new Error("Plan mode is not supported by this companion.");
254
+ sendResponse(socket, message.id, true, await adapter.setPlanMode(message.payload.enabled));
255
+ publishState();
256
+ break;
234
257
  case "interrupt":
235
258
  sendResponse(socket, message.id, true, await adapter.interrupt());
236
259
  break;
@@ -1,3 +1,9 @@
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.
1
7
  import net from "node:net";
2
8
  const ROUTE_POLL_INTERVAL_MS = 100;
3
9
  const MAX_BUFFER_SIZE = 1024 * 1024;
@@ -1,3 +1,9 @@
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.
1
7
  import fs from "node:fs";
2
8
  import net from "node:net";
3
9
  import path from "node:path";
@@ -167,7 +173,7 @@ export default function piTuiBridgeExtension(pi) {
167
173
  }
168
174
  },
169
175
  });
170
- const executeCommand = (command) => {
176
+ const executeCommand = async (command) => {
171
177
  try {
172
178
  if (command.type === "prompt" && typeof command.text === "string") {
173
179
  if (!latestContext?.isIdle()) {
@@ -180,6 +186,59 @@ export default function piTuiBridgeExtension(pi) {
180
186
  latestContext?.abort();
181
187
  writeFrame({ type: "response", id: command.id, success: true });
182
188
  }
189
+ else if (command.type === "list_models") {
190
+ if (!latestContext) {
191
+ throw new Error("Pi session context is not ready.");
192
+ }
193
+ const models = latestContext.scopedModels.length > 0
194
+ ? latestContext.scopedModels.map((entry) => entry.model)
195
+ : latestContext.modelRegistry.getAvailable();
196
+ writeFrame({
197
+ type: "response",
198
+ id: command.id,
199
+ success: true,
200
+ data: {
201
+ models: models.map((model) => ({
202
+ id: `${model.provider}/${model.id}`,
203
+ displayName: model.name
204
+ ? `${model.name} (${model.provider})`
205
+ : `${model.id} (${model.provider})`,
206
+ isCurrent: latestContext?.model?.provider === model.provider &&
207
+ latestContext.model.id === model.id,
208
+ })),
209
+ },
210
+ });
211
+ }
212
+ else if (command.type === "select_model" && typeof command.modelId === "string") {
213
+ if (!latestContext?.isIdle()) {
214
+ throw new Error("Pi TUI is already processing a turn.");
215
+ }
216
+ const models = latestContext.scopedModels.length > 0
217
+ ? latestContext.scopedModels.map((entry) => entry.model)
218
+ : latestContext.modelRegistry.getAvailable();
219
+ const model = models.find((candidate) => `${candidate.provider}/${candidate.id}` === command.modelId);
220
+ if (!model) {
221
+ throw new Error(`Pi model is no longer available: ${command.modelId}`);
222
+ }
223
+ const selected = await pi.setModel(model);
224
+ if (!selected) {
225
+ throw new Error(`Pi could not switch to model: ${command.modelId}`);
226
+ }
227
+ writeFrame({
228
+ type: "response",
229
+ id: command.id,
230
+ success: true,
231
+ data: {
232
+ model: {
233
+ id: `${model.provider}/${model.id}`,
234
+ displayName: model.name
235
+ ? `${model.name} (${model.provider})`
236
+ : `${model.id} (${model.provider})`,
237
+ isCurrent: true,
238
+ },
239
+ },
240
+ });
241
+ }
183
242
  else if (command.type === "new_session" && typeof command.id === "string") {
184
243
  pi.sendUserMessage(`/${INTERNAL_NEW_COMMAND} ${command.id}`, {
185
244
  expandPromptTemplates: true,
@@ -245,7 +304,7 @@ export default function piTuiBridgeExtension(pi) {
245
304
  try {
246
305
  const parsed = JSON.parse(line);
247
306
  if (isRecord(parsed)) {
248
- dispatchCommand(parsed);
307
+ void dispatchCommand(parsed);
249
308
  }
250
309
  }
251
310
  catch {
@@ -260,7 +319,7 @@ export default function piTuiBridgeExtension(pi) {
260
319
  latestContext = context;
261
320
  writeSessionState(context, typeof event.reason === "string" ? event.reason : "startup");
262
321
  for (const command of queuedCommands.splice(0)) {
263
- executeCommand(command);
322
+ void executeCommand(command);
264
323
  }
265
324
  });
266
325
  pi.on("input", (event, context) => {
@@ -26,6 +26,16 @@ export function parseBridgeControlCommand(text) {
26
26
  return { type: "deny" };
27
27
  case "/answer":
28
28
  return argument ? { type: "answer", raw: argument } : null;
29
+ case "/model":
30
+ return { type: "model", target: argument || undefined };
31
+ case "/plan":
32
+ if (!argument || argument.toLowerCase() === "on")
33
+ return { type: "plan", enabled: true };
34
+ if (argument.toLowerCase() === "off")
35
+ return { type: "plan", enabled: false };
36
+ return null;
37
+ case "/all":
38
+ return argument ? { type: "broadcast", text: argument } : null;
29
39
  default:
30
40
  return null;
31
41
  }
@@ -0,0 +1,25 @@
1
+ export function shouldDeferCodexInboundMessage(params) {
2
+ return (params.adapter === "codex" &&
3
+ !params.hasPendingConfirmation &&
4
+ !params.hasSystemCommand &&
5
+ params.activeTurnOrigin === "local" &&
6
+ (params.status === "busy" || params.status === "awaiting_approval"));
7
+ }
8
+ export function canDrainDeferredCodexInboundQueue(params) {
9
+ return (params.adapter === "codex" &&
10
+ params.deferredCount > 0 &&
11
+ !params.hasPendingConfirmation &&
12
+ !params.hasPendingUserInput &&
13
+ !params.hasPendingApproval &&
14
+ !params.hasActiveTask &&
15
+ !params.activeTurnId &&
16
+ params.status !== "busy" &&
17
+ params.status !== "awaiting_approval" &&
18
+ params.status !== "awaiting_input");
19
+ }
20
+ export function formatDeferredCodexInboundQueueMessage(queuePosition) {
21
+ return `Queued for delivery after the current local Codex turn finishes. Queue position: ${queuePosition}.`;
22
+ }
23
+ export function isRetryableDeferredCodexDrainError(errorText) {
24
+ return /still working|approval request is pending|waiting for local terminal input/i.test(errorText);
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
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 { AsyncLocalStorage } from "node:async_hooks";
8
+ /**
9
+ * Keeps the reply target scoped to one inbound async call chain. Different
10
+ * conversations may run concurrently without overwriting each other's target.
11
+ */
12
+ export class InboundConversationContext {
13
+ storage = new AsyncLocalStorage();
14
+ run(conversation, callback) {
15
+ return this.storage.run(conversation, callback);
16
+ }
17
+ get() {
18
+ return this.storage.getStore();
19
+ }
20
+ }
21
+ export function tryBeginTurn(state, task, conversation) {
22
+ if (state.activeTask) {
23
+ return null;
24
+ }
25
+ const lease = {
26
+ task,
27
+ previousConversation: state.activeConversation,
28
+ previousLastConversation: state.lastConversation,
29
+ };
30
+ state.activeTask = task;
31
+ if (conversation) {
32
+ state.activeConversation = conversation;
33
+ state.lastConversation = conversation;
34
+ }
35
+ return lease;
36
+ }
37
+ export function rollbackTurn(state, lease, options = {}) {
38
+ if (state.activeTask !== lease.task) {
39
+ return false;
40
+ }
41
+ state.activeTask = null;
42
+ state.activeConversation = lease.previousConversation;
43
+ if (options.restoreLastConversation) {
44
+ state.lastConversation = lease.previousLastConversation;
45
+ }
46
+ return true;
47
+ }
48
+ export function clearTurn(state, expectedTask) {
49
+ if (expectedTask && state.activeTask !== expectedTask) {
50
+ return false;
51
+ }
52
+ state.activeTask = null;
53
+ state.activeConversation = undefined;
54
+ return true;
55
+ }
56
+ export function resolveConversationTarget(params) {
57
+ return params.active ?? params.last ?? params.fallback;
58
+ }
@@ -1,3 +1,9 @@
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.
1
7
  import path from "node:path";
2
8
  import { fileURLToPath } from "node:url";
3
9
  function normalizeComparablePath(filePath) {
@@ -1,3 +1,9 @@
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.
1
7
  import crypto from "node:crypto";
2
8
  import fs from "node:fs";
3
9
  import path from "node:path";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Outbound target priority for standalone bridges:
3
+ * 1. an explicit override captured at enqueue time;
4
+ * 2. for the operator on multi-conversation channels, the pre-resolved turn
5
+ * target (callers pass `TurnCoordinator.resolveTarget(operatorConversation)`);
6
+ * 3. otherwise a direct conversation to the sender.
7
+ */
8
+ export function resolveOutboundConversationTarget(params) {
9
+ if (params.override) {
10
+ return params.override;
11
+ }
12
+ if (params.multiConversation && params.senderId === params.operatorId) {
13
+ return params.operatorTarget;
14
+ }
15
+ return params.directConversation(params.senderId);
16
+ }
17
+ /**
18
+ * Outbound target priority for daemon sends. Multi-conversation operators
19
+ * resolve through the current inbound conversation first, then the active
20
+ * slot's remembered target, then the daemon fallback conversation.
21
+ */
22
+ export function resolveDaemonOutboundTarget(params) {
23
+ if (params.override) {
24
+ return params.override;
25
+ }
26
+ if (params.multiConversation && params.senderId === params.operatorId) {
27
+ return params.inboundConversation
28
+ ?? params.activeSlotTarget
29
+ ?? params.fallbackConversation;
30
+ }
31
+ return params.directConversation(params.senderId);
32
+ }
@@ -1,3 +1,9 @@
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.
1
7
  const ANSI_ESCAPE_RE =
2
8
  // eslint-disable-next-line no-control-regex
3
9
  /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
@@ -0,0 +1,85 @@
1
+ import { clearTurn, resolveConversationTarget, rollbackTurn, tryBeginTurn, } from "./conversation-routing.js";
2
+ /**
3
+ * Owns the active remote turn for one dispatch surface: a standalone bridge
4
+ * or one daemon adapter slot. Wraps the turn ownership state machine (busy
5
+ * rejection, dispatch rollback, conditional completion) so standalone and
6
+ * daemon share identical gating semantics instead of re-implementing them.
7
+ */
8
+ export class TurnCoordinator {
9
+ state;
10
+ restoreLastConversationOnFailure;
11
+ constructor(options = {}) {
12
+ this.state = {
13
+ activeTask: null,
14
+ lastConversation: options.initialLastConversation ?? null,
15
+ };
16
+ this.restoreLastConversationOnFailure = options.restoreLastConversationOnFailure ?? false;
17
+ }
18
+ get activeTask() {
19
+ return this.state.activeTask;
20
+ }
21
+ get hasActiveTask() {
22
+ return this.state.activeTask !== null;
23
+ }
24
+ get activeConversation() {
25
+ return this.state.activeConversation;
26
+ }
27
+ get lastConversation() {
28
+ return this.state.lastConversation;
29
+ }
30
+ beginTurn(task, conversation) {
31
+ return tryBeginTurn(this.state, task, conversation);
32
+ }
33
+ rollback(lease, options = {}) {
34
+ return rollbackTurn(this.state, lease, options);
35
+ }
36
+ complete(expectedTask) {
37
+ return clearTurn(this.state, expectedTask);
38
+ }
39
+ /**
40
+ * Replaces the active task unconditionally. Used when a command path
41
+ * (approval confirmation, resume) takes over a continuing turn rather
42
+ * than dispatching fresh input.
43
+ */
44
+ setActiveTask(task) {
45
+ this.state.activeTask = task;
46
+ }
47
+ /** Binds a conversation as both active and remembered target. */
48
+ bindConversation(conversation) {
49
+ this.state.activeConversation = conversation;
50
+ this.state.lastConversation = conversation;
51
+ }
52
+ /** Records a conversation as the remembered fallback target only. */
53
+ observeConversation(conversation) {
54
+ this.state.lastConversation = conversation;
55
+ }
56
+ resolveTarget(fallback) {
57
+ return resolveConversationTarget({
58
+ active: this.state.activeConversation,
59
+ last: this.state.lastConversation,
60
+ fallback,
61
+ });
62
+ }
63
+ /**
64
+ * Busy-rejecting dispatch transaction: acquire the turn lease, forward the
65
+ * input, and roll the lease back when forwarding throws. A busy input runs
66
+ * `onBusy` (reminder) and returns without touching ownership.
67
+ */
68
+ async dispatch(params) {
69
+ const lease = this.beginTurn(params.task, params.conversation);
70
+ if (!lease) {
71
+ await params.onBusy?.();
72
+ return { status: "busy" };
73
+ }
74
+ try {
75
+ await params.forward();
76
+ }
77
+ catch (error) {
78
+ this.rollback(lease, {
79
+ restoreLastConversation: this.restoreLastConversationOnFailure,
80
+ });
81
+ throw error;
82
+ }
83
+ return { status: "dispatched", task: params.task };
84
+ }
85
+ }
@@ -1,3 +1,9 @@
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.
1
7
  import crypto from "node:crypto";
2
8
  import fs from "node:fs";
3
9
  import net from "node:net";
@@ -1,3 +1,9 @@
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.
1
7
  import fs from "node:fs";
2
8
  import { EMOJI_BINDINGS_FILE } from "../wechat/channel-config.js";
3
9
  import { t } from "../i18n/index.js";