@zergai/ztc 0.2.6-dev.36102446685 → 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
package/README.md CHANGED
@@ -51,25 +51,26 @@ you are ready for ZTC to make the proposed changes.
51
51
 
52
52
  Authentication options are Zerg Router browser login (`/auth login`), a provider API key
53
53
  (`/config key <api-key>`), or Claude Code OAuth (`claude setup-token`, then
54
- `/provider claude_code`).
54
+ `/model claude_code`).
55
55
 
56
56
  For browser login at **zergrouter.com**, run `ztc auth login` in your shell or
57
57
  `/auth login` inside ZTC. Both use the same flow and credentials. See
58
58
  [Zerg Router and DeepSeek setup](docs/zerg-router.md) for model selection,
59
59
  Claude Code/Codex emulation, and `ztc "prompt"` or `ztc -p "prompt"` usage.
60
60
 
61
- Use `/provider openai` or `/provider deepseek` for direct API calls with your
62
- local keys. Use `/provider zergrouter [upstream-provider]` to route through your
63
- Router login, or `/provider modal` for Modal shared endpoints. For Ollama,
61
+ Use `/model openai [model]` or `/model deepseek [model]` for direct API calls with your
62
+ local keys. Use `/model zergrouter <upstream-provider> [model]` to route through your
63
+ Router login, or `/model modal [model]` for Modal shared endpoints. For Ollama,
64
64
  LM Studio, or a custom server, configure `/config openai-compat` and select
65
- `/provider local`. Switching providers selects an appropriate default model;
65
+ `/model local [model]`. Switching providers selects an appropriate default model;
66
66
  selecting the same provider preserves your current model.
67
67
 
68
- Type `/provider ` or `/model ` to search suggestions by ID, name, or alias.
68
+ Type `/model ` to search providers and current-provider models by ID, name, or alias.
69
69
  Search is fuzzy, so `/model dskfl` finds DeepSeek Flash. Use Up/Down to select,
70
- Tab or Enter to complete, then Enter to run the command. Escape dismisses
71
- suggestions. Custom model IDs can still be entered directly.
72
- `/model` names the selected provider and lists only its models. A successful
70
+ Tab or Enter to complete a provider and continue to its models. Enter on a model
71
+ suggestion fills its ID; press Enter again to apply it. Escape dismisses suggestions.
72
+ Custom model IDs can still be entered directly.
73
+ After a provider is entered, the picker names it and lists only its models. A successful
73
74
  live catalog replaces bundled suggestions, including when the catalog is empty;
74
75
  bundled suggestions are used while loading or when discovery fails.
75
76
  Modal lists registered aliases, such as `kimi-k3` and `deepseek-v4.1-flash`;
@@ -567,11 +568,17 @@ When a saved session includes `zmux` state, `ztc resume` restores the pane layou
567
568
  | `/config statusline <left|right> <field,...>` | Set status line fields without opening the editor |
568
569
  | `/config statusline variant <field> <raw\|short\|long>` | Set one field's verbosity without opening the editor |
569
570
  | `/config statusline edit` | Open the status line field editor |
570
- | `/provider [name]` | Select direct API, local endpoint, or Zerg Router routing |
571
- | `/model <name>` | Select a model, with fuzzy autocomplete while typing |
571
+ | `/model <provider> [model]` | Select a provider and optional model; `/model <model>` keeps the active provider |
572
+ | `/model zergrouter <provider> [model]` | Select a Router provider and optional model |
572
573
  | `/models` | List available models |
573
574
  | `/permissions` | View/update tool permissions |
574
575
 
576
+ `/profile list` and `/settings` → **Config Scope** → **Active config profile** show one profile catalog. The built-in `ztc`, `ztc-coder`, `ztc-datascience`, and `scientist` profiles share the default settings and sign-ins; each selects its named emulation layer. They work without setup. An existing saved directory with one of those names remains an independent config profile, so its data stays accessible.
577
+
578
+ Custom config profiles keep settings and sign-ins separate. Start one with `ztc --profile work`, or switch the running session with `/profile work`. `/profile create work` starts a new profile. Switching keeps the session ID and conversation while rebuilding the agent. A later `ztc resume` restores the session's saved profile unless you specify `--profile`. The default config stays at `~/.ztc/config.json`; custom named configs live at `~/.ztc/profiles/<name>/config.json`. Session history stays shared. Switching does not rewrite settings after the one-time migration described below.
579
+
580
+ On first use of this release, each existing config profile adopts the new default settings once. Its account registrations, authentication, API keys, and credential-bearing integration settings are retained. Later edits to that profile are kept. New profiles start with the same defaults. The migration writes only the active profile's config file, so switching to an unused profile applies its migration then.
581
+
575
582
  #### Bottom status line
576
583
 
577
584
  The terminal status line has independently ordered left and right fields. Values that are unavailable are omitted. In narrow terminals, lower-priority fields are hidden before mode, connection state, or active errors.
@@ -608,7 +615,21 @@ Available fields are `mode`, `session-name`, `session-id`, `workspace`, `context
608
615
 
609
616
  ZTC separates the official OpenAI Codex App Server integration from the older direct ChatGPT Codex backend.
610
617
 
611
- Use `ztc --provider codex_app_server` for the documented OpenAI App Server path. It launches `codex app-server` over JSONL stdio and uses App Server auth/model discovery. This first milestone runs without ZTC tool schemas and fails explicit ZTC tool-calling backend requests, because Codex App Server owns tool execution and approval semantics; full App Server approval/tool event mapping is still a separate runtime-mode step. Run `codex login status` before starting ZTC if auth is unclear.
618
+ Use `ztc --provider codex_app_server` for the documented OpenAI App Server path. It launches `codex app-server` over JSONL stdio and uses App Server auth, model discovery, and dynamic tool calls. Run `codex login status` before starting ZTC if the machine-default login is unclear.
619
+
620
+ To keep several ChatGPT accounts signed in for manual switching, add each as a separate `codex_app_server` profile:
621
+
622
+ ```text
623
+ /accounts add codex_app_server work Work
624
+ /accounts login work
625
+ /accounts add codex_app_server personal Personal
626
+ /accounts login personal
627
+ /accounts use work
628
+ ```
629
+
630
+ Each login uses the official Codex CLI with its own `CODEX_HOME` under `~/.ztc/accounts/`, file credential storage, and ChatGPT authentication. The accounts stay signed in on disk without keeping an App Server process running for every account. `/accounts use <id>` switches the active profile; the browser Accounts panel offers the same one-click selection. Codex accounts never rotate automatically when a quota is reached, even if auto-rotation is enabled for Claude or Kimi.
631
+
632
+ The Accounts panel and `/accounts list` refresh the selected profiles through the official App Server. They show the ChatGPT identity, remaining quota windows, and token activity when OpenAI reports them. A failed quota or usage read is marked stale and retains the last observed value; an expired login needs a new `/accounts login <id>`. A profile whose login cannot be verified does not replace the current active profile. The panel's Refresh button checks again immediately. If an administrator requires keyring-only credentials, Codex may refuse the per-profile file-store setting; that profile will need a supported organization policy before it can be added to this pool.
612
633
 
613
634
  Use `ztc --provider codex_legacy` for the existing direct `chatgpt.com/backend-api/codex` backend backed by `~/.codex/auth.json`. Existing config values with `provider: "codex"` remain supported as a compatibility alias for `codex_legacy`; new configs should use the explicit provider IDs.
614
635
 
@@ -689,7 +710,7 @@ Optional App Server config:
689
710
 
690
711
  | Shortcut | Action |
691
712
  |----------|--------|
692
- | `Enter` | Apply the selected model/provider, run a command, or submit input |
713
+ | `Enter` | Fill a partial completion; run an exact command or model, or submit input |
693
714
  | `Shift+Enter` | Insert newline |
694
715
  | `Ctrl+C` | Exit |
695
716
  | `Ctrl+L` | Clear screen |
@@ -712,10 +733,15 @@ Optional App Server config:
712
733
  | `Ctrl+Y` | Paste back the most recent kill — undo a cleared prompt |
713
734
  | `Alt+Y` / `Ctrl+Alt+Y` | With Ctrl+Y, step through earlier kills |
714
735
 
715
- Slash commands, `/model`, and `/provider` share a fuzzy-search menu above the
716
- prompt. It shows up to four choices, the result position, and the current
717
- model/provider. Model choices belong to the active provider. Commands that need
718
- an argument open that step on Enter; Tab always lets you edit before running.
736
+ Slash commands and `/model` share a fuzzy-search menu above the prompt. It shows
737
+ as many choices as fit above the prompt while reserving transcript space. The
738
+ menu shows the result position and current model/provider. The
739
+ first `/model` step suggests providers and models for the active provider. Tab
740
+ or Enter on a provider opens its model step. Enter on a partial model fills the
741
+ draft; press Enter again to run it. An exact model ID runs on the first Enter.
742
+ Press Esc, then Enter to switch providers without
743
+ choosing a model. Model suggestions use the selected provider and route.
744
+ Commands that need an argument open that step on Enter or Tab.
719
745
  Argument syntax appears as one short usage line instead of selectable chips.
720
746
 
721
747
  ---
Binary file
Binary file
Binary file
package/dist/App.js CHANGED
@@ -383,7 +383,7 @@ export const App = () => {
383
383
  }, []);
384
384
  const showToastRef = useRef(null);
385
385
  /** Returns the post-enqueue size, or null if the queue was full. */
386
- const enqueueMessage = useCallback((text) => {
386
+ const enqueueMessage = useCallback((text, delivery = 'end-of-turn') => {
387
387
  const current = queueRef.current;
388
388
  if (current.length >= MAX_QUEUE) {
389
389
  showToastRef.current?.(`Queue full (${MAX_QUEUE}). Cancel or wait.`);
@@ -393,6 +393,7 @@ export const App = () => {
393
393
  id: generateId(),
394
394
  text,
395
395
  queuedAt: new Date(),
396
+ delivery,
396
397
  };
397
398
  const nextSize = current.length + 1;
398
399
  setQueue(prev => (prev.length >= MAX_QUEUE ? prev : [...prev, entry]));
@@ -574,6 +575,8 @@ export const App = () => {
574
575
  // are intentionally dropped on idle, as before.)
575
576
  contextTokens: s.contextTokens,
576
577
  contextWindow: s.contextWindow,
578
+ cacheInputTokens: s.cacheInputTokens,
579
+ cachedInputTokens: s.cachedInputTokens,
577
580
  tokensEstimated: s.tokensEstimated,
578
581
  authError: false
579
582
  }));
@@ -741,7 +744,10 @@ export const App = () => {
741
744
  getContextWindow: () => agentStateRef.current.contextWindow,
742
745
  // /clear -> 0 and /compact -> reduced size: reactively shrink the bar (the
743
746
  // backend won't report the drop until the next turn).
744
- setContextUsage: (used, estimated) => setAgentState(s => ({ ...s, contextTokens: used, tokensEstimated: estimated })),
747
+ setContextUsage: (used, estimated) => setAgentState(s => ({
748
+ ...s, contextTokens: used, tokensEstimated: estimated,
749
+ cacheInputTokens: undefined, cachedInputTokens: undefined,
750
+ })),
745
751
  model: configStore.get().model
746
752
  }), [addMessage, clearMessages, getMessages, setMessages, reloadAgent, exit, shellController, retryLast, inputMode, promptController, formController]);
747
753
  // Handle commands
@@ -1021,6 +1027,8 @@ export const App = () => {
1021
1027
  // Provider-reported window (Codex) feeds the bar denominator; keep the
1022
1028
  // last-known one when this event doesn't carry one.
1023
1029
  contextWindow: event.contextWindow ?? s.contextWindow,
1030
+ cacheInputTokens: event.cacheInputTokens,
1031
+ cachedInputTokens: event.contextCachedInputTokens,
1024
1032
  tokensEstimated: event.usage.estimated
1025
1033
  }));
1026
1034
  break;
@@ -1098,8 +1106,9 @@ export const App = () => {
1098
1106
  React.useEffect(() => {
1099
1107
  const prevStatus = prevStatusRef.current;
1100
1108
  prevStatusRef.current = agentState.status;
1101
- if (inputMode !== 'queue')
1102
- return;
1109
+ // Deliberately NOT gated on `inputMode`: /inputmode only selects what Enter
1110
+ // does while busy. A Tab-queued prompt promised end-of-turn delivery and
1111
+ // must still be sent when the run is configured to interrupt on Enter.
1103
1112
  if (agentState.status !== 'idle')
1104
1113
  return;
1105
1114
  if (prevStatus === 'idle' && queue.length === 0)
@@ -1108,6 +1117,11 @@ export const App = () => {
1108
1117
  return;
1109
1118
  if (flushingRef.current)
1110
1119
  return;
1120
+ // End-of-turn entries only; a `subturn` entry still waiting here is started
1121
+ // by its own fallback path, so taking it here would send it twice.
1122
+ const ready = queueRef.current.find(entry => entry.delivery !== 'subturn');
1123
+ if (!ready)
1124
+ return;
1111
1125
  flushingRef.current = true;
1112
1126
  const next = dequeueMessage();
1113
1127
  if (!next) {
@@ -1117,7 +1131,7 @@ export const App = () => {
1117
1131
  void handleSubmit(next.text).finally(() => {
1118
1132
  flushingRef.current = false;
1119
1133
  });
1120
- }, [agentState.status, queue.length, inputMode, dequeueMessage, handleSubmit]);
1134
+ }, [agentState.status, queue.length, dequeueMessage, handleSubmit]);
1121
1135
  const showToast = useCallback((message) => {
1122
1136
  setToast(message);
1123
1137
  if (toastTimerRef.current) {
@@ -105,6 +105,8 @@ export interface AgentRunResult {
105
105
  usage?: TokenUsage;
106
106
  stopReason: 'complete' | 'interactive' | 'max_iterations';
107
107
  reasoning?: string;
108
+ /** Ordered model-facing messages produced during a turn that accepted steering. */
109
+ turnMessages?: Message[];
108
110
  }
109
111
  export interface HarnessToolCallResult {
110
112
  success: boolean;
@@ -224,6 +226,8 @@ export declare class Agent {
224
226
  * immediately followed by tool_end.
225
227
  */
226
228
  private emitObservation;
229
+ /** Ask a provider-owned tool loop to accept a busy user message. */
230
+ steerUserMessage(message: Message): Promise<boolean>;
227
231
  /** Tools that end the agent turn when called (require user interaction before continuing). */
228
232
  private static readonly TURN_ENDING_TOOLS;
229
233
  /**
@@ -263,7 +267,7 @@ export declare class Agent {
263
267
  * authoritative history and mirrors the accepted user/assistant pair around
264
268
  * the same core loop used by unhosted ZTC.
265
269
  */
266
- run(messages: Message[], signal?: AbortSignal): Promise<AgentRunResult>;
270
+ run(messages: Message[], signal?: AbortSignal, takeSteering?: () => Message[]): Promise<AgentRunResult>;
267
271
  private runCore;
268
272
  /**
269
273
  * Release the backend. The backend lives as long as the Agent — the run loop
@@ -896,6 +896,12 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
896
896
  return;
897
897
  }
898
898
  }
899
+ /** Ask a provider-owned tool loop to accept a busy user message. */
900
+ async steerUserMessage(message) {
901
+ if (this.#conversationBinding)
902
+ return false;
903
+ return this.backend.steerUserMessage?.(message) ?? false;
904
+ }
899
905
  /** Tools that end the agent turn when called (require user interaction before continuing). */
900
906
  static TURN_ENDING_TOOLS = new Set([
901
907
  'ask_user',
@@ -955,10 +961,10 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
955
961
  * authoritative history and mirrors the accepted user/assistant pair around
956
962
  * the same core loop used by unhosted ZTC.
957
963
  */
958
- async run(messages, signal) {
964
+ async run(messages, signal, takeSteering) {
959
965
  const binding = this.#conversationBinding;
960
966
  if (!binding)
961
- return this.runCore(messages, signal);
967
+ return this.runCore(messages, signal, takeSteering);
962
968
  const userMessage = [...messages].reverse().find((message) => message.role === 'user'
963
969
  && !message.metadata?.synthetic
964
970
  && !message.metadata?.toolResult);
@@ -987,6 +993,9 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
987
993
  if (!workingMessages.some((message) => message.id === userMessage.id)) {
988
994
  workingMessages.push({ ...userMessage });
989
995
  }
996
+ // The host driver publishes only the initial user and final assistant
997
+ // for this turn. Leave busy input pending for a new hosted turn so the
998
+ // authoritative conversation cannot silently omit it.
990
999
  const result = await this.runCore(workingMessages, signal);
991
1000
  const assistantMessage = {
992
1001
  id: `host_assistant_${randomUUID()}`,
@@ -1033,11 +1042,13 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1033
1042
  }
1034
1043
  }
1035
1044
  // Main execution loop shared by hosted and standalone turns.
1036
- async runCore(messages, signal) {
1045
+ async runCore(messages, signal, takeSteering) {
1037
1046
  if (!this.hasApiKey()) {
1038
1047
  throw new AgentError('No API key configured. Use /config key <key> to set one.', 'NO_API_KEY');
1039
1048
  }
1040
1049
  const allToolCalls = [];
1050
+ const turnMessages = [];
1051
+ let acceptedSteering = false;
1041
1052
  let iterations = 0;
1042
1053
  // Each turn gets its own auto-continue budget; a long-running Agent must
1043
1054
  // not inherit one spent by the previous turn.
@@ -1110,7 +1121,10 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1110
1121
  const contextTokens = Math.max(0, usage.inputTokens - this._currentTransientOverageTokens);
1111
1122
  // `usage` (this turn) may carry a provider-reported context window (Codex);
1112
1123
  // forward it so the bar uses the real window over the static guess.
1113
- this.emit({ type: 'token_usage', usage: totalUsage, contextTokens, contextWindow: usage.contextWindow });
1124
+ this.emit({
1125
+ type: 'token_usage', usage: totalUsage, contextTokens, contextWindow: usage.contextWindow,
1126
+ cacheInputTokens: usage.inputTokens, contextCachedInputTokens: usage.cachedInputTokens,
1127
+ });
1114
1128
  // An incomplete provider response can still contain convincing prose or a
1115
1129
  // partially assembled tool call. Treat it as a failed inference before
1116
1130
  // either branch can persist the text as a final answer or execute an
@@ -1361,21 +1375,25 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1361
1375
  // so maxIterations exhaustion still has something to return.
1362
1376
  lastToolTurnText = textContent;
1363
1377
  }
1364
- messages.push({
1378
+ const toolUseMessage = {
1365
1379
  id: `assistant_${Date.now()}`,
1366
1380
  role: 'assistant',
1367
1381
  content: textContent || '[Using tools...]',
1368
1382
  timestamp: new Date(),
1369
1383
  toolCalls: allToolCalls.slice(-toolUseBlocks.length),
1370
1384
  ...(response.reasoning ? { metadata: { reasoningContent: response.reasoning } } : {}),
1371
- });
1372
- messages.push({
1385
+ };
1386
+ messages.push(toolUseMessage);
1387
+ turnMessages.push(toolUseMessage);
1388
+ const toolResultMessage = {
1373
1389
  id: `tool_${Date.now()}`,
1374
1390
  role: 'user',
1375
1391
  content: JSON.stringify(toolResults),
1376
1392
  timestamp: new Date(),
1377
1393
  metadata: { toolResult: true },
1378
- });
1394
+ };
1395
+ messages.push(toolResultMessage);
1396
+ turnMessages.push(toolResultMessage);
1379
1397
  // An explicit terminal tool may supply the answer after its bookkeeping
1380
1398
  // succeeds. Keep the tool-use/result pair above for conversation replay.
1381
1399
  if (combined.length === 1 && combined[0].toolCall.status === 'complete') {
@@ -1406,6 +1424,17 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1406
1424
  stopReason = 'interactive';
1407
1425
  break;
1408
1426
  }
1427
+ // A busy user message belongs after the completed tool-use/result pair
1428
+ // and before the next inference. Keep terminal tools and exhausted turns
1429
+ // on the normal next-turn path because no model request remains here.
1430
+ if (iterations < this.config.maxIterations) {
1431
+ const steering = takeSteering?.() ?? [];
1432
+ if (steering.length > 0) {
1433
+ messages.push(...steering);
1434
+ turnMessages.push(...steering);
1435
+ acceptedSteering = true;
1436
+ }
1437
+ }
1409
1438
  }
1410
1439
  catch (err) {
1411
1440
  const error = err.message;
@@ -1459,6 +1488,7 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1459
1488
  usage: totalUsage || undefined,
1460
1489
  stopReason,
1461
1490
  ...(finalReasoning ? { reasoning: finalReasoning } : {}),
1491
+ ...(acceptedSteering ? { turnMessages } : {}),
1462
1492
  };
1463
1493
  }
1464
1494
  /**
@@ -1790,6 +1820,19 @@ INVESTIGATION STRATEGY — before making tool calls to explore or debug, follow
1790
1820
  if (statusCode === 403 || errorType === 'PERMISSION_DENIED') {
1791
1821
  return { retryable: false, reason: admissionMessage || 'Permission denied' };
1792
1822
  }
1823
+ // Router billing admission (402 billing_required) is the first thing an
1824
+ // unsubscribed user hits on the default route, so say what to do. Only the
1825
+ // Router's H3 envelope may supply its public wording; any other 402 body
1826
+ // stays private behind the canonical message.
1827
+ if (statusCode === 402) {
1828
+ const guidance = 'Subscribe in Zerg Router (Dashboard → Billing), or choose another model with /model.';
1829
+ return {
1830
+ retryable: false,
1831
+ reason: admissionMessage
1832
+ ? `${admissionMessage.replace(/\.?\s*$/, '.')} ${guidance}`
1833
+ : `This model needs an active Zerg Router subscription. ${guidance}`,
1834
+ };
1835
+ }
1793
1836
  // Keep the Router's setup failure actionable without echoing arbitrary
1794
1837
  // remote bodies. Only these known provider names can enter the message.
1795
1838
  const missingRouterKey = typeof errorMessage === 'string'
@@ -2,12 +2,9 @@
2
2
  * Account pool — model + resolution for switchable Claude Code + Codex
3
3
  * subscription accounts.
4
4
  *
5
- * S0 scope: turn the configured active account (per provider) into the
6
- * backend-construction options consumed by {@link createBackendForProvider}.
7
- * The lever is pure path/env redirection — ZTC never touches tokens; each
8
- * official CLI (or the direct auth manager) refreshes its own creds in its own
9
- * per-account dir/file. Later slices add the `/accounts` ops and the rotation
10
- * engine on top of this module.
5
+ * Resolve each active account to an isolated credential path for the backend.
6
+ * Official CLI logins write each profile's credentials; this module probes
7
+ * their files and builds the corresponding process environment.
11
8
  */
12
9
  import type { Account, AccountProvider, AccountRotation, AccountSource, ZTCConfig } from '../../config/types.js';
13
10
  /** Root dir for ztc-owned per-account credential dirs. */
@@ -69,6 +66,8 @@ export declare function defaultSourceFor(provider: AccountProvider, dir: string)
69
66
  export declare function sourceDir(source: AccountSource): string;
70
67
  /** Create an account's credential dir (0700) if missing. Returns the dir. */
71
68
  export declare function ensureAccountDir(source: AccountSource): string;
69
+ /** A registered Codex profile already reading this same credential directory. */
70
+ export declare function findCodexHomeCollision(accounts: readonly Account[], source: AccountSource): Account | undefined;
72
71
  /**
73
72
  * Whether an account holds ISOLATED (per-account) credentials. Always probes
74
73
  * the account's EXPLICIT path — never the cached default — and for the Claude
@@ -2,22 +2,21 @@
2
2
  * Account pool — model + resolution for switchable Claude Code + Codex
3
3
  * subscription accounts.
4
4
  *
5
- * S0 scope: turn the configured active account (per provider) into the
6
- * backend-construction options consumed by {@link createBackendForProvider}.
7
- * The lever is pure path/env redirection — ZTC never touches tokens; each
8
- * official CLI (or the direct auth manager) refreshes its own creds in its own
9
- * per-account dir/file. Later slices add the `/accounts` ops and the rotation
10
- * engine on top of this module.
5
+ * Resolve each active account to an isolated credential path for the backend.
6
+ * Official CLI logins write each profile's credentials; this module probes
7
+ * their files and builds the corresponding process environment.
11
8
  */
12
9
  import { spawnSync } from 'node:child_process';
13
- import { mkdirSync } from 'node:fs';
10
+ import { mkdirSync, realpathSync } from 'node:fs';
14
11
  import { homedir } from 'node:os';
15
- import { basename, dirname, join } from 'node:path';
12
+ import { basename, dirname, join, resolve } from 'node:path';
16
13
  import { normalizeProviderId } from './provider_ids.js';
17
14
  import { hasUsableClaudeCodeAuth } from './claude_code_auth.js';
18
15
  import { hasUsableCodexAuth } from './codex_auth.js';
16
+ import { CODEX_SUBSCRIPTION_CONFIG_ARGS, buildCodexAppServerEnv } from './codex_app_server.js';
19
17
  import { hasUsableKimiCodeAuth } from './kimi_code_auth.js';
20
18
  import { runtimeDiagOnce } from '../../utils/debug_log.js';
19
+ import { profileConfigDir } from '../../config/profiles.js';
21
20
  /** Root dir for ztc-owned per-account credential dirs. */
22
21
  export const ACCOUNTS_ROOT = join(homedir(), '.ztc', 'accounts');
23
22
  const ACCOUNT_PROVIDERS = [
@@ -107,7 +106,10 @@ export function resolveRotation(config) {
107
106
  // ---------------------------------------------------------------------------
108
107
  /** Default ztc-owned credential dir for a freshly-added account. */
109
108
  export function defaultAccountDir(provider, id) {
110
- return join(ACCOUNTS_ROOT, provider, id);
109
+ // Resolve at call time, matching ConfigStore's ZTC_CONFIG_DIR override. This
110
+ // keeps managed credentials beside the selected config, including test homes.
111
+ const root = join(profileConfigDir(), 'accounts');
112
+ return join(root, provider, id);
111
113
  }
112
114
  /**
113
115
  * The {@link AccountSource} for a provider whose credentials live under `dir`.
@@ -126,9 +128,9 @@ export function defaultSourceFor(provider, dir) {
126
128
  case 'claude_code':
127
129
  return { kind: 'claude_config_dir', path: dir };
128
130
  case 'codex_app_server':
129
- return { kind: 'codex_home', path: dir };
131
+ return { kind: 'codex_home', path: resolve(dir) };
130
132
  case 'codex_legacy':
131
- return { kind: 'codex_auth_file', path: basename(dir) === 'auth.json' ? dir : join(dir, 'auth.json') };
133
+ return { kind: 'codex_auth_file', path: basename(dir) === 'auth.json' ? resolve(dir) : join(resolve(dir), 'auth.json') };
132
134
  case 'claude_code_direct':
133
135
  return {
134
136
  kind: 'claude_creds_file',
@@ -162,6 +164,24 @@ export function ensureAccountDir(source) {
162
164
  mkdirSync(dir, { recursive: true, mode: 0o700 });
163
165
  return dir;
164
166
  }
167
+ function canonicalAccountHome(path) {
168
+ // `.native` returns the on-disk case, so `CodexA` and `codexa` on a
169
+ // case-insensitive volume resolve to the same credential directory.
170
+ try {
171
+ return realpathSync.native(path);
172
+ }
173
+ catch {
174
+ return resolve(path);
175
+ }
176
+ }
177
+ /** A registered Codex profile already reading this same credential directory. */
178
+ export function findCodexHomeCollision(accounts, source) {
179
+ if (source.kind !== 'codex_home' && source.kind !== 'codex_auth_file')
180
+ return undefined;
181
+ const home = canonicalAccountHome(sourceDir(source));
182
+ return accounts.find((account) => (account.source.kind === 'codex_home' || account.source.kind === 'codex_auth_file')
183
+ && canonicalAccountHome(sourceDir(account.source)) === home);
184
+ }
165
185
  /**
166
186
  * Whether an account holds ISOLATED (per-account) credentials. Always probes
167
187
  * the account's EXPLICIT path — never the cached default — and for the Claude
@@ -258,6 +278,10 @@ export function warnSharedKeychainAccount(account, keychainPresent = sharedClaud
258
278
  + `share that one login — switching or rotating between them has no effect until each `
259
279
  + `logs in per-account (/accounts).`);
260
280
  }
281
+ /** Quote a path for the displayed POSIX shell command. */
282
+ function shellQuote(value) {
283
+ return `'${value.replaceAll("'", "'\\''")}'`;
284
+ }
261
285
  export function buildLoginCommand(account) {
262
286
  const source = account.source;
263
287
  switch (source.kind) {
@@ -274,11 +298,12 @@ export function buildLoginCommand(account) {
274
298
  case 'codex_home':
275
299
  case 'codex_auth_file': {
276
300
  const dir = sourceDir(source);
301
+ const args = [...CODEX_SUBSCRIPTION_CONFIG_ARGS, 'login'];
277
302
  return {
278
303
  command: 'codex',
279
- args: ['login'],
280
- env: { ...process.env, CODEX_HOME: dir },
281
- display: `CODEX_HOME=${dir} codex login`,
304
+ args,
305
+ env: buildCodexAppServerEnv(dir),
306
+ display: `CODEX_HOME=${shellQuote(dir)} codex ${args.map((arg) => arg === '-c' || arg === 'login' ? arg : shellQuote(arg)).join(' ')}`,
282
307
  };
283
308
  }
284
309
  case 'kimi_creds_file': {
@@ -11,6 +11,7 @@
11
11
  import type { AccountStateStore } from './account_state.js';
12
12
  import type { AgentBackend, BackendRequest, BackendResponse, StreamEvent } from './types.js';
13
13
  import type { Account } from '../../config/types.js';
14
+ import type { Message } from '../../types.js';
14
15
  export type RotationMode = 'failover' | 'round_robin';
15
16
  export interface AccountPoolBindings {
16
17
  /** Every configured account. */
@@ -99,7 +100,9 @@ export declare class RotatingBackend implements AgentBackend {
99
100
  * close() can also dispose one that is still mid-turn.
100
101
  */
101
102
  private current;
103
+ private steeringAccepted;
102
104
  constructor(opts: RotatingBackendOptions);
105
+ steerUserMessage(message: Message): Promise<boolean>;
103
106
  /** Close a per-turn inner and forget it as current if it still is. */
104
107
  private releaseInner;
105
108
  /**
@@ -120,14 +120,23 @@ export class RotatingBackend {
120
120
  * close() can also dispose one that is still mid-turn.
121
121
  */
122
122
  current = null;
123
+ steeringAccepted = false;
123
124
  constructor(opts) {
124
125
  this.opts = opts;
125
126
  this.now = opts.now ?? (() => Date.now());
126
127
  }
128
+ async steerUserMessage(message) {
129
+ const accepted = await (this.current?.steerUserMessage?.(message) ?? false);
130
+ if (accepted)
131
+ this.steeringAccepted = true;
132
+ return accepted;
133
+ }
127
134
  /** Close a per-turn inner and forget it as current if it still is. */
128
135
  releaseInner(inner) {
129
- if (this.current === inner)
136
+ if (this.current === inner) {
130
137
  this.current = null;
138
+ this.steeringAccepted = false;
139
+ }
131
140
  inner.close?.();
132
141
  }
133
142
  /**
@@ -220,6 +229,7 @@ export class RotatingBackend {
220
229
  if (!account)
221
230
  throw this.terminalError();
222
231
  const inner = this.current = this.opts.buildInner(account);
232
+ this.steeringAccepted = false;
223
233
  try {
224
234
  return await inner.generate(req);
225
235
  }
@@ -240,7 +250,7 @@ export class RotatingBackend {
240
250
  const next = this.rotate(account, sig);
241
251
  // A dynamic tool already ran this turn -> replaying re-fires its side
242
252
  // effect. Cool the failed account for the next turn, but surface this error.
243
- if (replayUnsafe)
253
+ if (replayUnsafe || this.steeringAccepted)
244
254
  throw error;
245
255
  if (!next)
246
256
  throw this.terminalError();
@@ -263,6 +273,7 @@ export class RotatingBackend {
263
273
  throw this.terminalError();
264
274
  let emitted = false;
265
275
  const inner = this.current = this.opts.buildInner(account);
276
+ this.steeringAccepted = false;
266
277
  try {
267
278
  // generateStream is required on AgentBackend — no fallback probe.
268
279
  for await (const event of inner.generateStream(req)) {
@@ -288,7 +299,7 @@ export class RotatingBackend {
288
299
  const next = this.rotate(account, sig);
289
300
  // Mid-stream partial output OR a dynamic tool that already ran -> can't
290
301
  // replay cleanly. Pool is rotated for the next turn; surface this error.
291
- if (emitted || replayUnsafe)
302
+ if (emitted || replayUnsafe || this.steeringAccepted)
292
303
  throw error;
293
304
  if (!next)
294
305
  throw this.terminalError();
@@ -0,0 +1,42 @@
1
+ export interface CodexQuotaWindow {
2
+ name: string;
3
+ remainingPercent: number;
4
+ resetAt?: number;
5
+ }
6
+ export interface CodexAccountStatus {
7
+ state: 'ready' | 'reauth_required' | 'quota_unavailable' | 'unavailable';
8
+ email?: string;
9
+ plan?: string;
10
+ windows: CodexQuotaWindow[];
11
+ observedAt?: number;
12
+ usage?: {
13
+ lifetimeTokens?: number;
14
+ todayTokens?: number;
15
+ observedAt: number;
16
+ };
17
+ usageStale?: boolean;
18
+ }
19
+ export type CodexStatusProbe = (home: string) => Promise<CodexAccountStatus>;
20
+ /**
21
+ * Minimum spacing between probes of one credential home, even when forced, for
22
+ * the shared mirror cache. A forced read spawns the Codex App Server and
23
+ * refreshes the login's token, so repeated refresh clicks (or a looping client)
24
+ * share one recent probe.
25
+ */
26
+ export declare const CODEX_STATUS_MIN_FORCED_INTERVAL_MS = 30000;
27
+ /** Cache official Codex status per credential home so a mirror poll does not spawn a CLI each time. */
28
+ export declare class CodexStatusCache {
29
+ private readonly probe;
30
+ private readonly entries;
31
+ private readonly pending;
32
+ private readonly generations;
33
+ private readonly minForcedIntervalMs;
34
+ constructor(probe: CodexStatusProbe, options?: {
35
+ minForcedIntervalMs?: number;
36
+ });
37
+ read(home: string, force?: boolean): Promise<CodexAccountStatus>;
38
+ invalidate(home: string): void;
39
+ }
40
+ /** Read identity and quota through the official Codex App Server under one isolated CODEX_HOME. */
41
+ export declare function inspectCodexAccount(home: string): Promise<CodexAccountStatus>;
42
+ export declare const codexStatusCache: CodexStatusCache;