codex-slot 0.1.24 → 0.1.26

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.
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getRunningPid = getRunningPid;
7
+ exports.buildLaunchAgentPlist = buildLaunchAgentPlist;
8
+ exports.resolveServiceManagerKind = resolveServiceManagerKind;
9
+ exports.buildSystemdUserUnit = buildSystemdUserUnit;
7
10
  exports.startManagedService = startManagedService;
8
11
  exports.stopManagedService = stopManagedService;
9
12
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -13,13 +16,14 @@ const node_child_process_1 = require("node:child_process");
13
16
  const undici_1 = require("undici");
14
17
  const account_service_1 = require("./account-service");
15
18
  const account_store_1 = require("../account-store");
19
+ const state_1 = require("../state");
16
20
  const codex_config_1 = require("../codex-config");
17
21
  const codex_auth_1 = require("../codex-auth");
18
22
  const cli_helpers_1 = require("../cli-helpers");
19
23
  const config_1 = require("../config");
20
- const scheduler_1 = require("../scheduler");
21
24
  const STARTUP_POLL_INTERVAL_MS = 100;
22
25
  const STARTUP_TIMEOUT_MS = 5000;
26
+ const LAUNCH_AGENT_LABEL_PREFIX = "com.openxiaobu.cslot";
23
27
  /**
24
28
  * 休眠指定毫秒数,供启动轮询流程复用。
25
29
  *
@@ -75,6 +79,361 @@ function resolveServeEntrypoint() {
75
79
  args: [`${serveBasePath}.js`]
76
80
  };
77
81
  }
82
+ /**
83
+ * 基于当前 HOME 目录生成稳定的 launchd label,避免测试隔离 HOME 与真实 HOME 互相冲突。
84
+ *
85
+ * @returns 当前 HOME 对应的 launchd label。
86
+ * @throws 无显式抛出。
87
+ */
88
+ function getLaunchAgentLabel() {
89
+ const home = (0, config_1.getUserHomeDir)();
90
+ let hash = 2166136261;
91
+ for (const character of home) {
92
+ hash ^= character.charCodeAt(0);
93
+ hash = Math.imul(hash, 16777619);
94
+ }
95
+ return `${LAUNCH_AGENT_LABEL_PREFIX}.${(hash >>> 0).toString(16)}`;
96
+ }
97
+ /**
98
+ * 返回当前 HOME 对应的 LaunchAgents plist 路径,并确保父目录存在。
99
+ *
100
+ * @returns launchd plist 绝对路径。
101
+ * @throws 当目录创建失败时抛出文件系统错误。
102
+ */
103
+ function getLaunchAgentPlistPath() {
104
+ const launchAgentsDir = node_path_1.default.join((0, config_1.getUserHomeDir)(), "Library", "LaunchAgents");
105
+ node_fs_1.default.mkdirSync(launchAgentsDir, { recursive: true });
106
+ return node_path_1.default.join(launchAgentsDir, `${getLaunchAgentLabel()}.plist`);
107
+ }
108
+ /**
109
+ * 对 XML 文本做最小转义,避免命令参数与日志路径写入 plist 时破坏结构。
110
+ *
111
+ * @param value 待写入 plist 的原始文本。
112
+ * @returns 完成 XML 转义后的文本。
113
+ * @throws 无显式抛出。
114
+ */
115
+ function escapeXml(value) {
116
+ return value
117
+ .replaceAll("&", "&")
118
+ .replaceAll("<", "&lt;")
119
+ .replaceAll(">", "&gt;")
120
+ .replaceAll("\"", "&quot;")
121
+ .replaceAll("'", "&apos;");
122
+ }
123
+ /**
124
+ * 生成 launchd plist 内容,使 cslot 服务具备开机自启与异常退出自动拉起能力。
125
+ *
126
+ * @param command 启动服务使用的绝对命令路径。
127
+ * @param args 命令参数列表。
128
+ * @param logPath 标准输出与错误输出写入的日志路径。
129
+ * @returns 可直接写入磁盘的 plist 文本。
130
+ * @throws 无显式抛出。
131
+ */
132
+ function buildLaunchAgentPlist(command, args, logPath) {
133
+ const programArguments = [command, ...args].map((item) => ` <string>${escapeXml(item)}</string>`).join("\n");
134
+ const home = escapeXml((0, config_1.getUserHomeDir)());
135
+ const label = escapeXml(getLaunchAgentLabel());
136
+ const escapedLogPath = escapeXml(logPath);
137
+ return [
138
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
139
+ "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
140
+ "<plist version=\"1.0\">",
141
+ "<dict>",
142
+ " <key>Label</key>",
143
+ ` <string>${label}</string>`,
144
+ " <key>ProgramArguments</key>",
145
+ " <array>",
146
+ programArguments,
147
+ " </array>",
148
+ " <key>RunAtLoad</key>",
149
+ " <true/>",
150
+ " <key>KeepAlive</key>",
151
+ " <true/>",
152
+ " <key>EnvironmentVariables</key>",
153
+ " <dict>",
154
+ " <key>HOME</key>",
155
+ ` <string>${home}</string>`,
156
+ " </dict>",
157
+ " <key>StandardOutPath</key>",
158
+ ` <string>${escapedLogPath}</string>`,
159
+ " <key>StandardErrorPath</key>",
160
+ ` <string>${escapedLogPath}</string>`,
161
+ "</dict>",
162
+ "</plist>",
163
+ ""
164
+ ].join("\n");
165
+ }
166
+ /**
167
+ * 判断当前环境是否应启用 launchd 托管。
168
+ *
169
+ * @returns macOS 且未显式禁用 launchd 时返回 `true`,否则返回 `false`。
170
+ * @throws 无显式抛出。
171
+ */
172
+ function shouldUseLaunchd() {
173
+ return process.platform === "darwin" && process.env.CSLOT_DISABLE_LAUNCHD !== "1";
174
+ }
175
+ /**
176
+ * 判断当前 Linux 环境是否应使用 systemd user service 托管。
177
+ *
178
+ * @returns Linux 且未显式禁用 systemd 托管时返回 `true`,否则返回 `false`。
179
+ * @throws 无显式抛出。
180
+ */
181
+ function shouldUseSystemdUser() {
182
+ return process.platform === "linux" && process.env.CSLOT_DISABLE_SYSTEMD !== "1";
183
+ }
184
+ /**
185
+ * 解析当前平台应使用的后台服务托管方式。
186
+ *
187
+ * @returns 当前平台对应的服务管理器类型。
188
+ * @throws 当 Linux 期望使用 systemd 但本机缺少 `systemctl` 时抛出异常。
189
+ */
190
+ function resolveServiceManagerKind() {
191
+ if (shouldUseLaunchd()) {
192
+ return "launchd";
193
+ }
194
+ if (shouldUseSystemdUser()) {
195
+ try {
196
+ (0, node_child_process_1.execFileSync)("systemctl", ["--user", "--version"], {
197
+ stdio: ["ignore", "ignore", "ignore"]
198
+ });
199
+ }
200
+ catch {
201
+ throw new Error("当前 Linux 环境缺少 systemd --user,无法提供自动拉起与开机自启。请安装 systemd user service,或显式设置 CSLOT_DISABLE_SYSTEMD=1 回退到 detached 模式。");
202
+ }
203
+ return "systemd-user";
204
+ }
205
+ return "detached";
206
+ }
207
+ /**
208
+ * 返回当前用户的 launchd domain,供 `launchctl bootstrap/bootout` 复用。
209
+ *
210
+ * @returns 类似 `gui/501` 的 domain 字符串。
211
+ * @throws 当当前进程无法获取 uid 时抛出异常。
212
+ */
213
+ function getLaunchctlDomain() {
214
+ if (typeof process.getuid !== "function") {
215
+ throw new Error("当前平台不支持 launchctl user domain");
216
+ }
217
+ return `gui/${process.getuid()}`;
218
+ }
219
+ /**
220
+ * 返回 systemd user service 的 unit 名称,按 HOME 做稳定区分,避免测试与真实环境互串。
221
+ *
222
+ * @returns 当前 HOME 对应的 systemd unit 文件名。
223
+ * @throws 无显式抛出。
224
+ */
225
+ function getSystemdUserUnitName() {
226
+ return `${getLaunchAgentLabel().replaceAll(".", "-")}.service`;
227
+ }
228
+ /**
229
+ * 返回 systemd user service unit 文件路径,并确保目录存在。
230
+ *
231
+ * @returns systemd user unit 绝对路径。
232
+ * @throws 当目录创建失败时抛出文件系统错误。
233
+ */
234
+ function getSystemdUserUnitPath() {
235
+ const unitDir = node_path_1.default.join((0, config_1.getUserHomeDir)(), ".config", "systemd", "user");
236
+ node_fs_1.default.mkdirSync(unitDir, { recursive: true });
237
+ return node_path_1.default.join(unitDir, getSystemdUserUnitName());
238
+ }
239
+ /**
240
+ * 对 systemd Environment 值做最小转义,避免空格或双引号破坏 unit 文件语义。
241
+ *
242
+ * @param value Environment 原始值。
243
+ * @returns 已转义的值文本。
244
+ * @throws 无显式抛出。
245
+ */
246
+ function escapeSystemdEnvironmentValue(value) {
247
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
248
+ }
249
+ /**
250
+ * 对 systemd ExecStart 参数做 shell 风格转义,确保路径和参数可被 systemd 正确拆分。
251
+ *
252
+ * @param value ExecStart 单个参数文本。
253
+ * @returns 安全可写入 unit 的参数文本。
254
+ * @throws 无显式抛出。
255
+ */
256
+ function quoteSystemdExecArgument(value) {
257
+ return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
258
+ }
259
+ /**
260
+ * 生成 systemd --user service unit,使 cslot 在 Linux 下具备用户级开机自启与异常退出自动重启能力。
261
+ *
262
+ * @param command 启动服务使用的绝对命令路径。
263
+ * @param args 命令参数列表。
264
+ * @param logPath 服务日志路径。
265
+ * @returns 可直接写入 systemd user unit 的文本。
266
+ * @throws 无显式抛出。
267
+ */
268
+ function buildSystemdUserUnit(command, args, logPath) {
269
+ const execStart = [command, ...args].map(quoteSystemdExecArgument).join(" ");
270
+ const home = escapeSystemdEnvironmentValue((0, config_1.getUserHomeDir)());
271
+ const safeLogPath = logPath.replaceAll("\\", "\\\\").replaceAll("%", "%%");
272
+ return [
273
+ "[Unit]",
274
+ "Description=cslot managed local proxy",
275
+ "After=network-online.target",
276
+ "Wants=network-online.target",
277
+ "",
278
+ "[Service]",
279
+ "Type=simple",
280
+ `Environment=HOME="${home}"`,
281
+ `ExecStart=${execStart}`,
282
+ "Restart=always",
283
+ "RestartSec=1",
284
+ `StandardOutput=append:${safeLogPath}`,
285
+ `StandardError=append:${safeLogPath}`,
286
+ "",
287
+ "[Install]",
288
+ "WantedBy=default.target",
289
+ ""
290
+ ].join("\n");
291
+ }
292
+ /**
293
+ * 执行一次 systemctl --user 命令,并在允许的退出码范围内按幂等成功处理。
294
+ *
295
+ * @param args systemctl 参数列表。
296
+ * @param allowedStatuses 允许按成功处理的退出码集合。
297
+ * @returns 标准输出文本。
298
+ * @throws 当命令失败且退出码不在允许列表中时抛出异常。
299
+ */
300
+ function runSystemctlUser(args, allowedStatuses = []) {
301
+ try {
302
+ return (0, node_child_process_1.execFileSync)("systemctl", ["--user", ...args], {
303
+ encoding: "utf8",
304
+ stdio: ["ignore", "pipe", "pipe"]
305
+ });
306
+ }
307
+ catch (error) {
308
+ const status = typeof error === "object" && error && "status" in error ? Number(error.status) : null;
309
+ if (status !== null && allowedStatuses.includes(status)) {
310
+ return "";
311
+ }
312
+ const stderr = typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
313
+ ? error.stderr.trim()
314
+ : "";
315
+ const message = stderr || (error instanceof Error ? error.message : String(error));
316
+ throw new Error(`systemctl --user ${args.join(" ")} 失败: ${message}`);
317
+ }
318
+ }
319
+ /**
320
+ * 启动或重载 systemd --user 托管的 cslot 服务。
321
+ *
322
+ * @param port 最终启动端口。
323
+ * @param logPath 服务日志路径。
324
+ * @returns 当前 systemd 托管实例的 PID。
325
+ * @throws 当 unit 写入、systemctl 调用或服务就绪检查失败时抛出异常。
326
+ */
327
+ async function startManagedServiceWithSystemdUser(port, logPath) {
328
+ const serveEntrypoint = resolveServeEntrypoint();
329
+ const unitPath = getSystemdUserUnitPath();
330
+ const unitName = getSystemdUserUnitName();
331
+ const unit = buildSystemdUserUnit(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], logPath);
332
+ node_fs_1.default.writeFileSync(unitPath, unit, "utf8");
333
+ runSystemctlUser(["daemon-reload"]);
334
+ runSystemctlUser(["enable", "--now", unitName]);
335
+ return await waitForManagedServicePid();
336
+ }
337
+ /**
338
+ * 停止并卸载 systemd --user 托管的 cslot 服务,同时清理本地 unit 工件。
339
+ *
340
+ * @returns 停止前记录到的 PID;若当时没有可见运行 PID 则返回 `null`。
341
+ * @throws 当 systemctl 卸载或清理失败时抛出异常。
342
+ */
343
+ function stopManagedServiceWithSystemdUser() {
344
+ const pid = getRunningPid();
345
+ const unitName = getSystemdUserUnitName();
346
+ const unitPath = getSystemdUserUnitPath();
347
+ runSystemctlUser(["disable", "--now", unitName], [1, 5]);
348
+ node_fs_1.default.rmSync(unitPath, { force: true });
349
+ runSystemctlUser(["daemon-reload"]);
350
+ runSystemctlUser(["reset-failed", unitName], [1, 5]);
351
+ node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
352
+ return pid;
353
+ }
354
+ /**
355
+ * 执行一次 launchctl 命令,并在允许的失败码内按幂等处理。
356
+ *
357
+ * @param args launchctl 参数列表。
358
+ * @param allowedStatuses 允许视为成功的退出码集合。
359
+ * @returns 标准输出文本。
360
+ * @throws 当命令执行失败且退出码不在允许集合中时抛出异常。
361
+ */
362
+ function runLaunchctl(args, allowedStatuses = []) {
363
+ try {
364
+ return (0, node_child_process_1.execFileSync)("launchctl", args, {
365
+ encoding: "utf8",
366
+ stdio: ["ignore", "pipe", "pipe"]
367
+ });
368
+ }
369
+ catch (error) {
370
+ const status = typeof error === "object" && error && "status" in error ? Number(error.status) : null;
371
+ if (status !== null && allowedStatuses.includes(status)) {
372
+ return "";
373
+ }
374
+ const stderr = typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
375
+ ? error.stderr.trim()
376
+ : "";
377
+ const message = stderr || (error instanceof Error ? error.message : String(error));
378
+ throw new Error(`launchctl ${args.join(" ")} 失败: ${message}`);
379
+ }
380
+ }
381
+ /**
382
+ * 等待服务进程把自己的 PID 写入本地状态文件,兼容 launchd 拉起的非子进程模型。
383
+ *
384
+ * @param timeoutMs 等待超时时间,单位毫秒。
385
+ * @returns Promise,成功时返回当前运行中的 PID。
386
+ * @throws 当超时后仍未拿到有效 PID 时抛出异常。
387
+ */
388
+ async function waitForManagedServicePid(timeoutMs = STARTUP_TIMEOUT_MS) {
389
+ const startedAt = Date.now();
390
+ while (Date.now() - startedAt < timeoutMs) {
391
+ const pid = getRunningPid();
392
+ if (pid) {
393
+ return pid;
394
+ }
395
+ await sleep(STARTUP_POLL_INTERVAL_MS);
396
+ }
397
+ throw new Error(`后台服务启动超时,未在 ${timeoutMs}ms 内写入有效 PID`);
398
+ }
399
+ /**
400
+ * 启动或重载 launchd 托管的 cslot 服务。
401
+ *
402
+ * @param port 最终启动端口。
403
+ * @param logPath 服务日志路径。
404
+ * @returns 当前 launchd 托管实例的 PID。
405
+ * @throws 当 plist 写入、launchctl 调用或服务就绪检查失败时抛出异常。
406
+ */
407
+ async function startManagedServiceWithLaunchd(port, logPath) {
408
+ const serveEntrypoint = resolveServeEntrypoint();
409
+ const plistPath = getLaunchAgentPlistPath();
410
+ const domain = getLaunchctlDomain();
411
+ const label = getLaunchAgentLabel();
412
+ const plist = buildLaunchAgentPlist(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], logPath);
413
+ node_fs_1.default.writeFileSync(plistPath, plist, "utf8");
414
+ // 先卸载旧 job,再用最新配置重载,避免端口或命令参数更新后仍复用旧定义。
415
+ runLaunchctl(["bootout", domain, plistPath], [3, 5, 36, 64, 113]);
416
+ runLaunchctl(["bootstrap", domain, plistPath]);
417
+ runLaunchctl(["enable", `${domain}/${label}`], [3, 5, 64, 113]);
418
+ runLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
419
+ const pid = await waitForManagedServicePid();
420
+ return pid;
421
+ }
422
+ /**
423
+ * 停止并卸载 launchd 托管的 cslot 服务,同时清理本地 plist 工件。
424
+ *
425
+ * @returns 停止前记录到的 PID;若当时没有可见运行 PID 则返回 `null`。
426
+ * @throws 当 launchctl 卸载失败时抛出异常。
427
+ */
428
+ function stopManagedServiceWithLaunchd() {
429
+ const pid = getRunningPid();
430
+ const plistPath = getLaunchAgentPlistPath();
431
+ const domain = getLaunchctlDomain();
432
+ runLaunchctl(["bootout", domain, plistPath], [3, 5, 36, 64, 113]);
433
+ node_fs_1.default.rmSync(plistPath, { force: true });
434
+ node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
435
+ return pid;
436
+ }
78
437
  /**
79
438
  * 检查指定地址与端口当前是否可绑定,用于启动前规避端口冲突。
80
439
  *
@@ -165,6 +524,18 @@ function rollbackFailedStart(pid, previousConfig) {
165
524
  }
166
525
  }
167
526
  node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
527
+ try {
528
+ const manager = resolveServiceManagerKind();
529
+ if (manager === "launchd") {
530
+ stopManagedServiceWithLaunchd();
531
+ }
532
+ else if (manager === "systemd-user") {
533
+ stopManagedServiceWithSystemdUser();
534
+ }
535
+ }
536
+ catch {
537
+ // 回滚阶段以尽力清理为主,不覆盖原始启动异常。
538
+ }
168
539
  (0, config_1.saveConfig)(previousConfig);
169
540
  (0, codex_config_1.deactivateManagedCodexConfig)();
170
541
  (0, codex_auth_1.deactivateManagedCodexAuth)();
@@ -173,19 +544,28 @@ function rollbackFailedStart(pid, previousConfig) {
173
544
  * 选择一个可用于接管主 `~/.codex` 登录态的账号。
174
545
  *
175
546
  * 业务规则:
176
- * 1. 优先复用当前调度器已经选中的最佳账号,保证 CLI 与代理请求走同一身份。
177
- * 2. 若当前没有可调度账号,则回退到首个启用且本地工作空间仍存在的账号。
178
- * 3. 若仍无可用账号,则返回 `null`,此时仅接管 provider 配置,不强行覆盖主登录态。
547
+ * 1. 优先使用状态面板中手动选择的 Codex App 登录态账号。
548
+ * 2. 手动选择存在但账号缺失或登录态不完整时直接报错,避免静默切到其他账号。
549
+ * 3. 未手动选择时,回退到首个启用且本地工作空间仍存在的账号。
550
+ * 4. 若仍无可用账号,则返回 `null`,此时仅接管 provider 配置,不强行覆盖主登录态。
179
551
  *
180
552
  * @returns 选中的受管账号;若不存在可接管账号则返回 `null`。
181
- * @throws 无显式抛出。
553
+ * @throws 当手动选择的账号不存在或登录态不完整时抛出错误。
182
554
  */
183
555
  function resolveManagedAuthAccount() {
184
- const selected = (0, scheduler_1.pickBestAccount)()?.account;
185
- if (selected && (0, account_store_1.hasCompleteCodexAuthState)(selected.codex_home)) {
556
+ const accounts = (0, account_service_1.listAccounts)();
557
+ const selectedAuthAccountId = (0, state_1.getSelectedCodexAuthAccountId)();
558
+ if (selectedAuthAccountId) {
559
+ const selected = accounts.find((account) => account.id === selectedAuthAccountId);
560
+ if (!selected) {
561
+ throw new Error(`手动选择的 Codex App 登录态账号不存在: ${selectedAuthAccountId}`);
562
+ }
563
+ if (!node_fs_1.default.existsSync(selected.codex_home) || !(0, account_store_1.hasCompleteCodexAuthState)(selected.codex_home)) {
564
+ throw new Error(`手动选择的 Codex App 登录态账号缺少完整 auth.json: ${selectedAuthAccountId}`);
565
+ }
186
566
  return selected;
187
567
  }
188
- return ((0, account_service_1.listAccounts)().find((account) => account.enabled &&
568
+ return (accounts.find((account) => account.enabled &&
189
569
  node_fs_1.default.existsSync(account.codex_home) &&
190
570
  (0, account_store_1.hasCompleteCodexAuthState)(account.codex_home)) ?? null);
191
571
  }
@@ -246,6 +626,7 @@ async function startManagedService(portOverride) {
246
626
  const config = (0, config_1.loadConfig)();
247
627
  const previousConfig = structuredClone(config);
248
628
  const { port, autoSwitched } = await resolveStartPort(config.server.host, portOverride);
629
+ const manager = resolveServiceManagerKind();
249
630
  const runningPid = getRunningPid();
250
631
  if (runningPid) {
251
632
  return {
@@ -253,7 +634,8 @@ async function startManagedService(portOverride) {
253
634
  pid: runningPid,
254
635
  port: config.server.port,
255
636
  logPath: (0, config_1.getServiceLogPath)(),
256
- autoSwitched: false
637
+ autoSwitched: false,
638
+ manager
257
639
  };
258
640
  }
259
641
  if (config.server.port !== port) {
@@ -263,21 +645,40 @@ async function startManagedService(portOverride) {
263
645
  (0, codex_config_1.applyManagedCodexConfig)(undefined, { config });
264
646
  applyManagedAuthIfPossible();
265
647
  const logPath = (0, config_1.getServiceLogPath)();
266
- const logFd = node_fs_1.default.openSync(logPath, "a");
267
- const serveEntrypoint = resolveServeEntrypoint();
268
- const child = (0, node_child_process_1.spawn)(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], {
269
- detached: true,
270
- stdio: ["ignore", logFd, logFd]
271
- });
272
- const childPid = child.pid ?? null;
273
- child.unref();
274
- if (!childPid) {
648
+ let childPid = null;
649
+ if (manager === "launchd") {
650
+ try {
651
+ childPid = await startManagedServiceWithLaunchd(port, logPath);
652
+ }
653
+ catch (error) {
654
+ rollbackFailedStart(null, previousConfig);
655
+ throw error;
656
+ }
657
+ }
658
+ else if (manager === "systemd-user") {
659
+ try {
660
+ childPid = await startManagedServiceWithSystemdUser(port, logPath);
661
+ }
662
+ catch (error) {
663
+ rollbackFailedStart(null, previousConfig);
664
+ throw error;
665
+ }
666
+ }
667
+ else {
668
+ const logFd = node_fs_1.default.openSync(logPath, "a");
669
+ const serveEntrypoint = resolveServeEntrypoint();
670
+ const child = (0, node_child_process_1.spawn)(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], {
671
+ detached: true,
672
+ stdio: ["ignore", logFd, logFd]
673
+ });
674
+ childPid = child.pid ?? null;
675
+ child.unref();
275
676
  node_fs_1.default.closeSync(logFd);
276
- rollbackFailedStart(null, previousConfig);
277
- throw new Error("后台服务启动失败,未获取到有效子进程 PID");
677
+ if (!childPid) {
678
+ rollbackFailedStart(null, previousConfig);
679
+ throw new Error("后台服务启动失败,未获取到有效子进程 PID");
680
+ }
278
681
  }
279
- node_fs_1.default.writeFileSync((0, config_1.getPidPath)(), `${childPid}\n`, "utf8");
280
- node_fs_1.default.closeSync(logFd);
281
682
  try {
282
683
  await waitForManagedServiceReady(config.server.host, port, childPid);
283
684
  }
@@ -290,7 +691,8 @@ async function startManagedService(portOverride) {
290
691
  pid: childPid,
291
692
  port,
292
693
  logPath,
293
- autoSwitched
694
+ autoSwitched,
695
+ manager
294
696
  };
295
697
  }
296
698
  /**
@@ -300,14 +702,25 @@ async function startManagedService(portOverride) {
300
702
  * @throws 当进程终止失败时透传底层异常。
301
703
  */
302
704
  function stopManagedService() {
705
+ const manager = resolveServiceManagerKind();
706
+ const hasLaunchAgent = manager === "launchd" && node_fs_1.default.existsSync(getLaunchAgentPlistPath());
707
+ const hasSystemdUnit = manager === "systemd-user" && node_fs_1.default.existsSync(getSystemdUserUnitPath());
303
708
  const pid = getRunningPid();
304
- if (!pid) {
709
+ if (!pid && !hasLaunchAgent && !hasSystemdUnit) {
305
710
  (0, codex_config_1.deactivateManagedCodexConfig)();
306
711
  (0, codex_auth_1.deactivateManagedCodexAuth)();
307
712
  return { stoppedPid: null };
308
713
  }
309
- process.kill(pid, "SIGTERM");
310
- node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
714
+ if (hasLaunchAgent) {
715
+ stopManagedServiceWithLaunchd();
716
+ }
717
+ else if (hasSystemdUnit) {
718
+ stopManagedServiceWithSystemdUser();
719
+ }
720
+ else if (pid) {
721
+ process.kill(pid, "SIGTERM");
722
+ node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
723
+ }
311
724
  (0, codex_config_1.deactivateManagedCodexConfig)();
312
725
  (0, codex_auth_1.deactivateManagedCodexAuth)();
313
726
  return { stoppedPid: pid };
@@ -6,6 +6,7 @@ exports.persistAccountEnabledState = persistAccountEnabledState;
6
6
  const config_1 = require("../config");
7
7
  const scheduler_1 = require("../scheduler");
8
8
  const status_1 = require("../status");
9
+ const state_1 = require("../state");
9
10
  const usage_sync_1 = require("../usage-sync");
10
11
  /**
11
12
  * 刷新全部账号额度并返回最新状态快照。
@@ -29,6 +30,7 @@ function getStatusSnapshot() {
29
30
  return {
30
31
  statuses,
31
32
  selectedName: selected?.account.name ?? null,
33
+ codexAuthAccountId: (0, state_1.getSelectedCodexAuthAccountId)(),
32
34
  summary: (0, status_1.summarizeAccountStatuses)(statuses)
33
35
  };
34
36
  }
package/dist/serve.js CHANGED
@@ -1,9 +1,60 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
3
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ const node_fs_1 = __importDefault(require("node:fs"));
4
8
  const config_1 = require("./config");
5
9
  const server_1 = require("./server");
6
10
  const text_1 = require("./text");
11
+ /**
12
+ * 将当前服务进程 PID 持久化到本地状态文件,供 `cslot stop` 与健康检查流程复用。
13
+ *
14
+ * @returns 无返回值。
15
+ * @throws 当 PID 文件写入失败时抛出文件系统错误。
16
+ */
17
+ function writeCurrentPid() {
18
+ node_fs_1.default.writeFileSync((0, config_1.getPidPath)(), `${process.pid}\n`, "utf8");
19
+ }
20
+ /**
21
+ * 按幂等方式清理当前服务进程留下的 PID 文件,避免异常退出后残留脏状态。
22
+ *
23
+ * @returns 无返回值。
24
+ * @throws 无显式抛出。
25
+ */
26
+ function cleanupPidFile() {
27
+ try {
28
+ const pidPath = (0, config_1.getPidPath)();
29
+ if (!node_fs_1.default.existsSync(pidPath)) {
30
+ return;
31
+ }
32
+ const raw = node_fs_1.default.readFileSync(pidPath, "utf8").trim();
33
+ if (Number(raw) === process.pid) {
34
+ node_fs_1.default.rmSync(pidPath, { force: true });
35
+ }
36
+ }
37
+ catch {
38
+ // 退出清理阶段以幂等为主,不阻塞真实退出流程。
39
+ }
40
+ }
41
+ /**
42
+ * 注册服务进程退出时的 PID 清理逻辑,兼容正常停止与 launchd 重启场景。
43
+ *
44
+ * @returns 无返回值。
45
+ * @throws 无显式抛出。
46
+ */
47
+ function registerPidCleanupHandlers() {
48
+ process.once("exit", cleanupPidFile);
49
+ process.once("SIGINT", () => {
50
+ cleanupPidFile();
51
+ process.exit(0);
52
+ });
53
+ process.once("SIGTERM", () => {
54
+ cleanupPidFile();
55
+ process.exit(0);
56
+ });
57
+ }
7
58
  /**
8
59
  * 后台服务进程入口。
9
60
  *
@@ -16,9 +67,12 @@ async function main() {
16
67
  const port = portArgIndex >= 0 && process.argv[portArgIndex + 1]
17
68
  ? Number(process.argv[portArgIndex + 1])
18
69
  : config.server.port;
70
+ writeCurrentPid();
71
+ registerPidCleanupHandlers();
19
72
  await (0, server_1.startServer)(port);
20
73
  }
21
74
  void main().catch((error) => {
75
+ cleanupPidFile();
22
76
  const message = error instanceof Error ? error.message : String(error);
23
77
  console.error((0, text_1.bi)(`cslot service 启动失败: ${message}`, `cslot service failed to start: ${message}`));
24
78
  process.exit(1);
package/dist/server.js CHANGED
@@ -26,6 +26,35 @@ async function readRawRequestBody(stream) {
26
26
  }
27
27
  return Buffer.concat(chunks);
28
28
  }
29
+ /**
30
+ * 将上游响应体安全地透传给已 hijack 的本地响应。
31
+ *
32
+ * 业务含义:
33
+ * 1. 一旦开始写出上游 header,就不能再把异常抛回 Fastify 默认错误处理器。
34
+ * 2. 上游流中途断开或客户端提前关闭时,只终止当前连接,避免整个 cslot 进程崩溃。
35
+ *
36
+ * @param reply 当前请求对应的 Fastify reply。
37
+ * @param result 已成功拿到响应头的上游代理结果。
38
+ * @returns Promise,流复制结束或被安全终止后返回。
39
+ * @throws 无显式抛出;异常会被当前方法内部吞掉并转成连接销毁。
40
+ */
41
+ async function streamProxyResponse(reply, result) {
42
+ reply.hijack();
43
+ reply.raw.writeHead(result.statusCode, result.headers);
44
+ try {
45
+ for await (const chunk of result.body) {
46
+ reply.raw.write(chunk);
47
+ }
48
+ reply.raw.end();
49
+ }
50
+ catch (error) {
51
+ console.error("cslot proxy stream aborted", error);
52
+ // 响应已开始输出,此时只能销毁当前连接,不能再交给 Fastify 二次写 header。
53
+ if (!reply.raw.destroyed) {
54
+ reply.raw.destroy?.(error instanceof Error ? error : new Error(String(error)));
55
+ }
56
+ }
57
+ }
29
58
  /**
30
59
  * 启动一个极轻量本地服务,供后续接入代理或脚本化查询使用。
31
60
  *
@@ -68,12 +97,7 @@ async function startServer(port) {
68
97
  reply.send(result.payload);
69
98
  return;
70
99
  }
71
- reply.hijack();
72
- reply.raw.writeHead(result.statusCode, result.headers);
73
- for await (const chunk of result.body) {
74
- reply.raw.write(chunk);
75
- }
76
- reply.raw.end();
100
+ await streamProxyResponse(reply, result);
77
101
  };
78
102
  const backendProxyHandler = async (request, reply) => {
79
103
  const requestBody = request.body ? await readRawRequestBody(request.body) : undefined;
@@ -91,12 +115,7 @@ async function startServer(port) {
91
115
  reply.send(result.payload);
92
116
  return;
93
117
  }
94
- reply.hijack();
95
- reply.raw.writeHead(result.statusCode, result.headers);
96
- for await (const chunk of result.body) {
97
- reply.raw.write(chunk);
98
- }
99
- reply.raw.end();
118
+ await streamProxyResponse(reply, result);
100
119
  };
101
120
  await app.register(async (proxyApp) => {
102
121
  // 代理路由需要原样透传 body,不能在本地先做 JSON 解析与大小限制拦截。
@@ -5,6 +5,16 @@ exports.handleStop = handleStop;
5
5
  const service_lifecycle_service_1 = require("./app/service-lifecycle-service");
6
6
  const config_1 = require("./config");
7
7
  const text_1 = require("./text");
8
+ function describeServiceManager(manager) {
9
+ switch (manager) {
10
+ case "launchd":
11
+ return (0, text_1.bi)("launchd(开机自启 + 异常自动拉起)", "launchd (auto-start on boot + auto-restart on failure)");
12
+ case "systemd-user":
13
+ return (0, text_1.bi)("systemd --user(开机自启 + 异常自动拉起)", "systemd --user (auto-start on boot + auto-restart on failure)");
14
+ default:
15
+ return (0, text_1.bi)("detached 进程(仅本次后台运行,不保证自动拉起)", "detached process (background only, no guaranteed auto-restart)");
16
+ }
17
+ }
8
18
  /**
9
19
  * 后台启动 cslot 服务并写入 PID 文件。
10
20
  *
@@ -17,6 +27,7 @@ async function handleStart(portOverride) {
17
27
  const result = await (0, service_lifecycle_service_1.startManagedService)(portOverride);
18
28
  if (result.alreadyRunning) {
19
29
  console.log((0, text_1.bi)(`服务已在运行,PID=${result.pid}`, `Service is already running. PID=${result.pid}`));
30
+ console.log(`托管 / Manager: ${describeServiceManager(result.manager)}`);
20
31
  if (portOverride) {
21
32
  console.log((0, text_1.bi)(`已将新端口写入配置: ${result.port}`, `The new port has been saved to config: ${result.port}`));
22
33
  console.log((0, text_1.bi)("请先执行 cslot stop,再执行 cslot start 使新端口生效。", "Run cslot stop first, then cslot start to apply the new port."));
@@ -27,6 +38,7 @@ async function handleStart(portOverride) {
27
38
  console.log((0, text_1.bi)(`默认端口 4399 已被占用,已自动切换到 ${result.port}`, `Default port 4399 is busy. Automatically switched to ${result.port}`));
28
39
  }
29
40
  console.log((0, text_1.bi)(`服务已启动: http://${config.server.host}:${result.port}`, `Service started: http://${config.server.host}:${result.port}`));
41
+ console.log(`托管 / Manager: ${describeServiceManager(result.manager)}`);
30
42
  console.log(`PID: ${result.pid}`);
31
43
  console.log((0, text_1.bi)(`日志: ${result.logPath}`, `Log: ${result.logPath}`));
32
44
  }
package/dist/state.js CHANGED
@@ -15,6 +15,8 @@ exports.getUsageCache = getUsageCache;
15
15
  exports.setUsageRefreshError = setUsageRefreshError;
16
16
  exports.clearUsageRefreshError = clearUsageRefreshError;
17
17
  exports.getUsageRefreshError = getUsageRefreshError;
18
+ exports.getSelectedCodexAuthAccountId = getSelectedCodexAuthAccountId;
19
+ exports.setSelectedCodexAuthAccountId = setSelectedCodexAuthAccountId;
18
20
  exports.getManagedCodexConfigState = getManagedCodexConfigState;
19
21
  exports.getManagedCodexAuthState = getManagedCodexAuthState;
20
22
  exports.setManagedCodexConfigState = setManagedCodexConfigState;
@@ -24,7 +26,7 @@ exports.clearManagedCodexAuthState = clearManagedCodexAuthState;
24
26
  const node_fs_1 = __importDefault(require("node:fs"));
25
27
  const node_path_1 = __importDefault(require("node:path"));
26
28
  const config_1 = require("./config");
27
- const STATE_SCHEMA_VERSION = 1;
29
+ const STATE_SCHEMA_VERSION = 2;
28
30
  function getStatePath() {
29
31
  return node_path_1.default.join((0, config_1.getCslotHome)(), "state.json");
30
32
  }
@@ -41,6 +43,7 @@ function getStatePath() {
41
43
  function createDefaultState() {
42
44
  return {
43
45
  state_version: STATE_SCHEMA_VERSION,
46
+ selected_codex_auth_account_id: null,
44
47
  account_blocks: {},
45
48
  usage_cache: {},
46
49
  usage_refresh_errors: {},
@@ -60,6 +63,7 @@ function normalizeState(parsed) {
60
63
  const defaults = createDefaultState();
61
64
  return {
62
65
  state_version: STATE_SCHEMA_VERSION,
66
+ selected_codex_auth_account_id: parsed?.selected_codex_auth_account_id ?? defaults.selected_codex_auth_account_id,
63
67
  account_blocks: parsed?.account_blocks ?? defaults.account_blocks,
64
68
  usage_cache: parsed?.usage_cache ?? defaults.usage_cache,
65
69
  usage_refresh_errors: parsed?.usage_refresh_errors ?? defaults.usage_refresh_errors,
@@ -231,6 +235,32 @@ function getUsageRefreshError(accountId) {
231
235
  const state = loadState();
232
236
  return state.usage_refresh_errors[accountId] ?? null;
233
237
  }
238
+ /**
239
+ * 读取用户在状态面板中手动选择的 Codex App 主登录态账号。
240
+ *
241
+ * 业务含义:
242
+ * 1. 该选择只决定 `cslot start` 复制哪个受管账号到主 `~/.codex/auth.json`。
243
+ * 2. 它不参与代理请求调度,也不改变账号 enabled 状态。
244
+ *
245
+ * @returns 手动选择的账号 id;未选择时返回 `null`。
246
+ * @throws 当 state 文件读取或解析失败时透传底层异常。
247
+ */
248
+ function getSelectedCodexAuthAccountId() {
249
+ const state = loadState();
250
+ return state.selected_codex_auth_account_id ?? null;
251
+ }
252
+ /**
253
+ * 保存用户在状态面板中手动选择的 Codex App 主登录态账号。
254
+ *
255
+ * @param accountId 账号 id;传入 `null` 表示清除手动选择并恢复默认回退规则。
256
+ * @returns 无返回值。
257
+ * @throws 当 state 文件写入失败时透传底层异常。
258
+ */
259
+ function setSelectedCodexAuthAccountId(accountId) {
260
+ updateState((state) => {
261
+ state.selected_codex_auth_account_id = accountId;
262
+ });
263
+ }
234
264
  /**
235
265
  * 读取当前记录的 Codex `config.toml` 接管快照。
236
266
  *
@@ -5,9 +5,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.handleStatus = handleStatus;
7
7
  const node_readline_1 = __importDefault(require("node:readline"));
8
+ const account_store_1 = require("./account-store");
8
9
  const account_service_1 = require("./app/account-service");
9
10
  const status_service_1 = require("./app/status-service");
10
11
  const scheduler_1 = require("./scheduler");
12
+ const state_1 = require("./state");
13
+ const codex_auth_1 = require("./codex-auth");
11
14
  const status_1 = require("./status");
12
15
  const text_1 = require("./text");
13
16
  const ANSI = {
@@ -206,16 +209,24 @@ function renderInteractiveScreen(lines) {
206
209
  * 计算交互式状态面板的初始光标位置。
207
210
  *
208
211
  * 业务规则:
209
- * 1. 优先定位到当前自动调度选中的账号。
210
- * 2. 若没有自动选中账号,则回退到首个可用账号。
211
- * 3. 若所有账号都不可用,则回退到首个已启用账号。
212
+ * 1. 优先定位到用户手动选择的 Codex App 登录态账号。
213
+ * 2. 若没有手动选择,则回退到当前自动调度选中的账号。
214
+ * 3. 若没有自动选中账号,则回退到首个可用账号。
215
+ * 4. 若所有账号都不可用,则回退到首个已启用账号。
212
216
  *
213
217
  * @param accounts 已按展示顺序排好的账号列表。
214
218
  * @param statuses 当前账号运行时状态快照。
219
+ * @param selectedAuthAccountId 用户手动选择的 Codex App 登录态账号 id。
215
220
  * @returns 初始光标所在的数组下标。
216
221
  * @throws 无显式抛出。
217
222
  */
218
- function resolveInitialCursorIndex(accounts, statuses) {
223
+ function resolveInitialCursorIndex(accounts, statuses, selectedAuthAccountId) {
224
+ if (selectedAuthAccountId) {
225
+ const selectedAuthIndex = accounts.findIndex((account) => account.id === selectedAuthAccountId);
226
+ if (selectedAuthIndex >= 0) {
227
+ return selectedAuthIndex;
228
+ }
229
+ }
219
230
  const selected = (0, scheduler_1.pickBestAccount)();
220
231
  if (selected) {
221
232
  const selectedIndex = accounts.findIndex((account) => account.id === selected.account.id);
@@ -234,6 +245,31 @@ function resolveInitialCursorIndex(accounts, statuses) {
234
245
  }
235
246
  return 0;
236
247
  }
248
+ /**
249
+ * 将状态面板中选中的账号立即应用为 Codex App 主登录态。
250
+ *
251
+ * 业务含义:
252
+ * 1. 该操作只切换主 `~/.codex/auth.json` 的来源账号,不改变代理调度顺序。
253
+ * 2. 被选择账号可以是 disabled,因为 enabled 只控制代理请求调度。
254
+ * 3. 登录态不完整时拒绝保存选择,避免下一次 `start` 静默失败或切错账号。
255
+ *
256
+ * @param account 用户在状态面板中选中的受管账号。
257
+ * @returns 失败时返回错误文本;成功时返回 `null`。
258
+ * @throws 无显式抛出;文件系统错误会转为返回文本。
259
+ */
260
+ function applyCodexAuthSelection(account) {
261
+ try {
262
+ if (!(0, account_store_1.hasCompleteCodexAuthState)(account.codex_home)) {
263
+ return `账号 ${account.id} 缺少完整 auth.json`;
264
+ }
265
+ (0, state_1.setSelectedCodexAuthAccountId)(account.id);
266
+ (0, codex_auth_1.applyManagedCodexAuth)(account.codex_home, { sourceAccountId: account.id });
267
+ return null;
268
+ }
269
+ catch (error) {
270
+ return error instanceof Error ? error.message : String(error);
271
+ }
272
+ }
237
273
  /**
238
274
  * 进入账号启用状态的交互式切换界面,并在用户确认退出后恢复终端状态。
239
275
  *
@@ -256,7 +292,8 @@ async function handleInteractiveToggle(initialStatuses) {
256
292
  return;
257
293
  }
258
294
  const accounts = [...accountsFromConfig].sort((left, right) => left.name.localeCompare(right.name));
259
- let cursor = resolveInitialCursorIndex(accounts, initialStatuses ?? (0, status_1.collectAccountStatuses)());
295
+ let selectedAuthAccountId = (0, state_1.getSelectedCodexAuthAccountId)();
296
+ let cursor = resolveInitialCursorIndex(accounts, initialStatuses ?? (0, status_1.collectAccountStatuses)(), selectedAuthAccountId);
260
297
  let changed = false;
261
298
  enterInteractiveScreen();
262
299
  return await new Promise((resolve) => {
@@ -277,9 +314,10 @@ async function handleInteractiveToggle(initialStatuses) {
277
314
  if (!status) {
278
315
  return null;
279
316
  }
317
+ const markers = `${account.id === autoSelectedId ? "*" : ""}${account.id === selectedAuthAccountId ? "@" : ""}`;
280
318
  return {
281
319
  ...status,
282
- name: account.id === autoSelectedId ? `${status.name}*` : status.name
320
+ name: markers ? `${status.name}${markers}` : status.name
283
321
  };
284
322
  })
285
323
  .filter((item) => item !== null);
@@ -305,11 +343,12 @@ async function handleInteractiveToggle(initialStatuses) {
305
343
  "",
306
344
  renderSectionHeader("summary", rightWidth, styled),
307
345
  renderSummaryLine(summary, rightWidth < 42, styled),
308
- `selected=${latestSnapshot.selectedName ?? "none"}`,
346
+ `scheduler=${latestSnapshot.selectedName ?? "none"}`,
347
+ `codex_auth=${selectedAuthAccountId ?? "none"}`,
309
348
  ...(refreshStatusText ? [`refresh=${refreshStatusText}`] : []),
310
349
  "",
311
350
  renderSectionHeader("help", rightWidth, styled),
312
- "↑/↓ move Space toggle r refresh Enter/q exit"
351
+ "↑/↓ move Space toggle a app-auth c clear r refresh Enter/q exit"
313
352
  ];
314
353
  if (wideLayout) {
315
354
  renderInteractiveScreen(renderColumns(accountLines, sideLines, 3));
@@ -367,6 +406,30 @@ async function handleInteractiveToggle(initialStatuses) {
367
406
  render();
368
407
  return;
369
408
  }
409
+ if (key.name === "a") {
410
+ const account = accounts[cursor];
411
+ if (!account) {
412
+ return;
413
+ }
414
+ const errorMessage = applyCodexAuthSelection(account);
415
+ if (errorMessage) {
416
+ refreshStatusText = errorMessage;
417
+ render();
418
+ return;
419
+ }
420
+ selectedAuthAccountId = account.id;
421
+ refreshStatusText = `codex_auth=${account.id}`;
422
+ render();
423
+ return;
424
+ }
425
+ if (key.name === "c") {
426
+ selectedAuthAccountId = null;
427
+ (0, state_1.setSelectedCodexAuthAccountId)(null);
428
+ (0, codex_auth_1.deactivateManagedCodexAuth)();
429
+ refreshStatusText = "codex_auth=cleared";
430
+ render();
431
+ return;
432
+ }
370
433
  if (key.name === "r") {
371
434
  if (refreshing) {
372
435
  return;
@@ -419,10 +482,11 @@ async function handleStatus(options) {
419
482
  }
420
483
  const displayStatuses = snapshot.statuses.map((item) => ({
421
484
  ...item,
422
- name: item.id === (0, scheduler_1.pickBestAccount)()?.account.id ? `${item.name}*` : item.name
485
+ name: `${item.name}${item.id === (0, scheduler_1.pickBestAccount)()?.account.id ? "*" : ""}${item.id === snapshot.codexAuthAccountId ? "@" : ""}`
423
486
  }));
424
487
  console.log((0, status_1.renderStatusTable)(displayStatuses));
425
488
  console.log("");
426
489
  console.log(`available=${snapshot.summary.available} 5h_limited=${snapshot.summary.fiveHourLimited} weekly_limited=${snapshot.summary.weeklyLimited}`);
427
- console.log(`selected=${snapshot.selectedName ?? "none"}`);
490
+ console.log(`scheduler=${snapshot.selectedName ?? "none"}`);
491
+ console.log(`codex_auth=${snapshot.codexAuthAccountId ?? "none"}`);
428
492
  }
package/dist/status.js CHANGED
@@ -201,7 +201,7 @@ function styleStatusCell(status, item, styled) {
201
201
  return status;
202
202
  }
203
203
  /**
204
- * 对当前自动选中账号的名称做轻量强调。
204
+ * 对当前自动调度账号或 Codex App 登录态账号的名称做轻量强调。
205
205
  *
206
206
  * @param name 账号展示名称。
207
207
  * @param styled 是否启用 ANSI 样式。
@@ -209,7 +209,7 @@ function styleStatusCell(status, item, styled) {
209
209
  * @throws 无显式抛出。
210
210
  */
211
211
  function styleNameCell(name, styled) {
212
- if (!styled || !name.endsWith("*")) {
212
+ if (!styled || (!name.includes("*") && !name.includes("@"))) {
213
213
  return name;
214
214
  }
215
215
  return styleCell(name, TABLE_ANSI.cyan, styled);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-slot",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",