@zergai/ztc 0.2.6-dev.36101352252 → 0.2.7-dev.36236183737

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +43 -17
  2. package/bin/zmux-darwin-arm64 +0 -0
  3. package/bin/ztc-tui-darwin-arm64 +0 -0
  4. package/bin/zterm-darwin-arm64 +0 -0
  5. package/dist/App.js +19 -5
  6. package/dist/agent/agent.d.ts +5 -1
  7. package/dist/agent/agent.js +51 -8
  8. package/dist/agent/backends/account_pool.d.ts +5 -6
  9. package/dist/agent/backends/account_pool.js +39 -14
  10. package/dist/agent/backends/account_rotation.d.ts +3 -0
  11. package/dist/agent/backends/account_rotation.js +14 -3
  12. package/dist/agent/backends/codex_account_status.d.ts +42 -0
  13. package/dist/agent/backends/codex_account_status.js +196 -0
  14. package/dist/agent/backends/codex_app_server.d.ts +12 -2
  15. package/dist/agent/backends/codex_app_server.js +66 -6
  16. package/dist/agent/backends/index.d.ts +2 -0
  17. package/dist/agent/backends/index.js +13 -5
  18. package/dist/agent/backends/openai.d.ts +2 -2
  19. package/dist/agent/backends/openai.js +4 -3
  20. package/dist/agent/backends/openai_compatible.js +10 -10
  21. package/dist/agent/backends/openai_responses.d.ts +17 -5
  22. package/dist/agent/backends/openai_responses.js +2 -4
  23. package/dist/agent/backends/token_usage.js +1 -1
  24. package/dist/agent/backends/types.d.ts +3 -0
  25. package/dist/agent/commands/accounts.d.ts +7 -1
  26. package/dist/agent/commands/accounts.js +299 -101
  27. package/dist/agent/commands/config.js +11 -3
  28. package/dist/agent/commands/config_profile_switch.d.ts +2 -0
  29. package/dist/agent/commands/config_profile_switch.js +22 -0
  30. package/dist/agent/commands/context.js +4 -3
  31. package/dist/agent/commands/index.js +1 -2
  32. package/dist/agent/commands/input_mode.js +1 -1
  33. package/dist/agent/commands/model.js +7 -2
  34. package/dist/agent/commands/plan.d.ts +12 -0
  35. package/dist/agent/commands/plan.js +39 -28
  36. package/dist/agent/commands/profile.d.ts +1 -1
  37. package/dist/agent/commands/profile.js +34 -257
  38. package/dist/agent/commands/provider_choices.d.ts +12 -1
  39. package/dist/agent/commands/provider_choices.js +29 -2
  40. package/dist/agent/commands/provider_selection.d.ts +3 -0
  41. package/dist/agent/commands/provider_selection.js +63 -0
  42. package/dist/agent/commands/settings.d.ts +2 -0
  43. package/dist/agent/commands/settings.js +655 -50
  44. package/dist/agent/commands/types.d.ts +19 -2
  45. package/dist/agent/tools/ask_user.js +2 -2
  46. package/dist/app/agent_runner.d.ts +1 -1
  47. package/dist/app/agent_runner.js +2 -2
  48. package/dist/app/ask_user_response.d.ts +3 -0
  49. package/dist/app/ask_user_response.js +16 -0
  50. package/dist/app/command_context.js +29 -13
  51. package/dist/app/controller.d.ts +17 -2
  52. package/dist/app/controller.js +24 -1
  53. package/dist/app/selection_completion.d.ts +9 -3
  54. package/dist/app/selection_completion.js +54 -22
  55. package/dist/cli_args.js +10 -1
  56. package/dist/cli_main.js +85 -2
  57. package/dist/cli_ratatui.d.ts +22 -4
  58. package/dist/cli_ratatui.js +421 -146
  59. package/dist/config/defaults.d.ts +6 -0
  60. package/dist/config/defaults.js +20 -0
  61. package/dist/config/profiles.d.ts +9 -0
  62. package/dist/config/profiles.js +87 -0
  63. package/dist/config/types.d.ts +2 -0
  64. package/dist/config.d.ts +7 -0
  65. package/dist/config.js +283 -40
  66. package/dist/headless.js +14 -0
  67. package/dist/panes/model_catalog.d.ts +1 -1
  68. package/dist/panes/model_catalog.js +24 -2
  69. package/dist/panes/ztc_launch.js +6 -0
  70. package/dist/router_auth.js +3 -2
  71. package/dist/session.d.ts +2 -0
  72. package/dist/session.js +3 -0
  73. package/dist/types.d.ts +23 -2
  74. package/dist/ui/views/app.d.ts +3 -1
  75. package/dist/ui/views/app.js +3 -2
  76. package/dist/ui/views/completion_menu.js +6 -6
  77. package/dist/ui/views/input_area.d.ts +2 -1
  78. package/dist/ui/views/input_area.js +9 -8
  79. package/dist/ui/views/status_bar.d.ts +2 -0
  80. package/dist/ui/views/status_bar.js +15 -4
  81. package/dist/ui/widgets/color_panel.d.ts +9 -0
  82. package/dist/ui/widgets/color_panel.js +41 -26
  83. package/dist/ui/widgets/navigator_panel.d.ts +9 -0
  84. package/dist/ui/widgets/navigator_panel.js +17 -16
  85. package/dist/ui/widgets/panel_frame.d.ts +83 -3
  86. package/dist/ui/widgets/panel_frame.js +107 -9
  87. package/dist/ui/widgets/questionnaire.d.ts +9 -0
  88. package/dist/ui/widgets/questionnaire.js +214 -16
  89. package/dist/ui/widgets/resume_panel.d.ts +9 -0
  90. package/dist/ui/widgets/resume_panel.js +18 -17
  91. package/dist/ui/widgets/rewind_panel.d.ts +9 -0
  92. package/dist/ui/widgets/rewind_panel.js +15 -16
  93. package/dist/ui/widgets/select.d.ts +6 -0
  94. package/dist/ui/widgets/select.js +47 -12
  95. package/dist/ui/widgets/settings_panel.d.ts +75 -0
  96. package/dist/ui/widgets/settings_panel.js +197 -39
  97. package/dist/ui/widgets/status_line_panel.d.ts +9 -0
  98. package/dist/ui/widgets/status_line_panel.js +18 -19
  99. package/dist/ui/widgets/transcript_preview.d.ts +8 -0
  100. package/dist/ui/widgets/transcript_preview.js +10 -0
  101. package/dist/utils/context.js +2 -0
  102. package/dist/utils/fireworks.d.ts +20 -0
  103. package/dist/utils/fireworks.js +23 -0
  104. package/dist/utils/models.d.ts +2 -1
  105. package/dist/utils/models.js +19 -0
  106. package/dist/web/accounts_mirror.d.ts +7 -0
  107. package/dist/web/accounts_mirror.js +59 -7
  108. package/dist/web/mirror_stream.js +19 -5
  109. package/dist/zlm-embed.mjs +5734 -5362
  110. package/package.json +2 -2
  111. package/dist/agent/commands/provider.d.ts +0 -2
  112. package/dist/agent/commands/provider.js +0 -75
@@ -14,12 +14,11 @@
14
14
  * reordering hard to land, so the pickup is explicit and you can see the row
15
15
  * travel.
16
16
  */
17
- import { box, text } from '../core/index.js';
18
17
  import { displayWidth, padEndDisplay } from '../core/text_width.js';
19
18
  import { TERMINAL_THEME } from '../views/theme.js';
20
19
  import { fuzzyMatch } from '../../utils/fuzzy_match.js';
21
20
  import { cycleStatusLineVariant, resolveStatusLineVariants, statusLineItemGroups, statusLineItemOptions, STATUS_LINE_VARIANT_LABELS, } from '../status_line_config.js';
22
- import { bodyBand, bottomBorder, clipText, dropLastGrapheme, fitSegments, framedLine, isInsideBand, joinColumns, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, } from './panel_frame.js';
21
+ import { bodyBand, bottomBorder, clipText, dropLastGrapheme, fitSegments, framedLine, isInsideBand, joinColumns, lineText, planPanelSplit, sideBySideBands, stackedBands, topBorder, buildWidgetFrameView, widgetFrameGeometry, } from './panel_frame.js';
23
22
  /** The order pane is the point of this page, so it keeps a workable width. */
24
23
  const ORDER_PANE_MIN_WIDTH = 34;
25
24
  /** Below this the panes stack instead of splitting. */
@@ -34,6 +33,15 @@ function sideLabel(side) {
34
33
  export class StatusLinePanelWidget {
35
34
  host;
36
35
  active = null;
36
+ /**
37
+ * Column the painted frame starts on, in surface coordinates.
38
+ *
39
+ * Zero unless the terminal is wide enough to trigger the shared cap, in which
40
+ * case it is that centring margin. The wheel bands add it back: a notch lands
41
+ * in terminal columns, so without it a capped page would scroll from a pane
42
+ * that is no longer under the pointer.
43
+ */
44
+ frameInset = 0;
37
45
  draft = {
38
46
  left: [],
39
47
  right: [],
@@ -620,17 +628,7 @@ export class StatusLinePanelWidget {
620
628
  const lines = this.buildSurfaceLines(cols, maxRows);
621
629
  return {
622
630
  content: lines.map(lineText).join('\n'),
623
- view: box(lines.map(line => box(line.segments.map(segment => text(segment.text, segment.style)), {
624
- flexDirection: 'row',
625
- height: 1,
626
- flexShrink: 0,
627
- backgroundColor: line.backgroundColor,
628
- })), {
629
- flexDirection: 'column',
630
- height: lines.length,
631
- flexShrink: 0,
632
- backgroundColor: TERMINAL_THEME.bg,
633
- }),
631
+ view: buildWidgetFrameView(lines, this.frameInset),
634
632
  lineCount: lines.length,
635
633
  };
636
634
  }
@@ -643,9 +641,10 @@ export class StatusLinePanelWidget {
643
641
  if (maxRows !== undefined && maxRows <= 0)
644
642
  return [];
645
643
  const termCols = Math.max(1, Math.floor(cols || process.stdout.columns || 80));
646
- // The host embeds the surface in a `paddingX: 1` column, so `cols - 2` is
647
- // the full usable width. No cap: a wide terminal gets wider panes.
648
- const width = Math.max(1, termCols - 2);
644
+ // The host embeds the surface in a `paddingX: 1` column, so the frame asks
645
+ // for `termCols - 2` and the shared cap centres whatever is left over.
646
+ const { width, inset } = widgetFrameGeometry(termCols);
647
+ this.frameInset = inset;
649
648
  const height = Math.max(1, Math.floor(maxRows ?? 20));
650
649
  const innerWidth = Math.max(0, width - 4);
651
650
  const lines = [topBorder(width, 'Status line', `${this.sideItems(this.side).length} on ${this.side}`)];
@@ -690,7 +689,7 @@ export class StatusLinePanelWidget {
690
689
  ...padRow(order.rows[index], split.previewWidth),
691
690
  ], width));
692
691
  }
693
- const bands = sideBySideBands(sideBandTop, lines.length - 1, split.listWidth, split.previewWidth);
692
+ const bands = sideBySideBands(sideBandTop, lines.length - 1, split.listWidth, split.previewWidth, this.frameInset);
694
693
  this.fieldBand = bands.list;
695
694
  this.lineBand = bands.preview;
696
695
  }
@@ -718,7 +717,7 @@ export class StatusLinePanelWidget {
718
717
  // Stacked, the panes share the width and are told apart by rows. The
719
718
  // field pane's top is the heading row it opened with (or the first row it
720
719
  // drew when the frame had no room for headings).
721
- const bands = stackedBands({ top: stackedFieldBottom - fieldCapacity, bottom: stackedFieldBottom }, { top: stackedLineTop, bottom: lines.length - 1 }, innerWidth);
720
+ const bands = stackedBands({ top: stackedFieldBottom - fieldCapacity, bottom: stackedFieldBottom }, { top: stackedLineTop, bottom: lines.length - 1 }, innerWidth, this.frameInset);
722
721
  this.fieldBand = bands.list;
723
722
  this.lineBand = bands.preview;
724
723
  }
@@ -735,7 +734,7 @@ export class StatusLinePanelWidget {
735
734
  lines.push(framedLine(windowed.rows[index]?.segments ?? [], width));
736
735
  }
737
736
  // Too short for two panes, so only the one on screen is a wheel target.
738
- const band = bodyBand(singleTop, lines.length - 1, innerWidth);
737
+ const band = bodyBand(singleTop, lines.length - 1, innerWidth, this.frameInset);
739
738
  this.fieldBand = kind === 'fields' ? band : null;
740
739
  this.lineBand = kind === 'line' ? band : null;
741
740
  }
@@ -124,6 +124,14 @@ export interface TranscriptHeaderInput {
124
124
  focused: boolean;
125
125
  /** Heading before the separator, e.g. `PREVIEW`. */
126
126
  heading?: string;
127
+ /**
128
+ * Columns the heading has to fit in.
129
+ *
130
+ * Without it an over-long heading reports four facts and lets the frame cut
131
+ * the last one in half. With it the heading keeps the title and the first
132
+ * fact whole, and the caller clips words it can afford to lose.
133
+ */
134
+ maxWidth?: number;
127
135
  }
128
136
  /**
129
137
  * Pane heading: marker, title, and how much of the transcript is on screen.
@@ -263,5 +263,15 @@ export function transcriptHeaderText(input) {
263
263
  const last = Math.min(total, input.scroll + rows);
264
264
  parts.push(`${first}-${last}/${total}`);
265
265
  }
266
+ // A narrow pane gets the title and the first fact, not four items clipped
267
+ // mid-word: the title says which session this is, and the first fact says how
268
+ // big it is. The caller drops whatever still does not fit, but it never has
269
+ // to cut a fact in half — each one is dropped whole.
270
+ if (input.maxWidth !== undefined && displayWidth(`${marker} ${heading} · ${parts.join(' · ')}`) > input.maxWidth) {
271
+ const kept = [parts[0]];
272
+ if (parts.length > 1)
273
+ kept.push(parts[1]);
274
+ return `${marker} ${heading} · ${kept.join(' · ')}`;
275
+ }
266
276
  return `${marker} ${heading} · ${parts.join(' · ')}`;
267
277
  }
@@ -44,6 +44,8 @@ export const MODEL_CONTEXT_WINDOWS = {
44
44
  // this bare key catches all gemini-* ids. Previously had no entry at all -> 128K.
45
45
  'gemini': 1000000,
46
46
  // GPT models
47
+ 'gpt-6-sol': 1050000,
48
+ 'gpt-6-luna': 1050000,
47
49
  'gpt-4': 8192,
48
50
  'gpt-4-32k': 32768,
49
51
  'gpt-4-turbo': 128000,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Fireworks' curated pay-per-token Serverless chat/vision catalog.
3
+ *
4
+ * Mirrors zapps/zergrouter/server/lib/fireworks-models.ts. Router is the source
5
+ * of truth and its /v1/models endpoint wins when discovery succeeds; this local
6
+ * copy only keeps the picker populated and gives the funded default a name when
7
+ * discovery fails or returns nothing. Keep the slugs aligned with Router.
8
+ */
9
+ export interface FireworksCatalogModel {
10
+ slug: string;
11
+ name: string;
12
+ }
13
+ export declare const FIREWORKS_CHAT_MODELS: readonly FireworksCatalogModel[];
14
+ /**
15
+ * ZergRouter funds exactly this slug on platform credits
16
+ * (ZROUTER_FUNDED_DEEPSEEK_PROVIDER === 'fireworks' in fly.prod.toml), so it is
17
+ * the default a Router-only user can select without a personal Fireworks key.
18
+ */
19
+ export declare const FIREWORKS_FUNDED_MODEL = "deepseek-v4p1-flash";
20
+ export declare function getFireworksModelName(slug: string): string | undefined;
@@ -0,0 +1,23 @@
1
+ export const FIREWORKS_CHAT_MODELS = [
2
+ { slug: 'deepseek-v4p1-flash', name: 'DeepSeek V4.1 Flash' },
3
+ { slug: 'glm-5p3-flash', name: 'GLM 5.3 Flash' },
4
+ { slug: 'glm-5p3', name: 'GLM 5.3' },
5
+ { slug: 'kimi-k3', name: 'Kimi K3' },
6
+ { slug: 'deepseek-v4-pro-0813', name: 'DeepSeek V4 Pro 0813' },
7
+ { slug: 'qwen3p8-max', name: 'Qwen 3.8 Max' },
8
+ { slug: 'muse-glimmer-30b', name: 'Muse Glimmer 30B' },
9
+ { slug: 'deepseek-v4-flash-0731', name: 'DeepSeek V4 Flash 0731' },
10
+ { slug: 'glm-5p2', name: 'GLM 5.2' },
11
+ { slug: 'kimi-k2p7-code', name: 'Kimi K2.7 Code' },
12
+ { slug: 'minimax-m3', name: 'MiniMax M3' },
13
+ { slug: 'ember-1', name: 'Ember-1' },
14
+ ];
15
+ /**
16
+ * ZergRouter funds exactly this slug on platform credits
17
+ * (ZROUTER_FUNDED_DEEPSEEK_PROVIDER === 'fireworks' in fly.prod.toml), so it is
18
+ * the default a Router-only user can select without a personal Fireworks key.
19
+ */
20
+ export const FIREWORKS_FUNDED_MODEL = 'deepseek-v4p1-flash';
21
+ export function getFireworksModelName(slug) {
22
+ return FIREWORKS_CHAT_MODELS.find(model => model.slug === slug)?.name;
23
+ }
@@ -1,11 +1,12 @@
1
1
  import type { CodexReasoningEffort } from '../config/types.js';
2
- export declare const OFFICIAL_CODEX_MODEL_IDS: readonly ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2"];
2
+ export declare const OFFICIAL_CODEX_MODEL_IDS: readonly ["gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2"];
3
3
  export interface OfficialCodexModelInfo {
4
4
  displayName: string;
5
5
  description: string;
6
6
  defaultReasoningEffort: CodexReasoningEffort;
7
7
  supportedReasoningEfforts: readonly CodexReasoningEffort[];
8
8
  }
9
+ export declare const GPT_6_API_EFFORTS: readonly ["none", "low", "medium", "high", "xhigh", "max"];
9
10
  export declare const CODEX_LEGACY_EFFORTS: readonly ["none", "minimal", "low", "medium", "high", "xhigh"];
10
11
  /** Picker metadata mirrored from Codex App Server model/list. */
11
12
  export declare const OFFICIAL_CODEX_MODEL_INFO: Record<(typeof OFFICIAL_CODEX_MODEL_IDS)[number], OfficialCodexModelInfo>;
@@ -6,6 +6,8 @@
6
6
  import { isCodexAppServerProvider, isCodexLegacyProvider } from '../agent/backends/provider_ids.js';
7
7
  import { DEEPSEEK_FLASH_EFFORTS, DEEPSEEK_FLASH_MODEL, MODAL_DEEPSEEK_FLASH_MODEL, MODAL_DEEPSEEK_FLASH_EFFORTS, isModalDeepSeekFlashModel, isDeepSeekFlashModel } from './deepseek.js';
8
8
  export const OFFICIAL_CODEX_MODEL_IDS = [
9
+ 'gpt-6-sol',
10
+ 'gpt-6-luna',
9
11
  'gpt-5.6-sol',
10
12
  'gpt-5.6-terra',
11
13
  'gpt-5.6-luna',
@@ -18,9 +20,22 @@ export const OFFICIAL_CODEX_MODEL_IDS = [
18
20
  const STANDARD_CODEX_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
19
21
  const GPT_5_6_EFFORTS = [...STANDARD_CODEX_EFFORTS, 'max'];
20
22
  const GPT_5_6_ULTRA_EFFORTS = [...GPT_5_6_EFFORTS, 'ultra'];
23
+ export const GPT_6_API_EFFORTS = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
21
24
  export const CODEX_LEGACY_EFFORTS = ['none', 'minimal', ...STANDARD_CODEX_EFFORTS];
22
25
  /** Picker metadata mirrored from Codex App Server model/list. */
23
26
  export const OFFICIAL_CODEX_MODEL_INFO = {
27
+ 'gpt-6-sol': {
28
+ displayName: 'GPT-6 Sol',
29
+ description: 'Frontier agentic coding model.',
30
+ defaultReasoningEffort: 'medium',
31
+ supportedReasoningEfforts: GPT_5_6_ULTRA_EFFORTS,
32
+ },
33
+ 'gpt-6-luna': {
34
+ displayName: 'GPT-6 Luna',
35
+ description: 'Fast and affordable model for everyday work.',
36
+ defaultReasoningEffort: 'medium',
37
+ supportedReasoningEfforts: GPT_5_6_EFFORTS,
38
+ },
24
39
  'gpt-5.6-sol': {
25
40
  displayName: 'GPT-5.6 Sol',
26
41
  description: 'Latest frontier agentic coding model.',
@@ -208,6 +223,10 @@ export function getSupportedCodexReasoningEfforts(model, provider) {
208
223
  const appServerEfforts = getOfficialCodexModelInfo(model)?.supportedReasoningEfforts;
209
224
  if (!appServerEfforts)
210
225
  return getSupportedClaudeReasoningEfforts(model);
226
+ if ((provider === 'openai' || provider === 'openai_compatible')
227
+ && (model === 'gpt-6-sol' || model === 'gpt-6-luna')) {
228
+ return GPT_6_API_EFFORTS;
229
+ }
211
230
  if (provider && isCodexLegacyProvider(provider))
212
231
  return CODEX_LEGACY_EFFORTS;
213
232
  if (provider === undefined) {
@@ -13,6 +13,7 @@ import type { LoginCommand } from '../agent/backends/account_pool.js';
13
13
  import type { AccountCredentialSource } from '../agent/backends/account_pool.js';
14
14
  import type { LoginAttempt } from '../agent/backends/account_login.js';
15
15
  import { AccountStateStore } from '../agent/backends/account_state.js';
16
+ import { type CodexAccountStatus } from '../agent/backends/codex_account_status.js';
16
17
  import { type KimiDeviceFlowStart, type KimiDeviceLoginResult } from '../agent/backends/kimi_code_auth.js';
17
18
  import type { AccountProvider } from '../config/types.js';
18
19
  export declare const ACCOUNT_PROVIDER_LABELS: Record<AccountProvider, string>;
@@ -31,6 +32,7 @@ export interface AccountView {
31
32
  active: boolean;
32
33
  /** How this account's credentials are backed (isolated / shared_keychain / none). */
33
34
  credentialSource: AccountCredentialSource;
35
+ codexStatus?: CodexAccountStatus;
34
36
  /** Epoch ms reset when this account is cooling in the rotation sidecar, else absent. */
35
37
  cooldownUntil?: number;
36
38
  /**
@@ -110,6 +112,10 @@ export interface AccountsPayload {
110
112
  loginStates?: Record<string, KimiLoginState>;
111
113
  }
112
114
  export declare function getAccountsPayload(store?: ConfigStore, deps?: AccountsQueryDeps): AccountsPayload;
115
+ export declare function getAccountsPayloadWithCodexStatus(store?: ConfigStore, deps?: AccountsQueryDeps & {
116
+ readCodexStatus?: (home: string) => Promise<CodexAccountStatus>;
117
+ forceCodexStatus?: boolean;
118
+ }): Promise<AccountsPayload>;
113
119
  export interface ActiveAccountBadge {
114
120
  provider: AccountProvider;
115
121
  providerLabel: string;
@@ -201,6 +207,7 @@ export interface AccountOpResult {
201
207
  * without spawning the real claude/codex CLIs. Production leaves them defaulted.
202
208
  */
203
209
  export interface AccountOpDeps {
210
+ readCodexStatus?: (home: string) => Promise<CodexAccountStatus>;
204
211
  runLogin?: (login: LoginCommand, isUsable: () => boolean) => Promise<LoginAttempt>;
205
212
  autoRunEnabled?: () => boolean;
206
213
  onAccountsChanged?: () => void;
@@ -11,10 +11,11 @@
11
11
  import { homedir } from 'os';
12
12
  import { join } from 'path';
13
13
  import { configStore as defaultStore } from '../config.js';
14
- import { accountCredentialSource, buildLoginCommand, canonicalAccountProvider, defaultAccountDir, defaultSourceFor, ensureAccountDir, isAccountProvider, probeAccountUsable, sharedClaudeKeychainPresent, sourceDir, } from '../agent/backends/account_pool.js';
14
+ import { accountCredentialSource, buildLoginCommand, canonicalAccountProvider, defaultAccountDir, defaultSourceFor, ensureAccountDir, findCodexHomeCollision, isAccountProvider, probeAccountUsable, sharedClaudeKeychainPresent, sourceDir, } from '../agent/backends/account_pool.js';
15
15
  import { loginAutoRunEnabled, runLoginCommand } from '../agent/backends/account_login.js';
16
16
  import { notifyAccountsChanged } from '../agent/backends/account_events.js';
17
17
  import { AccountStateStore } from '../agent/backends/account_state.js';
18
+ import { codexStatusCache } from '../agent/backends/codex_account_status.js';
18
19
  import { hasUsableClaudeCodeAuth } from '../agent/backends/claude_code_auth.js';
19
20
  import { hasUsableCodexAuth } from '../agent/backends/codex_auth.js';
20
21
  import { hasUsableKimiCodeAuth, runKimiDevicePollLoop, startKimiDeviceFlow, } from '../agent/backends/kimi_code_auth.js';
@@ -276,6 +277,33 @@ export function getAccountsPayload(store = defaultStore, deps = {}) {
276
277
  ...(Object.keys(loginStates).length > 0 ? { loginStates } : {}),
277
278
  };
278
279
  }
280
+ export async function getAccountsPayloadWithCodexStatus(store = defaultStore, deps = {}) {
281
+ const payload = getAccountsPayload(store, deps);
282
+ const accounts = await Promise.all(payload.accounts.map(async (account) => {
283
+ if (account.provider !== 'codex_app_server')
284
+ return account;
285
+ // The Default row is the machine's own ~/.codex, already verified usable
286
+ // by its native credential store. The status probe pins the file store, so
287
+ // it would call a keychain login signed out, and every cache miss would
288
+ // refresh the main login's token.
289
+ if (account.id.startsWith(AMBIENT_ACCOUNT_ID_PREFIX))
290
+ return account;
291
+ if (!account.usable && !deps.readCodexStatus) {
292
+ return { ...account, codexStatus: { state: 'reauth_required', windows: [] } };
293
+ }
294
+ try {
295
+ const status = await (deps.readCodexStatus
296
+ ? deps.readCodexStatus(account.path)
297
+ : codexStatusCache.read(account.path, deps.forceCodexStatus));
298
+ return { ...account, codexStatus: status,
299
+ usable: status.state === 'ready' || status.state === 'quota_unavailable' };
300
+ }
301
+ catch {
302
+ return { ...account, codexStatus: { state: 'unavailable', windows: [] } };
303
+ }
304
+ }));
305
+ return { ...payload, accounts };
306
+ }
279
307
  /**
280
308
  * The display-only badge payload: the live account per provider (only those with
281
309
  * one configured) plus any cooldown, read from the sidecar. Drives the system-bar
@@ -385,6 +413,17 @@ export async function applyAccountOp(body, store = defaultStore, reload, deps =
385
413
  const account = store.getAccount(id);
386
414
  if (!account)
387
415
  return { ok: false, error: `No account "${id}".` };
416
+ if (account.source.kind === 'codex_home') {
417
+ const status = await (deps.readCodexStatus
418
+ ? deps.readCodexStatus(account.source.path)
419
+ : codexStatusCache.read(account.source.path, true));
420
+ if (status.state === 'reauth_required') {
421
+ return { ok: false, error: `"${account.label}" needs a fresh ChatGPT sign-in. Sign in, then select it again.` };
422
+ }
423
+ if (status.state === 'unavailable') {
424
+ return { ok: false, error: `"${account.label}" could not be verified. Refresh its status or sign in, then select it again.` };
425
+ }
426
+ }
388
427
  store.setActiveAccount(account.provider, account.id);
389
428
  await store.save();
390
429
  // Explicit operator selection overrides a stranded cooldown. Clear it BEFORE
@@ -420,6 +459,11 @@ export async function applyAccountOp(body, store = defaultStore, reload, deps =
420
459
  const label = asString(body.label) ?? id;
421
460
  const dir = asString(body.path) ?? defaultAccountDir(provider, id);
422
461
  const source = defaultSourceFor(provider, dir);
462
+ // Check for a shared credential home before creating anything, so a
463
+ // refused add leaves no stray directory behind.
464
+ const collision = findCodexHomeCollision(store.listAccounts(), source);
465
+ if (collision)
466
+ return { ok: false, error: `That Codex credential home is already used by "${collision.label}".` };
423
467
  ensureAccountDir(source);
424
468
  const beforeActive = store.getActiveAccount(store.getProvider())?.id;
425
469
  store.addAccount({ id, label, provider: provider, source });
@@ -583,18 +627,26 @@ export async function applyAccountOp(body, store = defaultStore, reload, deps =
583
627
  // `accounts` SSE refreshes when the sign-in finishes (or fails).
584
628
  const run = deps.runLogin ?? ((l, u) => runLoginCommand(l, u));
585
629
  const notify = deps.onAccountsChanged ?? notifyAccountsChanged;
586
- // If this is the CURRENT provider's active account, a successful sign-in makes
587
- // its creds usable, so the live agent must hot-swap onto them.
588
- const isCurrentActive = store.getActiveAccount(store.getProvider())?.id === account.id;
630
+ // Check the active pointer again when the detached login finishes: the user
631
+ // may have selected a different account while completing browser OAuth.
589
632
  void run(login, () => probeAccountUsable(account))
590
- .then((attempt) => {
633
+ .then(async (attempt) => {
591
634
  // A successful sign-in mints fresh creds, so a stranded cooldown (an
592
635
  // auth-failover rest that account_rotation cools "until the user
593
636
  // re-auths") is now stale. Clear it before the reload rebuilds the pool.
594
- if (attempt.outcome === 'succeeded')
637
+ let verified = attempt.outcome === 'succeeded';
638
+ if (verified && account.source.kind === 'codex_home') {
639
+ codexStatusCache.invalidate(account.source.path);
640
+ const status = await (deps.readCodexStatus
641
+ ? deps.readCodexStatus(account.source.path)
642
+ : codexStatusCache.read(account.source.path, true));
643
+ verified = status.state === 'ready' || status.state === 'quota_unavailable';
644
+ }
645
+ if (verified) {
595
646
  (deps.stateStore ?? new AccountStateStore()).clear(account.id);
647
+ }
596
648
  notify();
597
- if (reload && isCurrentActive && attempt.outcome === 'succeeded')
649
+ if (reload && verified && store.getActiveAccount(store.getProvider())?.id === account.id)
598
650
  reload();
599
651
  })
600
652
  .catch(() => notify());
@@ -31,7 +31,7 @@ import { MemoryHistoryStore, JsonlHistoryStore } from './event_history_store.js'
31
31
  import { PaneRegistry } from './pane_registry.js';
32
32
  import { createMirrorAuthenticatorFromEnv, } from './mirror_authenticator.js';
33
33
  import { areMirrorCorsRequestHeadersAllowed, getMirrorOriginPolicy, MIRROR_CORS_ALLOW_HEADERS, rejectReasonForUnsafeMirrorWrite, } from './mirror_origin_policy.js';
34
- import { getAccountsPayload, applyAccountOp, getActiveAccountsSummary } from './accounts_mirror.js';
34
+ import { getAccountsPayload, getAccountsPayloadWithCodexStatus, applyAccountOp, getActiveAccountsSummary } from './accounts_mirror.js';
35
35
  import { onAccountsChanged } from '../agent/backends/account_events.js';
36
36
  import { getMirrorCommandUsages } from '../agent/commands/mirror_usages.js';
37
37
  const MAX_MIRROR_IMAGE_BYTES = 10 * 1024 * 1024;
@@ -864,9 +864,8 @@ export class MirrorStreamServer {
864
864
  // settings_page is a live-only navigation signal: replaying it on
865
865
  // reconnect would hijack the user's view every time.
866
866
  && event.type !== 'settings_page'
867
- // open_accounts is the same: a one-shot command that pops the Accounts
868
- // modal. Retaining it would replay via /events on any reload / new tab /
869
- // --resume restart and spring the modal open unbidden.
867
+ // Legacy open_accounts is also live-only: replay would navigate a fresh
868
+ // browser tab to Accounts without a new user command.
870
869
  && event.type !== 'open_accounts') {
871
870
  this.history.append(fullEvent);
872
871
  }
@@ -2662,8 +2661,23 @@ export class MirrorStreamServer {
2662
2661
  res.end(JSON.stringify({ error: 'Host account details are unavailable in hosted mode.' }));
2663
2662
  return;
2664
2663
  }
2664
+ // Codex status carries the ChatGPT email, plan and quota, and a refresh
2665
+ // spawns the host's Codex App Server against the operator's login. Both
2666
+ // are operator-only. A forced refresh also needs a same-origin request:
2667
+ // an auth-disabled loopback mirror treats every caller as the operator,
2668
+ // including a cross-site <img> pointing at ?refresh=1.
2669
+ const operator = this.canMutateAccounts(auth);
2670
+ const originPolicy = getMirrorOriginPolicy(req);
2671
+ const fetchSite = String(req.headers['sec-fetch-site'] || '').trim().toLowerCase();
2672
+ const sameOrigin = fetchSite !== 'cross-site'
2673
+ && !(originPolicy.requestOrigin && !originPolicy.isTrustedOrigin);
2674
+ const payload = operator
2675
+ ? await getAccountsPayloadWithCodexStatus(undefined, {
2676
+ forceCodexStatus: sameOrigin && url.searchParams.get('refresh') === '1',
2677
+ })
2678
+ : getAccountsPayload();
2665
2679
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
2666
- res.end(JSON.stringify(getAccountsPayload()));
2680
+ res.end(JSON.stringify(payload));
2667
2681
  return;
2668
2682
  }
2669
2683
  if (url.pathname === '/mirror/account' && req.method === 'POST') {