cli-wechat-bridge 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +146 -73
  2. package/README_EN.md +544 -0
  3. package/bin/telegram-check-update.mjs +10 -0
  4. package/bin/telegram-claude.mjs +10 -0
  5. package/bin/telegram-codex.mjs +10 -0
  6. package/bin/telegram-daemon.mjs +10 -0
  7. package/bin/telegram-opencode.mjs +10 -0
  8. package/bin/telegram-pi.mjs +10 -0
  9. package/bin/telegram-setup.mjs +10 -0
  10. package/dist/bridge/adapter-control.js +19 -12
  11. package/dist/bridge/bridge-adapters.claude.js +219 -115
  12. package/dist/bridge/bridge-adapters.codex.js +532 -99
  13. package/dist/bridge/bridge-adapters.core.js +84 -7
  14. package/dist/bridge/bridge-adapters.opencode.js +202 -55
  15. package/dist/bridge/bridge-adapters.pi.js +23 -18
  16. package/dist/bridge/bridge-adapters.shared.js +9 -9
  17. package/dist/bridge/bridge-session-resume.js +13 -6
  18. package/dist/bridge/bridge-state.js +3 -1
  19. package/dist/bridge/bridge-utils.js +94 -144
  20. package/dist/bridge/claude-hooks.js +60 -3
  21. package/dist/bridge/claude-native-controls.js +19 -12
  22. package/dist/bridge/language-control.js +33 -0
  23. package/dist/bridge/native-terminal-control.js +26 -10
  24. package/dist/bridge/opencode-v2-compat.js +11 -3
  25. package/dist/bridge/wechat-bridge.js +154 -72
  26. package/dist/channels/channel-driver-factory.js +56 -0
  27. package/dist/channels/local/local-channel-port.js +40 -0
  28. package/dist/channels/local/local-driver.js +217 -0
  29. package/dist/channels/telegram/setup-cli.js +12 -0
  30. package/dist/channels/telegram/setup.js +163 -0
  31. package/dist/channels/telegram/telegram-channel-port.js +47 -0
  32. package/dist/channels/telegram/telegram-config.js +66 -0
  33. package/dist/channels/telegram/telegram-driver.js +106 -0
  34. package/dist/channels/telegram/telegram-message.js +41 -0
  35. package/dist/channels/telegram/telegram-transport.js +378 -0
  36. package/dist/channels/wechat/wechat-driver.js +11 -2
  37. package/dist/channels/wecom/wecom-driver.js +8 -1
  38. package/dist/companion/local-companion-start.js +180 -61
  39. package/dist/companion/local-companion.js +27 -7
  40. package/dist/companion/pi-tui-bridge-extension.js +10 -4
  41. package/dist/core/bridge-control.js +3 -0
  42. package/dist/core/bridge-defer.js +3 -2
  43. package/dist/core/bridge-event-forwarder.js +3 -0
  44. package/dist/core/channel-types.js +13 -1
  45. package/dist/daemon/wechat-daemon.js +239 -126
  46. package/dist/i18n/index.js +25 -11
  47. package/dist/i18n/messages-en.js +245 -1
  48. package/dist/i18n/messages-zh.js +245 -1
  49. package/dist/runtime/legacy-adapter-runtime.js +7 -4
  50. package/dist/wechat/channel-config.js +3 -2
  51. package/package.json +17 -5
  52. package/skills/codex-bridge/SKILL.md +35 -0
  53. package/skills/codex-bridge/agents/openai.yaml +4 -0
  54. package/skills/codex-bridge/scripts/install.mjs +28 -0
@@ -5,13 +5,17 @@
5
5
  // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
6
  // must be released under AGPL-3.0-or-later with full source code; see
7
7
  // LICENSE.txt. Network services built on it must offer source to users.
8
+ import fs from "node:fs";
8
9
  import net from "node:net";
9
10
  import path from "node:path";
10
11
  import { spawn } from "node:child_process";
11
12
  import { fileURLToPath } from "node:url";
12
- import { BRIDGE_LOG_FILE } from "../wechat/channel-config.js";
13
+ import { BRIDGE_LOG_FILE, getWorkspaceChannelPaths } from "../wechat/channel-config.js";
14
+ import { resolveCodexDaemonControlSocketPath, } from "../bridge/bridge-adapters.codex.js";
13
15
  import { ensureWechatCredentials } from "../wechat/setup.js";
14
16
  import { ensureWecomAccount } from "../channels/wecom/setup.js";
17
+ import { ensureTelegramAccount } from "../channels/telegram/setup.js";
18
+ import { getBridgeChannelDisplayName, isBridgeChannelId, normalizeBridgeChannelId, } from "../core/channel-types.js";
15
19
  import { isDirectModuleRun } from "../core/direct-run.js";
16
20
  import { readBridgeLockFile, shouldAutoReclaimBridgeLock, } from "../bridge/bridge-state.js";
17
21
  import { clearLocalCompanionOccupancy, clearLocalCompanionEndpoint, readLocalCompanionEndpoint, } from "./local-companion-link.js";
@@ -23,8 +27,8 @@ const MODULE_DIR = path.dirname(MODULE_FILE);
23
27
  const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".js";
24
28
  const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
25
29
  const DEFAULT_ADAPTER = "codex";
26
- function log(adapter, message, channelId = "wechat") {
27
- process.stderr.write(`[${channelId}-${adapter}] ${message}\n`);
30
+ function log(options, message) {
31
+ process.stderr.write(`[${options.channelId ?? "wechat"}-${options.adapter}] ${message}\n`);
28
32
  }
29
33
  export function normalizeComparablePath(cwd) {
30
34
  const normalized = path.resolve(cwd);
@@ -115,6 +119,8 @@ export function parseCliArgs(argv) {
115
119
  let timeoutMs = DEFAULT_WAIT_TIMEOUT_MS;
116
120
  let sessionStartMode;
117
121
  let channelId = "wechat";
122
+ let attachCurrentSession = false;
123
+ let codexThreadId;
118
124
  const cliArgs = [];
119
125
  for (let i = 0; i < argv.length; i += 1) {
120
126
  const arg = argv[i];
@@ -134,15 +140,14 @@ export function parseCliArgs(argv) {
134
140
  process.stdout.write([
135
141
  `Usage: ${commandName} [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [--session-start-mode <restore|new>] [...${helpAdapter} args]`,
136
142
  "",
137
- `Ensures the ${channelId === "wecom" ? "WeCom" : "WeChat"} runtime for the current directory, then opens or reuses the visible CLI.`,
143
+ `Ensures the ${getBridgeChannelDisplayName(channelId)} runtime for the current directory, then opens or reuses the visible CLI.`,
138
144
  "If a same-workspace daemon is running, the command delegates to that daemon.",
139
- helpAdapter === "codex"
140
- ? "Codex restores the current session by default."
141
- : `${adapterLabel} starts a fresh session by default.`,
142
- helpAdapter === "codex"
143
- ? "Use --session-start-mode new to explicitly start a fresh session."
144
- : "Use --session-start-mode restore to explicitly restore the previous session.",
145
+ `${adapterLabel} starts a fresh session by default.`,
146
+ "Use --session-start-mode restore to explicitly restore the previous session.",
145
147
  "Use --doctor to check the selected adapter and workspace without starting it.",
148
+ helpAdapter === "codex"
149
+ ? `Use --attach-current-session to attach ${getBridgeChannelDisplayName(channelId)} to the live Codex daemon session in this directory (Codex >= 0.162 shared background server), instead of opening a visible client.`
150
+ : "",
146
151
  "Without a daemon, the transient runtime is companion-bound and stops when the visible CLI closes.",
147
152
  "Unknown arguments are forwarded to the visible CLI client.",
148
153
  "",
@@ -153,7 +158,7 @@ export function parseCliArgs(argv) {
153
158
  continue;
154
159
  }
155
160
  if (arg === "--channel") {
156
- if (!next || (next !== "wechat" && next !== "wecom")) {
161
+ if (!isBridgeChannelId(next) || next === "local") {
157
162
  throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
158
163
  }
159
164
  channelId = next;
@@ -204,8 +209,23 @@ export function parseCliArgs(argv) {
204
209
  i += 1;
205
210
  continue;
206
211
  }
212
+ if (arg === "--attach-current-session") {
213
+ attachCurrentSession = true;
214
+ continue;
215
+ }
216
+ if (arg === "--codex-thread-id") {
217
+ if (!next) {
218
+ throw new Error("--codex-thread-id requires a value");
219
+ }
220
+ codexThreadId = next;
221
+ i += 1;
222
+ continue;
223
+ }
207
224
  cliArgs.push(arg);
208
225
  }
226
+ if (attachCurrentSession && adapter !== "codex") {
227
+ throw new Error("--attach-current-session requires the codex adapter");
228
+ }
209
229
  return {
210
230
  adapter,
211
231
  cwd,
@@ -214,6 +234,8 @@ export function parseCliArgs(argv) {
214
234
  sessionStartMode: sessionStartMode ?? defaultSessionStartMode(adapter),
215
235
  cliArgs,
216
236
  channelId,
237
+ attachCurrentSession,
238
+ codexThreadId,
217
239
  };
218
240
  }
219
241
  function isPidAlive(pid) {
@@ -238,9 +260,9 @@ async function waitForProcessExit(pid, timeoutMs) {
238
260
  }
239
261
  return !isPidAlive(pid);
240
262
  }
241
- async function stopExistingBridge(lock, requestedAdapter) {
263
+ async function stopExistingBridge(lock, options) {
242
264
  const { pid, cwd } = lock;
243
- log(requestedAdapter, `Stopping existing bridge for ${cwd} (pid=${pid})...`);
265
+ log(options, `Stopping existing bridge for ${cwd} (pid=${pid})...`);
244
266
  try {
245
267
  process.kill(pid);
246
268
  }
@@ -264,7 +286,7 @@ async function stopExistingBridge(lock, requestedAdapter) {
264
286
  else {
265
287
  clearLocalCompanionEndpoint(cwd);
266
288
  }
267
- log(requestedAdapter, `Cleared stale local companion endpoint for previous workspace ${cwd}.`);
289
+ log(options, `Cleared stale local companion endpoint for previous workspace ${cwd}.`);
268
290
  }
269
291
  async function isEndpointReachable(endpoint) {
270
292
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -289,7 +311,8 @@ async function isEndpointReachable(endpoint) {
289
311
  socket.once("error", () => finish(false));
290
312
  });
291
313
  }
292
- async function readUsableEndpoint(cwd, adapter) {
314
+ async function readUsableEndpoint(options) {
315
+ const { cwd, adapter } = options;
293
316
  const endpoint = readLocalCompanionEndpoint(cwd, { adapter });
294
317
  if (!endpoint || endpoint.kind !== adapter) {
295
318
  return { endpoint: null };
@@ -298,7 +321,7 @@ async function readUsableEndpoint(cwd, adapter) {
298
321
  return { endpoint };
299
322
  }
300
323
  clearLocalCompanionEndpoint(cwd, endpoint.instanceId, { adapter });
301
- log(adapter, `Removed stale local companion endpoint for ${cwd}.`);
324
+ log(options, `Removed stale local companion endpoint for ${cwd}.`);
302
325
  return { endpoint: null };
303
326
  }
304
327
  function isCompanionAlive(endpoint) {
@@ -314,21 +337,31 @@ function isCompanionAlive(endpoint) {
314
337
  return false;
315
338
  }
316
339
  export function buildBackgroundBridgeArgs(entryPath, options) {
317
- const lifecycle = "companion_bound";
340
+ // The attach bridge outlives this launcher (no visible client keeps the
341
+ // foreground alive), so it must not be companion-bound to the launcher pid.
342
+ const lifecycle = options.attachCurrentSession ? "persistent" : "companion_bound";
318
343
  const args = ["--no-warnings"];
319
344
  if (path.extname(entryPath) === ".ts") {
320
345
  args.push("--experimental-strip-types");
321
346
  }
322
347
  args.push(entryPath, "--adapter", options.adapter, "--cwd", options.cwd, "--lifecycle", lifecycle);
323
- if (options.channelId === "wecom") {
324
- args.push("--channel", "wecom");
348
+ if (options.channelId) {
349
+ args.push("--channel", options.channelId);
325
350
  }
326
- if (options.sessionStartMode !== "restore") {
327
- args.push("--session-start-mode", options.sessionStartMode);
351
+ // Attach discovery owns thread selection; do not restore stale bridge state.
352
+ const sessionStartMode = options.attachCurrentSession ? "new" : options.sessionStartMode;
353
+ if (sessionStartMode !== "restore") {
354
+ args.push("--session-start-mode", sessionStartMode);
328
355
  }
329
356
  if (options.profile) {
330
357
  args.push("--profile", options.profile);
331
358
  }
359
+ if (options.attachCurrentSession) {
360
+ args.push("--codex-attach-daemon");
361
+ if (options.codexThreadId) {
362
+ args.push("--codex-thread-id", options.codexThreadId);
363
+ }
364
+ }
332
365
  return args;
333
366
  }
334
367
  function startBridgeInBackground(options) {
@@ -348,10 +381,11 @@ function startBridgeInBackground(options) {
348
381
  });
349
382
  child.unref();
350
383
  }
351
- async function waitForEndpoint(cwd, adapter, timeoutMs) {
384
+ async function waitForEndpoint(options) {
385
+ const { cwd, adapter, timeoutMs } = options;
352
386
  const deadline = Date.now() + timeoutMs;
353
387
  while (Date.now() < deadline) {
354
- const result = await readUsableEndpoint(cwd, adapter);
388
+ const result = await readUsableEndpoint(options);
355
389
  if (result.endpoint) {
356
390
  return result.endpoint;
357
391
  }
@@ -361,25 +395,25 @@ async function waitForEndpoint(cwd, adapter, timeoutMs) {
361
395
  }
362
396
  async function ensureBridgeReady(options) {
363
397
  // If the lock is absent or the lock-holding process is dead, do NOT trust a
364
- // leftover endpoint. The bridge (WeChat transport) may have crashed while
398
+ // leftover endpoint. The channel transport may have crashed while
365
399
  // the opencode server kept running. Starting only the panel would leave no
366
- // bridge to poll WeChat messages.
400
+ // bridge to receive channel messages.
367
401
  const lock = readBridgeLockFile();
368
402
  const lockProcessAlive = lock ? isPidAlive(lock.pid) : false;
369
403
  if (!lock || !lockProcessAlive) {
370
404
  if (lock && !lockProcessAlive) {
371
- log(options.adapter, `Found stale lock for ${options.cwd} (pid=${lock.pid} dead). Clearing.`);
405
+ log(options, `Found stale lock for ${options.cwd} (pid=${lock.pid} dead). Clearing.`);
372
406
  clearLocalCompanionEndpoint(options.cwd, undefined, {
373
407
  adapter: options.adapter,
374
408
  });
375
409
  }
376
- log(options.adapter, `Starting bridge in background for ${options.cwd}...`);
410
+ log(options, `Starting bridge in background for ${options.cwd}...`);
377
411
  startBridgeInBackground(options);
378
- await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
412
+ await waitForEndpoint(options);
379
413
  return { shouldOpenVisibleClient: true };
380
414
  }
381
415
  // Lock is held by a live process; decide whether to reuse, switch, or replace it.
382
- const endpointResult = await readUsableEndpoint(options.cwd, options.adapter);
416
+ const endpointResult = await readUsableEndpoint(options);
383
417
  const decision = decideLaunchAction({
384
418
  requestedAdapter: options.adapter,
385
419
  requestedCwd: options.cwd,
@@ -391,31 +425,31 @@ async function ensureBridgeReady(options) {
391
425
  sessionStartMode: options.sessionStartMode,
392
426
  requestedChannelId: options.channelId,
393
427
  });
394
- log(options.adapter, decision.message);
428
+ log(options, decision.message);
395
429
  if (decision.kind === "already_active") {
396
430
  return { shouldOpenVisibleClient: false };
397
431
  }
398
432
  if (decision.kind === "open_companion") {
399
433
  if (!endpointResult.endpoint) {
400
- await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
434
+ await waitForEndpoint(options);
401
435
  }
402
436
  return { shouldOpenVisibleClient: true };
403
437
  }
404
438
  if (decision.kind === "switch_workspace") {
405
439
  try {
406
- await stopExistingBridge(lock, options.adapter);
440
+ await stopExistingBridge(lock, options);
407
441
  }
408
442
  catch (error) {
409
- log(options.adapter, decision.failureMessage);
443
+ log(options, decision.failureMessage);
410
444
  throw error;
411
445
  }
412
446
  }
413
447
  else {
414
- await stopExistingBridge(lock, options.adapter);
448
+ await stopExistingBridge(lock, options);
415
449
  }
416
- log(options.adapter, `Starting replacement bridge in background for ${options.cwd}...`);
450
+ log(options, `Starting replacement bridge in background for ${options.cwd}...`);
417
451
  startBridgeInBackground(options);
418
- await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
452
+ await waitForEndpoint(options);
419
453
  return { shouldOpenVisibleClient: true };
420
454
  }
421
455
  // The daemon's ensure_slot can legitimately take a while: opening a visible
@@ -460,10 +494,10 @@ export async function tryDelegateToDaemon(options, deps = {}) {
460
494
  // launch: fall back to a local companion-bound bridge so the user still
461
495
  // gets a working terminal instead of an error for work the daemon may
462
496
  // still complete.
463
- log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
497
+ log(options, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
464
498
  return false;
465
499
  }
466
- log(options.adapter, `Delegated to running ${requestedChannel}-daemon for ${options.cwd}.`, requestedChannel);
500
+ log(options, `Delegated to running ${requestedChannel}-daemon for ${options.cwd}.`);
467
501
  return true;
468
502
  }
469
503
  export async function runVisibleClient(options, runners = {}) {
@@ -487,17 +521,30 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
487
521
  await ensureCredentials({
488
522
  requireUserId: true,
489
523
  validateExisting: true,
490
- log: (message) => log(adapter, message),
524
+ log: (message) => log({ adapter, channelId: "wechat" }, message),
491
525
  });
492
526
  }
493
- export async function ensureCompanionStartChannelCredentials(options) {
494
- if (options.channelId === "wecom") {
527
+ const channelCredentialSetups = {
528
+ wechat: async (options) => {
529
+ await ensureCompanionStartWechatCredentials(options.adapter);
530
+ },
531
+ wecom: async (options) => {
495
532
  await ensureWecomAccount({
496
- log: (message) => log(options.adapter, message, "wecom"),
533
+ log: (message) => log(options, message),
534
+ });
535
+ },
536
+ telegram: async (options) => {
537
+ await ensureTelegramAccount({
538
+ log: (message) => log(options, message),
497
539
  });
498
- return;
540
+ },
541
+ };
542
+ export async function ensureCompanionStartChannelCredentials(options) {
543
+ const channelId = options.channelId ?? "wechat";
544
+ if (channelId === "local") {
545
+ throw new Error("The local channel does not support companion launchers.");
499
546
  }
500
- await ensureCompanionStartWechatCredentials(options.adapter);
547
+ await channelCredentialSetups[channelId](options);
501
548
  }
502
549
  export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
503
550
  if (argv.includes("--doctor")) {
@@ -507,6 +554,9 @@ export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
507
554
  }
508
555
  const options = parseCliArgs(argv);
509
556
  await ensureCompanionStartChannelCredentials(options);
557
+ if (options.attachCurrentSession) {
558
+ return await runAttachCurrentSession(options);
559
+ }
510
560
  if (await tryDelegateToDaemon(options)) {
511
561
  return 0;
512
562
  }
@@ -516,29 +566,98 @@ export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
516
566
  }
517
567
  return await runVisibleClient(options);
518
568
  }
569
+ export function resolveAttachedBridgeThreadId(state, lock, options) {
570
+ if (!lock || !state || typeof state !== "object") {
571
+ return null;
572
+ }
573
+ const parsed = state;
574
+ const channelId = options.channelId ?? "wechat";
575
+ if (lock.adapter !== "codex" ||
576
+ !isSameWorkspaceCwd(lock.cwd, options.cwd) ||
577
+ (lock.channelId ?? "wechat") !== channelId ||
578
+ parsed.instanceId !== lock.instanceId ||
579
+ parsed.adapter !== "codex" ||
580
+ typeof parsed.cwd !== "string" ||
581
+ !isSameWorkspaceCwd(parsed.cwd, options.cwd) ||
582
+ (parsed.channelId ?? "wechat") !== channelId ||
583
+ typeof parsed.sharedThreadId !== "string" ||
584
+ !parsed.sharedThreadId ||
585
+ (options.codexThreadId && parsed.sharedThreadId !== options.codexThreadId)) {
586
+ return null;
587
+ }
588
+ return parsed.sharedThreadId;
589
+ }
590
+ export function formatAttachedBridgeMessage(options, sharedThreadId) {
591
+ return `Attached Codex daemon thread ${sharedThreadId.slice(0, 8)} for ${options.cwd}. ${getBridgeChannelDisplayName(options.channelId ?? "wechat")} forwarding is active; the visible Codex session keeps running unchanged.`;
592
+ }
593
+ async function waitForAttachedBridgeState(options) {
594
+ const { cwd, timeoutMs } = options;
595
+ const stateFile = getWorkspaceChannelPaths(cwd).stateFile;
596
+ const deadline = Date.now() + timeoutMs;
597
+ while (Date.now() < deadline) {
598
+ try {
599
+ const raw = fs.readFileSync(stateFile, "utf8");
600
+ const parsed = JSON.parse(raw);
601
+ const lock = readBridgeLockFile();
602
+ const sharedThreadId = resolveAttachedBridgeThreadId(parsed, lock, options);
603
+ if (sharedThreadId && lock && isPidAlive(lock.pid)) {
604
+ return { sharedThreadId };
605
+ }
606
+ }
607
+ catch {
608
+ // Not written yet; keep polling until the deadline.
609
+ }
610
+ await new Promise((resolve) => setTimeout(resolve, 250));
611
+ }
612
+ throw new Error(`Timed out waiting for the attach bridge to bind a Codex thread for ${cwd}. Check ${BRIDGE_LOG_FILE}.`);
613
+ }
614
+ /**
615
+ * Attach flow: bind the selected channel to the live Codex shared-daemon session in the
616
+ * workspace. No visible client is opened — the owning Codex TUI stays as-is —
617
+ * so readiness is the persisted bridge state carrying the attached thread id.
618
+ */
619
+ async function runAttachCurrentSession(options) {
620
+ const daemonEndpoint = readDaemonEndpoint();
621
+ if (daemonEndpoint && (await isDaemonEndpointAlive(daemonEndpoint))) {
622
+ const channel = daemonEndpoint.channelId ?? "wechat";
623
+ throw new Error(`A ${channel}-daemon is running for ${daemonEndpoint.cwd}. Attach mode needs the workspace bridge lock; stop the daemon first or use its slots.`);
624
+ }
625
+ const socketPath = resolveCodexDaemonControlSocketPath();
626
+ if (!fs.existsSync(socketPath)) {
627
+ throw new Error(`No shared Codex app-server daemon socket at ${socketPath}. Attach mode requires Codex >= 0.162 with its shared background server (the default). Sessions started with --no-daemon, --oss, or a --profile, and desktop-app embedded sessions, cannot be attached.`);
628
+ }
629
+ const lock = readBridgeLockFile();
630
+ if (lock) {
631
+ if (isPidAlive(lock.pid)) {
632
+ await stopExistingBridge(lock, options);
633
+ }
634
+ else {
635
+ clearLocalCompanionEndpoint(options.cwd, undefined, { adapter: options.adapter });
636
+ log(options, `Cleared stale bridge lock and endpoint for ${lock.cwd}.`);
637
+ }
638
+ }
639
+ log(options, `Starting attach bridge in background for ${options.cwd}...`);
640
+ startBridgeInBackground(options);
641
+ const state = await waitForAttachedBridgeState(options);
642
+ log(options, formatAttachedBridgeMessage(options, state.sharedThreadId));
643
+ return 0;
644
+ }
519
645
  export async function main(argv = process.argv.slice(2)) {
520
646
  try {
521
647
  const exitCode = await runLocalCompanionStart(argv);
522
648
  process.exit(exitCode);
523
649
  }
524
650
  catch (error) {
525
- const adapter = (() => {
526
- try {
527
- return parseCliArgs(argv).adapter;
528
- }
529
- catch {
530
- return DEFAULT_ADAPTER;
531
- }
532
- })();
533
- const channelId = (() => {
534
- try {
535
- return parseCliArgs(argv).channelId ?? "wechat";
536
- }
537
- catch {
538
- return "wechat";
539
- }
540
- })();
541
- log(adapter, error instanceof Error ? error.message : String(error), channelId);
651
+ // Argument validation may fail; retain the wrapper's channel and adapter
652
+ // without running the failing parser again.
653
+ const adapterIndex = argv.lastIndexOf("--adapter");
654
+ const adapterValue = adapterIndex >= 0 ? argv[adapterIndex + 1] : undefined;
655
+ const adapter = adapterValue && ["codex", "claude", "opencode", "pi"].includes(adapterValue)
656
+ ? adapterValue
657
+ : DEFAULT_ADAPTER;
658
+ const channelIndex = argv.lastIndexOf("--channel");
659
+ const channelId = normalizeBridgeChannelId(channelIndex >= 0 ? argv[channelIndex + 1] : undefined);
660
+ log({ adapter, channelId }, error instanceof Error ? error.message : String(error));
542
661
  process.exit(1);
543
662
  }
544
663
  }
@@ -7,6 +7,7 @@
7
7
  // LICENSE.txt. Network services built on it must offer source to users.
8
8
  import net from "node:net";
9
9
  import path from "node:path";
10
+ import { parseLocale, setLocale, t } from "../i18n/index.js";
10
11
  import { createBridgeAdapter } from "../bridge/bridge-adapters.js";
11
12
  import { LOCAL_COMPANION_RECONNECT_GRACE_MS, } from "../bridge/bridge-adapters.shared.js";
12
13
  import { runCodexRemoteClientFromEndpoint } from "./codex-remote-client.js";
@@ -170,7 +171,7 @@ export async function runLocalCompanion(options) {
170
171
  }
171
172
  sendLocalCompanionMessage(activeSocket, {
172
173
  type: "state",
173
- state: adapter.getState(),
174
+ state: { ...adapter.getState(), supportsLocaleUpdates: true },
174
175
  });
175
176
  };
176
177
  const sendResponse = (socket, id, ok, result, error) => {
@@ -211,6 +212,15 @@ export async function runLocalCompanion(options) {
211
212
  const handleBridgeRequest = async (socket, message) => {
212
213
  try {
213
214
  switch (message.payload.command) {
215
+ case "set_locale": {
216
+ const locale = parseLocale(message.payload.locale);
217
+ if (!locale)
218
+ throw new Error(t("language.usage"));
219
+ setLocale(locale);
220
+ process.env.CLI_BRIDGE_LANG = locale;
221
+ sendResponse(socket, message.id, true);
222
+ break;
223
+ }
214
224
  case "send_input":
215
225
  await adapter.sendInput(message.payload.text);
216
226
  sendResponse(socket, message.id, true);
@@ -239,18 +249,18 @@ export async function runLocalCompanion(options) {
239
249
  break;
240
250
  case "list_models":
241
251
  if (!adapter.listModels)
242
- throw new Error("Model listing is not supported by this companion.");
252
+ throw new Error(t("companion.modelsUnsupported"));
243
253
  sendResponse(socket, message.id, true, await adapter.listModels());
244
254
  break;
245
255
  case "select_model":
246
256
  if (!adapter.selectModel)
247
- throw new Error("Model selection is not supported by this companion.");
257
+ throw new Error(t("companion.modelUnsupported"));
248
258
  sendResponse(socket, message.id, true, await adapter.selectModel(message.payload.modelId));
249
259
  publishState();
250
260
  break;
251
261
  case "set_plan_mode":
252
262
  if (!adapter.setPlanMode)
253
- throw new Error("Plan mode is not supported by this companion.");
263
+ throw new Error(t("companion.planUnsupported"));
254
264
  sendResponse(socket, message.id, true, await adapter.setPlanMode(message.payload.enabled));
255
265
  publishState();
256
266
  break;
@@ -263,10 +273,14 @@ export async function runLocalCompanion(options) {
263
273
  sendResponse(socket, message.id, true);
264
274
  break;
265
275
  case "resolve_approval":
266
- sendResponse(socket, message.id, true, await adapter.resolveApproval(message.payload.action));
276
+ sendResponse(socket, message.id, true, message.payload.requestId === undefined
277
+ ? false
278
+ : await adapter.resolveApproval(message.payload.action, message.payload.requestId));
267
279
  break;
268
280
  case "submit_user_input":
269
- sendResponse(socket, message.id, true, await adapter.submitUserInput(message.payload.answers));
281
+ sendResponse(socket, message.id, true, adapter.getState().supportsUserInputRequestIds && !message.payload.requestId
282
+ ? false
283
+ : await adapter.submitUserInput(message.payload.answers, message.payload.requestId));
270
284
  break;
271
285
  case "dispose":
272
286
  sendResponse(socket, message.id, true);
@@ -308,6 +322,11 @@ export async function runLocalCompanion(options) {
308
322
  localDetach = attachLocalCompanionMessageListener(socket, (message) => {
309
323
  if (!helloAcknowledged) {
310
324
  if (message.type === "hello_ack") {
325
+ const locale = parseLocale(message.locale);
326
+ if (locale) {
327
+ setLocale(locale);
328
+ process.env.CLI_BRIDGE_LANG = locale;
329
+ }
311
330
  helloAcknowledged = true;
312
331
  closeReason = null;
313
332
  activeSocket = socket;
@@ -356,6 +375,7 @@ export async function runLocalCompanion(options) {
356
375
  type: "hello",
357
376
  token: endpoint.token,
358
377
  companionPid: process.pid,
378
+ supportsLocaleUpdates: true,
359
379
  });
360
380
  });
361
381
  socket.once("error", (error) => {
@@ -402,13 +422,13 @@ export async function runLocalCompanion(options) {
402
422
  }
403
423
  };
404
424
  adapter.setEventSink((event) => {
425
+ publishState();
405
426
  if (activeSocket) {
406
427
  sendLocalCompanionMessage(activeSocket, {
407
428
  type: "event",
408
429
  event,
409
430
  });
410
431
  }
411
- publishState();
412
432
  if (event.type === "fatal_error") {
413
433
  void closeCompanion(1, "fatal_error");
414
434
  return;
@@ -330,11 +330,14 @@ export default function piTuiBridgeExtension(pi) {
330
330
  });
331
331
  pi.on("message_end", (event, context) => {
332
332
  latestContext = context;
333
+ const message = isRecord(event.message) ? event.message : {};
334
+ if (message.role !== "assistant") {
335
+ return;
336
+ }
333
337
  const text = extractAssistantText(event.message);
334
- if (!text) {
338
+ if (!text && message.stopReason !== "error") {
335
339
  return;
336
340
  }
337
- const message = isRecord(event.message) ? event.message : {};
338
341
  writeFrame({
339
342
  type: "assistant_message",
340
343
  text,
@@ -346,9 +349,12 @@ export default function piTuiBridgeExtension(pi) {
346
349
  latestContext = context;
347
350
  writeFrame({ type: "agent_start" });
348
351
  });
349
- pi.on("agent_settled", (_event, context) => {
352
+ pi.on("agent_settled", (event, context) => {
350
353
  latestContext = context;
351
354
  writeSessionState(context, "settled");
352
- writeFrame({ type: "agent_settled" });
355
+ writeFrame({
356
+ type: "agent_settled",
357
+ ...(typeof event.aborted === "boolean" ? { aborted: event.aborted } : {}),
358
+ });
353
359
  });
354
360
  }
@@ -7,6 +7,9 @@ export function parseBridgeControlCommand(text) {
7
7
  const command = rawCommand.toLowerCase();
8
8
  const argument = rest.join(" ").trim();
9
9
  switch (command) {
10
+ case "/lang":
11
+ case "/language":
12
+ return { type: "language", target: argument || undefined };
10
13
  case "/status":
11
14
  return { type: "status" };
12
15
  case "/resume":
@@ -1,3 +1,4 @@
1
+ import { t } from "../i18n/index.js";
1
2
  export function shouldDeferCodexInboundMessage(params) {
2
3
  return (params.adapter === "codex" &&
3
4
  !params.hasPendingConfirmation &&
@@ -18,8 +19,8 @@ export function canDrainDeferredCodexInboundQueue(params) {
18
19
  params.status !== "awaiting_input");
19
20
  }
20
21
  export function formatDeferredCodexInboundQueueMessage(queuePosition) {
21
- return `Queued for delivery after the current local Codex turn finishes. Queue position: ${queuePosition}.`;
22
+ return t("bridge.deferred", { position: queuePosition });
22
23
  }
23
24
  export function isRetryableDeferredCodexDrainError(errorText) {
24
- return /still working|approval request is pending|waiting for local terminal input/i.test(errorText);
25
+ return /still working|approval request is pending|waiting for local terminal input|仍在运行|有待审批请求|正在等待本地终端输入/i.test(errorText);
25
26
  }
@@ -19,6 +19,9 @@ export async function forwardBridgeEvent(event, handlers) {
19
19
  case "thinking":
20
20
  await handlers.thinking?.(event);
21
21
  return;
22
+ case "approval_state_changed":
23
+ await handlers.approvalStateChanged?.(event);
24
+ return;
22
25
  case "approval_required":
23
26
  await handlers.approvalRequired?.(event);
24
27
  return;
@@ -1,3 +1,15 @@
1
+ export const BRIDGE_CHANNEL_DISPLAY_NAMES = {
2
+ wechat: "WeChat",
3
+ wecom: "WeCom",
4
+ telegram: "Telegram",
5
+ local: "Local",
6
+ };
7
+ export function isBridgeChannelId(value) {
8
+ return typeof value === "string" && Object.hasOwn(BRIDGE_CHANNEL_DISPLAY_NAMES, value);
9
+ }
10
+ export function getBridgeChannelDisplayName(channelId) {
11
+ return BRIDGE_CHANNEL_DISPLAY_NAMES[channelId];
12
+ }
1
13
  export function normalizeBridgeChannelId(value) {
2
- return value === "wecom" ? "wecom" : "wechat";
14
+ return isBridgeChannelId(value) ? value : "wechat";
3
15
  }