codex-slot 0.1.8 → 0.1.16

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.
@@ -103,29 +103,22 @@ function findMarkedBlockRange(content, startMarker, endMarker) {
103
103
  return { start, end };
104
104
  }
105
105
  /**
106
- * 恢复文本中由 cslot 管理的配置块,得到接管前的原始内容基线。
106
+ * 反复移除文本中所有带指定标记的受管块,避免异常退出后残留旧块导致后续写入出现重复或串位。
107
107
  *
108
108
  * @param content 当前 `config.toml` 内容。
109
- * @param managedState 上一次接管时保存的原始片段快照。
110
- * @returns 恢复后的文本内容。
109
+ * @param startMarker 块起始标记。
110
+ * @param endMarker 块结束标记。
111
+ * @returns 清理后的文本内容。
111
112
  */
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);
113
+ function stripMarkedBlocks(content, startMarker, endMarker) {
114
+ let stripped = content;
115
+ while (true) {
116
+ const range = findMarkedBlockRange(stripped, startMarker, endMarker);
117
+ if (!range) {
118
+ return stripped;
119
+ }
120
+ stripped = stripped.slice(0, range.start) + stripped.slice(range.end);
127
121
  }
128
- return restored;
129
122
  }
130
123
  /**
131
124
  * 查找首个 `model_provider` 配置块,兼容已启用与注释掉的场景。
@@ -183,12 +176,13 @@ function findModelProviderLine(content) {
183
176
  return null;
184
177
  }
185
178
  /**
186
- * 查找 `[model_providers.cslot]` 表块的文本范围。
179
+ * 查找指定表块的文本范围。
187
180
  *
188
181
  * @param content 当前 `config.toml` 内容。
182
+ * @param header 目标表头,例如 `[model_providers.cslot]`。
189
183
  * @returns 命中时返回完整表块范围;未命中返回 `null`。
190
184
  */
191
- function findProviderSectionRange(content) {
185
+ function findTableSectionRange(content, header) {
192
186
  const lines = content.split(/\r?\n/);
193
187
  let offset = 0;
194
188
  let startLineIndex = -1;
@@ -196,7 +190,7 @@ function findProviderSectionRange(content) {
196
190
  for (let i = 0; i < lines.length; i += 1) {
197
191
  const line = lines[i];
198
192
  const lineEnd = offset + line.length;
199
- if (line.trim() === "[model_providers.cslot]") {
193
+ if (line.trim() === header) {
200
194
  startLineIndex = i;
201
195
  startOffset = offset;
202
196
  break;
@@ -234,6 +228,164 @@ function findProviderSectionRange(content) {
234
228
  value: content.slice(startOffset, endOffset)
235
229
  };
236
230
  }
231
+ /**
232
+ * 查找 `[model_providers.cslot]` 表块的文本范围。
233
+ *
234
+ * @param content 当前 `config.toml` 内容。
235
+ * @returns 命中时返回完整表块范围;未命中返回 `null`。
236
+ */
237
+ function findProviderSectionRange(content) {
238
+ return findTableSectionRange(content, "[model_providers.cslot]");
239
+ }
240
+ /**
241
+ * 查找指定表头所在的行起始偏移。
242
+ *
243
+ * @param content 当前 `config.toml` 内容。
244
+ * @param header 目标表头。
245
+ * @returns 命中时返回表头行起始偏移;未命中返回 `null`。
246
+ */
247
+ function findTableHeaderOffset(content, header) {
248
+ const lines = content.split(/\r?\n/);
249
+ let offset = 0;
250
+ for (const line of lines) {
251
+ const lineEnd = offset + line.length;
252
+ if (line.trim() === header) {
253
+ return offset;
254
+ }
255
+ offset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
256
+ }
257
+ return null;
258
+ }
259
+ /**
260
+ * 查找指定偏移之前最近的表头,供恢复原有表块位置时作为后备锚点。
261
+ *
262
+ * @param content 当前 `config.toml` 内容。
263
+ * @param offset 截止偏移。
264
+ * @returns 最近的表头文本;未命中返回 `null`。
265
+ */
266
+ function findPreviousTableHeaderBeforeOffset(content, offset) {
267
+ const lines = content.split(/\r?\n/);
268
+ let currentOffset = 0;
269
+ let previousHeader = null;
270
+ for (const line of lines) {
271
+ const lineEnd = currentOffset + line.length;
272
+ const trimmed = line.trim();
273
+ if (currentOffset >= offset) {
274
+ break;
275
+ }
276
+ if (trimmed.startsWith("[") && !trimmed.startsWith("[[") && !trimmed.startsWith("#")) {
277
+ previousHeader = trimmed;
278
+ }
279
+ currentOffset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
280
+ }
281
+ return previousHeader;
282
+ }
283
+ /**
284
+ * 查找指定偏移之后的首个表头,供恢复原有表块位置时作为优先锚点。
285
+ *
286
+ * @param content 当前 `config.toml` 内容。
287
+ * @param offset 起始偏移。
288
+ * @returns 首个后续表头文本;未命中返回 `null`。
289
+ */
290
+ function findNextTableHeaderAfterOffset(content, offset) {
291
+ const lines = content.split(/\r?\n/);
292
+ let currentOffset = 0;
293
+ for (const line of lines) {
294
+ const lineEnd = currentOffset + line.length;
295
+ const trimmed = line.trim();
296
+ if (currentOffset >= offset &&
297
+ trimmed.startsWith("[") &&
298
+ !trimmed.startsWith("[[") &&
299
+ !trimmed.startsWith("#")) {
300
+ return trimmed;
301
+ }
302
+ currentOffset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
303
+ }
304
+ return null;
305
+ }
306
+ /**
307
+ * 清理文本中的所有 `model_provider` 配置块,确保每次接管都以单一稳定块重新写入。
308
+ *
309
+ * @param content 当前 `config.toml` 内容。
310
+ * @returns 移除后的文本内容。
311
+ */
312
+ function removeAllModelProviderLines(content) {
313
+ let nextContent = content;
314
+ while (true) {
315
+ const range = findModelProviderLine(nextContent);
316
+ if (!range) {
317
+ return nextContent;
318
+ }
319
+ nextContent = nextContent.slice(0, range.start) + nextContent.slice(range.end);
320
+ }
321
+ }
322
+ /**
323
+ * 清理文本中的所有 `[model_providers.cslot]` 表块,避免残留旧块影响下一段配置。
324
+ *
325
+ * @param content 当前 `config.toml` 内容。
326
+ * @returns 移除后的文本内容。
327
+ */
328
+ function removeAllProviderSections(content) {
329
+ let nextContent = content;
330
+ while (true) {
331
+ const range = findProviderSectionRange(nextContent);
332
+ if (!range) {
333
+ return nextContent;
334
+ }
335
+ nextContent = nextContent.slice(0, range.start) + nextContent.slice(range.end);
336
+ }
337
+ }
338
+ /**
339
+ * 移除所有 cslot 受管标记块,得到不包含历史残留接管片段的基线内容。
340
+ *
341
+ * @param content 当前 `config.toml` 内容。
342
+ * @returns 清理后的基线内容。
343
+ */
344
+ function stripAllManagedBlocks(content) {
345
+ const withoutProviderBlock = stripMarkedBlocks(content, PROVIDER_BLOCK_START_MARKER, PROVIDER_BLOCK_END_MARKER);
346
+ return stripMarkedBlocks(withoutProviderBlock, MODEL_PROVIDER_START_MARKER, MODEL_PROVIDER_END_MARKER);
347
+ }
348
+ /**
349
+ * 查找根级配置区的尾部插入点。
350
+ *
351
+ * 业务规则:
352
+ * 1. 若文件中存在 table header,则插入到首个 table 之前,保证 `model_provider` 仍处于根级作用域。
353
+ * 2. 若文件不存在任何 table,则允许直接追加到文件尾部。
354
+ *
355
+ * @param content 当前 `config.toml` 内容。
356
+ * @returns 可用于插入根级配置块的偏移位置。
357
+ */
358
+ function findRootSectionInsertOffset(content) {
359
+ const lines = content.split(/\r?\n/);
360
+ let offset = 0;
361
+ for (const line of lines) {
362
+ const lineEnd = offset + line.length;
363
+ const trimmed = line.trim();
364
+ if (trimmed.startsWith("[") && !trimmed.startsWith("#")) {
365
+ return offset;
366
+ }
367
+ offset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
368
+ }
369
+ return content.length;
370
+ }
371
+ /**
372
+ * 将根级配置块插回根级区域。
373
+ *
374
+ * 若能命中原始记录的后续表头,则优先插回该表头前;否则回退到首个表头前。
375
+ *
376
+ * @param content 当前 `config.toml` 内容。
377
+ * @param block 待插入的根级配置块。
378
+ * @param eol 目标换行符。
379
+ * @param preferredNextTableHeader 原始记录的后续表头锚点。
380
+ * @returns 插入后的完整文本。
381
+ */
382
+ function insertRootBlock(content, block, eol, preferredNextTableHeader) {
383
+ const preferredOffset = preferredNextTableHeader
384
+ ? findTableHeaderOffset(content, preferredNextTableHeader)
385
+ : null;
386
+ const insertOffset = preferredOffset ?? findRootSectionInsertOffset(content);
387
+ return insertBlockBetween(content.slice(0, insertOffset), block, content.slice(insertOffset), eol);
388
+ }
237
389
  /**
238
390
  * 将指定文本规范为单个块插入形式,避免在块两侧不断叠加多余空行。
239
391
  *
@@ -248,6 +400,103 @@ function insertBlockBetween(before, block, after, eol) {
248
400
  const normalizedAfter = after.startsWith(eol) || after.length === 0 ? after : `${eol}${after}`;
249
401
  return `${normalizedBefore}${block}${normalizedAfter}`;
250
402
  }
403
+ /**
404
+ * 将配置块稳定追加到文件尾部,统一清理尾部多余空行,避免多次接管后空行不断累积。
405
+ *
406
+ * @param content 当前 `config.toml` 内容。
407
+ * @param block 待追加配置块。
408
+ * @param eol 目标换行符。
409
+ * @returns 追加后的完整文本。
410
+ */
411
+ function appendBlockToEnd(content, block, eol) {
412
+ let trimmed = content;
413
+ while (trimmed.endsWith(eol)) {
414
+ trimmed = trimmed.slice(0, -eol.length);
415
+ }
416
+ if (trimmed.length === 0) {
417
+ return `${block}${eol}`;
418
+ }
419
+ return `${trimmed}${eol}${eol}${block}${eol}`;
420
+ }
421
+ /**
422
+ * 将表块尽量插回原有相邻表头附近;若锚点已不存在,则退回文件尾部追加。
423
+ *
424
+ * @param content 当前 `config.toml` 内容。
425
+ * @param block 待插入的表块。
426
+ * @param eol 目标换行符。
427
+ * @param preferredNextTableHeader 原始后续表头锚点,命中时优先插到该表之前。
428
+ * @param preferredPreviousTableHeader 原始前驱表头锚点,当前者失效时插到该表之后。
429
+ * @returns 插入后的完整文本。
430
+ */
431
+ function insertTableBlock(content, block, eol, preferredNextTableHeader, preferredPreviousTableHeader) {
432
+ if (preferredNextTableHeader) {
433
+ const nextOffset = findTableHeaderOffset(content, preferredNextTableHeader);
434
+ if (nextOffset !== null) {
435
+ return insertBlockBetween(content.slice(0, nextOffset), block, content.slice(nextOffset), eol);
436
+ }
437
+ }
438
+ if (preferredPreviousTableHeader) {
439
+ const previousRange = findTableSectionRange(content, preferredPreviousTableHeader);
440
+ if (previousRange) {
441
+ return insertBlockBetween(content.slice(0, previousRange.end), block, content.slice(previousRange.end), eol);
442
+ }
443
+ }
444
+ return appendBlockToEnd(content, block, eol);
445
+ }
446
+ /**
447
+ * 解析当前目标文件对应的上一轮接管快照。
448
+ *
449
+ * @param targetFile 当前准备接管或恢复的 `config.toml` 路径。
450
+ * @returns 命中同一目标文件时返回上一轮快照;否则返回 `null`。
451
+ */
452
+ function resolveManagedStateForTarget(targetFile) {
453
+ const managedState = (0, state_1.getManagedCodexConfigState)();
454
+ if (!managedState || managedState.target_file !== targetFile) {
455
+ return null;
456
+ }
457
+ return managedState;
458
+ }
459
+ /**
460
+ * 基于当前未受管的配置文本与上一轮快照,生成本轮接管所需的最小恢复快照。
461
+ *
462
+ * 业务规则:
463
+ * 1. 优先记录当前文件里实际存在的原始 `model_provider` 与 `[model_providers.cslot]`。
464
+ * 2. 若当前文件只剩残留受管块,允许继承上一轮快照中的原始片段。
465
+ * 3. 仅保存 cslot 自己声明所有权的两块配置及其锚点,不保存整文件内容。
466
+ *
467
+ * @param targetFile 当前准备接管的 `config.toml` 路径。
468
+ * @param strippedCurrent 已移除受管标记块后的配置文本。
469
+ * @param previousManagedState 同一目标文件的上一轮快照;不存在时传 `null`。
470
+ * @returns 本轮接管后用于 stop 恢复的快照。
471
+ */
472
+ function buildManagedSnapshot(targetFile, strippedCurrent, previousManagedState) {
473
+ const originalModelProviderLine = findModelProviderLine(strippedCurrent);
474
+ const originalProviderSection = findProviderSectionRange(strippedCurrent);
475
+ return {
476
+ target_file: targetFile,
477
+ original_model_provider_block: originalModelProviderLine?.value ??
478
+ previousManagedState?.original_model_provider_block ??
479
+ null,
480
+ original_model_provider_next_table_header: (originalModelProviderLine
481
+ ? findNextTableHeaderAfterOffset(strippedCurrent, originalModelProviderLine.end)
482
+ : null) ??
483
+ previousManagedState?.original_model_provider_next_table_header ??
484
+ null,
485
+ original_cslot_provider_block: originalProviderSection?.value ??
486
+ previousManagedState?.original_cslot_provider_block ??
487
+ null,
488
+ original_cslot_provider_previous_table_header: (originalProviderSection
489
+ ? findPreviousTableHeaderBeforeOffset(strippedCurrent, originalProviderSection.start)
490
+ : null) ??
491
+ previousManagedState?.original_cslot_provider_previous_table_header ??
492
+ null,
493
+ original_cslot_provider_next_table_header: (originalProviderSection
494
+ ? findNextTableHeaderAfterOffset(strippedCurrent, originalProviderSection.end)
495
+ : null) ??
496
+ previousManagedState?.original_cslot_provider_next_table_header ??
497
+ null
498
+ };
499
+ }
251
500
  /**
252
501
  * 将 cslot 需要的 provider 配置写入指定 `config.toml`,并保存恢复快照。
253
502
  *
@@ -260,51 +509,16 @@ function applyManagedCodexConfig(targetPathOrDir, options) {
260
509
  const rawTarget = targetPathOrDir ? (0, config_1.expandHome)(targetPathOrDir) : getDefaultCodexConfigPath();
261
510
  const targetFile = rawTarget.endsWith(".toml") ? rawTarget : node_path_1.default.join(rawTarget, "config.toml");
262
511
  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
- };
512
+ const previousManagedState = resolveManagedStateForTarget(targetFile);
513
+ const strippedCurrent = stripAllManagedBlocks(current);
514
+ const eol = detectEol(strippedCurrent);
515
+ const snapshot = buildManagedSnapshot(targetFile, strippedCurrent, previousManagedState);
275
516
  const config = options?.config ?? (0, config_1.loadConfig)();
276
- let nextContent = baseContent;
277
517
  const managedModelProviderBlock = buildManagedModelProviderBlock(eol);
278
518
  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
- }
519
+ const cleanedBaseContent = removeAllProviderSections(removeAllModelProviderLines(strippedCurrent));
520
+ let nextContent = insertRootBlock(cleanedBaseContent, managedModelProviderBlock, eol, snapshot.original_model_provider_next_table_header);
521
+ nextContent = appendBlockToEnd(nextContent, managedProviderBlock, eol);
308
522
  if (!nextContent.endsWith(eol)) {
309
523
  nextContent = `${nextContent}${eol}`;
310
524
  }
@@ -335,7 +549,16 @@ function deactivateManagedCodexConfig() {
335
549
  return null;
336
550
  }
337
551
  const current = node_fs_1.default.readFileSync(targetFile, "utf8");
338
- const restored = restoreManagedContent(current, managedState);
552
+ const eol = detectEol(current);
553
+ let restored = stripAllManagedBlocks(current);
554
+ const existingModelProviderLine = findModelProviderLine(restored);
555
+ if (!existingModelProviderLine && managedState.original_model_provider_block) {
556
+ restored = insertRootBlock(restored, managedState.original_model_provider_block, eol, managedState.original_model_provider_next_table_header);
557
+ }
558
+ const existingProviderSection = findProviderSectionRange(restored);
559
+ if (!existingProviderSection && managedState.original_cslot_provider_block) {
560
+ restored = insertTableBlock(restored, managedState.original_cslot_provider_block, eol, managedState.original_cslot_provider_next_table_header, managedState.original_cslot_provider_previous_table_header);
561
+ }
339
562
  writeFileAtomic(targetFile, restored);
340
563
  (0, state_1.clearManagedCodexConfigState)();
341
564
  return targetFile;
package/dist/state.js CHANGED
@@ -10,6 +10,9 @@ 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;
14
17
  exports.setManagedCodexConfigState = setManagedCodexConfigState;
15
18
  exports.clearManagedCodexConfigState = clearManagedCodexConfigState;
@@ -30,6 +33,7 @@ function loadState() {
30
33
  return {
31
34
  account_blocks: {},
32
35
  usage_cache: {},
36
+ usage_refresh_errors: {},
33
37
  managed_codex_config: null
34
38
  };
35
39
  }
@@ -39,11 +43,13 @@ function loadState() {
39
43
  : {
40
44
  account_blocks: {},
41
45
  usage_cache: {},
46
+ usage_refresh_errors: {},
42
47
  managed_codex_config: null
43
48
  };
44
49
  return {
45
50
  account_blocks: parsed.account_blocks ?? {},
46
51
  usage_cache: parsed.usage_cache ?? {},
52
+ usage_refresh_errors: parsed.usage_refresh_errors ?? {},
47
53
  managed_codex_config: parsed.managed_codex_config ?? null
48
54
  };
49
55
  }
@@ -125,6 +131,41 @@ function getUsageCache(accountId) {
125
131
  const state = loadState();
126
132
  return state.usage_cache[accountId] ?? null;
127
133
  }
134
+ /**
135
+ * 记录指定账号最近一次额度刷新失败的状态,供 `status` 命令渲染为账号状态而不是直接打印异常。
136
+ *
137
+ * @param usageError 刷新失败信息,包含账号、状态码与原始错误摘要。
138
+ * @returns 无返回值。
139
+ */
140
+ function setUsageRefreshError(usageError) {
141
+ const state = loadState();
142
+ state.usage_refresh_errors[usageError.accountId] = usageError;
143
+ saveState(state);
144
+ }
145
+ /**
146
+ * 清理指定账号最近一次记录的额度刷新失败状态,避免后续成功刷新后继续展示旧错误。
147
+ *
148
+ * @param accountId 账号标识。
149
+ * @returns 无返回值。
150
+ */
151
+ function clearUsageRefreshError(accountId) {
152
+ const state = loadState();
153
+ if (!(accountId in state.usage_refresh_errors)) {
154
+ return;
155
+ }
156
+ delete state.usage_refresh_errors[accountId];
157
+ saveState(state);
158
+ }
159
+ /**
160
+ * 读取指定账号最近一次记录的额度刷新失败状态。
161
+ *
162
+ * @param accountId 账号标识。
163
+ * @returns 刷新失败信息;若不存在则返回 `null`。
164
+ */
165
+ function getUsageRefreshError(accountId) {
166
+ const state = loadState();
167
+ return state.usage_refresh_errors[accountId] ?? null;
168
+ }
128
169
  /**
129
170
  * 读取当前记录的 Codex `config.toml` 接管快照。
130
171
  *
@@ -10,6 +10,75 @@ 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 summary 状态摘要计数。
68
+ * @param narrowScreen 是否窄屏布局。
69
+ * @param styled 是否启用 ANSI 样式。
70
+ * @returns 摘要展示文本。
71
+ * @throws 无显式抛出。
72
+ */
73
+ function renderSummaryLine(summary, narrowScreen, styled) {
74
+ const available = paint(String(summary.available), ANSI.green, styled);
75
+ const fiveHourLimited = paint(String(summary.fiveHourLimited), ANSI.yellow, styled);
76
+ const weeklyLimited = paint(String(summary.weeklyLimited), ANSI.yellow, styled);
77
+ if (narrowScreen) {
78
+ return `ok=${available} 5h=${fiveHourLimited} wk=${weeklyLimited}`;
79
+ }
80
+ return `available=${available} 5h_limited=${fiveHourLimited} weekly_limited=${weeklyLimited}`;
81
+ }
13
82
  /**
14
83
  * 进入交互式全屏缓冲区,并隐藏光标,确保后续重绘始终基于固定画布。
15
84
  *
@@ -103,6 +172,9 @@ async function handleInteractiveToggle(initialStatuses) {
103
172
  return await new Promise((resolve) => {
104
173
  let closed = false;
105
174
  const render = () => {
175
+ const screenWidth = process.stdout.columns ?? 80;
176
+ const narrowScreen = screenWidth < 72;
177
+ const styled = shouldUseAnsiStyle();
106
178
  const latestSnapshot = (0, status_service_1.getStatusSnapshot)();
107
179
  const statusSource = changed ? latestSnapshot.statuses : (initialStatuses ?? latestSnapshot.statuses);
108
180
  const statusById = new Map(statusSource.map((item) => [item.id, item]));
@@ -120,18 +192,26 @@ async function handleInteractiveToggle(initialStatuses) {
120
192
  };
121
193
  })
122
194
  .filter((item) => item !== null);
195
+ const currentItem = displayStatuses.find((item) => item.id === accounts[cursor]?.id) ?? null;
123
196
  renderInteractiveScreen([
197
+ renderSectionHeader("accounts", screenWidth, styled),
124
198
  (0, status_1.renderStatusTable)(displayStatuses, {
199
+ compact: true,
200
+ maxWidth: screenWidth,
125
201
  selectorColumn: {
126
202
  enabledById: Object.fromEntries(accounts.map((account) => [account.id, account.enabled])),
127
203
  cursorAccountId: accounts[cursor]?.id ?? null
128
204
  }
129
205
  }),
130
206
  "",
131
- `available=${summary.available} 5h_limited=${summary.fiveHourLimited} weekly_limited=${summary.weeklyLimited}`,
207
+ (0, status_1.renderStatusDetails)(currentItem, { maxWidth: screenWidth }),
208
+ "",
209
+ renderSectionHeader("summary", screenWidth, styled),
210
+ renderSummaryLine(summary, narrowScreen, styled),
132
211
  `selected=${latestSnapshot.selectedName ?? "none"}`,
133
212
  "",
134
- (0, text_1.bi)("空格切换当前行启用状态,Enter / q 退出。", "Press Space to toggle the current row, Enter or q to exit.")
213
+ renderSectionHeader("help", screenWidth, styled),
214
+ (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
215
  ]);
136
216
  };
137
217
  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,92 @@ 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
+ if (!item) {
332
+ return ["[ current ]", "slot -"].join("\n");
333
+ }
334
+ const maxWidth = options?.maxWidth ?? Number.POSITIVE_INFINITY;
335
+ const narrow = maxWidth < 72;
336
+ const lines = [
337
+ "[ current ]",
338
+ formatDetailLine("slot", `${item.name} plan=${item.plan}`, maxWidth),
339
+ formatDetailLine("email", item.email ?? "-", maxWidth),
340
+ formatDetailLine("status", resolveStatusLabel(item), maxWidth),
341
+ narrow
342
+ ? formatDetailLine("5h", `${formatPercent(item.fiveHourLeftPercent)} reset=${formatReset(item.fiveHourResetsAt)}`, maxWidth)
343
+ : formatDetailLine("5h", `${formatPercent(item.fiveHourLeftPercent)} reset=${formatReset(item.fiveHourResetsAt)}`, maxWidth),
344
+ narrow
345
+ ? formatDetailLine("week", `${formatPercent(item.weeklyLeftPercent)} reset=${formatReset(item.weeklyResetsAt)}`, maxWidth)
346
+ : formatDetailLine("week", `${formatPercent(item.weeklyLeftPercent)} reset=${formatReset(item.weeklyResetsAt)}`, maxWidth)
347
+ ];
348
+ if (item.refreshErrorMessage) {
349
+ lines.push(formatDetailLine("error", item.refreshErrorMessage, narrow ? Math.max(28, maxWidth) : Math.min(maxWidth, 96)));
350
+ }
351
+ return lines.join("\n");
352
+ }
@@ -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.16",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",