cli-wechat-bridge 1.1.2 → 1.1.4

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 (46) hide show
  1. package/README.md +129 -150
  2. package/bin/_run-entry.mjs +12 -0
  3. package/bin/wechat-claude-start.mjs +7 -2
  4. package/bin/wechat-claude.mjs +1 -1
  5. package/bin/wechat-codex-start.mjs +7 -2
  6. package/bin/wechat-codex.mjs +1 -1
  7. package/bin/wechat-opencode-start.mjs +7 -2
  8. package/bin/wechat-opencode.mjs +1 -1
  9. package/bin/wechat-pi-start.mjs +10 -0
  10. package/bin/wechat-pi.mjs +5 -0
  11. package/dist/bridge/bridge-adapter-common.js +2 -0
  12. package/dist/bridge/bridge-adapters.claude.js +21 -1
  13. package/dist/bridge/bridge-adapters.codex.js +85 -6
  14. package/dist/bridge/bridge-adapters.core.js +112 -6
  15. package/dist/bridge/bridge-adapters.js +5 -3
  16. package/dist/bridge/bridge-adapters.opencode.js +390 -52
  17. package/dist/bridge/bridge-adapters.pi.js +707 -0
  18. package/dist/bridge/bridge-adapters.shared.js +22 -102
  19. package/dist/bridge/bridge-final-reply.js +17 -2
  20. package/dist/bridge/bridge-process-reaper.js +14 -4
  21. package/dist/bridge/bridge-state.js +42 -6
  22. package/dist/bridge/bridge-utils.js +94 -237
  23. package/dist/bridge/wechat-bridge.js +77 -131
  24. package/dist/bridge/wechat-forwarding.js +58 -0
  25. package/dist/companion/codex-remote-client.js +20 -6
  26. package/dist/companion/local-companion-link.js +13 -3
  27. package/dist/companion/local-companion-start.js +64 -16
  28. package/dist/companion/local-companion.js +8 -5
  29. package/dist/companion/pi-tui-bridge-extension.js +256 -0
  30. package/dist/daemon/daemon-link.js +25 -3
  31. package/dist/daemon/wechat-daemon.js +264 -77
  32. package/dist/i18n/index.js +0 -3
  33. package/dist/i18n/messages-en.js +4 -5
  34. package/dist/i18n/messages-zh.js +4 -5
  35. package/dist/utils/atomic-file.js +15 -0
  36. package/dist/utils/doctor.js +27 -11
  37. package/dist/wechat/channel-config.js +0 -2
  38. package/dist/wechat/setup.js +5 -9
  39. package/dist/wechat/wechat-transport.js +14 -8
  40. package/package.json +113 -102
  41. package/bin/wechat-bridge-claude.mjs +0 -5
  42. package/bin/wechat-bridge-codex.mjs +0 -5
  43. package/bin/wechat-bridge-opencode.mjs +0 -5
  44. package/bin/wechat-bridge-shell.mjs +0 -5
  45. package/bin/wechat-bridge.mjs +0 -5
  46. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -16,7 +16,7 @@ const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".
16
16
  const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
17
17
  const DEFAULT_ADAPTER = "codex";
18
18
  function log(adapter, message) {
19
- process.stderr.write(`[wechat-${adapter}-start] ${message}\n`);
19
+ process.stderr.write(`[wechat-${adapter}] ${message}\n`);
20
20
  }
21
21
  export function normalizeComparablePath(cwd) {
22
22
  const normalized = path.resolve(cwd);
@@ -38,7 +38,7 @@ export function formatRestartUnhealthyMessage(cwd) {
38
38
  return `Detected unhealthy companion state for ${cwd}. Restarting bridge...`;
39
39
  }
40
40
  export function defaultSessionStartMode(adapter) {
41
- return adapter === "claude" || adapter === "opencode" ? "new" : "restore";
41
+ return adapter === "codex" ? "restore" : "new";
42
42
  }
43
43
  export function decideLaunchAction(input) {
44
44
  if (input.lockShouldAutoReclaim) {
@@ -59,7 +59,10 @@ export function decideLaunchAction(input) {
59
59
  };
60
60
  }
61
61
  if (input.sessionStartMode === "new" &&
62
- (input.requestedAdapter === "claude" || input.requestedAdapter === "opencode")) {
62
+ (input.requestedAdapter === "claude" ||
63
+ input.requestedAdapter === "opencode" ||
64
+ input.requestedAdapter === "pi" ||
65
+ input.requestedAdapter === "codex")) {
63
66
  return {
64
67
  kind: "start_bridge",
65
68
  message: `Starting a fresh ${input.requestedAdapter} session for ${input.requestedCwd}...`,
@@ -95,6 +98,7 @@ export function parseCliArgs(argv) {
95
98
  let cwd = process.cwd();
96
99
  let profile;
97
100
  let timeoutMs = DEFAULT_WAIT_TIMEOUT_MS;
101
+ let sessionStartMode;
98
102
  const cliArgs = [];
99
103
  for (let i = 0; i < argv.length; i += 1) {
100
104
  const arg = argv[i];
@@ -103,22 +107,38 @@ export function parseCliArgs(argv) {
103
107
  }
104
108
  const next = argv[i + 1];
105
109
  if (arg === "--help" || arg === "-h") {
110
+ const helpAdapter = adapter;
111
+ const commandName = `wechat-${helpAdapter}`;
112
+ const adapterLabel = {
113
+ codex: "Codex",
114
+ claude: "Claude Code",
115
+ opencode: "OpenCode",
116
+ pi: "Pi",
117
+ }[helpAdapter];
106
118
  process.stdout.write([
107
- "Usage: wechat-codex-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...codex args]",
108
- " wechat-claude-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...claude args]",
109
- " wechat-opencode-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...opencode args]",
110
- " local-companion-start [--adapter <codex|claude|opencode>] [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...cli args]",
119
+ `Usage: ${commandName} [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [--session-start-mode <restore|new>] [...${helpAdapter} args]`,
111
120
  "",
112
- "Starts a bridge for the current directory, waits for the local endpoint, then opens the visible companion or panel.",
113
- "Claude and OpenCode launchers start a fresh CLI session by default.",
114
- "All adapters are companion-bound: closing the companion/panel also stops the bridge.",
121
+ "Ensures the WeChat runtime for the current directory, then opens or reuses the visible CLI.",
122
+ "If a same-workspace daemon is running, the command delegates to that daemon.",
123
+ helpAdapter === "codex"
124
+ ? "Codex restores the current session by default."
125
+ : `${adapterLabel} starts a fresh session by default.`,
126
+ helpAdapter === "codex"
127
+ ? "Use --session-start-mode new to explicitly start a fresh session."
128
+ : "Use --session-start-mode restore to explicitly restore the previous session.",
129
+ "Use --doctor to check the selected adapter and workspace without starting it.",
130
+ "Without a daemon, the transient runtime is companion-bound and stops when the visible CLI closes.",
115
131
  "Unknown arguments are forwarded to the visible CLI client.",
132
+ `Deprecated alias: ${commandName}-start.`,
116
133
  "",
117
134
  ].join("\n"));
118
135
  process.exit(0);
119
136
  }
137
+ if (arg === "--doctor") {
138
+ continue;
139
+ }
120
140
  if (arg === "--adapter") {
121
- if (!next || !["codex", "claude", "opencode"].includes(next)) {
141
+ if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
122
142
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
123
143
  }
124
144
  adapter = next;
@@ -153,6 +173,14 @@ export function parseCliArgs(argv) {
153
173
  i += 1;
154
174
  continue;
155
175
  }
176
+ if (arg === "--session-start-mode") {
177
+ if (!next || !["restore", "new"].includes(next)) {
178
+ throw new Error(`Invalid session start mode: ${next ?? "(missing)"}`);
179
+ }
180
+ sessionStartMode = next;
181
+ i += 1;
182
+ continue;
183
+ }
156
184
  cliArgs.push(arg);
157
185
  }
158
186
  return {
@@ -160,7 +188,7 @@ export function parseCliArgs(argv) {
160
188
  cwd,
161
189
  profile,
162
190
  timeoutMs,
163
- sessionStartMode: defaultSessionStartMode(adapter),
191
+ sessionStartMode: sessionStartMode ?? defaultSessionStartMode(adapter),
164
192
  cliArgs,
165
193
  };
166
194
  }
@@ -203,7 +231,10 @@ async function stopExistingBridge(lock, requestedAdapter) {
203
231
  if (!(await waitForProcessExit(pid, 10_000))) {
204
232
  throw new Error(`Timed out waiting for existing bridge pid=${pid} to exit.`);
205
233
  }
206
- if (lock.adapter === "codex" || lock.adapter === "claude" || lock.adapter === "opencode") {
234
+ if (lock.adapter === "codex" ||
235
+ lock.adapter === "claude" ||
236
+ lock.adapter === "opencode" ||
237
+ lock.adapter === "pi") {
207
238
  clearLocalCompanionEndpoint(cwd, undefined, { adapter: lock.adapter });
208
239
  }
209
240
  else {
@@ -359,6 +390,12 @@ async function ensureBridgeReady(options) {
359
390
  await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
360
391
  return { shouldOpenVisibleClient: true };
361
392
  }
393
+ // The daemon's ensure_slot can legitimately take a while: opening a visible
394
+ // client waits up to 15s for it to connect, and codex waits another 15s for
395
+ // the visible thread — all serialized per adapter. The default 2s IPC timeout
396
+ // would expire on nearly every cold start, so delegation uses a generous
397
+ // deadline and falls back to a local bridge when the daemon cannot deliver.
398
+ const ENSURE_SLOT_DELEGATION_TIMEOUT_MS = 45_000;
362
399
  export async function tryDelegateToDaemon(options, deps = {}) {
363
400
  const readEndpoint = deps.readEndpoint ?? readDaemonEndpoint;
364
401
  const isEndpointAlive = deps.isEndpointAlive ?? isDaemonEndpointAlive;
@@ -383,10 +420,15 @@ export async function tryDelegateToDaemon(options, deps = {}) {
383
420
  cliArgs: options.cliArgs,
384
421
  openVisible: true,
385
422
  sessionStartMode: options.sessionStartMode,
386
- reuseExistingVisible: true,
387
- });
423
+ reuseExistingVisible: options.adapter !== "pi" || options.sessionStartMode !== "new",
424
+ }, { timeoutMs: ENSURE_SLOT_DELEGATION_TIMEOUT_MS });
388
425
  if (!response.ok) {
389
- throw new Error(response.error);
426
+ // A daemon that cannot answer in time (or at all) must not abort the
427
+ // launch: fall back to a local companion-bound bridge so the user still
428
+ // gets a working terminal instead of an error for work the daemon may
429
+ // still complete.
430
+ log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
431
+ return false;
390
432
  }
391
433
  log(options.adapter, `Delegated to running wechat-daemon for ${options.cwd}.`);
392
434
  return true;
@@ -398,6 +440,7 @@ export async function runVisibleClient(options, runners = {}) {
398
440
  return await (runners.codexRemoteClient ?? runCodexRemoteClient)({
399
441
  cwd: options.cwd,
400
442
  cliArgs: options.cliArgs,
443
+ sessionStartMode: options.sessionStartMode,
401
444
  });
402
445
  }
403
446
  return await (runners.localCompanion ?? runLocalCompanion)({
@@ -415,6 +458,11 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
415
458
  });
416
459
  }
417
460
  export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
461
+ if (argv.includes("--doctor")) {
462
+ const { runDoctorCheck } = await import("../utils/doctor.js");
463
+ await runDoctorCheck(argv, { mode: "bridge" });
464
+ return 0;
465
+ }
418
466
  const options = parseCliArgs(argv);
419
467
  await ensureCompanionStartWechatCredentials(options.adapter);
420
468
  if (await tryDelegateToDaemon(options)) {
@@ -23,16 +23,16 @@ function parseCliArgs(argv) {
23
23
  const next = argv[i + 1];
24
24
  if (arg === "--help" || arg === "-h") {
25
25
  process.stdout.write([
26
- "Usage: local-companion --adapter <codex|claude|opencode> [--cwd <path>] [...cli args]",
26
+ "Internal companion usage: local-companion --adapter <codex|claude|opencode|pi> [--cwd <path>] [...cli args]",
27
27
  "",
28
- 'Starts the visible local companion and connects it to the matching running bridge for the current directory.',
28
+ "Connects a visible local CLI to the matching bridge runtime for the current directory.",
29
29
  "Unknown arguments are forwarded to the visible CLI client.",
30
30
  "",
31
31
  ].join("\n"));
32
32
  process.exit(0);
33
33
  }
34
34
  if (arg === "--adapter") {
35
- if (!next || !["codex", "claude", "opencode"].includes(next)) {
35
+ if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
36
36
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
37
37
  }
38
38
  adapter = next;
@@ -58,7 +58,7 @@ function parseCliArgs(argv) {
58
58
  cliArgs.push(arg);
59
59
  }
60
60
  if (!adapter) {
61
- throw new Error("Missing required --adapter <codex|claude|opencode>");
61
+ throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
62
62
  }
63
63
  return { adapter, cwd, sessionStartMode, cliArgs };
64
64
  }
@@ -73,7 +73,7 @@ function readMatchingEndpoint(options) {
73
73
  adapter: options.adapter,
74
74
  });
75
75
  if (!endpoint || endpoint.kind !== options.adapter) {
76
- throw new Error(`No active ${options.adapter} bridge endpoint was found for ${options.cwd}. Start "wechat-bridge-${options.adapter}" in that directory first.`);
76
+ throw new Error(`No active ${options.adapter} runtime was found for ${options.cwd}. Run "wechat-${options.adapter}" in that directory to recreate it.`);
77
77
  }
78
78
  return endpoint;
79
79
  }
@@ -241,6 +241,9 @@ export async function runLocalCompanion(options) {
241
241
  case "resolve_approval":
242
242
  sendResponse(socket, message.id, true, await adapter.resolveApproval(message.payload.action));
243
243
  break;
244
+ case "submit_user_input":
245
+ sendResponse(socket, message.id, true, await adapter.submitUserInput(message.payload.answers));
246
+ break;
244
247
  case "dispose":
245
248
  sendResponse(socket, message.id, true);
246
249
  await closeCompanion(0, "bridge_dispose");
@@ -0,0 +1,256 @@
1
+ import net from "node:net";
2
+ const BRIDGE_HOST = "127.0.0.1";
3
+ const INTERNAL_NEW_COMMAND = "__cli_bridge_new";
4
+ const INTERNAL_SWITCH_COMMAND = "__cli_bridge_switch";
5
+ const MAX_BUFFER_SIZE = 8 * 1024 * 1024;
6
+ function isRecord(value) {
7
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+ function extractAssistantText(message) {
10
+ if (!isRecord(message) || message.role !== "assistant") {
11
+ return "";
12
+ }
13
+ if (typeof message.content === "string") {
14
+ return message.content.trim();
15
+ }
16
+ if (!Array.isArray(message.content)) {
17
+ return "";
18
+ }
19
+ return message.content
20
+ .filter((block) => isRecord(block))
21
+ .filter((block) => block.type === "text" && typeof block.text === "string")
22
+ .map((block) => block.text)
23
+ .join("\n")
24
+ .trim();
25
+ }
26
+ function parseControlArgs(args) {
27
+ const trimmed = args.trim();
28
+ if (!trimmed) {
29
+ return null;
30
+ }
31
+ const separatorIndex = trimmed.indexOf(" ");
32
+ if (separatorIndex < 0) {
33
+ return { id: trimmed };
34
+ }
35
+ return {
36
+ id: trimmed.slice(0, separatorIndex),
37
+ value: trimmed.slice(separatorIndex + 1),
38
+ };
39
+ }
40
+ export default function piTuiBridgeExtension(pi) {
41
+ const bridgePort = Number(process.env.CLI_BRIDGE_PI_TUI_PORT ?? "");
42
+ const bridgeToken = process.env.CLI_BRIDGE_PI_TUI_TOKEN ?? "";
43
+ if (!Number.isInteger(bridgePort) || bridgePort <= 0 || !bridgeToken) {
44
+ return;
45
+ }
46
+ const socket = net.connect({ host: BRIDGE_HOST, port: bridgePort });
47
+ let buffer = "";
48
+ let connected = false;
49
+ const queuedFrames = [];
50
+ const queuedCommands = [];
51
+ let latestContext = null;
52
+ const writeFrame = (frame) => {
53
+ if (socket.destroyed) {
54
+ return;
55
+ }
56
+ if (!connected) {
57
+ queuedFrames.push(frame);
58
+ return;
59
+ }
60
+ socket.write(`${JSON.stringify(frame)}\n`);
61
+ };
62
+ const writeSessionState = (context, reason) => {
63
+ writeFrame({
64
+ type: "session_state",
65
+ sessionId: context.sessionManager.getSessionId(),
66
+ sessionFile: context.sessionManager.getSessionFile(),
67
+ reason,
68
+ });
69
+ };
70
+ pi.registerCommand(INTERNAL_NEW_COMMAND, {
71
+ description: "Internal CLI WeChat Bridge session command",
72
+ handler: async (args, context) => {
73
+ const parsed = parseControlArgs(args);
74
+ if (!parsed) {
75
+ return;
76
+ }
77
+ try {
78
+ let sessionState = {};
79
+ const result = await context.newSession({
80
+ withSession: async (nextContext) => {
81
+ writeSessionState(nextContext, "new");
82
+ sessionState = {
83
+ sessionId: nextContext.sessionManager.getSessionId(),
84
+ sessionFile: nextContext.sessionManager.getSessionFile(),
85
+ };
86
+ },
87
+ });
88
+ writeFrame({
89
+ type: "response",
90
+ id: parsed.id,
91
+ success: true,
92
+ data: { ...result, ...sessionState },
93
+ });
94
+ }
95
+ catch (error) {
96
+ writeFrame({
97
+ type: "response",
98
+ id: parsed.id,
99
+ success: false,
100
+ error: error instanceof Error ? error.message : String(error),
101
+ });
102
+ }
103
+ },
104
+ });
105
+ pi.registerCommand(INTERNAL_SWITCH_COMMAND, {
106
+ description: "Internal CLI WeChat Bridge session command",
107
+ handler: async (args, context) => {
108
+ const parsed = parseControlArgs(args);
109
+ if (!parsed?.value) {
110
+ return;
111
+ }
112
+ try {
113
+ let sessionState = {};
114
+ const result = await context.switchSession(parsed.value, {
115
+ withSession: async (nextContext) => {
116
+ writeSessionState(nextContext, "resume");
117
+ sessionState = {
118
+ sessionId: nextContext.sessionManager.getSessionId(),
119
+ sessionFile: nextContext.sessionManager.getSessionFile(),
120
+ };
121
+ },
122
+ });
123
+ writeFrame({
124
+ type: "response",
125
+ id: parsed.id,
126
+ success: true,
127
+ data: { ...result, ...sessionState },
128
+ });
129
+ }
130
+ catch (error) {
131
+ writeFrame({
132
+ type: "response",
133
+ id: parsed.id,
134
+ success: false,
135
+ error: error instanceof Error ? error.message : String(error),
136
+ });
137
+ }
138
+ },
139
+ });
140
+ const executeCommand = (command) => {
141
+ try {
142
+ if (command.type === "prompt" && typeof command.text === "string") {
143
+ if (!latestContext?.isIdle()) {
144
+ throw new Error("Pi TUI is already processing a turn.");
145
+ }
146
+ pi.sendUserMessage(command.text);
147
+ writeFrame({ type: "response", id: command.id, success: true });
148
+ }
149
+ else if (command.type === "abort") {
150
+ latestContext?.abort();
151
+ writeFrame({ type: "response", id: command.id, success: true });
152
+ }
153
+ else if (command.type === "new_session" && typeof command.id === "string") {
154
+ pi.sendUserMessage(`/${INTERNAL_NEW_COMMAND} ${command.id}`, {
155
+ expandPromptTemplates: true,
156
+ });
157
+ }
158
+ else if (command.type === "switch_session" &&
159
+ typeof command.id === "string" &&
160
+ typeof command.sessionPath === "string") {
161
+ pi.sendUserMessage(`/${INTERNAL_SWITCH_COMMAND} ${command.id} ${command.sessionPath}`, { expandPromptTemplates: true });
162
+ }
163
+ }
164
+ catch (error) {
165
+ writeFrame({
166
+ type: "response",
167
+ id: command.id,
168
+ success: false,
169
+ error: error instanceof Error ? error.message : String(error),
170
+ });
171
+ }
172
+ };
173
+ const dispatchCommand = (command) => {
174
+ if (!latestContext) {
175
+ queuedCommands.push(command);
176
+ return;
177
+ }
178
+ executeCommand(command);
179
+ };
180
+ socket.setEncoding("utf8");
181
+ socket.on("connect", () => {
182
+ socket.setNoDelay(true);
183
+ socket.write(`${JSON.stringify({ type: "hello", token: bridgeToken })}\n`);
184
+ connected = true;
185
+ for (const frame of queuedFrames.splice(0)) {
186
+ writeFrame(frame);
187
+ }
188
+ });
189
+ socket.on("data", (chunk) => {
190
+ buffer += chunk;
191
+ if (buffer.length > MAX_BUFFER_SIZE) {
192
+ buffer = "";
193
+ socket.destroy();
194
+ return;
195
+ }
196
+ while (true) {
197
+ const newlineIndex = buffer.indexOf("\n");
198
+ if (newlineIndex < 0) {
199
+ return;
200
+ }
201
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
202
+ buffer = buffer.slice(newlineIndex + 1);
203
+ if (!line.trim()) {
204
+ continue;
205
+ }
206
+ try {
207
+ const parsed = JSON.parse(line);
208
+ if (isRecord(parsed)) {
209
+ dispatchCommand(parsed);
210
+ }
211
+ }
212
+ catch {
213
+ // Ignore malformed bridge commands and keep the native TUI alive.
214
+ }
215
+ }
216
+ });
217
+ socket.on("error", () => {
218
+ // The visible TUI remains usable if the bridge side disappears.
219
+ });
220
+ pi.on("session_start", (event, context) => {
221
+ latestContext = context;
222
+ writeSessionState(context, typeof event.reason === "string" ? event.reason : "startup");
223
+ for (const command of queuedCommands.splice(0)) {
224
+ executeCommand(command);
225
+ }
226
+ });
227
+ pi.on("input", (event, context) => {
228
+ latestContext = context;
229
+ if (event.source === "interactive" && typeof event.text === "string") {
230
+ writeFrame({ type: "local_input", text: event.text });
231
+ }
232
+ });
233
+ pi.on("message_end", (event, context) => {
234
+ latestContext = context;
235
+ const text = extractAssistantText(event.message);
236
+ if (!text) {
237
+ return;
238
+ }
239
+ const message = isRecord(event.message) ? event.message : {};
240
+ writeFrame({
241
+ type: "assistant_message",
242
+ text,
243
+ stopReason: message.stopReason,
244
+ errorMessage: message.errorMessage,
245
+ });
246
+ });
247
+ pi.on("agent_start", (_event, context) => {
248
+ latestContext = context;
249
+ writeFrame({ type: "agent_start" });
250
+ });
251
+ pi.on("agent_settled", (_event, context) => {
252
+ latestContext = context;
253
+ writeSessionState(context, "settled");
254
+ writeFrame({ type: "agent_settled" });
255
+ });
256
+ }
@@ -65,8 +65,12 @@ export function isPidAlive(pid) {
65
65
  process.kill(pid, 0);
66
66
  return true;
67
67
  }
68
- catch {
69
- return false;
68
+ catch (error) {
69
+ // EPERM means the OS found the pid but denied access (e.g. a Windows
70
+ // process running under another privilege level). The process exists, so
71
+ // it must be treated as alive — returning false here would let callers
72
+ // delete a live daemon's endpoint file and break its singleton.
73
+ return error instanceof Error && error.code === "EPERM";
70
74
  }
71
75
  }
72
76
  export function sendDaemonResponse(socket, id, response) {
@@ -170,6 +174,11 @@ export async function sendDaemonRequest(endpoint, payload, options = {}) {
170
174
  socket.once("error", () => {
171
175
  finish({ ok: false, error: "Daemon endpoint is not reachable." });
172
176
  });
177
+ socket.once("close", () => {
178
+ // The daemon may die or be taken over mid-request; without this the
179
+ // caller waits out the full timeout for a socket that is already gone.
180
+ finish({ ok: false, error: "Daemon connection closed before a response." });
181
+ });
173
182
  });
174
183
  }
175
184
  export async function isDaemonEndpointAlive(endpoint, options = {}) {
@@ -177,5 +186,18 @@ export async function isDaemonEndpointAlive(endpoint, options = {}) {
177
186
  return false;
178
187
  }
179
188
  const response = await sendDaemonRequest(endpoint, { command: "status" }, options);
180
- return response.ok;
189
+ if (response.ok) {
190
+ return true;
191
+ }
192
+ // A momentarily busy daemon (e.g. a synchronous session-directory scan
193
+ // blocking its event loop) can miss the short status deadline. Retry once
194
+ // before declaring it dead: a false negative makes callers delete a live
195
+ // daemon's endpoint file and break its singleton.
196
+ const timedOut = (response.error ?? "").includes("Timed out");
197
+ if (!timedOut) {
198
+ return false;
199
+ }
200
+ await new Promise((resolve) => setTimeout(resolve, 250));
201
+ const retry = await sendDaemonRequest(endpoint, { command: "status" }, options);
202
+ return retry.ok;
181
203
  }