@unlinearity/cli-wechat-bridge 1.0.4 → 1.0.6

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.
@@ -8,9 +8,10 @@ import { ensureWechatCredentials } from "../wechat/setup.js";
8
8
  import { BridgeStateStore } from "./bridge-state.js";
9
9
  import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
10
10
  import { createRuntimeHost } from "../runtime/create-runtime-host.js";
11
- import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
12
- import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, } from "../wechat/wechat-transport.js";
11
+ import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
12
+ import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
13
13
  import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
14
+ import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
14
15
  const POLL_RETRY_BASE_MS = 1_000;
15
16
  const POLL_RETRY_MAX_MS = 30_000;
16
17
  const PARENT_PROCESS_POLL_MS = 5_000;
@@ -75,16 +76,22 @@ export function formatUserFacingInboundError(params) {
75
76
  export function formatWechatSendFailureLogEntry(params) {
76
77
  return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
77
78
  }
79
+ export function formatWechatContextTokenStaleLogEntry(params) {
80
+ return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
81
+ }
78
82
  function formatWechatSendRetryLogEntry(params) {
79
83
  return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
80
84
  }
81
- function isRetryableWechatSendError(error) {
85
+ export function isRetryableWechatSendError(error) {
86
+ if (isWechatContextTokenStaleError(error)) {
87
+ return false;
88
+ }
82
89
  const classification = classifyWechatTransportError(error);
83
90
  if (classification.retryable) {
84
91
  return true;
85
92
  }
86
93
  const details = describeWechatTransportError(error);
87
- return /^Error: sendmessage failed:/i.test(details) &&
94
+ return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
88
95
  !/errcode=-14\b.*session timeout/i.test(details);
89
96
  }
90
97
  function computeWechatSendRetryDelayMs(attempt) {
@@ -104,6 +111,15 @@ function toPendingApproval(request) {
104
111
  createdAt: nowIso(),
105
112
  };
106
113
  }
114
+ function toPendingUserInput(request) {
115
+ if (typeof request.createdAt === "string") {
116
+ return request;
117
+ }
118
+ return {
119
+ ...request,
120
+ createdAt: nowIso(),
121
+ };
122
+ }
107
123
  export function shouldDeferCodexInboundMessage(params) {
108
124
  return (params.adapter === "codex" &&
109
125
  !params.hasPendingConfirmation &&
@@ -115,11 +131,13 @@ export function canDrainDeferredCodexInboundQueue(params) {
115
131
  return (params.adapter === "codex" &&
116
132
  params.deferredCount > 0 &&
117
133
  !params.hasPendingConfirmation &&
134
+ !params.hasPendingUserInput &&
118
135
  !params.hasPendingApproval &&
119
136
  !params.hasActiveTask &&
120
137
  !params.activeTurnId &&
121
138
  params.status !== "busy" &&
122
- params.status !== "awaiting_approval");
139
+ params.status !== "awaiting_approval" &&
140
+ params.status !== "awaiting_input");
123
141
  }
124
142
  export function formatDeferredCodexInboundQueueMessage(queuePosition) {
125
143
  return `Queued for delivery after the current local Codex turn finishes. Queue position: ${queuePosition}.`;
@@ -214,6 +232,14 @@ function printUsageAndExit() {
214
232
  }
215
233
  async function main() {
216
234
  const options = parseCliArgs(process.argv.slice(2));
235
+ const daemonEndpoint = readDaemonEndpoint();
236
+ if (daemonEndpoint && await isDaemonEndpointAlive(daemonEndpoint, { timeoutMs: 500 })) {
237
+ throw new Error(`wechat-daemon is already running (pid=${daemonEndpoint.pid}, cwd=${daemonEndpoint.cwd}). Stop it before starting a standalone bridge.`);
238
+ }
239
+ if (daemonEndpoint) {
240
+ clearDaemonEndpoint(daemonEndpoint.pid);
241
+ log(`Cleared stale wechat-daemon endpoint for pid=${daemonEndpoint.pid}.`);
242
+ }
217
243
  const credentials = await ensureWechatCredentials({
218
244
  requireUserId: true,
219
245
  validateExisting: true,
@@ -313,6 +339,17 @@ async function main() {
313
339
  return true;
314
340
  }
315
341
  catch (err) {
342
+ if (isWechatContextTokenStaleError(err)) {
343
+ transport.clearCachedContextToken(senderId);
344
+ const hint = "WeChat conversation context is stale. Ask the WeChat owner to send any message first, then local terminal replies can sync back to WeChat.";
345
+ logError(`Failed to send WeChat ${context}: ${hint}`);
346
+ stateStore.appendLog(formatWechatContextTokenStaleLogEntry({
347
+ context,
348
+ recipientId: senderId,
349
+ error: err,
350
+ }));
351
+ return false;
352
+ }
316
353
  if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
317
354
  const delayMs = computeWechatSendRetryDelayMs(attempt);
318
355
  logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
@@ -368,6 +405,7 @@ async function main() {
368
405
  status: adapterState.status,
369
406
  activeTurnId: adapterState.activeTurnId,
370
407
  hasPendingConfirmation: Boolean(stateStore.getState().pendingConfirmation),
408
+ hasPendingUserInput: Boolean(stateStore.getState().pendingUserInput),
371
409
  hasPendingApproval: Boolean(adapterState.pendingApproval),
372
410
  hasActiveTask: Boolean(activeTask),
373
411
  })) {
@@ -691,6 +729,9 @@ function wireAdapterEvents(params) {
691
729
  if (bridgeState.pendingConfirmation && !adapterState.pendingApproval) {
692
730
  stateStore.clearPendingConfirmation();
693
731
  }
732
+ if (bridgeState.pendingUserInput && !adapterState.pendingUserInput) {
733
+ stateStore.clearPendingUserInput();
734
+ }
694
735
  const authorizedUserId = stateStore.getState().authorizedUserId;
695
736
  switch (event.type) {
696
737
  case "stdout":
@@ -749,6 +790,14 @@ function wireAdapterEvents(params) {
749
790
  await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
750
791
  }));
751
792
  break;
793
+ case "user_input_required":
794
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
795
+ const pending = toPendingUserInput(event.request);
796
+ stateStore.setPendingUserInput(pending);
797
+ stateStore.appendLog(`User input requested: questions=${pending.questions.length}`);
798
+ await queueWechatMessage(authorizedUserId, formatUserInputRequestMessage(pending, adapterState), "user_input_required");
799
+ }));
800
+ break;
752
801
  case "mirrored_user_input":
753
802
  stateStore.appendLog(`mirrored_local_input: ${truncatePreview(event.text)}`);
754
803
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
@@ -787,6 +836,7 @@ function wireAdapterEvents(params) {
787
836
  case "task_complete":
788
837
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
789
838
  stateStore.clearPendingConfirmation();
839
+ stateStore.clearPendingUserInput();
790
840
  if (options.adapter === "shell") {
791
841
  const summary = buildCompletionSummary({
792
842
  adapter: options.adapter,
@@ -803,6 +853,7 @@ function wireAdapterEvents(params) {
803
853
  case "task_failed":
804
854
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
805
855
  stateStore.clearPendingConfirmation();
856
+ stateStore.clearPendingUserInput();
806
857
  clearActiveTask();
807
858
  await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, event.message), "task_failed");
808
859
  await maybeDrainDeferredInboundMessages();
@@ -812,6 +863,7 @@ function wireAdapterEvents(params) {
812
863
  logError(event.message);
813
864
  stateStore.appendLog(`fatal_error: ${event.message}`);
814
865
  stateStore.clearPendingConfirmation();
866
+ stateStore.clearPendingUserInput();
815
867
  clearActiveTask();
816
868
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
817
869
  await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(event.message), "fatal_error");
@@ -837,6 +889,17 @@ function buildCompletionSummary(params) {
837
889
  lines.push(`recent_output:\n${params.recentOutput}`);
838
890
  return lines.join("\n");
839
891
  }
892
+ function formatInboundMessagePreview(message) {
893
+ if (message.text.trim()) {
894
+ return message.text;
895
+ }
896
+ if (message.attachments.length > 0) {
897
+ return message.attachments
898
+ .map((attachment) => `${attachment.kind}: ${attachment.path}`)
899
+ .join("\n");
900
+ }
901
+ return "(empty)";
902
+ }
840
903
  async function handleInboundMessage(params) {
841
904
  const { message, options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
842
905
  const state = stateStore.getState();
@@ -877,6 +940,7 @@ async function handleInboundMessage(params) {
877
940
  await outputBatcher.flushNow();
878
941
  outputBatcher.clear();
879
942
  stateStore.clearPendingConfirmation();
943
+ stateStore.clearPendingUserInput();
880
944
  stateStore.clearSharedSessionId();
881
945
  await adapter.createSession();
882
946
  stateStore.appendLog(`New ${options.adapter} session requested by owner.`);
@@ -893,6 +957,7 @@ async function handleInboundMessage(params) {
893
957
  await outputBatcher.flushNow();
894
958
  outputBatcher.clear();
895
959
  stateStore.clearPendingConfirmation();
960
+ stateStore.clearPendingUserInput();
896
961
  stateStore.clearSharedSessionId();
897
962
  await adapter.reset();
898
963
  stateStore.appendLog("Worker reset by owner.");
@@ -937,11 +1002,39 @@ async function handleInboundMessage(params) {
937
1002
  await queueWechatMessage(message.senderId, "Approval denied.");
938
1003
  return null;
939
1004
  }
1005
+ case "answer": {
1006
+ const pending = state.pendingUserInput;
1007
+ if (!pending) {
1008
+ await queueWechatMessage(message.senderId, "No pending user input request.");
1009
+ return null;
1010
+ }
1011
+ const parsed = parsePendingUserInputAnswerCommand(systemCommand.raw, pending);
1012
+ if ("error" in parsed) {
1013
+ await queueWechatMessage(message.senderId, parsed.error);
1014
+ return null;
1015
+ }
1016
+ const submitted = await adapter.submitUserInput(parsed.answers);
1017
+ if (!submitted) {
1018
+ await queueWechatMessage(message.senderId, "The worker could not apply this answer.");
1019
+ return null;
1020
+ }
1021
+ stateStore.clearPendingUserInput();
1022
+ stateStore.appendLog(`User input answered: ${parsed.preview}`);
1023
+ await queueWechatMessage(message.senderId, "Answer submitted. Continuing...");
1024
+ return {
1025
+ startedAt: Date.now(),
1026
+ inputPreview: parsed.preview,
1027
+ };
1028
+ }
940
1029
  }
941
1030
  if (state.pendingConfirmation) {
942
1031
  await queueWechatMessage(message.senderId, formatPendingApprovalReminder(state.pendingConfirmation, adapter.getState()));
943
1032
  return null;
944
1033
  }
1034
+ if (state.pendingUserInput) {
1035
+ await queueWechatMessage(message.senderId, formatPendingUserInputReminder(state.pendingUserInput));
1036
+ return null;
1037
+ }
945
1038
  const adapterState = adapter.getState();
946
1039
  if (shouldDeferCodexInboundMessage({
947
1040
  adapter: options.adapter,
@@ -971,12 +1064,13 @@ async function handleInboundMessage(params) {
971
1064
  }
972
1065
  async function dispatchInboundWechatText(params) {
973
1066
  const { message, options, stateStore, adapter } = params;
1067
+ const preview = formatInboundMessagePreview(message);
974
1068
  const activeTask = {
975
1069
  startedAt: Date.now(),
976
- inputPreview: truncatePreview(message.text, 180),
1070
+ inputPreview: truncatePreview(preview, 180),
977
1071
  };
978
- stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(message.text)}`);
979
- await adapter.sendInput(buildWechatInboundPrompt(message.text));
1072
+ stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(preview)}`);
1073
+ await adapter.sendInput(buildWechatInboundPrompt(message.text, message.attachments));
980
1074
  return activeTask;
981
1075
  }
982
1076
  const isDirectRun = Boolean(import.meta.main);
@@ -40,7 +40,7 @@ export function parseCliArgs(argv) {
40
40
  return { cwd, cliArgs };
41
41
  }
42
42
  export function readCodexRuntimeEndpoint(cwd) {
43
- const endpoint = readLocalCompanionEndpoint(cwd);
43
+ const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
44
44
  if (!endpoint || endpoint.kind !== "codex") {
45
45
  throw new Error(`No active Codex bridge endpoint was found for ${cwd}. Start "wechat-bridge-codex" in that directory first.`);
46
46
  }
@@ -83,14 +83,18 @@ export async function runCodexRemoteClientFromEndpoint(endpoint, options = {}) {
83
83
  updateLocalCompanionOccupancy(endpoint.cwd, {
84
84
  companionPid: child.pid,
85
85
  companionConnectedAt: new Date().toISOString(),
86
- }, endpoint.instanceId);
86
+ }, endpoint.instanceId, { adapter: "codex" });
87
87
  }
88
88
  child.once("error", (error) => {
89
- clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId);
89
+ clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
90
+ adapter: "codex",
91
+ });
90
92
  reject(error);
91
93
  });
92
94
  child.once("exit", (code, signal) => {
93
- clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId);
95
+ clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
96
+ adapter: "codex",
97
+ });
94
98
  if (signal) {
95
99
  process.kill(process.pid, signal);
96
100
  return;
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
- import { ensureWorkspaceChannelDir, getWorkspaceChannelPaths, } from "../wechat/channel-config.js";
3
+ import { ensureWorkspaceChannelDir, getWorkspaceAdapterEndpointFile, getWorkspaceChannelPaths, } from "../wechat/channel-config.js";
4
4
  import { LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
5
5
  function normalizeEndpoint(value) {
6
6
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -66,51 +66,81 @@ function normalizeEndpoint(value) {
66
66
  export function buildLocalCompanionToken() {
67
67
  return crypto.randomBytes(18).toString("hex");
68
68
  }
69
- export function writeLocalCompanionEndpoint(endpoint) {
70
- const { endpointFile } = ensureWorkspaceChannelDir(endpoint.cwd);
71
- const payload = {
69
+ function serializeEndpoint(endpoint) {
70
+ return {
72
71
  ...endpoint,
73
72
  protocolVersion: endpoint.protocolVersion ?? LOCAL_CLIENT_PROTOCOL_VERSION,
74
73
  sharedThreadId: endpoint.kind === "codex" || endpoint.kind === "opencode"
75
74
  ? endpoint.sharedSessionId ?? endpoint.sharedThreadId
76
75
  : undefined,
77
76
  };
78
- fs.writeFileSync(endpointFile, JSON.stringify(payload, null, 2), "utf8");
79
77
  }
80
- export function readLocalCompanionEndpoint(cwd) {
78
+ function readEndpointFile(filePath) {
79
+ if (!fs.existsSync(filePath)) {
80
+ return null;
81
+ }
82
+ return normalizeEndpoint(JSON.parse(fs.readFileSync(filePath, "utf8")));
83
+ }
84
+ export function writeLocalCompanionEndpoint(endpoint, options = {}) {
85
+ const { endpointFile } = ensureWorkspaceChannelDir(endpoint.cwd);
86
+ const payload = {
87
+ ...serializeEndpoint(endpoint),
88
+ };
89
+ fs.writeFileSync(getWorkspaceAdapterEndpointFile(endpoint.cwd, endpoint.kind), JSON.stringify(payload, null, 2), "utf8");
90
+ if (options.writeLegacy !== false) {
91
+ fs.writeFileSync(endpointFile, JSON.stringify(payload, null, 2), "utf8");
92
+ }
93
+ }
94
+ export function readLocalCompanionEndpoint(cwd, options = {}) {
81
95
  try {
82
96
  const { endpointFile } = getWorkspaceChannelPaths(cwd);
83
- if (!fs.existsSync(endpointFile)) {
84
- return null;
97
+ if (options.adapter) {
98
+ const scoped = readEndpointFile(getWorkspaceAdapterEndpointFile(cwd, options.adapter));
99
+ if (scoped) {
100
+ return scoped;
101
+ }
102
+ const legacy = readEndpointFile(endpointFile);
103
+ return legacy?.kind === options.adapter ? legacy : null;
85
104
  }
86
- return normalizeEndpoint(JSON.parse(fs.readFileSync(endpointFile, "utf8")));
105
+ return readEndpointFile(endpointFile);
87
106
  }
88
107
  catch {
89
108
  return null;
90
109
  }
91
110
  }
92
- export function clearLocalCompanionEndpoint(cwd, instanceId) {
111
+ export function clearLocalCompanionEndpoint(cwd, instanceId, options = {}) {
93
112
  try {
94
113
  const { endpointFile } = getWorkspaceChannelPaths(cwd);
95
- if (!fs.existsSync(endpointFile)) {
96
- return;
97
- }
98
- if (!instanceId) {
99
- fs.rmSync(endpointFile, { force: true });
100
- return;
101
- }
102
- const endpoint = readLocalCompanionEndpoint(cwd);
103
- if (!endpoint || endpoint.instanceId === instanceId) {
104
- fs.rmSync(endpointFile, { force: true });
114
+ const files = options.adapter
115
+ ? [getWorkspaceAdapterEndpointFile(cwd, options.adapter), endpointFile]
116
+ : [
117
+ endpointFile,
118
+ getWorkspaceAdapterEndpointFile(cwd, "codex"),
119
+ getWorkspaceAdapterEndpointFile(cwd, "claude"),
120
+ getWorkspaceAdapterEndpointFile(cwd, "opencode"),
121
+ getWorkspaceAdapterEndpointFile(cwd, "shell"),
122
+ ];
123
+ for (const filePath of files) {
124
+ if (!fs.existsSync(filePath)) {
125
+ continue;
126
+ }
127
+ if (!instanceId) {
128
+ fs.rmSync(filePath, { force: true });
129
+ continue;
130
+ }
131
+ const endpoint = readEndpointFile(filePath);
132
+ if (!endpoint || endpoint.instanceId === instanceId) {
133
+ fs.rmSync(filePath, { force: true });
134
+ }
105
135
  }
106
136
  }
107
137
  catch {
108
138
  // Best effort cleanup.
109
139
  }
110
140
  }
111
- export function clearLocalCompanionOccupancy(cwd, instanceId) {
141
+ export function clearLocalCompanionOccupancy(cwd, instanceId, options = {}) {
112
142
  try {
113
- const endpoint = readLocalCompanionEndpoint(cwd);
143
+ const endpoint = readLocalCompanionEndpoint(cwd, options);
114
144
  if (!endpoint) {
115
145
  return;
116
146
  }
@@ -130,9 +160,9 @@ export function clearLocalCompanionOccupancy(cwd, instanceId) {
130
160
  // Best effort cleanup.
131
161
  }
132
162
  }
133
- export function updateLocalCompanionHealth(cwd, patch, instanceId) {
163
+ export function updateLocalCompanionHealth(cwd, patch, instanceId, options = {}) {
134
164
  try {
135
- const endpoint = readLocalCompanionEndpoint(cwd);
165
+ const endpoint = readLocalCompanionEndpoint(cwd, options);
136
166
  if (!endpoint) {
137
167
  return;
138
168
  }
@@ -150,9 +180,9 @@ export function updateLocalCompanionHealth(cwd, patch, instanceId) {
150
180
  // Best effort cleanup.
151
181
  }
152
182
  }
153
- export function updateLocalCompanionOccupancy(cwd, patch, instanceId) {
183
+ export function updateLocalCompanionOccupancy(cwd, patch, instanceId, options = {}) {
154
184
  try {
155
- const endpoint = readLocalCompanionEndpoint(cwd);
185
+ const endpoint = readLocalCompanionEndpoint(cwd, options);
156
186
  if (!endpoint) {
157
187
  return;
158
188
  }
@@ -9,6 +9,7 @@ import { readBridgeLockFile, shouldAutoReclaimBridgeLock, } from "../bridge/brid
9
9
  import { clearLocalCompanionOccupancy, clearLocalCompanionEndpoint, readLocalCompanionEndpoint, } from "./local-companion-link.js";
10
10
  import { runCodexRemoteClient } from "./codex-remote-client.js";
11
11
  import { runLocalCompanion } from "./local-companion.js";
12
+ import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, sendDaemonRequest, } from "../daemon/daemon-link.js";
12
13
  const MODULE_FILE = fileURLToPath(import.meta.url);
13
14
  const MODULE_DIR = path.dirname(MODULE_FILE);
14
15
  const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".js";
@@ -184,7 +185,12 @@ async function stopExistingBridge(lock, requestedAdapter) {
184
185
  if (!(await waitForProcessExit(pid, 10_000))) {
185
186
  throw new Error(`Timed out waiting for existing bridge pid=${pid} to exit.`);
186
187
  }
187
- clearLocalCompanionEndpoint(cwd);
188
+ if (lock.adapter === "codex" || lock.adapter === "claude" || lock.adapter === "opencode") {
189
+ clearLocalCompanionEndpoint(cwd, undefined, { adapter: lock.adapter });
190
+ }
191
+ else {
192
+ clearLocalCompanionEndpoint(cwd);
193
+ }
188
194
  log(requestedAdapter, `Cleared stale local companion endpoint for previous workspace ${cwd}.`);
189
195
  }
190
196
  async function isEndpointReachable(endpoint) {
@@ -211,14 +217,14 @@ async function isEndpointReachable(endpoint) {
211
217
  });
212
218
  }
213
219
  async function readUsableEndpoint(cwd, adapter) {
214
- const endpoint = readLocalCompanionEndpoint(cwd);
220
+ const endpoint = readLocalCompanionEndpoint(cwd, { adapter });
215
221
  if (!endpoint || endpoint.kind !== adapter) {
216
222
  return { endpoint: null };
217
223
  }
218
224
  if (await isEndpointReachable(endpoint)) {
219
225
  return { endpoint };
220
226
  }
221
- clearLocalCompanionEndpoint(cwd, endpoint.instanceId);
227
+ clearLocalCompanionEndpoint(cwd, endpoint.instanceId, { adapter });
222
228
  log(adapter, `Removed stale local companion endpoint for ${cwd}.`);
223
229
  return { endpoint: null };
224
230
  }
@@ -229,7 +235,9 @@ function isCompanionAlive(endpoint) {
229
235
  if (isPidAlive(endpoint.companionPid)) {
230
236
  return true;
231
237
  }
232
- clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId);
238
+ clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
239
+ adapter: endpoint.kind,
240
+ });
233
241
  return false;
234
242
  }
235
243
  export function buildBackgroundBridgeArgs(entryPath, options) {
@@ -277,7 +285,9 @@ async function ensureBridgeReady(options) {
277
285
  if (!lock || !lockProcessAlive) {
278
286
  if (lock && !lockProcessAlive) {
279
287
  log(options.adapter, `Found stale lock for ${options.cwd} (pid=${lock.pid} dead). Clearing.`);
280
- clearLocalCompanionEndpoint(options.cwd);
288
+ clearLocalCompanionEndpoint(options.cwd, undefined, {
289
+ adapter: options.adapter,
290
+ });
281
291
  }
282
292
  log(options.adapter, `Starting bridge in background for ${options.cwd}...`);
283
293
  startBridgeInBackground(options);
@@ -322,6 +332,36 @@ async function ensureBridgeReady(options) {
322
332
  await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
323
333
  return { shouldOpenVisibleClient: true };
324
334
  }
335
+ export async function tryDelegateToDaemon(options, deps = {}) {
336
+ const readEndpoint = deps.readEndpoint ?? readDaemonEndpoint;
337
+ const isEndpointAlive = deps.isEndpointAlive ?? isDaemonEndpointAlive;
338
+ const sendRequest = deps.sendRequest ?? sendDaemonRequest;
339
+ const clearEndpoint = deps.clearEndpoint ?? clearDaemonEndpoint;
340
+ const endpoint = readEndpoint();
341
+ if (!endpoint) {
342
+ return false;
343
+ }
344
+ if (!(await isEndpointAlive(endpoint))) {
345
+ clearEndpoint(endpoint.pid);
346
+ return false;
347
+ }
348
+ if (!isSameWorkspaceCwd(endpoint.cwd, options.cwd)) {
349
+ throw new Error(`wechat-daemon is running for ${endpoint.cwd}. This launcher requested ${options.cwd}; v1 daemon switching is limited to its startup cwd.`);
350
+ }
351
+ const response = await sendRequest(endpoint, {
352
+ command: "ensure_slot",
353
+ adapter: options.adapter,
354
+ cwd: options.cwd,
355
+ profile: options.profile,
356
+ cliArgs: options.cliArgs,
357
+ openVisible: true,
358
+ });
359
+ if (!response.ok) {
360
+ throw new Error(response.error);
361
+ }
362
+ log(options.adapter, `Delegated to running wechat-daemon for ${options.cwd}.`);
363
+ return true;
364
+ }
325
365
  export async function runVisibleClient(options, runners = {}) {
326
366
  // Keep the foreground client in-process so Windows does not briefly flash an
327
367
  // extra bootstrap console before the real companion UI appears.
@@ -347,6 +387,9 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
347
387
  export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
348
388
  const options = parseCliArgs(argv);
349
389
  await ensureCompanionStartWechatCredentials(options.adapter);
390
+ if (await tryDelegateToDaemon(options)) {
391
+ return 0;
392
+ }
350
393
  const ready = await ensureBridgeReady(options);
351
394
  if (!ready.shouldOpenVisibleClient) {
352
395
  return 0;
@@ -60,7 +60,9 @@ function delay(ms) {
60
60
  return new Promise((resolve) => setTimeout(resolve, ms));
61
61
  }
62
62
  function readMatchingEndpoint(options) {
63
- const endpoint = readLocalCompanionEndpoint(options.cwd);
63
+ const endpoint = readLocalCompanionEndpoint(options.cwd, {
64
+ adapter: options.adapter,
65
+ });
64
66
  if (!endpoint || endpoint.kind !== options.adapter) {
65
67
  throw new Error(`No active ${options.adapter} bridge endpoint was found for ${options.cwd}. Start "wechat-bridge-${options.adapter}" in that directory first.`);
66
68
  }