codex-slot 0.1.8 → 0.1.17

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.
@@ -11,9 +11,13 @@ const node_net_1 = __importDefault(require("node:net"));
11
11
  const node_path_1 = __importDefault(require("node:path"));
12
12
  const node_child_process_1 = require("node:child_process");
13
13
  const undici_1 = require("undici");
14
+ const account_service_1 = require("./account-service");
15
+ const account_store_1 = require("../account-store");
14
16
  const codex_config_1 = require("../codex-config");
17
+ const codex_auth_1 = require("../codex-auth");
15
18
  const cli_helpers_1 = require("../cli-helpers");
16
19
  const config_1 = require("../config");
20
+ const scheduler_1 = require("../scheduler");
17
21
  const STARTUP_POLL_INTERVAL_MS = 100;
18
22
  const STARTUP_TIMEOUT_MS = 5000;
19
23
  /**
@@ -163,6 +167,41 @@ function rollbackFailedStart(pid, previousConfig) {
163
167
  node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
164
168
  (0, config_1.saveConfig)(previousConfig);
165
169
  (0, codex_config_1.deactivateManagedCodexConfig)();
170
+ (0, codex_auth_1.deactivateManagedCodexAuth)();
171
+ }
172
+ /**
173
+ * 选择一个可用于接管主 `~/.codex` 登录态的账号。
174
+ *
175
+ * 业务规则:
176
+ * 1. 优先复用当前调度器已经选中的最佳账号,保证 CLI 与代理请求走同一身份。
177
+ * 2. 若当前没有可调度账号,则回退到首个启用且本地工作空间仍存在的账号。
178
+ * 3. 若仍无可用账号,则返回 `null`,此时仅接管 provider 配置,不强行覆盖主登录态。
179
+ *
180
+ * @returns 选中的受管账号;若不存在可接管账号则返回 `null`。
181
+ * @throws 无显式抛出。
182
+ */
183
+ function resolveManagedAuthAccount() {
184
+ const selected = (0, scheduler_1.pickBestAccount)()?.account;
185
+ if (selected && (0, account_store_1.hasCompleteCodexAuthState)(selected.codex_home)) {
186
+ return selected;
187
+ }
188
+ return ((0, account_service_1.listAccounts)().find((account) => account.enabled &&
189
+ node_fs_1.default.existsSync(account.codex_home) &&
190
+ (0, account_store_1.hasCompleteCodexAuthState)(account.codex_home)) ?? null);
191
+ }
192
+ /**
193
+ * 将主 `~/.codex` 登录态切换到当前受管账号,供 `codex_apps` 等依赖主登录态的链路复用。
194
+ *
195
+ * @returns 实际接管的账号;若没有合适账号则返回 `null`。
196
+ * @throws 当目标账号目录缺少完整登录态时抛出异常。
197
+ */
198
+ function applyManagedAuthIfPossible() {
199
+ const account = resolveManagedAuthAccount();
200
+ if (!account) {
201
+ return null;
202
+ }
203
+ (0, codex_auth_1.applyManagedCodexAuth)(account.codex_home, { sourceAccountId: account.id });
204
+ return account;
166
205
  }
167
206
  /**
168
207
  * 为后台服务挑选最终启动端口。
@@ -225,6 +264,7 @@ async function startManagedService(portOverride) {
225
264
  // 每次真正启动服务前都轮换一次本地 api_key,并让受管 config.toml 使用同一新值。
226
265
  const persistedConfig = (0, config_1.rotateServerApiKey)(config);
227
266
  (0, codex_config_1.applyManagedCodexConfig)(undefined, { config: persistedConfig });
267
+ applyManagedAuthIfPossible();
228
268
  const logPath = (0, config_1.getServiceLogPath)();
229
269
  const logFd = node_fs_1.default.openSync(logPath, "a");
230
270
  const serveEntrypoint = resolveServeEntrypoint();
@@ -267,10 +307,12 @@ function stopManagedService() {
267
307
  const pid = getRunningPid();
268
308
  if (!pid) {
269
309
  (0, codex_config_1.deactivateManagedCodexConfig)();
310
+ (0, codex_auth_1.deactivateManagedCodexAuth)();
270
311
  return { stoppedPid: null };
271
312
  }
272
313
  process.kill(pid, "SIGTERM");
273
314
  node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
274
315
  (0, codex_config_1.deactivateManagedCodexConfig)();
316
+ (0, codex_auth_1.deactivateManagedCodexAuth)();
275
317
  return { stoppedPid: pid };
276
318
  }
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getDefaultCodexHome = getDefaultCodexHome;
7
+ exports.applyManagedCodexAuth = applyManagedCodexAuth;
8
+ exports.deactivateManagedCodexAuth = deactivateManagedCodexAuth;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const account_store_1 = require("./account-store");
12
+ const state_1 = require("./state");
13
+ /**
14
+ * 返回默认的 Codex HOME 目录。
15
+ *
16
+ * @returns 当前进程 HOME;未设置时返回空字符串。
17
+ */
18
+ function getDefaultCodexHome() {
19
+ return process.env.HOME ?? "";
20
+ }
21
+ /**
22
+ * 读取目标 HOME 下 `.codex/accounts` 目录中的所有 `.auth.json` 文件内容。
23
+ *
24
+ * @param codexHome 目标 HOME 目录。
25
+ * @returns 文件名到原始文本的映射;目录不存在时返回空对象。
26
+ */
27
+ function snapshotAccountAuthFiles(codexHome) {
28
+ const accountsDir = node_path_1.default.join((0, account_store_1.getCodexDataDir)(codexHome), "accounts");
29
+ if (!node_fs_1.default.existsSync(accountsDir)) {
30
+ return {};
31
+ }
32
+ const snapshots = {};
33
+ for (const entry of node_fs_1.default.readdirSync(accountsDir, { withFileTypes: true })) {
34
+ if (!entry.isFile() || !entry.name.endsWith(".auth.json")) {
35
+ continue;
36
+ }
37
+ snapshots[entry.name] = node_fs_1.default.readFileSync(node_path_1.default.join(accountsDir, entry.name), "utf8");
38
+ }
39
+ return snapshots;
40
+ }
41
+ /**
42
+ * 基于当前目标 HOME 生成登录态恢复快照。
43
+ *
44
+ * @param targetHome 需要接管的主 HOME 目录。
45
+ * @param sourceAccountId 可选的来源账号标识,仅用于状态记录。
46
+ * @returns 用于 stop 恢复的快照。
47
+ */
48
+ function buildManagedAuthSnapshot(targetHome, sourceAccountId) {
49
+ const codexDir = (0, account_store_1.getCodexDataDir)(targetHome);
50
+ const authPath = node_path_1.default.join(codexDir, "auth.json");
51
+ const registryPath = node_path_1.default.join(codexDir, "accounts", "registry.json");
52
+ return {
53
+ target_home: targetHome,
54
+ source_account_id: sourceAccountId ?? null,
55
+ original_auth_file: node_fs_1.default.existsSync(authPath) ? node_fs_1.default.readFileSync(authPath, "utf8") : null,
56
+ original_registry_file: node_fs_1.default.existsSync(registryPath) ? node_fs_1.default.readFileSync(registryPath, "utf8") : null,
57
+ original_account_auth_files: snapshotAccountAuthFiles(targetHome)
58
+ };
59
+ }
60
+ /**
61
+ * 将指定文件恢复为快照内容;快照为空时删除目标文件。
62
+ *
63
+ * @param targetFile 目标文件路径。
64
+ * @param content 原始文件内容;为 `null` 时表示恢复为不存在。
65
+ * @returns 无返回值。
66
+ */
67
+ function restoreSnapshotFile(targetFile, content) {
68
+ if (content === null) {
69
+ node_fs_1.default.rmSync(targetFile, { force: true });
70
+ return;
71
+ }
72
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(targetFile), { recursive: true });
73
+ node_fs_1.default.writeFileSync(targetFile, content, "utf8");
74
+ }
75
+ /**
76
+ * 将主 HOME 中多余的 `.auth.json` 文件删除,再恢复快照中记录的文件集合。
77
+ *
78
+ * @param targetHome 目标 HOME 目录。
79
+ * @param snapshot 登录态快照。
80
+ * @returns 无返回值。
81
+ */
82
+ function restoreAccountAuthFiles(targetHome, snapshot) {
83
+ const accountsDir = node_path_1.default.join((0, account_store_1.getCodexDataDir)(targetHome), "accounts");
84
+ node_fs_1.default.mkdirSync(accountsDir, { recursive: true });
85
+ for (const entry of node_fs_1.default.readdirSync(accountsDir, { withFileTypes: true })) {
86
+ if (!entry.isFile() || !entry.name.endsWith(".auth.json")) {
87
+ continue;
88
+ }
89
+ if (!(entry.name in snapshot.original_account_auth_files)) {
90
+ node_fs_1.default.rmSync(node_path_1.default.join(accountsDir, entry.name), { force: true });
91
+ }
92
+ }
93
+ for (const [fileName, content] of Object.entries(snapshot.original_account_auth_files)) {
94
+ restoreSnapshotFile(node_path_1.default.join(accountsDir, fileName), content);
95
+ }
96
+ }
97
+ /**
98
+ * 将主 `~/.codex` 登录态切换到指定受管账号,供 `codex_apps` 等依赖主登录态的能力复用。
99
+ *
100
+ * @param sourceHome 来源账号 HOME。
101
+ * @param options 可选控制项;可指定目标 HOME 与来源账号标识。
102
+ * @returns 实际接管的目标 HOME 路径。
103
+ */
104
+ function applyManagedCodexAuth(sourceHome, options) {
105
+ const targetHome = options?.targetHome ?? getDefaultCodexHome();
106
+ const previousState = (0, state_1.getManagedCodexAuthState)();
107
+ const snapshot = previousState && previousState.target_home === targetHome
108
+ ? previousState
109
+ : buildManagedAuthSnapshot(targetHome, options?.sourceAccountId);
110
+ (0, account_store_1.cloneCodexAuthState)(sourceHome, targetHome);
111
+ (0, state_1.setManagedCodexAuthState)({
112
+ ...snapshot,
113
+ source_account_id: options?.sourceAccountId ?? snapshot.source_account_id ?? null
114
+ });
115
+ return targetHome;
116
+ }
117
+ /**
118
+ * 恢复主 `~/.codex` 登录态到 cslot 接管前的原始状态。
119
+ *
120
+ * @returns 恢复的目标 HOME 路径;若没有接管快照则返回 `null`。
121
+ */
122
+ function deactivateManagedCodexAuth() {
123
+ const snapshot = (0, state_1.getManagedCodexAuthState)();
124
+ if (!snapshot) {
125
+ return null;
126
+ }
127
+ const targetHome = snapshot.target_home;
128
+ const codexDir = (0, account_store_1.getCodexDataDir)(targetHome);
129
+ restoreSnapshotFile(node_path_1.default.join(codexDir, "auth.json"), snapshot.original_auth_file);
130
+ restoreSnapshotFile(node_path_1.default.join(codexDir, "accounts", "registry.json"), snapshot.original_registry_file);
131
+ restoreAccountAuthFiles(targetHome, snapshot);
132
+ (0, state_1.clearManagedCodexAuthState)();
133
+ return targetHome;
134
+ }
@@ -73,9 +73,31 @@ function buildManagedProviderBlock(eol, config) {
73
73
  `base_url = "http://${config.server.host}:${config.server.port}/v1"`,
74
74
  'wire_api = "responses"',
75
75
  `experimental_bearer_token = "${config.server.api_key}"`,
76
+ "[model_providers.cslot.http_headers]",
77
+ `Authorization = "Bearer ${config.server.api_key}"`,
76
78
  PROVIDER_BLOCK_END_MARKER
77
79
  ].join(eol);
78
80
  }
81
+ /**
82
+ * 判断候选表头是否属于指定父表的子表。
83
+ *
84
+ * @param parentHeader 父表表头,例如 `[model_providers.cslot]`。
85
+ * @param candidateHeader 当前扫描到的候选表头。
86
+ * @returns `true` 表示候选表头属于父表子表;否则返回 `false`。
87
+ */
88
+ function isChildTableHeader(parentHeader, candidateHeader) {
89
+ const normalizedParent = parentHeader.trim();
90
+ const normalizedCandidate = candidateHeader.trim();
91
+ if (!normalizedParent.startsWith("[") || !normalizedParent.endsWith("]")) {
92
+ return false;
93
+ }
94
+ if (!normalizedCandidate.startsWith("[") || !normalizedCandidate.endsWith("]")) {
95
+ return false;
96
+ }
97
+ const parentName = normalizedParent.slice(1, -1);
98
+ const candidateName = normalizedCandidate.slice(1, -1);
99
+ return candidateName.startsWith(`${parentName}.`);
100
+ }
79
101
  /**
80
102
  * 在文本中定位受 cslot 接管的块范围。
81
103
  *
@@ -103,29 +125,22 @@ function findMarkedBlockRange(content, startMarker, endMarker) {
103
125
  return { start, end };
104
126
  }
105
127
  /**
106
- * 恢复文本中由 cslot 管理的配置块,得到接管前的原始内容基线。
128
+ * 反复移除文本中所有带指定标记的受管块,避免异常退出后残留旧块导致后续写入出现重复或串位。
107
129
  *
108
130
  * @param content 当前 `config.toml` 内容。
109
- * @param managedState 上一次接管时保存的原始片段快照。
110
- * @returns 恢复后的文本内容。
131
+ * @param startMarker 块起始标记。
132
+ * @param endMarker 块结束标记。
133
+ * @returns 清理后的文本内容。
111
134
  */
112
- function restoreManagedContent(content, managedState) {
113
- let restored = content;
114
- const providerRange = findMarkedBlockRange(restored, PROVIDER_BLOCK_START_MARKER, PROVIDER_BLOCK_END_MARKER);
115
- if (providerRange) {
116
- restored =
117
- restored.slice(0, providerRange.start) +
118
- (managedState.original_cslot_provider_block ?? "") +
119
- restored.slice(providerRange.end);
120
- }
121
- const modelProviderRange = findMarkedBlockRange(restored, MODEL_PROVIDER_START_MARKER, MODEL_PROVIDER_END_MARKER);
122
- if (modelProviderRange) {
123
- restored =
124
- restored.slice(0, modelProviderRange.start) +
125
- (managedState.original_model_provider_block ?? "") +
126
- restored.slice(modelProviderRange.end);
135
+ function stripMarkedBlocks(content, startMarker, endMarker) {
136
+ let stripped = content;
137
+ while (true) {
138
+ const range = findMarkedBlockRange(stripped, startMarker, endMarker);
139
+ if (!range) {
140
+ return stripped;
141
+ }
142
+ stripped = stripped.slice(0, range.start) + stripped.slice(range.end);
127
143
  }
128
- return restored;
129
144
  }
130
145
  /**
131
146
  * 查找首个 `model_provider` 配置块,兼容已启用与注释掉的场景。
@@ -183,12 +198,13 @@ function findModelProviderLine(content) {
183
198
  return null;
184
199
  }
185
200
  /**
186
- * 查找 `[model_providers.cslot]` 表块的文本范围。
201
+ * 查找指定表块的文本范围。
187
202
  *
188
203
  * @param content 当前 `config.toml` 内容。
204
+ * @param header 目标表头,例如 `[model_providers.cslot]`。
189
205
  * @returns 命中时返回完整表块范围;未命中返回 `null`。
190
206
  */
191
- function findProviderSectionRange(content) {
207
+ function findTableSectionRange(content, header) {
192
208
  const lines = content.split(/\r?\n/);
193
209
  let offset = 0;
194
210
  let startLineIndex = -1;
@@ -196,7 +212,7 @@ function findProviderSectionRange(content) {
196
212
  for (let i = 0; i < lines.length; i += 1) {
197
213
  const line = lines[i];
198
214
  const lineEnd = offset + line.length;
199
- if (line.trim() === "[model_providers.cslot]") {
215
+ if (line.trim() === header) {
200
216
  startLineIndex = i;
201
217
  startOffset = offset;
202
218
  break;
@@ -212,7 +228,10 @@ function findProviderSectionRange(content) {
212
228
  const line = lines[i];
213
229
  const lineEnd = offset + line.length;
214
230
  const trimmed = line.trim();
215
- if (i > startLineIndex && trimmed.startsWith("[") && !trimmed.startsWith("[[")) {
231
+ if (i > startLineIndex &&
232
+ trimmed.startsWith("[") &&
233
+ !trimmed.startsWith("[[") &&
234
+ !isChildTableHeader(header, trimmed)) {
216
235
  break;
217
236
  }
218
237
  endOffset = lineEnd;
@@ -234,6 +253,164 @@ function findProviderSectionRange(content) {
234
253
  value: content.slice(startOffset, endOffset)
235
254
  };
236
255
  }
256
+ /**
257
+ * 查找 `[model_providers.cslot]` 表块的文本范围。
258
+ *
259
+ * @param content 当前 `config.toml` 内容。
260
+ * @returns 命中时返回完整表块范围;未命中返回 `null`。
261
+ */
262
+ function findProviderSectionRange(content) {
263
+ return findTableSectionRange(content, "[model_providers.cslot]");
264
+ }
265
+ /**
266
+ * 查找指定表头所在的行起始偏移。
267
+ *
268
+ * @param content 当前 `config.toml` 内容。
269
+ * @param header 目标表头。
270
+ * @returns 命中时返回表头行起始偏移;未命中返回 `null`。
271
+ */
272
+ function findTableHeaderOffset(content, header) {
273
+ const lines = content.split(/\r?\n/);
274
+ let offset = 0;
275
+ for (const line of lines) {
276
+ const lineEnd = offset + line.length;
277
+ if (line.trim() === header) {
278
+ return offset;
279
+ }
280
+ offset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
281
+ }
282
+ return null;
283
+ }
284
+ /**
285
+ * 查找指定偏移之前最近的表头,供恢复原有表块位置时作为后备锚点。
286
+ *
287
+ * @param content 当前 `config.toml` 内容。
288
+ * @param offset 截止偏移。
289
+ * @returns 最近的表头文本;未命中返回 `null`。
290
+ */
291
+ function findPreviousTableHeaderBeforeOffset(content, offset) {
292
+ const lines = content.split(/\r?\n/);
293
+ let currentOffset = 0;
294
+ let previousHeader = null;
295
+ for (const line of lines) {
296
+ const lineEnd = currentOffset + line.length;
297
+ const trimmed = line.trim();
298
+ if (currentOffset >= offset) {
299
+ break;
300
+ }
301
+ if (trimmed.startsWith("[") && !trimmed.startsWith("[[") && !trimmed.startsWith("#")) {
302
+ previousHeader = trimmed;
303
+ }
304
+ currentOffset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
305
+ }
306
+ return previousHeader;
307
+ }
308
+ /**
309
+ * 查找指定偏移之后的首个表头,供恢复原有表块位置时作为优先锚点。
310
+ *
311
+ * @param content 当前 `config.toml` 内容。
312
+ * @param offset 起始偏移。
313
+ * @returns 首个后续表头文本;未命中返回 `null`。
314
+ */
315
+ function findNextTableHeaderAfterOffset(content, offset) {
316
+ const lines = content.split(/\r?\n/);
317
+ let currentOffset = 0;
318
+ for (const line of lines) {
319
+ const lineEnd = currentOffset + line.length;
320
+ const trimmed = line.trim();
321
+ if (currentOffset >= offset &&
322
+ trimmed.startsWith("[") &&
323
+ !trimmed.startsWith("[[") &&
324
+ !trimmed.startsWith("#")) {
325
+ return trimmed;
326
+ }
327
+ currentOffset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
328
+ }
329
+ return null;
330
+ }
331
+ /**
332
+ * 清理文本中的所有 `model_provider` 配置块,确保每次接管都以单一稳定块重新写入。
333
+ *
334
+ * @param content 当前 `config.toml` 内容。
335
+ * @returns 移除后的文本内容。
336
+ */
337
+ function removeAllModelProviderLines(content) {
338
+ let nextContent = content;
339
+ while (true) {
340
+ const range = findModelProviderLine(nextContent);
341
+ if (!range) {
342
+ return nextContent;
343
+ }
344
+ nextContent = nextContent.slice(0, range.start) + nextContent.slice(range.end);
345
+ }
346
+ }
347
+ /**
348
+ * 清理文本中的所有 `[model_providers.cslot]` 表块,避免残留旧块影响下一段配置。
349
+ *
350
+ * @param content 当前 `config.toml` 内容。
351
+ * @returns 移除后的文本内容。
352
+ */
353
+ function removeAllProviderSections(content) {
354
+ let nextContent = content;
355
+ while (true) {
356
+ const range = findProviderSectionRange(nextContent);
357
+ if (!range) {
358
+ return nextContent;
359
+ }
360
+ nextContent = nextContent.slice(0, range.start) + nextContent.slice(range.end);
361
+ }
362
+ }
363
+ /**
364
+ * 移除所有 cslot 受管标记块,得到不包含历史残留接管片段的基线内容。
365
+ *
366
+ * @param content 当前 `config.toml` 内容。
367
+ * @returns 清理后的基线内容。
368
+ */
369
+ function stripAllManagedBlocks(content) {
370
+ const withoutProviderBlock = stripMarkedBlocks(content, PROVIDER_BLOCK_START_MARKER, PROVIDER_BLOCK_END_MARKER);
371
+ return stripMarkedBlocks(withoutProviderBlock, MODEL_PROVIDER_START_MARKER, MODEL_PROVIDER_END_MARKER);
372
+ }
373
+ /**
374
+ * 查找根级配置区的尾部插入点。
375
+ *
376
+ * 业务规则:
377
+ * 1. 若文件中存在 table header,则插入到首个 table 之前,保证 `model_provider` 仍处于根级作用域。
378
+ * 2. 若文件不存在任何 table,则允许直接追加到文件尾部。
379
+ *
380
+ * @param content 当前 `config.toml` 内容。
381
+ * @returns 可用于插入根级配置块的偏移位置。
382
+ */
383
+ function findRootSectionInsertOffset(content) {
384
+ const lines = content.split(/\r?\n/);
385
+ let offset = 0;
386
+ for (const line of lines) {
387
+ const lineEnd = offset + line.length;
388
+ const trimmed = line.trim();
389
+ if (trimmed.startsWith("[") && !trimmed.startsWith("#")) {
390
+ return offset;
391
+ }
392
+ offset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
393
+ }
394
+ return content.length;
395
+ }
396
+ /**
397
+ * 将根级配置块插回根级区域。
398
+ *
399
+ * 若能命中原始记录的后续表头,则优先插回该表头前;否则回退到首个表头前。
400
+ *
401
+ * @param content 当前 `config.toml` 内容。
402
+ * @param block 待插入的根级配置块。
403
+ * @param eol 目标换行符。
404
+ * @param preferredNextTableHeader 原始记录的后续表头锚点。
405
+ * @returns 插入后的完整文本。
406
+ */
407
+ function insertRootBlock(content, block, eol, preferredNextTableHeader) {
408
+ const preferredOffset = preferredNextTableHeader
409
+ ? findTableHeaderOffset(content, preferredNextTableHeader)
410
+ : null;
411
+ const insertOffset = preferredOffset ?? findRootSectionInsertOffset(content);
412
+ return insertBlockBetween(content.slice(0, insertOffset), block, content.slice(insertOffset), eol);
413
+ }
237
414
  /**
238
415
  * 将指定文本规范为单个块插入形式,避免在块两侧不断叠加多余空行。
239
416
  *
@@ -248,6 +425,103 @@ function insertBlockBetween(before, block, after, eol) {
248
425
  const normalizedAfter = after.startsWith(eol) || after.length === 0 ? after : `${eol}${after}`;
249
426
  return `${normalizedBefore}${block}${normalizedAfter}`;
250
427
  }
428
+ /**
429
+ * 将配置块稳定追加到文件尾部,统一清理尾部多余空行,避免多次接管后空行不断累积。
430
+ *
431
+ * @param content 当前 `config.toml` 内容。
432
+ * @param block 待追加配置块。
433
+ * @param eol 目标换行符。
434
+ * @returns 追加后的完整文本。
435
+ */
436
+ function appendBlockToEnd(content, block, eol) {
437
+ let trimmed = content;
438
+ while (trimmed.endsWith(eol)) {
439
+ trimmed = trimmed.slice(0, -eol.length);
440
+ }
441
+ if (trimmed.length === 0) {
442
+ return `${block}${eol}`;
443
+ }
444
+ return `${trimmed}${eol}${eol}${block}${eol}`;
445
+ }
446
+ /**
447
+ * 将表块尽量插回原有相邻表头附近;若锚点已不存在,则退回文件尾部追加。
448
+ *
449
+ * @param content 当前 `config.toml` 内容。
450
+ * @param block 待插入的表块。
451
+ * @param eol 目标换行符。
452
+ * @param preferredNextTableHeader 原始后续表头锚点,命中时优先插到该表之前。
453
+ * @param preferredPreviousTableHeader 原始前驱表头锚点,当前者失效时插到该表之后。
454
+ * @returns 插入后的完整文本。
455
+ */
456
+ function insertTableBlock(content, block, eol, preferredNextTableHeader, preferredPreviousTableHeader) {
457
+ if (preferredNextTableHeader) {
458
+ const nextOffset = findTableHeaderOffset(content, preferredNextTableHeader);
459
+ if (nextOffset !== null) {
460
+ return insertBlockBetween(content.slice(0, nextOffset), block, content.slice(nextOffset), eol);
461
+ }
462
+ }
463
+ if (preferredPreviousTableHeader) {
464
+ const previousRange = findTableSectionRange(content, preferredPreviousTableHeader);
465
+ if (previousRange) {
466
+ return insertBlockBetween(content.slice(0, previousRange.end), block, content.slice(previousRange.end), eol);
467
+ }
468
+ }
469
+ return appendBlockToEnd(content, block, eol);
470
+ }
471
+ /**
472
+ * 解析当前目标文件对应的上一轮接管快照。
473
+ *
474
+ * @param targetFile 当前准备接管或恢复的 `config.toml` 路径。
475
+ * @returns 命中同一目标文件时返回上一轮快照;否则返回 `null`。
476
+ */
477
+ function resolveManagedStateForTarget(targetFile) {
478
+ const managedState = (0, state_1.getManagedCodexConfigState)();
479
+ if (!managedState || managedState.target_file !== targetFile) {
480
+ return null;
481
+ }
482
+ return managedState;
483
+ }
484
+ /**
485
+ * 基于当前未受管的配置文本与上一轮快照,生成本轮接管所需的最小恢复快照。
486
+ *
487
+ * 业务规则:
488
+ * 1. 优先记录当前文件里实际存在的原始 `model_provider` 与 `[model_providers.cslot]`。
489
+ * 2. 若当前文件只剩残留受管块,允许继承上一轮快照中的原始片段。
490
+ * 3. 仅保存 cslot 自己声明所有权的两块配置及其锚点,不保存整文件内容。
491
+ *
492
+ * @param targetFile 当前准备接管的 `config.toml` 路径。
493
+ * @param strippedCurrent 已移除受管标记块后的配置文本。
494
+ * @param previousManagedState 同一目标文件的上一轮快照;不存在时传 `null`。
495
+ * @returns 本轮接管后用于 stop 恢复的快照。
496
+ */
497
+ function buildManagedSnapshot(targetFile, strippedCurrent, previousManagedState) {
498
+ const originalModelProviderLine = findModelProviderLine(strippedCurrent);
499
+ const originalProviderSection = findProviderSectionRange(strippedCurrent);
500
+ return {
501
+ target_file: targetFile,
502
+ original_model_provider_block: originalModelProviderLine?.value ??
503
+ previousManagedState?.original_model_provider_block ??
504
+ null,
505
+ original_model_provider_next_table_header: (originalModelProviderLine
506
+ ? findNextTableHeaderAfterOffset(strippedCurrent, originalModelProviderLine.end)
507
+ : null) ??
508
+ previousManagedState?.original_model_provider_next_table_header ??
509
+ null,
510
+ original_cslot_provider_block: originalProviderSection?.value ??
511
+ previousManagedState?.original_cslot_provider_block ??
512
+ null,
513
+ original_cslot_provider_previous_table_header: (originalProviderSection
514
+ ? findPreviousTableHeaderBeforeOffset(strippedCurrent, originalProviderSection.start)
515
+ : null) ??
516
+ previousManagedState?.original_cslot_provider_previous_table_header ??
517
+ null,
518
+ original_cslot_provider_next_table_header: (originalProviderSection
519
+ ? findNextTableHeaderAfterOffset(strippedCurrent, originalProviderSection.end)
520
+ : null) ??
521
+ previousManagedState?.original_cslot_provider_next_table_header ??
522
+ null
523
+ };
524
+ }
251
525
  /**
252
526
  * 将 cslot 需要的 provider 配置写入指定 `config.toml`,并保存恢复快照。
253
527
  *
@@ -260,51 +534,16 @@ function applyManagedCodexConfig(targetPathOrDir, options) {
260
534
  const rawTarget = targetPathOrDir ? (0, config_1.expandHome)(targetPathOrDir) : getDefaultCodexConfigPath();
261
535
  const targetFile = rawTarget.endsWith(".toml") ? rawTarget : node_path_1.default.join(rawTarget, "config.toml");
262
536
  const current = node_fs_1.default.existsSync(targetFile) ? node_fs_1.default.readFileSync(targetFile, "utf8") : "";
263
- const previousManagedState = (0, state_1.getManagedCodexConfigState)();
264
- const baseContent = previousManagedState && previousManagedState.target_file === targetFile
265
- ? restoreManagedContent(current, previousManagedState)
266
- : current;
267
- const eol = detectEol(baseContent);
268
- const originalModelProviderLine = findModelProviderLine(baseContent);
269
- const originalProviderSection = findProviderSectionRange(baseContent);
270
- const snapshot = {
271
- target_file: targetFile,
272
- original_model_provider_block: originalModelProviderLine?.value ?? null,
273
- original_cslot_provider_block: originalProviderSection?.value ?? null
274
- };
537
+ const previousManagedState = resolveManagedStateForTarget(targetFile);
538
+ const strippedCurrent = stripAllManagedBlocks(current);
539
+ const eol = detectEol(strippedCurrent);
540
+ const snapshot = buildManagedSnapshot(targetFile, strippedCurrent, previousManagedState);
275
541
  const config = options?.config ?? (0, config_1.loadConfig)();
276
- let nextContent = baseContent;
277
542
  const managedModelProviderBlock = buildManagedModelProviderBlock(eol);
278
543
  const managedProviderBlock = buildManagedProviderBlock(eol, config);
279
- // 先处理 provider 表块,再处理 model_provider 行,避免前面的插入导致后续偏移失效。
280
- if (originalProviderSection) {
281
- nextContent =
282
- nextContent.slice(0, originalProviderSection.start) +
283
- managedProviderBlock +
284
- nextContent.slice(originalProviderSection.end);
285
- }
286
- else if (nextContent.length > 0) {
287
- nextContent = insertBlockBetween(nextContent, managedProviderBlock, "", eol);
288
- }
289
- else {
290
- nextContent = `${managedProviderBlock}${eol}`;
291
- }
292
- const modelProviderLine = findModelProviderLine(nextContent);
293
- if (modelProviderLine) {
294
- nextContent =
295
- nextContent.slice(0, modelProviderLine.start) +
296
- managedModelProviderBlock +
297
- nextContent.slice(modelProviderLine.end);
298
- }
299
- else {
300
- const firstNonWhitespaceMatch = nextContent.match(/\S/);
301
- if (firstNonWhitespaceMatch && firstNonWhitespaceMatch.index !== undefined) {
302
- nextContent = insertBlockBetween(nextContent.slice(0, firstNonWhitespaceMatch.index), managedModelProviderBlock, nextContent.slice(firstNonWhitespaceMatch.index), eol);
303
- }
304
- else {
305
- nextContent = `${managedModelProviderBlock}${eol}`;
306
- }
307
- }
544
+ const cleanedBaseContent = removeAllProviderSections(removeAllModelProviderLines(strippedCurrent));
545
+ let nextContent = insertRootBlock(cleanedBaseContent, managedModelProviderBlock, eol, snapshot.original_model_provider_next_table_header);
546
+ nextContent = appendBlockToEnd(nextContent, managedProviderBlock, eol);
308
547
  if (!nextContent.endsWith(eol)) {
309
548
  nextContent = `${nextContent}${eol}`;
310
549
  }
@@ -335,7 +574,16 @@ function deactivateManagedCodexConfig() {
335
574
  return null;
336
575
  }
337
576
  const current = node_fs_1.default.readFileSync(targetFile, "utf8");
338
- const restored = restoreManagedContent(current, managedState);
577
+ const eol = detectEol(current);
578
+ let restored = stripAllManagedBlocks(current);
579
+ const existingModelProviderLine = findModelProviderLine(restored);
580
+ if (!existingModelProviderLine && managedState.original_model_provider_block) {
581
+ restored = insertRootBlock(restored, managedState.original_model_provider_block, eol, managedState.original_model_provider_next_table_header);
582
+ }
583
+ const existingProviderSection = findProviderSectionRange(restored);
584
+ if (!existingProviderSection && managedState.original_cslot_provider_block) {
585
+ restored = insertTableBlock(restored, managedState.original_cslot_provider_block, eol, managedState.original_cslot_provider_next_table_header, managedState.original_cslot_provider_previous_table_header);
586
+ }
339
587
  writeFileAtomic(targetFile, restored);
340
588
  (0, state_1.clearManagedCodexConfigState)();
341
589
  return targetFile;
package/dist/state.js CHANGED
@@ -10,9 +10,15 @@ exports.pruneExpiredBlocks = pruneExpiredBlocks;
10
10
  exports.getAccountBlock = getAccountBlock;
11
11
  exports.setUsageCache = setUsageCache;
12
12
  exports.getUsageCache = getUsageCache;
13
+ exports.setUsageRefreshError = setUsageRefreshError;
14
+ exports.clearUsageRefreshError = clearUsageRefreshError;
15
+ exports.getUsageRefreshError = getUsageRefreshError;
13
16
  exports.getManagedCodexConfigState = getManagedCodexConfigState;
17
+ exports.getManagedCodexAuthState = getManagedCodexAuthState;
14
18
  exports.setManagedCodexConfigState = setManagedCodexConfigState;
19
+ exports.setManagedCodexAuthState = setManagedCodexAuthState;
15
20
  exports.clearManagedCodexConfigState = clearManagedCodexConfigState;
21
+ exports.clearManagedCodexAuthState = clearManagedCodexAuthState;
16
22
  const node_fs_1 = __importDefault(require("node:fs"));
17
23
  const node_path_1 = __importDefault(require("node:path"));
18
24
  const config_1 = require("./config");
@@ -30,6 +36,8 @@ function loadState() {
30
36
  return {
31
37
  account_blocks: {},
32
38
  usage_cache: {},
39
+ usage_refresh_errors: {},
40
+ managed_codex_auth: null,
33
41
  managed_codex_config: null
34
42
  };
35
43
  }
@@ -39,11 +47,15 @@ function loadState() {
39
47
  : {
40
48
  account_blocks: {},
41
49
  usage_cache: {},
50
+ usage_refresh_errors: {},
51
+ managed_codex_auth: null,
42
52
  managed_codex_config: null
43
53
  };
44
54
  return {
45
55
  account_blocks: parsed.account_blocks ?? {},
46
56
  usage_cache: parsed.usage_cache ?? {},
57
+ usage_refresh_errors: parsed.usage_refresh_errors ?? {},
58
+ managed_codex_auth: parsed.managed_codex_auth ?? null,
47
59
  managed_codex_config: parsed.managed_codex_config ?? null
48
60
  };
49
61
  }
@@ -125,6 +137,41 @@ function getUsageCache(accountId) {
125
137
  const state = loadState();
126
138
  return state.usage_cache[accountId] ?? null;
127
139
  }
140
+ /**
141
+ * 记录指定账号最近一次额度刷新失败的状态,供 `status` 命令渲染为账号状态而不是直接打印异常。
142
+ *
143
+ * @param usageError 刷新失败信息,包含账号、状态码与原始错误摘要。
144
+ * @returns 无返回值。
145
+ */
146
+ function setUsageRefreshError(usageError) {
147
+ const state = loadState();
148
+ state.usage_refresh_errors[usageError.accountId] = usageError;
149
+ saveState(state);
150
+ }
151
+ /**
152
+ * 清理指定账号最近一次记录的额度刷新失败状态,避免后续成功刷新后继续展示旧错误。
153
+ *
154
+ * @param accountId 账号标识。
155
+ * @returns 无返回值。
156
+ */
157
+ function clearUsageRefreshError(accountId) {
158
+ const state = loadState();
159
+ if (!(accountId in state.usage_refresh_errors)) {
160
+ return;
161
+ }
162
+ delete state.usage_refresh_errors[accountId];
163
+ saveState(state);
164
+ }
165
+ /**
166
+ * 读取指定账号最近一次记录的额度刷新失败状态。
167
+ *
168
+ * @param accountId 账号标识。
169
+ * @returns 刷新失败信息;若不存在则返回 `null`。
170
+ */
171
+ function getUsageRefreshError(accountId) {
172
+ const state = loadState();
173
+ return state.usage_refresh_errors[accountId] ?? null;
174
+ }
128
175
  /**
129
176
  * 读取当前记录的 Codex `config.toml` 接管快照。
130
177
  *
@@ -134,6 +181,15 @@ function getManagedCodexConfigState() {
134
181
  const state = loadState();
135
182
  return state.managed_codex_config ?? null;
136
183
  }
184
+ /**
185
+ * 读取当前记录的 Codex 主 HOME 登录态接管快照。
186
+ *
187
+ * @returns 最近一次接管时保存的登录态快照;不存在时返回 `null`。
188
+ */
189
+ function getManagedCodexAuthState() {
190
+ const state = loadState();
191
+ return state.managed_codex_auth ?? null;
192
+ }
137
193
  /**
138
194
  * 保存 Codex `config.toml` 接管快照,用于后续停止服务时精确恢复。
139
195
  *
@@ -145,6 +201,17 @@ function setManagedCodexConfigState(managedState) {
145
201
  state.managed_codex_config = managedState;
146
202
  saveState(state);
147
203
  }
204
+ /**
205
+ * 保存 Codex 主 HOME 登录态接管快照,用于 stop 时恢复原始登录态文件。
206
+ *
207
+ * @param managedState 接管前保存的原始登录态快照。
208
+ * @returns 无返回值。
209
+ */
210
+ function setManagedCodexAuthState(managedState) {
211
+ const state = loadState();
212
+ state.managed_codex_auth = managedState;
213
+ saveState(state);
214
+ }
148
215
  /**
149
216
  * 清理 Codex `config.toml` 接管快照。
150
217
  *
@@ -155,3 +222,13 @@ function clearManagedCodexConfigState() {
155
222
  state.managed_codex_config = null;
156
223
  saveState(state);
157
224
  }
225
+ /**
226
+ * 清理 Codex 主 HOME 登录态接管快照。
227
+ *
228
+ * @returns 无返回值。
229
+ */
230
+ function clearManagedCodexAuthState() {
231
+ const state = loadState();
232
+ state.managed_codex_auth = null;
233
+ saveState(state);
234
+ }
@@ -10,6 +10,87 @@ const status_service_1 = require("./app/status-service");
10
10
  const scheduler_1 = require("./scheduler");
11
11
  const status_1 = require("./status");
12
12
  const text_1 = require("./text");
13
+ const ANSI = {
14
+ reset: "\x1b[0m",
15
+ bold: "\x1b[1m",
16
+ dim: "\x1b[2m",
17
+ cyan: "\x1b[36m",
18
+ green: "\x1b[32m",
19
+ yellow: "\x1b[33m"
20
+ };
21
+ /**
22
+ * 判断当前终端是否适合启用 ANSI 样式,避免在 dumb/no-color 环境输出控制字符。
23
+ *
24
+ * @returns 可安全启用样式时返回 `true`,否则返回 `false`。
25
+ * @throws 无显式抛出。
26
+ */
27
+ function shouldUseAnsiStyle() {
28
+ return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
29
+ }
30
+ /**
31
+ * 对文本应用 ANSI 样式;当样式关闭时原样返回。
32
+ *
33
+ * @param text 原始文本。
34
+ * @param color ANSI 颜色码。
35
+ * @param enabled 是否启用 ANSI 样式。
36
+ * @returns 样式化后的文本或原文。
37
+ * @throws 无显式抛出。
38
+ */
39
+ function paint(text, color, enabled) {
40
+ if (!enabled) {
41
+ return text;
42
+ }
43
+ return `${color}${text}${ANSI.reset}`;
44
+ }
45
+ /**
46
+ * 渲染分区标题行,兼容窄终端与普通宽度终端。
47
+ *
48
+ * @param title 分区标题文本。
49
+ * @param width 当前终端宽度。
50
+ * @param styled 是否启用 ANSI 样式。
51
+ * @returns 可直接打印的单行分区标题。
52
+ * @throws 无显式抛出。
53
+ */
54
+ function renderSectionHeader(title, width, styled) {
55
+ if (width < 44) {
56
+ return paint(`[ ${title} ]`, ANSI.cyan, styled);
57
+ }
58
+ const plainLabel = ` ${title} `;
59
+ const targetWidth = Math.max(plainLabel.length + 2, Math.min(width, 96));
60
+ const side = Math.max(1, Math.floor((targetWidth - plainLabel.length) / 2));
61
+ const line = `${"-".repeat(side)}${plainLabel}${"-".repeat(side)}`;
62
+ return paint(line.slice(0, targetWidth), ANSI.cyan, styled);
63
+ }
64
+ /**
65
+ * 渲染轻量分隔线,用于账号主表与当前账号明细之间建立清晰层次。
66
+ *
67
+ * @param width 当前终端宽度。
68
+ * @param styled 是否启用 ANSI 样式。
69
+ * @returns 可直接打印的分隔线文本。
70
+ * @throws 无显式抛出。
71
+ */
72
+ function renderDivider(width, styled) {
73
+ const dividerWidth = Math.max(24, Math.min(width, 96));
74
+ return paint("-".repeat(dividerWidth), ANSI.dim, styled);
75
+ }
76
+ /**
77
+ * 渲染摘要区可读性更高的计数文本,并对关键指标做轻量着色。
78
+ *
79
+ * @param summary 状态摘要计数。
80
+ * @param narrowScreen 是否窄屏布局。
81
+ * @param styled 是否启用 ANSI 样式。
82
+ * @returns 摘要展示文本。
83
+ * @throws 无显式抛出。
84
+ */
85
+ function renderSummaryLine(summary, narrowScreen, styled) {
86
+ const available = paint(String(summary.available), ANSI.green, styled);
87
+ const fiveHourLimited = paint(String(summary.fiveHourLimited), ANSI.yellow, styled);
88
+ const weeklyLimited = paint(String(summary.weeklyLimited), ANSI.yellow, styled);
89
+ if (narrowScreen) {
90
+ return `ok=${available} 5h=${fiveHourLimited} wk=${weeklyLimited}`;
91
+ }
92
+ return `available=${available} 5h_limited=${fiveHourLimited} weekly_limited=${weeklyLimited}`;
93
+ }
13
94
  /**
14
95
  * 进入交互式全屏缓冲区,并隐藏光标,确保后续重绘始终基于固定画布。
15
96
  *
@@ -103,6 +184,9 @@ async function handleInteractiveToggle(initialStatuses) {
103
184
  return await new Promise((resolve) => {
104
185
  let closed = false;
105
186
  const render = () => {
187
+ const screenWidth = process.stdout.columns ?? 80;
188
+ const narrowScreen = screenWidth < 72;
189
+ const styled = shouldUseAnsiStyle();
106
190
  const latestSnapshot = (0, status_service_1.getStatusSnapshot)();
107
191
  const statusSource = changed ? latestSnapshot.statuses : (initialStatuses ?? latestSnapshot.statuses);
108
192
  const statusById = new Map(statusSource.map((item) => [item.id, item]));
@@ -120,18 +204,28 @@ async function handleInteractiveToggle(initialStatuses) {
120
204
  };
121
205
  })
122
206
  .filter((item) => item !== null);
207
+ const currentItem = displayStatuses.find((item) => item.id === accounts[cursor]?.id) ?? null;
123
208
  renderInteractiveScreen([
209
+ renderSectionHeader("accounts", screenWidth, styled),
124
210
  (0, status_1.renderStatusTable)(displayStatuses, {
211
+ compact: true,
212
+ maxWidth: screenWidth,
125
213
  selectorColumn: {
126
214
  enabledById: Object.fromEntries(accounts.map((account) => [account.id, account.enabled])),
127
215
  cursorAccountId: accounts[cursor]?.id ?? null
128
216
  }
129
217
  }),
130
218
  "",
131
- `available=${summary.available} 5h_limited=${summary.fiveHourLimited} weekly_limited=${summary.weeklyLimited}`,
219
+ renderDivider(screenWidth, styled),
220
+ renderSectionHeader("current", screenWidth, styled),
221
+ (0, status_1.renderStatusDetails)(currentItem, { maxWidth: screenWidth, header: false }),
222
+ "",
223
+ renderSectionHeader("summary", screenWidth, styled),
224
+ renderSummaryLine(summary, narrowScreen, styled),
132
225
  `selected=${latestSnapshot.selectedName ?? "none"}`,
133
226
  "",
134
- (0, text_1.bi)("空格切换当前行启用状态,Enter / q 退出。", "Press Space to toggle the current row, Enter or q to exit.")
227
+ renderSectionHeader("help", screenWidth, styled),
228
+ (0, text_1.bi)(narrowScreen ? "Space 切换,Enter / q 退出。" : "Space 切换启用状态,Enter / q 退出。", narrowScreen ? "Space toggles, Enter or q exits." : "Space toggles enabled state, Enter or q exits.")
135
229
  ]);
136
230
  };
137
231
  const applyChanges = () => {
package/dist/status.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.collectAccountStatuses = collectAccountStatuses;
4
4
  exports.summarizeAccountStatuses = summarizeAccountStatuses;
5
5
  exports.renderStatusTable = renderStatusTable;
6
+ exports.renderStatusDetails = renderStatusDetails;
6
7
  const config_1 = require("./config");
7
8
  const account_store_1 = require("./account-store");
8
9
  const state_1 = require("./state");
@@ -28,6 +29,69 @@ function formatPercent(value) {
28
29
  function formatReset(unixSeconds) {
29
30
  return (0, text_1.formatLocalDateTime)(unixSeconds);
30
31
  }
32
+ /**
33
+ * 按给定最大宽度截断单元格文本,优先保证表格整体不换行。
34
+ *
35
+ * @param value 原始文本。
36
+ * @param maxWidth 最大宽度。
37
+ * @returns 截断后的文本;宽度过小时退化为最短可读形式。
38
+ */
39
+ function truncateCell(value, maxWidth) {
40
+ if (maxWidth <= 0) {
41
+ return "";
42
+ }
43
+ if (value.length <= maxWidth) {
44
+ return value;
45
+ }
46
+ if (maxWidth <= 2) {
47
+ return value.slice(0, maxWidth);
48
+ }
49
+ return `${value.slice(0, maxWidth - 1)}…`;
50
+ }
51
+ /**
52
+ * 生成固定标签宽度的详情行,超出终端宽度时自动截断值部分。
53
+ *
54
+ * @param label 字段标签。
55
+ * @param value 字段值。
56
+ * @param maxWidth 当前可用最大宽度。
57
+ * @returns 单行详情文本。
58
+ */
59
+ function formatDetailLine(label, value, maxWidth) {
60
+ const prefix = `${label.padEnd(6)} `;
61
+ const safeWidth = Number.isFinite(maxWidth) ? maxWidth : prefix.length + value.length;
62
+ const valueWidth = Math.max(8, safeWidth - prefix.length);
63
+ return `${prefix}${truncateCell(value, valueWidth)}`;
64
+ }
65
+ /**
66
+ * 将状态对象归一化为单个紧凑状态标签,供表格与详情面板复用。
67
+ *
68
+ * @param item 单个账号状态。
69
+ * @returns 适合在终端展示的状态标签。
70
+ */
71
+ function resolveStatusLabel(item) {
72
+ if (item.refreshErrorCode) {
73
+ return item.refreshErrorCode;
74
+ }
75
+ if (!item.exists) {
76
+ return "missing";
77
+ }
78
+ if (!item.enabled) {
79
+ return "disabled";
80
+ }
81
+ if (item.localBlockUntil && item.localBlockUntil * 1000 > Date.now()) {
82
+ return formatBlockedStatus(item.localBlockReason, item.localBlockUntil);
83
+ }
84
+ if (item.isWeeklyLimited) {
85
+ return formatLimitStatus("weekly_limited", item.weeklyResetsAt);
86
+ }
87
+ if (item.isFiveHourLimited) {
88
+ return formatLimitStatus("5h_limited", item.fiveHourResetsAt);
89
+ }
90
+ if (item.isAvailable) {
91
+ return "available";
92
+ }
93
+ return "unknown";
94
+ }
31
95
  function formatLimitStatus(label, resetAt) {
32
96
  const remaining = formatRemainingDuration(resetAt);
33
97
  if (!remaining) {
@@ -44,6 +108,46 @@ function normalizeBlockReason(reason) {
44
108
  }
45
109
  return reason;
46
110
  }
111
+ /**
112
+ * 将账号工作空间读取异常统一归类为状态码,避免状态汇总阶段直接抛出异常中断整个命令。
113
+ *
114
+ * @param error 工作空间读取过程中抛出的异常。
115
+ * @returns 归一化后的状态码与错误摘要。
116
+ */
117
+ function classifyWorkspaceStatusError(error) {
118
+ const message = error instanceof Error ? error.message : String(error);
119
+ return {
120
+ code: "workspace_invalid",
121
+ message
122
+ };
123
+ }
124
+ /**
125
+ * 读取账号工作空间的本地登录态摘要;若目录损坏或 JSON 非法,则降级为工作空间不可用状态。
126
+ *
127
+ * @param codexHome 账号隔离 HOME 目录。
128
+ * @returns 是否存在完整登录态、主账号信息以及可选的工作空间错误状态。
129
+ */
130
+ function readWorkspaceSnapshot(codexHome) {
131
+ try {
132
+ const exists = (0, account_store_1.hasCompleteCodexAuthState)(codexHome);
133
+ const primary = exists ? (0, account_store_1.resolvePrimaryRegistryAccount)(codexHome) : null;
134
+ return {
135
+ exists,
136
+ primary,
137
+ workspaceErrorCode: null,
138
+ workspaceErrorMessage: null
139
+ };
140
+ }
141
+ catch (error) {
142
+ const workspaceError = classifyWorkspaceStatusError(error);
143
+ return {
144
+ exists: false,
145
+ primary: null,
146
+ workspaceErrorCode: workspaceError.code,
147
+ workspaceErrorMessage: workspaceError.message
148
+ };
149
+ }
150
+ }
47
151
  /**
48
152
  * 将剩余秒数格式化为紧凑的人类可读文本,便于在状态列中展示熔断剩余时间。
49
153
  *
@@ -92,10 +196,10 @@ function formatBlockedStatus(reason, until) {
92
196
  function collectAccountStatuses() {
93
197
  const config = (0, config_1.loadConfig)();
94
198
  return config.accounts.map((account) => {
95
- const exists = (0, account_store_1.hasCompleteCodexAuthState)(account.codex_home);
96
- const primary = exists ? (0, account_store_1.resolvePrimaryRegistryAccount)(account.codex_home) : null;
199
+ const workspace = readWorkspaceSnapshot(account.codex_home);
97
200
  const usageCache = (0, state_1.getUsageCache)(account.id);
98
- const activeEmail = usageCache?.email ?? primary?.email ?? account.email;
201
+ const refreshError = (0, state_1.getUsageRefreshError)(account.id);
202
+ const activeEmail = usageCache?.email ?? workspace.primary?.email ?? account.email;
99
203
  const fiveHourUsed = usageCache?.fiveHourUsedPercent ?? null;
100
204
  const fiveHourReset = usageCache?.fiveHourResetAt ?? null;
101
205
  const weeklyUsed = usageCache?.weeklyUsedPercent ?? null;
@@ -106,13 +210,15 @@ function collectAccountStatuses() {
106
210
  const isWeeklyLimited = isLimited(weeklyUsed, weeklyReset);
107
211
  const localBlock = (0, state_1.getAccountBlock)(account.id);
108
212
  const localBlocked = localBlock?.until != null ? localBlock.until * 1000 > Date.now() : false;
213
+ const refreshErrorCode = workspace.workspaceErrorCode ?? refreshError?.code ?? null;
214
+ const refreshErrorMessage = workspace.workspaceErrorMessage ?? refreshError?.message ?? null;
109
215
  return {
110
216
  id: account.id,
111
217
  name: account.name,
112
218
  email: activeEmail,
113
219
  enabled: account.enabled,
114
- exists,
115
- plan: usageCache?.plan ?? primary?.plan ?? "-",
220
+ exists: workspace.exists,
221
+ plan: usageCache?.plan ?? workspace.primary?.plan ?? "-",
116
222
  fiveHourLeftPercent,
117
223
  fiveHourResetsAt: fiveHourReset,
118
224
  weeklyLeftPercent,
@@ -121,8 +227,11 @@ function collectAccountStatuses() {
121
227
  isWeeklyLimited,
122
228
  localBlockReason: localBlock?.reason,
123
229
  localBlockUntil: localBlock?.until ?? null,
230
+ refreshErrorCode,
231
+ refreshErrorMessage,
124
232
  isAvailable: account.enabled &&
125
- exists &&
233
+ workspace.exists &&
234
+ !refreshErrorCode &&
126
235
  !isFiveHourLimited &&
127
236
  !isWeeklyLimited &&
128
237
  !localBlocked,
@@ -152,58 +261,95 @@ function summarizeAccountStatuses(statuses) {
152
261
  */
153
262
  function renderStatusTable(statuses, options) {
154
263
  const selectorColumn = options?.selectorColumn;
264
+ const compact = options?.compact ?? false;
265
+ const maxWidth = options?.maxWidth ?? Number.POSITIVE_INFINITY;
266
+ const compactHeader = maxWidth < 68;
267
+ const compactSlotWidth = maxWidth < 56 ? 8 : 12;
268
+ const compactPlanWidth = maxWidth < 56 ? 4 : 6;
269
+ const compactStatusWidth = maxWidth < 56 ? 12 : 18;
155
270
  const rows = [
156
- [
157
- ...(selectorColumn ? [" "] : []),
158
- "NAME",
159
- "EMAIL",
160
- "PLAN",
161
- "5H_LEFT",
162
- "5H_RESET",
163
- "WEEK_LEFT",
164
- "WEEK_RESET",
165
- "STATUS"
166
- ]
271
+ compact
272
+ ? [
273
+ ...(selectorColumn ? [" "] : []),
274
+ compactHeader ? "ID" : "SLOT",
275
+ compactHeader ? "P" : "PLAN",
276
+ "5H",
277
+ compactHeader ? "WK" : "WEEK",
278
+ compactHeader ? "ST" : "STATUS"
279
+ ]
280
+ : [
281
+ ...(selectorColumn ? [" "] : []),
282
+ "NAME",
283
+ "EMAIL",
284
+ "PLAN",
285
+ "5H_LEFT",
286
+ "5H_RESET",
287
+ "WEEK_LEFT",
288
+ "WEEK_RESET",
289
+ "STATUS"
290
+ ]
167
291
  ];
168
292
  for (const item of statuses) {
169
- let status = "missing";
170
- if (item.exists) {
171
- if (!item.enabled) {
172
- status = "disabled";
173
- }
174
- else if (item.localBlockUntil && item.localBlockUntil * 1000 > Date.now()) {
175
- status = formatBlockedStatus(item.localBlockReason, item.localBlockUntil);
176
- }
177
- else if (item.isWeeklyLimited) {
178
- status = formatLimitStatus("weekly_limited", item.weeklyResetsAt);
179
- }
180
- else if (item.isFiveHourLimited) {
181
- status = formatLimitStatus("5h_limited", item.fiveHourResetsAt);
182
- }
183
- else if (item.isAvailable) {
184
- status = "available";
185
- }
186
- else {
187
- status = "unknown";
188
- }
189
- }
293
+ const status = resolveStatusLabel(item);
190
294
  const selectorCell = selectorColumn
191
295
  ? `${selectorColumn.cursorAccountId === item.id ? ">" : " "}[${selectorColumn.enabledById[item.id] ? "x" : " "}]`
192
296
  : null;
193
- rows.push([
194
- ...(selectorCell ? [selectorCell] : []),
195
- item.name,
196
- item.email ?? "-",
197
- item.plan,
198
- formatPercent(item.fiveHourLeftPercent),
199
- formatReset(item.fiveHourResetsAt),
200
- formatPercent(item.weeklyLeftPercent),
201
- formatReset(item.weeklyResetsAt),
202
- status
203
- ]);
297
+ rows.push(compact
298
+ ? [
299
+ ...(selectorCell ? [selectorCell] : []),
300
+ truncateCell(item.name, compactSlotWidth),
301
+ truncateCell(item.plan, compactPlanWidth),
302
+ formatPercent(item.fiveHourLeftPercent),
303
+ formatPercent(item.weeklyLeftPercent),
304
+ truncateCell(status, compactStatusWidth)
305
+ ]
306
+ : [
307
+ ...(selectorCell ? [selectorCell] : []),
308
+ item.name,
309
+ item.email ?? "-",
310
+ item.plan,
311
+ formatPercent(item.fiveHourLeftPercent),
312
+ formatReset(item.fiveHourResetsAt),
313
+ formatPercent(item.weeklyLeftPercent),
314
+ formatReset(item.weeklyResetsAt),
315
+ status
316
+ ]);
204
317
  }
205
318
  const widths = rows[0].map((_, columnIndex) => Math.max(...rows.map((row) => row[columnIndex].length)));
206
319
  return rows
207
320
  .map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" "))
208
321
  .join("\n");
209
322
  }
323
+ /**
324
+ * 将当前选中账号渲染为紧凑详情区,补充主表中省略的邮箱、重置时间与错误摘要。
325
+ *
326
+ * @param item 当前选中的账号状态;为空时返回占位提示。
327
+ * @param options 详情区渲染选项。
328
+ * @returns 适合直接打印的详情区文本。
329
+ */
330
+ function renderStatusDetails(item, options) {
331
+ const includeHeader = options?.header ?? true;
332
+ if (!item) {
333
+ return [includeHeader ? "[ current ]" : "slot -", includeHeader ? "slot -" : ""]
334
+ .filter((line) => line.length > 0)
335
+ .join("\n");
336
+ }
337
+ const maxWidth = options?.maxWidth ?? Number.POSITIVE_INFINITY;
338
+ const narrow = maxWidth < 72;
339
+ const lines = [
340
+ ...(includeHeader ? ["[ current ]"] : []),
341
+ formatDetailLine("slot", `${item.name} plan=${item.plan}`, maxWidth),
342
+ formatDetailLine("email", item.email ?? "-", maxWidth),
343
+ formatDetailLine("status", resolveStatusLabel(item), maxWidth),
344
+ narrow
345
+ ? formatDetailLine("5h", `${formatPercent(item.fiveHourLeftPercent)} reset=${formatReset(item.fiveHourResetsAt)}`, maxWidth)
346
+ : formatDetailLine("5h", `${formatPercent(item.fiveHourLeftPercent)} reset=${formatReset(item.fiveHourResetsAt)}`, maxWidth),
347
+ narrow
348
+ ? formatDetailLine("week", `${formatPercent(item.weeklyLeftPercent)} reset=${formatReset(item.weeklyResetsAt)}`, maxWidth)
349
+ : formatDetailLine("week", `${formatPercent(item.weeklyLeftPercent)} reset=${formatReset(item.weeklyResetsAt)}`, maxWidth)
350
+ ];
351
+ if (item.refreshErrorMessage) {
352
+ lines.push(formatDetailLine("error", item.refreshErrorMessage, narrow ? Math.max(28, maxWidth) : Math.min(maxWidth, 96)));
353
+ }
354
+ return lines.join("\n");
355
+ }
@@ -21,6 +21,32 @@ function normalizeResetAt(value, resetAfterSeconds) {
21
21
  }
22
22
  return null;
23
23
  }
24
+ /**
25
+ * 将额度刷新异常归类为可直接展示在 `status` 表格中的状态码。
26
+ *
27
+ * @param accountId 刷新失败的账号标识。
28
+ * @param error 刷新流程抛出的原始异常。
29
+ * @returns 归一化后的刷新失败状态。
30
+ */
31
+ function classifyUsageRefreshError(accountId, error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ const workspaceInvalidPatterns = [
34
+ "未找到账号",
35
+ "缺少 access_token",
36
+ "缺少 refresh_token",
37
+ "Unexpected end of JSON input",
38
+ "Unexpected token"
39
+ ];
40
+ const code = workspaceInvalidPatterns.some((pattern) => message.includes(pattern))
41
+ ? "workspace_invalid"
42
+ : "refresh_failed";
43
+ return {
44
+ accountId,
45
+ code,
46
+ message,
47
+ updatedAt: new Date().toISOString()
48
+ };
49
+ }
24
50
  /**
25
51
  * 使用 refresh token 刷新指定账号的 access token,并回写到账号目录。
26
52
  *
@@ -122,6 +148,7 @@ async function refreshAccountUsage(accountId) {
122
148
  refreshedAt: new Date().toISOString()
123
149
  };
124
150
  (0, state_1.setUsageCache)(result);
151
+ (0, state_1.clearUsageRefreshError)(accountId);
125
152
  return result;
126
153
  }
127
154
  /**
@@ -182,8 +209,7 @@ async function refreshAllAccountUsage() {
182
209
  results.push(result);
183
210
  }
184
211
  catch (error) {
185
- const message = error instanceof Error ? error.message : String(error);
186
- console.error((0, text_1.bi)(`[refresh] ${account.id} 失败: ${message}`, `[refresh] ${account.id} failed: ${message}`));
212
+ (0, state_1.setUsageRefreshError)(classifyUsageRefreshError(account.id, error));
187
213
  }
188
214
  }
189
215
  return results;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-slot",
3
- "version": "0.1.8",
3
+ "version": "0.1.17",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",