@unlinearity/cli-wechat-bridge 1.0.7 → 1.0.9

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.
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import net from "node:net";
4
4
  import path from "node:path";
5
- import { spawn } from "node:child_process";
5
+ import { execFileSync, spawn } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { resolveDefaultAdapterCommand, } from "../bridge/bridge-adapters.js";
8
8
  import { delay, getSharedSessionIdFromAdapterState, quoteWindowsCommandArg, } from "../bridge/bridge-adapters.shared.js";
@@ -14,6 +14,7 @@ import { buildOneTimeCode, buildWechatInboundPrompt, formatApprovalMessage, form
14
14
  import { formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-bridge.js";
15
15
  import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
16
16
  import { ensureWechatCredentials } from "../wechat/setup.js";
17
+ import { listBindings, loadEmojiBindings, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
17
18
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, isWechatContextTokenStaleError, WeChatTransport, } from "../wechat/wechat-transport.js";
18
19
  import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
19
20
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
@@ -135,6 +136,22 @@ export function parseDaemonSwitchCommand(text) {
135
136
  return null;
136
137
  }
137
138
  }
139
+ export function parseEmojiBindingsCommand(text) {
140
+ const trimmed = text.trim();
141
+ const lower = trimmed.toLowerCase();
142
+ if (lower === "/bindings") {
143
+ return { type: "list" };
144
+ }
145
+ const bindMatch = trimmed.match(/^\/bind\s+(\[[^\]]+\])\s+(.+)$/i);
146
+ if (bindMatch) {
147
+ return { type: "bind", emoji: bindMatch[1], command: bindMatch[2].trim() };
148
+ }
149
+ const unbindMatch = trimmed.match(/^\/unbind\s+(\[[^\]]+\])$/i);
150
+ if (unbindMatch) {
151
+ return { type: "unbind", emoji: unbindMatch[1] };
152
+ }
153
+ return null;
154
+ }
138
155
  export function defaultDaemonSessionStartMode(adapter) {
139
156
  return adapter === "claude" || adapter === "opencode" ? "new" : "restore";
140
157
  }
@@ -208,6 +225,33 @@ export function buildWindowsVisibleClientLaunchCommand(params) {
208
225
  ...params.args.map((arg) => quoteWindowsCommandArg(arg)),
209
226
  ].join(" ");
210
227
  }
228
+ const LINUX_TERMINALS = [
229
+ { cmd: "gnome-terminal", buildArgs: (title) => ["--title", title, "--"] },
230
+ { cmd: "konsole", buildArgs: (title) => ["-p", `tabtitle=${title}`, "-e"] },
231
+ { cmd: "xfce4-terminal", buildArgs: (title) => ["--title", title, "-e"] },
232
+ { cmd: "xterm", buildArgs: (title) => ["-title", title, "-e"] },
233
+ ];
234
+ let cachedLinuxTerminal;
235
+ function detectLinuxTerminal() {
236
+ if (cachedLinuxTerminal !== undefined) {
237
+ return cachedLinuxTerminal;
238
+ }
239
+ for (const entry of LINUX_TERMINALS) {
240
+ try {
241
+ execFileSync("which", [entry.cmd], { stdio: "ignore" });
242
+ cachedLinuxTerminal = entry;
243
+ return entry;
244
+ }
245
+ catch {
246
+ // not found, try next
247
+ }
248
+ }
249
+ cachedLinuxTerminal = null;
250
+ return null;
251
+ }
252
+ function shellQuotePosix(s) {
253
+ return "'" + s.replace(/'/g, "'\\''") + "'";
254
+ }
211
255
  function formatLaunchPreview(launch) {
212
256
  return [launch.command, ...launch.args].join(" ");
213
257
  }
@@ -242,12 +286,53 @@ function openVisibleClient(params) {
242
286
  pid: child.pid,
243
287
  };
244
288
  }
289
+ const title = `wechat-${params.adapter}`;
290
+ const fullArgs = [process.execPath, ...args];
291
+ if (process.platform === "darwin") {
292
+ const cmdLine = fullArgs.map(shellQuotePosix).join(" ");
293
+ const script = `tell application "Terminal"
294
+ activate
295
+ do script "cd ${shellQuotePosix(params.cwd)} && exec ${cmdLine}"
296
+ end tell`;
297
+ const child = spawn("osascript", ["-e", script], {
298
+ cwd: params.cwd,
299
+ detached: true,
300
+ stdio: "ignore",
301
+ });
302
+ child.once("error", (error) => {
303
+ params.onError?.(error instanceof Error ? error : new Error(String(error)));
304
+ });
305
+ child.unref();
306
+ return {
307
+ command: "osascript",
308
+ args: ["-e", script],
309
+ pid: child.pid,
310
+ };
311
+ }
312
+ const terminal = detectLinuxTerminal();
313
+ if (terminal) {
314
+ const termArgs = [...terminal.buildArgs(title), ...fullArgs];
315
+ const child = spawn(terminal.cmd, termArgs, {
316
+ cwd: params.cwd,
317
+ env: process.env,
318
+ detached: true,
319
+ stdio: "ignore",
320
+ });
321
+ child.once("error", (error) => {
322
+ params.onError?.(error instanceof Error ? error : new Error(String(error)));
323
+ });
324
+ child.unref();
325
+ return {
326
+ command: terminal.cmd,
327
+ args: termArgs,
328
+ pid: child.pid,
329
+ };
330
+ }
245
331
  const child = spawn(process.execPath, args, {
246
332
  cwd: params.cwd,
247
333
  env: process.env,
248
334
  detached: true,
249
335
  stdio: "ignore",
250
- windowsHide: false,
251
336
  });
252
337
  child.once("error", (error) => {
253
338
  params.onError?.(error instanceof Error ? error : new Error(String(error)));
@@ -375,6 +460,7 @@ class WechatDaemon {
375
460
  startedAt = new Date().toISOString();
376
461
  bridgeStartedAtMs = Date.now();
377
462
  activeAdapter = null;
463
+ takenOverAdapter;
378
464
  textSendChain = Promise.resolve();
379
465
  attachmentSendChain = Promise.resolve();
380
466
  pendingWechatForwardTasks = new Set();
@@ -598,7 +684,11 @@ class WechatDaemon {
598
684
  let slot = this.slots.get(adapter);
599
685
  let created = false;
600
686
  if (!slot) {
601
- const createSessionStartMode = options.sessionStartMode ?? defaultDaemonSessionStartMode(adapter);
687
+ const createSessionStartMode = options.sessionStartMode ??
688
+ (adapter === this.takenOverAdapter ? "restore" : defaultDaemonSessionStartMode(adapter));
689
+ if (adapter === this.takenOverAdapter) {
690
+ this.takenOverAdapter = undefined;
691
+ }
602
692
  slot = await this.createSlot(adapter, {
603
693
  profile: options.profile ?? this.profile,
604
694
  sessionStartMode: createSessionStartMode,
@@ -861,6 +951,30 @@ class WechatDaemon {
861
951
  await this.queueWechatMessage(message.senderId, "Unauthorized. This daemon only accepts messages from the configured WeChat owner.");
862
952
  return;
863
953
  }
954
+ const emojiMatch = resolveEmojiCommand(message.text);
955
+ if (emojiMatch) {
956
+ const switchTarget = parseDaemonSwitchCommand(emojiMatch.command);
957
+ if (switchTarget && emojiMatch.remainder) {
958
+ const result = await this.ensureSlot(switchTarget, {
959
+ openVisible: true,
960
+ reuseExistingVisible: true,
961
+ });
962
+ if (result.activated) {
963
+ message = { ...message, text: emojiMatch.remainder };
964
+ }
965
+ else {
966
+ const detail = formatDaemonSwitchResultDetail(result);
967
+ await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchTarget}.\n${detail}`);
968
+ return;
969
+ }
970
+ }
971
+ else {
972
+ const rewritten = emojiMatch.remainder
973
+ ? `${emojiMatch.command} ${emojiMatch.remainder}`
974
+ : emojiMatch.command;
975
+ message = { ...message, text: rewritten };
976
+ }
977
+ }
864
978
  const switchAdapter = parseDaemonSwitchCommand(message.text);
865
979
  if (switchAdapter) {
866
980
  const result = await this.ensureSlot(switchAdapter, {
@@ -881,6 +995,11 @@ class WechatDaemon {
881
995
  }, 0);
882
996
  return;
883
997
  }
998
+ const bindingsCmd = parseEmojiBindingsCommand(message.text);
999
+ if (bindingsCmd) {
1000
+ await this.handleEmojiBindingsCommand(message.senderId, bindingsCmd);
1001
+ return;
1002
+ }
884
1003
  const slot = this.getActiveSlot();
885
1004
  if (!slot) {
886
1005
  await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
@@ -910,6 +1029,32 @@ class WechatDaemon {
910
1029
  }
911
1030
  await this.dispatchInboundWechatText(message, slot);
912
1031
  }
1032
+ async handleEmojiBindingsCommand(senderId, cmd) {
1033
+ switch (cmd.type) {
1034
+ case "list": {
1035
+ const map = listBindings();
1036
+ if (map.size === 0) {
1037
+ await this.queueWechatMessage(senderId, "No emoji bindings configured.");
1038
+ return;
1039
+ }
1040
+ const lines = Array.from(map.entries()).map(([emoji, command]) => `${emoji} → ${command}`);
1041
+ await this.queueWechatMessage(senderId, `Emoji bindings:\n${lines.join("\n")}`);
1042
+ return;
1043
+ }
1044
+ case "bind": {
1045
+ setBinding(cmd.emoji, cmd.command);
1046
+ await this.queueWechatMessage(senderId, `Bound ${cmd.emoji} → ${cmd.command}`);
1047
+ return;
1048
+ }
1049
+ case "unbind": {
1050
+ const removed = removeBinding(cmd.emoji);
1051
+ await this.queueWechatMessage(senderId, removed
1052
+ ? `Unbound ${cmd.emoji}`
1053
+ : `No binding found for ${cmd.emoji}`);
1054
+ return;
1055
+ }
1056
+ }
1057
+ }
913
1058
  async handleSystemCommand(message, activeSlot, command) {
914
1059
  switch (command.type) {
915
1060
  case "status":
@@ -1322,7 +1467,7 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1322
1467
  const readLock = deps.readLock ?? readBridgeLockFile;
1323
1468
  const isAlive = deps.isAlive ?? isPidAlive;
1324
1469
  const killProcess = deps.killProcess ?? ((pid, signal) => {
1325
- if (signal === "SIGKILL") {
1470
+ if (signal === "SIGKILL" || process.platform === "win32") {
1326
1471
  killProcessTreeSync(pid);
1327
1472
  return;
1328
1473
  }
@@ -1398,8 +1543,9 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1398
1543
  }
1399
1544
  export async function runDaemon(options) {
1400
1545
  migrateLegacyChannelFiles((message) => log(message));
1546
+ loadEmojiBindings();
1401
1547
  await cleanupDaemonBeforeStart({ cwd: options.cwd });
1402
- await cleanupSingleBridgeBeforeDaemon();
1548
+ const cleanupResult = await cleanupSingleBridgeBeforeDaemon();
1403
1549
  const reapedPeerPids = await reapPeerBridgeProcesses({
1404
1550
  logger: (message) => appendDaemonLog(message),
1405
1551
  });
@@ -1426,17 +1572,26 @@ export async function runDaemon(options) {
1426
1572
  authorizedUserId: credentials.userId,
1427
1573
  transport: new WeChatTransport({ log, logError }),
1428
1574
  });
1575
+ if (cleanupResult.action === "stopped" && isDaemonAdapterKind(cleanupResult.lock.adapter)) {
1576
+ daemon.takenOverAdapter = cleanupResult.lock.adapter;
1577
+ }
1429
1578
  await daemon.startIpcServer();
1430
1579
  await daemon.runInitialAdapter(options);
1431
- const requestShutdown = (signal) => {
1580
+ let shutdownInProgress = false;
1581
+ const handleSignal = (signal) => {
1582
+ if (shutdownInProgress) {
1583
+ log(`Received ${signal} during shutdown, forcing exit.`);
1584
+ process.exit(1);
1585
+ }
1586
+ shutdownInProgress = true;
1432
1587
  log(`Received ${signal}. Stopping daemon.`);
1433
1588
  void daemon.shutdown().finally(() => process.exit(0));
1434
1589
  };
1435
- process.once("SIGINT", () => requestShutdown("SIGINT"));
1436
- process.once("SIGTERM", () => requestShutdown("SIGTERM"));
1437
- process.once("SIGHUP", () => requestShutdown("SIGHUP"));
1590
+ process.on("SIGINT", () => handleSignal("SIGINT"));
1591
+ process.on("SIGTERM", () => handleSignal("SIGTERM"));
1592
+ process.on("SIGHUP", () => handleSignal("SIGHUP"));
1438
1593
  if (process.platform === "win32") {
1439
- process.once("SIGBREAK", () => requestShutdown("SIGBREAK"));
1594
+ process.on("SIGBREAK", () => handleSignal("SIGBREAK"));
1440
1595
  }
1441
1596
  process.on("exit", () => {
1442
1597
  clearDaemonEndpoint();
@@ -23,6 +23,7 @@ export const CODEX_PANEL_ENDPOINT_FILE = path.join(CHANNEL_DATA_DIR, "codex-pane
23
23
  export const WORKSPACES_DIR = path.join(CHANNEL_DATA_DIR, "workspaces");
24
24
  export const INBOUND_MESSAGE_CLAIMS_DIR = path.join(CHANNEL_DATA_DIR, "inbound-message-claims");
25
25
  export const INBOUND_ATTACHMENTS_DIR = path.join(CHANNEL_DATA_DIR, "inbound-attachments");
26
+ export const EMOJI_BINDINGS_FILE = path.join(CHANNEL_DATA_DIR, "emoji-bindings.json");
26
27
  const LEGACY_GLOBAL_CHANNEL_DATA_DIR = path.join(os.homedir(), ".claude", "channels", "wechat");
27
28
  const LEGACY_REPO_CHANNEL_DATA_DIR = path.join(PROJECT_DIR, "~", ".claude", "channels", "wechat");
28
29
  const LEGACY_ENV_CHANNEL_DATA_DIR = process.env.CLAUDE_WECHAT_CHANNEL_DATA_DIR?.trim()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unlinearity/cli-wechat-bridge",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "CLI WeChat Bridge for Codex, Claude Code, OpenCode, and PowerShell over WeChat",
5
5
  "type": "module",
6
6
  "files": [
@@ -19,7 +19,7 @@
19
19
  "terminal"
20
20
  ],
21
21
  "author": "UNLINEARITY",
22
- "license": "MIT",
22
+ "license": "AGPL-3.0-or-later",
23
23
  "repository": {
24
24
  "type": "git",
25
25
  "url": "git+https://github.com/UNLINEARITY/CLI-WeChat-Bridge.git"