@unlinearity/cli-wechat-bridge 1.0.3 → 1.0.5

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.
@@ -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";
@@ -87,6 +88,9 @@ export function parseCliArgs(argv) {
87
88
  const cliArgs = [];
88
89
  for (let i = 0; i < argv.length; i += 1) {
89
90
  const arg = argv[i];
91
+ if (!arg) {
92
+ continue;
93
+ }
90
94
  const next = argv[i + 1];
91
95
  if (arg === "--help" || arg === "-h") {
92
96
  process.stdout.write([
@@ -173,13 +177,20 @@ async function stopExistingBridge(lock, requestedAdapter) {
173
177
  catch (error) {
174
178
  if (isPidAlive(pid)) {
175
179
  const message = error instanceof Error ? error.message : String(error);
176
- throw new Error(`Failed to stop existing bridge pid=${pid}: ${message}`);
180
+ throw new Error(`Failed to stop existing bridge pid=${pid}: ${message}`, {
181
+ cause: error,
182
+ });
177
183
  }
178
184
  }
179
185
  if (!(await waitForProcessExit(pid, 10_000))) {
180
186
  throw new Error(`Timed out waiting for existing bridge pid=${pid} to exit.`);
181
187
  }
182
- 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
+ }
183
194
  log(requestedAdapter, `Cleared stale local companion endpoint for previous workspace ${cwd}.`);
184
195
  }
185
196
  async function isEndpointReachable(endpoint) {
@@ -206,14 +217,14 @@ async function isEndpointReachable(endpoint) {
206
217
  });
207
218
  }
208
219
  async function readUsableEndpoint(cwd, adapter) {
209
- const endpoint = readLocalCompanionEndpoint(cwd);
220
+ const endpoint = readLocalCompanionEndpoint(cwd, { adapter });
210
221
  if (!endpoint || endpoint.kind !== adapter) {
211
222
  return { endpoint: null };
212
223
  }
213
224
  if (await isEndpointReachable(endpoint)) {
214
225
  return { endpoint };
215
226
  }
216
- clearLocalCompanionEndpoint(cwd, endpoint.instanceId);
227
+ clearLocalCompanionEndpoint(cwd, endpoint.instanceId, { adapter });
217
228
  log(adapter, `Removed stale local companion endpoint for ${cwd}.`);
218
229
  return { endpoint: null };
219
230
  }
@@ -224,7 +235,9 @@ function isCompanionAlive(endpoint) {
224
235
  if (isPidAlive(endpoint.companionPid)) {
225
236
  return true;
226
237
  }
227
- clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId);
238
+ clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
239
+ adapter: endpoint.kind,
240
+ });
228
241
  return false;
229
242
  }
230
243
  export function buildBackgroundBridgeArgs(entryPath, options) {
@@ -272,7 +285,9 @@ async function ensureBridgeReady(options) {
272
285
  if (!lock || !lockProcessAlive) {
273
286
  if (lock && !lockProcessAlive) {
274
287
  log(options.adapter, `Found stale lock for ${options.cwd} (pid=${lock.pid} dead). Clearing.`);
275
- clearLocalCompanionEndpoint(options.cwd);
288
+ clearLocalCompanionEndpoint(options.cwd, undefined, {
289
+ adapter: options.adapter,
290
+ });
276
291
  }
277
292
  log(options.adapter, `Starting bridge in background for ${options.cwd}...`);
278
293
  startBridgeInBackground(options);
@@ -317,6 +332,36 @@ async function ensureBridgeReady(options) {
317
332
  await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
318
333
  return { shouldOpenVisibleClient: true };
319
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
+ }
320
365
  export async function runVisibleClient(options, runners = {}) {
321
366
  // Keep the foreground client in-process so Windows does not briefly flash an
322
367
  // extra bootstrap console before the real companion UI appears.
@@ -342,6 +387,9 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
342
387
  export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
343
388
  const options = parseCliArgs(argv);
344
389
  await ensureCompanionStartWechatCredentials(options.adapter);
390
+ if (await tryDelegateToDaemon(options)) {
391
+ return 0;
392
+ }
345
393
  const ready = await ensureBridgeReady(options);
346
394
  if (!ready.shouldOpenVisibleClient) {
347
395
  return 0;
@@ -16,6 +16,9 @@ function parseCliArgs(argv) {
16
16
  const cliArgs = [];
17
17
  for (let i = 0; i < argv.length; i += 1) {
18
18
  const arg = argv[i];
19
+ if (!arg) {
20
+ continue;
21
+ }
19
22
  const next = argv[i + 1];
20
23
  if (arg === "--help" || arg === "-h") {
21
24
  process.stdout.write([
@@ -57,7 +60,9 @@ function delay(ms) {
57
60
  return new Promise((resolve) => setTimeout(resolve, ms));
58
61
  }
59
62
  function readMatchingEndpoint(options) {
60
- const endpoint = readLocalCompanionEndpoint(options.cwd);
63
+ const endpoint = readLocalCompanionEndpoint(options.cwd, {
64
+ adapter: options.adapter,
65
+ });
61
66
  if (!endpoint || endpoint.kind !== options.adapter) {
62
67
  throw new Error(`No active ${options.adapter} bridge endpoint was found for ${options.cwd}. Start "wechat-bridge-${options.adapter}" in that directory first.`);
63
68
  }
@@ -0,0 +1,175 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import net from "node:net";
4
+ import { DAEMON_ENDPOINT_FILE, ensureChannelDataDir, } from "../wechat/channel-config.js";
5
+ export const DAEMON_PROTOCOL_VERSION = 1;
6
+ function normalizeDaemonEndpoint(value) {
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
8
+ return null;
9
+ }
10
+ const record = value;
11
+ if (typeof record.protocolVersion !== "number" ||
12
+ typeof record.pid !== "number" ||
13
+ typeof record.port !== "number" ||
14
+ typeof record.token !== "string" ||
15
+ typeof record.cwd !== "string" ||
16
+ typeof record.startedAt !== "string") {
17
+ return null;
18
+ }
19
+ return {
20
+ protocolVersion: record.protocolVersion,
21
+ pid: record.pid,
22
+ port: record.port,
23
+ token: record.token,
24
+ cwd: record.cwd,
25
+ startedAt: record.startedAt,
26
+ };
27
+ }
28
+ export function buildDaemonToken() {
29
+ return crypto.randomBytes(18).toString("hex");
30
+ }
31
+ export function writeDaemonEndpoint(endpoint) {
32
+ ensureChannelDataDir();
33
+ fs.writeFileSync(DAEMON_ENDPOINT_FILE, JSON.stringify({
34
+ ...endpoint,
35
+ protocolVersion: DAEMON_PROTOCOL_VERSION,
36
+ }, null, 2), "utf8");
37
+ }
38
+ export function readDaemonEndpoint() {
39
+ try {
40
+ if (!fs.existsSync(DAEMON_ENDPOINT_FILE)) {
41
+ return null;
42
+ }
43
+ return normalizeDaemonEndpoint(JSON.parse(fs.readFileSync(DAEMON_ENDPOINT_FILE, "utf8")));
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ export function clearDaemonEndpoint(pid = process.pid) {
50
+ try {
51
+ const endpoint = readDaemonEndpoint();
52
+ if (!endpoint || endpoint.pid === pid) {
53
+ fs.rmSync(DAEMON_ENDPOINT_FILE, { force: true });
54
+ }
55
+ }
56
+ catch {
57
+ // Best effort cleanup.
58
+ }
59
+ }
60
+ export function isPidAlive(pid) {
61
+ if (!Number.isInteger(pid) || pid <= 0) {
62
+ return false;
63
+ }
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ export function sendDaemonResponse(socket, id, response) {
73
+ if (socket.destroyed || socket.writableEnded) {
74
+ return false;
75
+ }
76
+ try {
77
+ return socket.write(`${JSON.stringify({ id, response })}\n`);
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ export function attachDaemonRequestListener(socket, onRequest) {
84
+ let buffer = "";
85
+ const onData = (chunk) => {
86
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
87
+ while (true) {
88
+ const newlineIndex = buffer.indexOf("\n");
89
+ if (newlineIndex < 0) {
90
+ return;
91
+ }
92
+ const line = buffer.slice(0, newlineIndex).trim();
93
+ buffer = buffer.slice(newlineIndex + 1);
94
+ if (!line) {
95
+ continue;
96
+ }
97
+ try {
98
+ onRequest(JSON.parse(line));
99
+ }
100
+ catch {
101
+ // Ignore malformed daemon IPC frames.
102
+ }
103
+ }
104
+ };
105
+ socket.setEncoding("utf8");
106
+ socket.on("data", onData);
107
+ return () => {
108
+ socket.off("data", onData);
109
+ };
110
+ }
111
+ export async function sendDaemonRequest(endpoint, payload, options = {}) {
112
+ const timeoutMs = options.timeoutMs ?? 2_000;
113
+ const id = crypto.randomUUID();
114
+ return await new Promise((resolve) => {
115
+ const socket = net.connect({
116
+ host: "127.0.0.1",
117
+ port: endpoint.port,
118
+ });
119
+ let buffer = "";
120
+ let settled = false;
121
+ const timer = setTimeout(() => {
122
+ finish({ ok: false, error: "Timed out waiting for daemon response." });
123
+ }, timeoutMs);
124
+ const finish = (response) => {
125
+ if (settled) {
126
+ return;
127
+ }
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ socket.destroy();
131
+ resolve(response);
132
+ };
133
+ socket.setEncoding("utf8");
134
+ socket.once("connect", () => {
135
+ socket.write(`${JSON.stringify({
136
+ id,
137
+ token: endpoint.token,
138
+ payload,
139
+ })}\n`);
140
+ });
141
+ socket.on("data", (chunk) => {
142
+ buffer += chunk;
143
+ while (true) {
144
+ const newlineIndex = buffer.indexOf("\n");
145
+ if (newlineIndex < 0) {
146
+ return;
147
+ }
148
+ const line = buffer.slice(0, newlineIndex).trim();
149
+ buffer = buffer.slice(newlineIndex + 1);
150
+ if (!line) {
151
+ continue;
152
+ }
153
+ try {
154
+ const frame = JSON.parse(line);
155
+ if (frame.id === id) {
156
+ finish(frame.response);
157
+ }
158
+ }
159
+ catch {
160
+ // Ignore malformed daemon IPC frames.
161
+ }
162
+ }
163
+ });
164
+ socket.once("error", () => {
165
+ finish({ ok: false, error: "Daemon endpoint is not reachable." });
166
+ });
167
+ });
168
+ }
169
+ export async function isDaemonEndpointAlive(endpoint, options = {}) {
170
+ if (!isPidAlive(endpoint.pid)) {
171
+ return false;
172
+ }
173
+ const response = await sendDaemonRequest(endpoint, { command: "status" }, options);
174
+ return response.ok;
175
+ }