@ardennguyen/zalo-agent-cli 2.5.5 → 2.5.7

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,12 +1,24 @@
1
1
  /**
2
2
  * Multi-account registry at ~/.zalo-agent-cli/accounts.json
3
3
  * Maps each account to its own proxy (1:1) and tracks active account.
4
+ *
5
+ * A fleet of daemons and the `account` / `login` commands share this one file, so it is written the
6
+ * way a shared file must be (src/utils/atomic-file.js):
7
+ * - every change is read-modify-write under a lock file (`accounts.json.lock`), so two writers no
8
+ * longer lose each other's change;
9
+ * - every write is a temp file renamed over the target, so a reader never sees a half-written
10
+ * registry and a crash leaves the previous one;
11
+ * - the previous good registry is kept as `accounts.json.bak`;
12
+ * - a file that exists but cannot be read is an ERROR, never "no accounts": the next `login` used
13
+ * to replace it with a registry of one. Reads fall back to the `.bak`; with none, nothing is
14
+ * touched and the message says what to do.
4
15
  */
5
16
 
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "fs";
17
+ import { readFileSync, existsSync, mkdirSync, rmSync, renameSync } from "fs";
7
18
  import { join } from "path";
8
19
  import { CONFIG_DIR, getAccountDir, deleteCredentials } from "./credentials.js";
9
20
  import { checkLock } from "./lock.js";
21
+ import { sleepSync, sweepStaleTemps, withFileLock, writeFileAtomic } from "../utils/atomic-file.js";
10
22
 
11
23
  function accountsFile() {
12
24
  return join(CONFIG_DIR, "accounts.json");
@@ -16,21 +28,130 @@ function ensureDir() {
16
28
  mkdirSync(CONFIG_DIR, { recursive: true });
17
29
  }
18
30
 
19
- function load() {
31
+ /** The registry exists but cannot be used as the account list. Nothing has been changed. */
32
+ export class RegistryError extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = "RegistryError";
36
+ }
37
+ }
38
+
39
+ /** Errors a read can raise on Windows for the instant a writer's rename replaces the file. */
40
+ const TRANSIENT_READ = new Set(["EPERM", "EBUSY", "EACCES", "ENOENT"]);
41
+
42
+ /** @param {string} text @returns {object[]} */
43
+ function parseRegistry(text) {
44
+ const data = JSON.parse(text);
45
+ if (!Array.isArray(data)) throw new Error("it is not a JSON array of accounts");
46
+ return data;
47
+ }
48
+
49
+ /** @param {string} file */
50
+ function readFileRetrying(file) {
51
+ for (let attempt = 0; ; attempt++) {
52
+ try {
53
+ return readFileSync(file, "utf-8");
54
+ } catch (e) {
55
+ if (!TRANSIENT_READ.has(e.code) || attempt >= 5) throw e;
56
+ sleepSync(10);
57
+ }
58
+ }
59
+ }
60
+
61
+ let _warnedBackup = false;
62
+
63
+ /**
64
+ * Read the registry.
65
+ *
66
+ * @returns {{ accounts: object[], source: "missing"|"file"|"backup" }}
67
+ * @throws {RegistryError} when the file exists but is not a usable account list and there is no good backup
68
+ */
69
+ function readRegistry() {
20
70
  const file = accountsFile();
21
- if (!existsSync(file)) return [];
71
+ if (!existsSync(file)) return { accounts: [], source: "missing" };
72
+
73
+ let problem;
74
+ // A writer from before atomic writes can leave a half-written file for an instant: look again.
75
+ for (let attempt = 0; attempt < 3; attempt++) {
76
+ try {
77
+ return { accounts: parseRegistry(readFileRetrying(file)), source: "file" };
78
+ } catch (e) {
79
+ problem = e;
80
+ if (attempt < 2) sleepSync(20);
81
+ }
82
+ }
83
+
84
+ const backup = `${file}.bak`;
22
85
  try {
23
- return JSON.parse(readFileSync(file, "utf-8"));
86
+ const accounts = parseRegistry(readFileSync(backup, "utf-8"));
87
+ if (!_warnedBackup) {
88
+ _warnedBackup = true;
89
+ console.error(
90
+ `[accounts] ${file} is unreadable (${problem.message}); using its last good copy, ${backup}. ` +
91
+ "The next change to the registry replaces the damaged file and keeps it as accounts.json.corrupt-*.",
92
+ );
93
+ }
94
+ return { accounts, source: "backup" };
24
95
  } catch {
25
- return [];
96
+ throw new RegistryError(
97
+ `${file} exists but cannot be read as the account list (${problem.message}). It was left untouched. ` +
98
+ "Fix it, or move it aside to start with no accounts.",
99
+ );
26
100
  }
27
101
  }
28
102
 
103
+ function load() {
104
+ return readRegistry().accounts;
105
+ }
106
+
107
+ /** Write the registry atomically, keeping the previous good one as accounts.json.bak. */
29
108
  function save(accounts) {
30
109
  ensureDir();
31
110
  const file = accountsFile();
32
- writeFileSync(file, JSON.stringify(accounts, null, 2), "utf-8");
33
- chmodSync(file, 0o600);
111
+ try {
112
+ const current = readFileSync(file, "utf-8");
113
+ parseRegistry(current);
114
+ writeFileAtomic(`${file}.bak`, current, { durable: false });
115
+ } catch {
116
+ /* no current registry worth keeping */
117
+ }
118
+ writeFileAtomic(file, JSON.stringify(accounts, null, 2));
119
+ }
120
+
121
+ /**
122
+ * Change the registry: lock, read the CURRENT contents, apply `fn`, write if it says it changed
123
+ * something. `fn` gets the account array and returns `{ changed, value }`.
124
+ *
125
+ * @template T
126
+ * @param {(accounts: object[]) => { changed: boolean, value: T }} fn
127
+ * @param {{ timeoutMs?: number }} [lockOptions]
128
+ * @returns {T}
129
+ */
130
+ function mutate(fn, lockOptions) {
131
+ ensureDir();
132
+ return withFileLock(
133
+ `${accountsFile()}.lock`,
134
+ () => {
135
+ const { accounts, source } = readRegistry();
136
+ const result = fn(accounts);
137
+ if (result.changed) {
138
+ if (source === "backup") {
139
+ // Keep the damaged file for whoever wants to know what happened to it.
140
+ try {
141
+ renameSync(accountsFile(), `${accountsFile()}.corrupt-${Date.now()}`);
142
+ } catch {
143
+ /* it is replaced either way */
144
+ }
145
+ }
146
+ save(accounts);
147
+ // A writer killed between creating its temp file and renaming it leaves that file behind.
148
+ sweepStaleTemps(accountsFile());
149
+ sweepStaleTemps(`${accountsFile()}.bak`);
150
+ }
151
+ return result.value;
152
+ },
153
+ lockOptions,
154
+ );
34
155
  }
35
156
 
36
157
  let _pinnedAccountId = null;
@@ -68,18 +189,18 @@ export function getActive() {
68
189
 
69
190
  /** Set an account as active (deactivates others). Returns false if not found. */
70
191
  export function setActive(ownId) {
71
- const accounts = load();
72
- let found = false;
73
- for (const a of accounts) {
74
- if (a.ownId === ownId) {
75
- a.active = true;
76
- found = true;
77
- } else {
78
- a.active = false;
192
+ return mutate((accounts) => {
193
+ let found = false;
194
+ for (const a of accounts) {
195
+ if (a.ownId === ownId) {
196
+ a.active = true;
197
+ found = true;
198
+ } else {
199
+ a.active = false;
200
+ }
79
201
  }
80
- }
81
- if (found) save(accounts);
82
- return found;
202
+ return { changed: found, value: found };
203
+ });
83
204
  }
84
205
 
85
206
  /**
@@ -98,16 +219,17 @@ export function setActive(ownId) {
98
219
  * in line with it.)
99
220
  */
100
221
  export function addAccount(ownId, name = "", proxy = null) {
101
- const accounts = load();
102
- const existing = accounts.find((a) => a.ownId === ownId);
103
- for (const a of accounts) a.active = a.ownId === ownId;
104
- if (existing) {
105
- existing.name = name || existing.name || "";
106
- existing.proxy = proxy;
107
- } else {
108
- accounts.push({ ownId, name, proxy, active: true });
109
- }
110
- save(accounts);
222
+ mutate((accounts) => {
223
+ const existing = accounts.find((a) => a.ownId === ownId);
224
+ for (const a of accounts) a.active = a.ownId === ownId;
225
+ if (existing) {
226
+ existing.name = name || existing.name || "";
227
+ existing.proxy = proxy;
228
+ } else {
229
+ accounts.push({ ownId, name, proxy, active: true });
230
+ }
231
+ return { changed: true, value: undefined };
232
+ });
111
233
  }
112
234
 
113
235
  /**
@@ -161,12 +283,16 @@ export function removeAccount(ownId) {
161
283
  return { removed: false, skippedLocked };
162
284
  }
163
285
 
164
- const filtered = accounts.filter((a) => a.ownId !== ownId);
165
- // If removed was active, activate first remaining
166
- if (filtered.length && !filtered.some((a) => a.active)) {
167
- filtered[0].active = true;
168
- }
169
- save(filtered);
286
+ mutate((current) => {
287
+ const filtered = current.filter((a) => a.ownId !== ownId);
288
+ // If removed was active, activate first remaining
289
+ if (filtered.length && !filtered.some((a) => a.active)) {
290
+ filtered[0].active = true;
291
+ }
292
+ // The array is edited in place: `mutate` writes the one it handed us.
293
+ current.splice(0, current.length, ...filtered);
294
+ return { changed: true, value: undefined };
295
+ });
170
296
  deleteCredentials(ownId);
171
297
  return { removed: true, wiped };
172
298
  }
@@ -183,12 +309,28 @@ export function updateAccountName(ownId, name) {
183
309
  const id = ownId ? String(ownId).trim() : "";
184
310
  const trimmed = typeof name === "string" ? name.trim() : "";
185
311
  if (!id || !trimmed) return false;
186
- const accounts = load();
187
- const existing = accounts.find((a) => a.ownId === id);
188
- if (!existing || existing.name === trimmed) return false;
189
- existing.name = trimmed;
190
- save(accounts);
191
- return true;
312
+ // The common case, a daemon re-reading a name it already has, takes no lock and writes nothing.
313
+ const unchanged = (accounts) => {
314
+ const existing = accounts.find((a) => a.ownId === id);
315
+ return !existing || existing.name === trimmed;
316
+ };
317
+ try {
318
+ if (unchanged(load())) return false;
319
+ return mutate(
320
+ (accounts) => {
321
+ if (unchanged(accounts)) return { changed: false, value: false };
322
+ accounts.find((a) => a.ownId === id).name = trimmed;
323
+ return { changed: true, value: true };
324
+ },
325
+ // Daemons call this from event handlers: a busy registry costs a missed cosmetic refresh,
326
+ // not seconds of a blocked event loop.
327
+ { timeoutMs: 3000 },
328
+ );
329
+ } catch (e) {
330
+ // A cached display name is not worth a daemon: say so and carry on.
331
+ console.error(`[accounts] could not update the cached name of ${id}: ${e.message}`);
332
+ return false;
333
+ }
192
334
  }
193
335
 
194
336
  /** Get account by ownId. */
@@ -10,10 +10,11 @@
10
10
  * All credential files use 0600 permissions (owner read/write only).
11
11
  */
12
12
 
13
- import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSync } from "fs";
13
+ import { readFileSync, unlinkSync, existsSync, mkdirSync } from "fs";
14
14
  import { join, resolve } from "path";
15
15
  import { homedir } from "os";
16
16
  import { parseEnv } from "node:util";
17
+ import { writeFileAtomic } from "../utils/atomic-file.js";
17
18
 
18
19
  const defaultConfigDir = join(homedir(), ".zalo-agent-cli");
19
20
 
@@ -176,8 +177,9 @@ function credPath(ownId) {
176
177
  export function saveCredentials(ownId, creds) {
177
178
  ensureDirs();
178
179
  const target = credPath(ownId);
179
- writeFileSync(target, JSON.stringify(creds, null, 2), "utf-8");
180
- chmodSync(target, 0o600);
180
+ // Atomic and created at 0600: a login is never left half written, and the file is never readable
181
+ // by others, even for the moment between a write and a chmod.
182
+ writeFileAtomic(target, JSON.stringify(creds, null, 2));
181
183
  return target;
182
184
  }
183
185
 
@@ -80,6 +80,7 @@ import http from "node:http";
80
80
  import fs from "node:fs";
81
81
  import path from "node:path";
82
82
  import { spawn, spawnSync } from "node:child_process";
83
+ import { createLogSink, createRotatingFile, resolveLogSettings } from "./log-sink.js";
83
84
  import { randomBytes, timingSafeEqual } from "node:crypto";
84
85
  import { maskSecretArgs } from "../utils/secrets.js";
85
86
 
@@ -296,6 +297,8 @@ function readJsonBody(req, reply) {
296
297
  * long-poll callback invoked by `POST /wait` (`zalo-agent mcp wait`).
297
298
  * @param {(params: object) => Promise<object>} [args.onMarkRead] -
298
299
  * read-cursor callback invoked by `POST /mark-read` (`zalo-agent mcp mark-read`).
300
+ * @param {() => object} [args.onStatus] - health snapshot returned by `POST /status`
301
+ * (`listen --status`, `mcp start --status`; see ./daemon-health.js).
299
302
  * @returns {Promise<{port: number, stop: () => void}>}
300
303
  */
301
304
  export function startDaemonChannel({
@@ -307,6 +310,7 @@ export function startDaemonChannel({
307
310
  onStop = null,
308
311
  onWait = null,
309
312
  onMarkRead = null,
313
+ onStatus = null,
310
314
  }) {
311
315
  const token = randomBytes(24).toString("hex");
312
316
 
@@ -457,6 +461,17 @@ export function startDaemonChannel({
457
461
  }
458
462
  };
459
463
 
464
+ /** The daemon's health snapshot (`listen --status`, `mcp start --status`). */
465
+ const serveStatus = (body, reply) => {
466
+ if (!tokenMatches(body.token, token)) return reply(403, { error: "bad token" });
467
+ if (typeof onStatus !== "function") return reply(503, { error: "this daemon does not report health" });
468
+ try {
469
+ reply(200, { ok: true, health: onStatus() });
470
+ } catch (e) {
471
+ reply(200, { ok: false, error: e?.message || String(e) });
472
+ }
473
+ };
474
+
460
475
  const server = http.createServer(async (req, res) => {
461
476
  const reply = (code, body) => {
462
477
  res.writeHead(code, { "content-type": "application/json" });
@@ -470,6 +485,7 @@ export function startDaemonChannel({
470
485
  req.url !== "/stop" &&
471
486
  req.url !== "/wait" &&
472
487
  req.url !== "/mark-read" &&
488
+ req.url !== "/status" &&
473
489
  !stage
474
490
  ) {
475
491
  return reply(404, { error: "not found" });
@@ -480,6 +496,7 @@ export function startDaemonChannel({
480
496
  if (req.url === "/stop") return serveStop(body, reply);
481
497
  if (req.url === "/wait") return serveWait(body, res, reply);
482
498
  if (req.url === "/mark-read") return serveMarkRead(body, reply);
499
+ if (req.url === "/status") return serveStatus(body, reply);
483
500
  return stage ? serveStage(stage, body, res, reply) : serveAttachments(body, reply);
484
501
  });
485
502
 
@@ -841,7 +858,10 @@ export function stopViaDaemon(accountDir, { timeoutMs = 3000 } = {}) {
841
858
  * @returns {Promise<{ok: boolean, status?: "message"|"timeout"|number, message?: object|null, cursor?: number, error?: string}|null>}
842
859
  * null when no daemon channel is published
843
860
  */
844
- export function waitViaDaemon(accountDir, { threadId = null, since = undefined, timeoutSec = 55, consumer } = {}) {
861
+ export function waitViaDaemon(
862
+ accountDir,
863
+ { threadId = null, since = undefined, timeoutSec = 55, consumer, limit = 1, settleMs = 0 } = {},
864
+ ) {
845
865
  const chan = getDaemonChannel(accountDir);
846
866
  if (!chan) return Promise.resolve(null);
847
867
 
@@ -851,6 +871,8 @@ export function waitViaDaemon(accountDir, { threadId = null, since = undefined,
851
871
  ...(threadId ? { threadId: String(threadId) } : {}),
852
872
  ...(typeof since === "number" && Number.isFinite(since) ? { since } : {}),
853
873
  timeoutSec: clampedSec,
874
+ ...(limit > 1 ? { limit } : {}),
875
+ ...(settleMs > 0 ? { settleMs } : {}),
854
876
  ...(consumer ? { consumer: String(consumer) } : {}),
855
877
  });
856
878
 
@@ -881,6 +903,9 @@ export function waitViaDaemon(accountDir, { threadId = null, since = undefined,
881
903
  ok: true,
882
904
  status: body.status,
883
905
  message: body.message ?? null,
906
+ messages: Array.isArray(body.messages) ? body.messages : body.message ? [body.message] : [],
907
+ count: body.count ?? (body.message ? 1 : 0),
908
+ hasMore: Boolean(body.hasMore),
884
909
  cursor: body.cursor,
885
910
  });
886
911
  } else {
@@ -1065,33 +1090,82 @@ function quoteWinArg(arg) {
1065
1090
  */
1066
1091
  export function attachDaemonLogIfChild() {
1067
1092
  if (process.env.ZALO_DAEMON_CHILD !== "1" || !process.env.ZALO_DAEMON_LOG) return;
1068
- let logFd;
1093
+ const logPath = process.env.ZALO_DAEMON_LOG;
1094
+ // The account is the name of the directory the log lives in (<data dir>/<ownId>/daemon.log).
1095
+ const ownId = path.basename(path.dirname(logPath));
1096
+ const settings = resolveLogSettings({ env: process.env, ownId });
1097
+
1098
+ let file;
1069
1099
  try {
1070
- logFd = fs.openSync(process.env.ZALO_DAEMON_LOG, "a", 0o600);
1100
+ file = createRotatingFile({ file: logPath, maxBytes: settings.maxBytes, keep: settings.keep });
1101
+ file.write(""); // fail now, here, if the log cannot be opened
1071
1102
  } catch {
1072
1103
  return;
1073
1104
  }
1074
- const writeToLog = (chunk, encoding, cb) => {
1075
- try {
1076
- const buf = Buffer.isBuffer(chunk)
1077
- ? chunk
1078
- : Buffer.from(String(chunk), typeof encoding === "string" ? encoding : "utf8");
1079
- fs.writeSync(logFd, buf);
1080
- } catch {
1081
- /* ignore logging errors */
1082
- }
1105
+ // Levels, timestamps (or JSON) and rotation in front of the file; see ./log-sink.js.
1106
+ const sink = createLogSink({ write: file.write, level: settings.level, format: settings.format, accountId: ownId });
1107
+ daemonSink = sink;
1108
+ const done = (encoding, cb) => {
1083
1109
  if (typeof encoding === "function") encoding();
1084
1110
  else if (typeof cb === "function") cb();
1085
1111
  return true;
1086
1112
  };
1087
- process.stdout.write = writeToLog;
1088
- process.stderr.write = writeToLog;
1113
+ process.stdout.write = (chunk, encoding, cb) => {
1114
+ sink.write(chunk);
1115
+ return done(encoding, cb);
1116
+ };
1117
+ process.stderr.write = (chunk, encoding, cb) => {
1118
+ sink.write(chunk);
1119
+ return done(encoding, cb);
1120
+ };
1121
+ for (const w of settings.warnings) sink.write(`[logging] ${w}\n`);
1089
1122
  process.on("uncaughtException", (err) => {
1090
- writeToLog(`${err?.stack || err}\n`);
1123
+ sink.write(`${err?.stack || err}\n`);
1124
+ sink.flush();
1091
1125
  process.exit(1);
1092
1126
  });
1127
+ process.on("exit", () => sink.flush());
1128
+ }
1129
+
1130
+ /** The daemon child's log sink, once attachDaemonLogIfChild has built it. */
1131
+ let daemonSink = null;
1132
+
1133
+ /**
1134
+ * Apply `--log-level` and the per-account settings once the account is known, and, for a run that is not a
1135
+ * background daemon, filter stderr when a level or format was asked for.
1136
+ *
1137
+ * @param {object} args
1138
+ * @param {string} args.ownId
1139
+ * @param {string|null} [args.levelFlag] - Already validated
1140
+ * @param {boolean} [args.filterStderr=true] - Filter stderr of a foreground run; false for the process that only launches a daemon
1141
+ * @returns {{ level: string, format: string, warnings: string[] }}
1142
+ */
1143
+ export function configureLogging({ ownId, levelFlag = null, filterStderr = true }) {
1144
+ const settings = resolveLogSettings({ env: process.env, ownId, levelFlag });
1145
+ if (daemonSink) {
1146
+ daemonSink.setLevel(settings.level);
1147
+ daemonSink.setFormat(settings.format);
1148
+ } else if (filterStderr && settings.explicit && !foregroundFilter) {
1149
+ // A foreground run writes to the terminal: filter stderr in place, keep stdout (the output) alone.
1150
+ const original = process.stderr.write.bind(process.stderr);
1151
+ foregroundFilter = createLogSink({
1152
+ write: (text) => original(text),
1153
+ level: settings.level,
1154
+ format: settings.format,
1155
+ accountId: ownId,
1156
+ });
1157
+ process.stderr.write = (chunk, encoding, cb) => {
1158
+ foregroundFilter.write(chunk);
1159
+ if (typeof encoding === "function") encoding();
1160
+ else if (typeof cb === "function") cb();
1161
+ return true;
1162
+ };
1163
+ }
1164
+ return { level: settings.level, format: settings.format, warnings: settings.warnings };
1093
1165
  }
1094
1166
 
1167
+ let foregroundFilter = null;
1168
+
1095
1169
  /**
1096
1170
  * On Windows, spawn via `Win32_Process.Create` (WMI) so the background daemon
1097
1171
  * is parented to `WmiPrvSE.exe` outside any terminal or agent Job Object
@@ -1104,7 +1178,7 @@ export function attachDaemonLogIfChild() {
1104
1178
  * @param {string} logPath
1105
1179
  * @returns {number|null}
1106
1180
  */
1107
- function trySpawnWin32JobBreakaway(execPath, args, logPath) {
1181
+ function trySpawnWin32JobBreakaway(execPath, args, logPath, extraEnv = {}) {
1108
1182
  if (process.platform !== "win32") return null;
1109
1183
  try {
1110
1184
  const cmdLine = [quoteWinArg(execPath), ...args.map(quoteWinArg)].join(" ");
@@ -1123,6 +1197,7 @@ function trySpawnWin32JobBreakaway(execPath, args, logPath) {
1123
1197
  timeout: 8000,
1124
1198
  env: {
1125
1199
  ...process.env,
1200
+ ...extraEnv,
1126
1201
  ZALO_DAEMON_CHILD: "1",
1127
1202
  ZALO_DAEMON_LOG: logPath,
1128
1203
  ZALO_DAEMON_CMD: cmdLine,
@@ -1147,6 +1222,7 @@ function trySpawnWin32JobBreakaway(execPath, args, logPath) {
1147
1222
  * @param {string} opts.accountDir
1148
1223
  * @param {string[]} [opts.args] - CLI arguments (defaults to `process.argv.slice(1)`)
1149
1224
  * @param {number} [opts.timeoutMs=15000] - how long to wait for the channel to come up
1225
+ * @param {Record<string, string>} [opts.env] - extra environment for this daemon only (a fleet gives each account its own token)
1150
1226
  * @returns {Promise<{
1151
1227
  * ok: boolean,
1152
1228
  * alreadyRunning?: boolean,
@@ -1156,7 +1232,7 @@ function trySpawnWin32JobBreakaway(execPath, args, logPath) {
1156
1232
  * error?: string,
1157
1233
  * }>}
1158
1234
  */
1159
- export async function spawnDaemonProcess({ accountDir, args = process.argv.slice(1), timeoutMs = 15_000 }) {
1235
+ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice(1), timeoutMs = 15_000, env = {} }) {
1160
1236
  const status = getDaemonStatus(accountDir);
1161
1237
  if (status.running) {
1162
1238
  return {
@@ -1183,7 +1259,7 @@ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice
1183
1259
  /* ignore */
1184
1260
  }
1185
1261
 
1186
- let pid = trySpawnWin32JobBreakaway(process.execPath, filteredArgs, logPath);
1262
+ let pid = trySpawnWin32JobBreakaway(process.execPath, filteredArgs, logPath, env);
1187
1263
  if (pid !== null) {
1188
1264
  try {
1189
1265
  fs.closeSync(logFd);
@@ -1195,7 +1271,7 @@ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice
1195
1271
  detached: true,
1196
1272
  windowsHide: true,
1197
1273
  stdio: ["ignore", logFd, logFd],
1198
- env: { ...process.env, ZALO_DAEMON_CHILD: "1" },
1274
+ env: { ...process.env, ...env, ZALO_DAEMON_CHILD: "1", ZALO_DAEMON_LOG: logPath },
1199
1275
  });
1200
1276
  try {
1201
1277
  fs.closeSync(logFd);
@@ -1242,3 +1318,53 @@ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice
1242
1318
  error: "Timed out waiting for background daemon to finish starting.",
1243
1319
  };
1244
1320
  }
1321
+
1322
+ /**
1323
+ * Ask the running daemon for its health snapshot (`listen --status`, `mcp start --status`).
1324
+ *
1325
+ * @param {string} accountDir
1326
+ * @returns {Promise<{ok: boolean, health?: object, error?: string}|null>} null when no daemon channel is published;
1327
+ * `ok: false` for a daemon too old to answer (it has the channel but not the route)
1328
+ */
1329
+ export function statusViaDaemon(accountDir) {
1330
+ const chan = getDaemonChannel(accountDir);
1331
+ if (!chan) return Promise.resolve(null);
1332
+ const payload = JSON.stringify({ token: chan.token });
1333
+ return new Promise((resolve) => {
1334
+ const r = http.request(
1335
+ {
1336
+ host: "127.0.0.1",
1337
+ port: chan.port,
1338
+ path: "/status",
1339
+ method: "POST",
1340
+ headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) },
1341
+ timeout: 5000,
1342
+ agent: false,
1343
+ },
1344
+ (res) => {
1345
+ let raw = "";
1346
+ res.setEncoding("utf8");
1347
+ res.on("data", (c) => (raw += c));
1348
+ res.on("end", () => {
1349
+ let body = {};
1350
+ try {
1351
+ body = JSON.parse(raw);
1352
+ } catch {
1353
+ /* non-JSON response */
1354
+ }
1355
+ resolve(
1356
+ res.statusCode === 200 && body.ok
1357
+ ? { ok: true, health: body.health }
1358
+ : { ok: false, error: body.error || `HTTP ${res.statusCode}` },
1359
+ );
1360
+ });
1361
+ },
1362
+ );
1363
+ r.on("timeout", () => {
1364
+ r.destroy();
1365
+ resolve({ ok: false, error: "timeout waiting for the daemon" });
1366
+ });
1367
+ r.on("error", (e) => resolve({ ok: false, error: e?.message || String(e) }));
1368
+ r.end(payload);
1369
+ });
1370
+ }