@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
@@ -18,6 +18,8 @@ export interface ConfigController {
18
18
  clearSessionOverrides?: () => void;
19
19
  withConfigScope?: <T>(scope: ZTCConfigScope, fn: () => T | Promise<T>) => Promise<T>;
20
20
  refresh?: () => Promise<void>;
21
+ factoryReset?: () => Promise<void>;
22
+ switchProfile?: (name: string, activate: () => void | Promise<void>) => Promise<void>;
21
23
  hasApiKey: () => boolean;
22
24
  getApiKey: (provider?: string) => string | undefined;
23
25
  setApiKey: (key: string, provider?: string) => void;
@@ -92,6 +94,7 @@ export interface ClipboardController {
92
94
  export interface ModelsController {
93
95
  list: (provider?: string, options?: {
94
96
  signal?: AbortSignal;
97
+ authMode?: 'direct' | 'zrouter';
95
98
  }) => Promise<string[]>;
96
99
  }
97
100
  export interface SkillsController {
@@ -267,12 +270,14 @@ export interface CommandContext {
267
270
  */
268
271
  getContextWindow?: () => number | undefined;
269
272
  /**
270
- * Provider-reported cached input tokens from the last turn, a subset of
271
- * getContextTokens(). Undefined when the provider reported no cache
273
+ * Provider-reported cached input tokens from the last model call, a subset of
274
+ * getCacheInputTokens(). Undefined when the provider reported no cache
272
275
  * accounting — which is not the same as a 0% hit rate, so /context omits the
273
276
  * line rather than reporting a measured miss.
274
277
  */
275
278
  getCachedInputTokens?: () => number | undefined;
279
+ /** Raw provider input for the last model call, the cache rate denominator. */
280
+ getCacheInputTokens?: () => number | undefined;
276
281
  /**
277
282
  * Push a new context size to the bar/state after a CLIENT-side context change
278
283
  * that the backend won't report (e.g. /clear -> 0, /compact -> reduced size).
@@ -285,6 +290,8 @@ export interface CommandContext {
285
290
  reloadAgent: (options?: {
286
291
  defer?: boolean;
287
292
  }) => void;
293
+ /** Reconfigure this running session from another config profile. */
294
+ switchConfigProfile?: (name: string) => Promise<void>;
288
295
  /**
289
296
  * Apply a provider/model selection to the currently running process. This
290
297
  * updates any session-local runtime override before recreating the agent, so
@@ -407,9 +414,19 @@ export interface SettingItem {
407
414
  } | void;
408
415
  }
409
416
  export interface SettingsPanelController {
417
+ /** Release the panel before handing focus to a form or another widget. */
418
+ closeSettingsPanel?: () => void;
410
419
  showSettingsPanel: (options: {
411
420
  title?: string;
412
421
  categories: SettingCategory[];
422
+ /** Render one category as a compact interaction page, without navigation chrome. */
423
+ layout?: 'page';
424
+ /** Validation or save error displayed below the page title. */
425
+ notice?: string;
426
+ /** Select this row when opening the page, for example after validation. */
427
+ initialSetting?: string;
428
+ /** Select a category without narrowing the native category list. */
429
+ initialCategory?: string;
413
430
  initialFilter?: string;
414
431
  onSettingChange: (setting: string, value: string) => void | Promise<void>;
415
432
  /**
@@ -122,7 +122,7 @@ export const askUserTool = {
122
122
  name: 'ask_user',
123
123
  description: `Ask the user one or more questions with predefined options. Use this to get user preferences, clarify requirements, or confirm approach before proceeding.
124
124
 
125
- The user will see a selection widget (or multi-question form for multiple questions) with the options you provide, plus an "Other..." option for custom input. Wait for the user's response before continuing.
125
+ The user will see a selection widget (or multi-question form for multiple questions) with the options you provide and a field for optional additional context. Wait for the user's response before continuing.
126
126
 
127
127
  ## Single question mode
128
128
  Use "question" and "options_json" parameters.
@@ -155,7 +155,7 @@ Example: [{"question": "Which framework?", "options": [{"label": "React", "descr
155
155
  },
156
156
  allow_other: {
157
157
  type: 'boolean',
158
- description: 'Whether to show an "Other..." free-text input option. Defaults to true.',
158
+ description: 'Legacy browser-mirror custom-answer setting. The terminal questionnaire uses an optional note with the selected option.',
159
159
  },
160
160
  },
161
161
  required: [],
@@ -52,7 +52,7 @@ export declare class AgentRunLifecycle {
52
52
  * The callback should contain the renderer-specific event switch.
53
53
  * @returns The agent response and whether it was cancelled
54
54
  */
55
- run(agent: Agent, messages: Message[], onEvent: (event: AgentEvent) => void): Promise<AgentRunResult>;
55
+ run(agent: Agent, messages: Message[], onEvent: (event: AgentEvent) => void, takeSteering?: () => Message[]): Promise<AgentRunResult>;
56
56
  /**
57
57
  * Cancel the currently active run (if any).
58
58
  * Aborts the in-flight API call and stops event delivery.
@@ -140,7 +140,7 @@ export class AgentRunLifecycle {
140
140
  * The callback should contain the renderer-specific event switch.
141
141
  * @returns The agent response and whether it was cancelled
142
142
  */
143
- async run(agent, messages, onEvent) {
143
+ async run(agent, messages, onEvent, takeSteering) {
144
144
  // Abort previous run if still in flight to avoid concurrent API calls
145
145
  if (this.abortController) {
146
146
  this.abortController.abort();
@@ -160,7 +160,7 @@ export class AgentRunLifecycle {
160
160
  // iterates. Renderers own their controller state and persist the visible
161
161
  // final turn themselves, so keep those API-only mutations off the live
162
162
  // transcript array.
163
- const response = await agent.run(messages.slice(), controller.signal);
163
+ const response = await agent.run(messages.slice(), controller.signal, takeSteering);
164
164
  const cancelled = !isActive();
165
165
  return { response: cancelled ? null : response, cancelled };
166
166
  }
@@ -0,0 +1,3 @@
1
+ import type { AskUserQuestion } from '../agent/tools/ask_user.js';
2
+ /** Keep the chosen option distinct from any prose the user adds. */
3
+ export declare function formatAskUserResponse(questions: AskUserQuestion[], answers: Record<string, string | string[]>): string;
@@ -0,0 +1,16 @@
1
+ /** Keep the chosen option distinct from any prose the user adds. */
2
+ export function formatAskUserResponse(questions, answers) {
3
+ const values = questions.map((question, index) => {
4
+ const answer = answers[`q${index}`];
5
+ const note = answers[`q${index}_note`];
6
+ return {
7
+ question: question.question,
8
+ answer: Array.isArray(answer) ? answer.join(', ') : (answer || '(no answer)'),
9
+ ...(typeof note === 'string' && note.trim() ? { note } : {}),
10
+ };
11
+ });
12
+ if (values.length !== 1)
13
+ return JSON.stringify(values);
14
+ const { answer, note } = values[0];
15
+ return note ? `${answer}\n\nAdditional context: ${note}` : answer;
16
+ }
@@ -30,7 +30,7 @@ export function routeUserInput(text) {
30
30
  }
31
31
  // Slash commands (but not file paths)
32
32
  if (text.startsWith('/') && !FILE_PATH_PREFIXES.some(p => text.startsWith(p))) {
33
- const parts = text.slice(1).split(/\s+/);
33
+ const parts = text.slice(1).trimEnd().split(/\s+/);
34
34
  const cmd = parts[0];
35
35
  if (!cmd.includes('/')) {
36
36
  return { type: 'command', cmd, args: parts.slice(1) };
@@ -45,7 +45,8 @@ export function routeUserInput(text) {
45
45
  export function buildModelsController(config) {
46
46
  return {
47
47
  list: async (provider, options) => {
48
- const llm = config.resolveLLMBackendConfig({ provider });
48
+ const llm = config.resolveLLMBackendConfig({ provider,
49
+ config: options?.authMode ? { ...config.get(), authMode: options.authMode } : undefined });
49
50
  // Preserve discovery failures so callers can distinguish an unavailable
50
51
  // catalog from a successful provider response with zero supported models.
51
52
  return listModels({
@@ -174,10 +175,36 @@ export function buildAutoSelectWidgets() {
174
175
  },
175
176
  };
176
177
  }
178
+ /** Session controls do not execute the plan or change project files. */
179
+ const PLAN_MODE_SESSION_COMMANDS = new Set([
180
+ 'accounts',
181
+ 'auth',
182
+ 'config',
183
+ 'context',
184
+ 'effort',
185
+ 'fast',
186
+ 'help',
187
+ 'keybindings',
188
+ 'keymap',
189
+ 'login',
190
+ 'logout',
191
+ 'model',
192
+ 'models',
193
+ 'permissions',
194
+ 'profile',
195
+ 'settings',
196
+ 'shortcuts',
197
+ 'skills',
198
+ 'status',
199
+ 'theme',
200
+ 'thinking',
201
+ ]);
177
202
  export function getPlanModeCommandDenial(currentMode, cmd, args) {
178
203
  if (currentMode !== 'plan')
179
204
  return null;
180
205
  const normalizedCmd = cmd.toLowerCase();
206
+ if (PLAN_MODE_SESSION_COMMANDS.has(normalizedCmd))
207
+ return null;
181
208
  if (normalizedCmd === 'execute')
182
209
  return null;
183
210
  if (normalizedCmd === 'plan')
@@ -190,17 +217,6 @@ export function getPlanModeCommandDenial(currentMode, cmd, args) {
190
217
  return null;
191
218
  if (normalizedCmd === 'modes')
192
219
  return null;
193
- if (normalizedCmd === 'auth') {
194
- if (args.length === 1 && args[0].toLowerCase() === 'login')
195
- return null;
196
- }
197
- if (normalizedCmd === 'config') {
198
- const subcommand = args[0]?.toLowerCase();
199
- if (subcommand === 'provider' && args.length === 2)
200
- return null;
201
- if (subcommand === 'key' && (args.length === 2 || args.length === 3))
202
- return null;
203
- }
204
220
  if (normalizedCmd === 'mode') {
205
221
  if (args.length === 0)
206
222
  return null;
@@ -7,7 +7,7 @@
7
7
  * The state is a single immutable object that gets updated via dispatch().
8
8
  * Subscribers are notified whenever the state changes.
9
9
  */
10
- import type { Message, AgentState, InputMode, MessageInput, QueueEntry, TodoStatus } from '../types.js';
10
+ import type { Message, AgentState, InputMode, MessageInput, QueueEntry, DeliveryIntent, TodoStatus } from '../types.js';
11
11
  import type { InputState } from '../ui/core/input_state.js';
12
12
  import type { ContextStatus } from '../utils/context.js';
13
13
  import type { Skill } from '../skills/types.js';
@@ -262,13 +262,28 @@ export declare class AppController {
262
262
  /**
263
263
  * Enqueue a message. Returns true if accepted, false if queue is full.
264
264
  * Single writer for queue mutations (together with dequeue/remove/clear).
265
+ *
266
+ * `delivery` records when the message should reach the model. It defaults to
267
+ * `end-of-turn`, the conservative choice: an entry whose intent was never
268
+ * stated must wait for the whole turn rather than slip into the middle of one.
265
269
  */
266
- enqueueMessage(input: string | MessageInput): boolean;
270
+ enqueueMessage(input: string | MessageInput, delivery?: DeliveryIntent): boolean;
267
271
  /**
268
272
  * Remove the head of the queue and return it.
269
273
  * Called by the flush-on-idle path.
270
274
  */
271
275
  dequeueMessage(): QueueEntry | undefined;
276
+ /**
277
+ * Remove the OLDEST `subturn` entry and return it, leaving `end-of-turn`
278
+ * entries in place. Returns undefined when nothing is marked for sub-turn
279
+ * delivery.
280
+ *
281
+ * Enter-during-a-turn and Tab-during-a-turn are different promises: the first
282
+ * wants the next tool boundary, the second wants the end of the turn. Failing
283
+ * over to `dequeueMessage` here would let a Tab-queued prompt jump the queue
284
+ * and land mid-turn, so this returns nothing instead and lets the caller wait.
285
+ */
286
+ dequeueSubturnMessage(): QueueEntry | undefined;
272
287
  removeQueueEntry(id: string): void;
273
288
  /** Rewrite a queued entry's text in place. Returns false when the id is no longer queued. */
274
289
  updateQueueEntry(id: string, text: string): boolean;
@@ -413,8 +413,12 @@ export class AppController {
413
413
  /**
414
414
  * Enqueue a message. Returns true if accepted, false if queue is full.
415
415
  * Single writer for queue mutations (together with dequeue/remove/clear).
416
+ *
417
+ * `delivery` records when the message should reach the model. It defaults to
418
+ * `end-of-turn`, the conservative choice: an entry whose intent was never
419
+ * stated must wait for the whole turn rather than slip into the middle of one.
416
420
  */
417
- enqueueMessage(input) {
421
+ enqueueMessage(input, delivery = 'end-of-turn') {
418
422
  if (this.state.queue.length >= MAX_QUEUE)
419
423
  return false;
420
424
  const text = typeof input === 'string' ? input : input.text;
@@ -423,6 +427,7 @@ export class AppController {
423
427
  text,
424
428
  attachments: typeof input === 'string' ? [] : input.attachments,
425
429
  queuedAt: new Date(),
430
+ delivery,
426
431
  };
427
432
  this.dispatch({ type: 'ENQUEUE_MESSAGE', entry });
428
433
  return true;
@@ -438,6 +443,24 @@ export class AppController {
438
443
  this.dispatch({ type: 'DEQUEUE_MESSAGE' });
439
444
  return head;
440
445
  }
446
+ /**
447
+ * Remove the OLDEST `subturn` entry and return it, leaving `end-of-turn`
448
+ * entries in place. Returns undefined when nothing is marked for sub-turn
449
+ * delivery.
450
+ *
451
+ * Enter-during-a-turn and Tab-during-a-turn are different promises: the first
452
+ * wants the next tool boundary, the second wants the end of the turn. Failing
453
+ * over to `dequeueMessage` here would let a Tab-queued prompt jump the queue
454
+ * and land mid-turn, so this returns nothing instead and lets the caller wait.
455
+ */
456
+ dequeueSubturnMessage() {
457
+ const index = this.state.queue.findIndex(entry => entry.delivery === 'subturn');
458
+ if (index < 0)
459
+ return undefined;
460
+ const entry = this.state.queue[index];
461
+ this.dispatch({ type: 'REMOVE_QUEUE_ENTRY', id: entry.id });
462
+ return entry;
463
+ }
441
464
  removeQueueEntry(id) {
442
465
  this.dispatch({ type: 'REMOVE_QUEUE_ENTRY', id });
443
466
  }
@@ -12,6 +12,9 @@ export interface SelectionCompletionView {
12
12
  query?: string;
13
13
  usage?: string;
14
14
  provider?: string;
15
+ mixed?: boolean;
16
+ /** True when this model branch dispatches through Router, not a direct key. */
17
+ routed?: boolean;
15
18
  candidates: SelectionCandidate[];
16
19
  selected: number;
17
20
  firstVisible?: number;
@@ -30,10 +33,13 @@ export declare class SelectionCompletion {
30
33
  private changed;
31
34
  private text;
32
35
  private provider;
36
+ private catalogProvider;
37
+ private catalogRoute;
33
38
  private parsed;
34
39
  private candidates;
35
40
  private selected;
36
41
  private firstVisible;
42
+ private visibleRows;
37
43
  private dismissedInput;
38
44
  private discovered;
39
45
  private loading;
@@ -44,9 +50,10 @@ export declare class SelectionCompletion {
44
50
  private commands;
45
51
  private currentModel?;
46
52
  constructor(changed?: () => void);
47
- update(text: string, provider: string, loadModels?: (signal: AbortSignal) => Promise<string[]>, commands?: SlashCommandUsage[], currentModel?: string): void;
53
+ update(text: string, provider: string, loadModels?: (signal: AbortSignal, provider: string, authMode?: 'direct' | 'zrouter') => Promise<string[]>, commands?: SlashCommandUsage[], currentModel?: string): void;
48
54
  private filter;
49
55
  private revealSelection;
56
+ setVisibleRows(rows: number): void;
50
57
  /** Keep the menu closed for text the user did not type character by character
51
58
  * (a prompt recalled from history). The candidate list would otherwise capture
52
59
  * Up/Down, so the arrows could no longer walk history. Editing the text clears
@@ -66,8 +73,7 @@ export declare class SelectionCompletion {
66
73
  }): {
67
74
  handled: boolean;
68
75
  text?: string;
69
- submit?: boolean;
70
76
  };
71
77
  }
72
78
  export declare const SELECTION_VISIBLE_ROWS = 4;
73
- export declare function selectionCompletionRows(view?: SelectionCompletionView | null): number;
79
+ export declare function selectionCompletionRows(view?: SelectionCompletionView | null, availableRows?: number): number;
@@ -1,5 +1,5 @@
1
1
  import { MODEL_CATALOG } from '../panes/model_catalog.js';
2
- import { canonicalProvider, PROVIDER_CHOICES, ROUTER_PROVIDERS } from '../agent/commands/provider_choices.js';
2
+ import { canRouteThroughRouter, canonicalProvider, PROVIDER_CHOICES } from '../agent/commands/provider_choices.js';
3
3
  import { resolveSlashUsage } from '../agent/commands/usage_spec.js';
4
4
  import { splitInputArgs } from '../agent/commands/usage_parser.js';
5
5
  const fold = (value) => value.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
@@ -42,14 +42,6 @@ function commandUsage(text, commands) {
42
42
  function parseSelectionInput(text, commands) {
43
43
  if (commands.length && /^\/[\w-]*$/.test(text))
44
44
  return { kind: 'command', prefix: '/', query: text.slice(1) };
45
- const provider = text.match(/^\/provider\s+([^\n]*)$/i);
46
- if (provider) {
47
- const routed = provider[1].match(/^(\S+)\s+(.*)$/);
48
- if (routed && canonicalProvider(routed[1]) === 'zergrouter') {
49
- return { kind: 'provider', prefix: `/provider ${routed[1]} `, query: routed[2], routerOnly: true };
50
- }
51
- return { kind: 'provider', prefix: '/provider ', query: provider[1] };
52
- }
53
45
  const model = text.match(/^\/(model|models)\s+([^\n]*)$/i);
54
46
  if (!model)
55
47
  return commandUsage(text, commands);
@@ -60,7 +52,23 @@ function parseSelectionInput(text, commands) {
60
52
  return { kind: 'model', prefix: `/${model[1]} set `, query: arg.replace(/^set\s+/i, '') };
61
53
  if (model[1].toLowerCase() === 'models' || arg.toLowerCase() === 'set')
62
54
  return commandUsage(text, commands);
63
- return { kind: 'model', prefix: '/model ', query: arg };
55
+ const first = arg.match(/^(\S+)(?:\s+(.*))?$/);
56
+ const choice = first && canonicalProvider(first[1]);
57
+ if (choice === 'zergrouter' && first?.[2] !== undefined) {
58
+ const upstream = first[2].match(/^(\S+)(?:\s+(.*))?$/);
59
+ // Router's catalog is the source of truth, so an upstream it serves is
60
+ // accepted even when ZTC ships no PROVIDER_CHOICES entry for it yet. Only
61
+ // the exclusion rule (local logins, nesting, private endpoints) blocks one.
62
+ const selected = upstream && (canonicalProvider(upstream[1]) ?? upstream[1]);
63
+ if (selected && canRouteThroughRouter(selected) && upstream?.[2] !== undefined) {
64
+ return { kind: 'model', prefix: `/model ${first[1]} ${upstream[1]} `, query: upstream[2], modelProvider: selected, routed: true };
65
+ }
66
+ return { kind: 'provider', prefix: `/model ${first[1]} `, query: first[2], routerOnly: true };
67
+ }
68
+ if (choice && first?.[2] !== undefined) {
69
+ return { kind: 'model', prefix: `/model ${first[1]} `, query: first[2], modelProvider: choice, routed: choice === 'modal' };
70
+ }
71
+ return { kind: 'provider', prefix: '/model ', query: arg, includeModels: true };
64
72
  }
65
73
  export function modelSelections(provider, discovered) {
66
74
  const normalized = canonicalProvider(provider) || provider;
@@ -102,10 +110,13 @@ export class SelectionCompletion {
102
110
  changed;
103
111
  text = '';
104
112
  provider = '';
113
+ catalogProvider = '';
114
+ catalogRoute;
105
115
  parsed = null;
106
116
  candidates = [];
107
117
  selected = 0;
108
118
  firstVisible = 0;
119
+ visibleRows = SELECTION_VISIBLE_ROWS;
109
120
  dismissedInput = null;
110
121
  discovered;
111
122
  loading = false;
@@ -122,7 +133,9 @@ export class SelectionCompletion {
122
133
  this.commands = commands;
123
134
  this.currentModel = currentModel;
124
135
  const parsed = parseSelectionInput(text, commands);
125
- if (provider !== this.provider || parsed?.kind !== this.parsed?.kind) {
136
+ const catalogProvider = parsed?.modelProvider ?? provider;
137
+ if (provider !== this.provider || catalogProvider !== this.catalogProvider
138
+ || parsed?.routed !== this.catalogRoute || parsed?.kind !== this.parsed?.kind) {
126
139
  this.requestController?.abort();
127
140
  this.generation++;
128
141
  this.requested = false;
@@ -139,15 +152,18 @@ export class SelectionCompletion {
139
152
  this.dismissedInput = null;
140
153
  this.text = text;
141
154
  this.provider = provider;
155
+ this.catalogProvider = catalogProvider;
156
+ this.catalogRoute = parsed?.routed;
142
157
  this.parsed = parsed;
143
158
  this.filter();
144
- if (parsed?.kind === 'model' && !this.requested && loadModels) {
159
+ if ((parsed?.kind === 'model' || parsed?.includeModels) && !this.requested && loadModels) {
145
160
  this.requested = true;
146
161
  this.loading = true;
147
162
  this.requestController = new AbortController();
148
163
  const signal = this.requestController.signal;
149
164
  const generation = this.generation;
150
- void Promise.resolve().then(() => loadModels(signal)).then(models => {
165
+ const authMode = parsed?.routed === undefined ? undefined : parsed.routed ? 'zrouter' : 'direct';
166
+ void Promise.resolve().then(() => loadModels(signal, catalogProvider, authMode)).then(models => {
151
167
  if (generation !== this.generation)
152
168
  return;
153
169
  this.discovered = models;
@@ -165,10 +181,14 @@ export class SelectionCompletion {
165
181
  }
166
182
  filter() {
167
183
  const selectedValue = this.candidates[this.selected]?.value;
184
+ const models = modelSelections(this.catalogProvider, this.discovered)
185
+ .map(model => ({ ...model, current: this.catalogProvider === this.provider && model.value === this.currentModel }));
186
+ const providers = PROVIDER_CHOICES.filter(p => !this.parsed?.routerOnly || canRouteThroughRouter(p.id))
187
+ .map(p => ({ value: p.id, label: p.label, description: p.description, aliases: p.aliases,
188
+ current: p.id === this.provider, needsArgument: true }));
168
189
  const source = this.parsed?.kind === 'command' ? commandSelections(this.commands)
169
- : this.parsed?.kind === 'model' ? modelSelections(this.provider, this.discovered).map(model => ({ ...model, current: model.value === this.currentModel }))
170
- : PROVIDER_CHOICES.filter(p => !this.parsed?.routerOnly || ROUTER_PROVIDERS.includes(p.id))
171
- .map(p => ({ value: p.id, label: p.label, description: p.description, aliases: p.aliases, current: p.id === this.provider }));
190
+ : this.parsed?.kind === 'model' ? models
191
+ : this.parsed?.includeModels ? [...providers, ...models] : providers;
172
192
  this.candidates = this.parsed && this.parsed.kind !== 'usage' ? fuzzySelections(source, this.parsed.query) : [];
173
193
  const previousIndex = this.candidates.findIndex(c => c.value === selectedValue);
174
194
  this.selected = previousIndex >= 0 ? previousIndex : 0;
@@ -176,8 +196,13 @@ export class SelectionCompletion {
176
196
  }
177
197
  revealSelection() {
178
198
  this.firstVisible = Math.max(0, Math.min(this.firstVisible, this.selected));
179
- if (this.selected >= this.firstVisible + SELECTION_VISIBLE_ROWS)
180
- this.firstVisible = this.selected - SELECTION_VISIBLE_ROWS + 1;
199
+ if (this.selected >= this.firstVisible + this.visibleRows)
200
+ this.firstVisible = this.selected - this.visibleRows + 1;
201
+ this.firstVisible = Math.min(this.firstVisible, Math.max(0, this.candidates.length - this.visibleRows));
202
+ }
203
+ setVisibleRows(rows) {
204
+ this.visibleRows = Math.max(1, Math.floor(rows));
205
+ this.revealSelection();
181
206
  }
182
207
  /** Keep the menu closed for text the user did not type character by character
183
208
  * (a prompt recalled from history). The candidate list would otherwise capture
@@ -193,7 +218,9 @@ export class SelectionCompletion {
193
218
  return null;
194
219
  return { candidates: this.candidates, selected: this.selected, firstVisible: this.firstVisible, loading: this.loading, unavailable: this.unavailable,
195
220
  kind: this.parsed.kind, query: this.parsed.query, usage: this.parsed.usage,
196
- provider: this.parsed.kind === 'model' ? this.provider : undefined };
221
+ provider: this.parsed.kind === 'model' ? this.catalogProvider : undefined,
222
+ routed: this.parsed.kind === 'model' ? this.catalogRoute : undefined,
223
+ mixed: this.parsed.includeModels };
197
224
  }
198
225
  handleKey(key) {
199
226
  if (!this.parsed || key.shift || key.ctrl || key.meta)
@@ -225,15 +252,20 @@ export class SelectionCompletion {
225
252
  if (this.parsed.kind === 'command' && (key.tab || candidate.needsArgument)) {
226
253
  return { handled: true, text: `${completed} ` };
227
254
  }
255
+ if (this.parsed.kind === 'provider' && candidate.needsArgument) {
256
+ return { handled: true, text: `${completed} ` };
257
+ }
258
+ if (key.return && completed === this.text)
259
+ return { handled: false };
228
260
  this.dismissedInput = completed;
229
- return { handled: true, text: completed, ...(key.return ? { submit: true } : {}) };
261
+ return { handled: true, text: completed };
230
262
  }
231
263
  return { handled: false };
232
264
  }
233
265
  }
234
266
  export const SELECTION_VISIBLE_ROWS = 4;
235
- export function selectionCompletionRows(view) {
267
+ export function selectionCompletionRows(view, availableRows = SELECTION_VISIBLE_ROWS + 1) {
236
268
  if (view?.kind === 'usage')
237
269
  return 1;
238
- return view ? Math.max(1, Math.min(SELECTION_VISIBLE_ROWS, view.candidates.length)) + 1 : 0;
270
+ return view ? Math.min(availableRows, Math.max(1, view.candidates.length) + 1) : 0;
239
271
  }
package/dist/cli_args.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { CODEX_REASONING_EFFORTS } from './config/types.js';
2
2
  import { getSupportedCodexReasoningEfforts, unsupportedEffortMessage } from './utils/models.js';
3
+ import { validateProfileName } from './config/profiles.js';
3
4
  const BOOLEAN_FLAGS = new Set([
4
5
  '--no-pin-task-header', '--help', '-h', '--version', '-v', '--list-sessions', '--plan', '--desktop', '--mux', '--no-mux', '--new-window', '--fullscreen', '--pane', '--human-sim-pane', '--zlicky', '--computer-use', '--ml', '--sfx-list', '--voyager-loop', '--mirror', '--open-mirror', '--mirror-open', '--no-browser-open', '--navigator-hidden', '--headless-mirror', '--headless-mirror-open', '--headless-interactive', '--headless', '--json', '--json-stream', '--quiet', '-q', '--timestamps', '--verbose', '--colony',
5
6
  ]);
6
7
  const REQUIRED_VALUE_FLAGS = new Set([
7
- '--cwd', '--name', '--session-id', '--session-file', '--mode', '--agent-domain', '--domain-paths', '--embedded', '--embedded-rom', '--embedded-workflow', '--embedded-runtime-tick-ms', '--embedded-runtime-frame-budget', '--bench', '--bench-workflow', '--num-zergs', '--provider', '--model', '--effort', '--thinking', '--emulation', '--prompt', '--max-iterations', '--timeout', '--output', '--voyager-task', '--voyager-max-rounds', '--mirror-port', '--navigator-group', '--connect-zerg',
8
+ '--cwd', '--name', '--session-id', '--session-file', '--mode', '--agent-domain', '--domain-paths', '--embedded', '--embedded-rom', '--embedded-workflow', '--embedded-runtime-tick-ms', '--embedded-runtime-frame-budget', '--bench', '--bench-workflow', '--num-zergs', '--provider', '--model', '--profile', '--effort', '--thinking', '--emulation', '--prompt', '--max-iterations', '--timeout', '--output', '--voyager-task', '--voyager-max-rounds', '--mirror-port', '--navigator-group', '--connect-zerg',
8
9
  ]);
9
10
  const OPTIONAL_VALUE_FLAGS = new Set(['--resume', '--continue', '-c', '--sfx']);
10
11
  /** Separate prompt text from options before the legacy flag consumers see it. */
@@ -87,6 +88,14 @@ export function validateCliArgs(args) {
87
88
  value: next,
88
89
  };
89
90
  }
91
+ if (token === '--profile') {
92
+ try {
93
+ validateProfileName(next);
94
+ }
95
+ catch (error) {
96
+ return { ok: false, message: error.message, error: 'invalid_profile', flag: token, value: next };
97
+ }
98
+ }
90
99
  if (token === '--effort') {
91
100
  if (!CODEX_REASONING_EFFORTS.includes(next)) {
92
101
  return { ok: false, message: `--effort must be one of: ${CODEX_REASONING_EFFORTS.join(', ')}.`, error: 'invalid_effort', flag: token, value: next };
package/dist/cli_main.js CHANGED
@@ -30,13 +30,71 @@ import { computeHeadlessMirror } from './panes/headless_mode.js';
30
30
  import { parsePromptInvocation, requiresPlanSafeRenderer, validateCliArgs, } from './cli_args.js';
31
31
  import { parseHeadlessInteractionMode } from './app/headless_interaction.js';
32
32
  import { buildCliCommonStarts } from './onboarding/first_run.js';
33
+ import { validateProfileName } from './config/profiles.js';
33
34
  // Parse command line args
34
35
  const args = process.argv.slice(2);
36
+ const profileExplicitAtLaunch = args.includes('--profile') || process.env.ZTC_PROFILE !== undefined;
37
+ const profileFlags = args.flatMap((arg, index) => arg === '--profile' ? [index] : []);
38
+ if (profileFlags.length > 1) {
39
+ console.error('[ztc] Specify --profile only once.');
40
+ process.exit(2);
41
+ }
42
+ if (profileFlags.length === 1) {
43
+ const value = args[profileFlags[0] + 1];
44
+ try {
45
+ process.env.ZTC_PROFILE = validateProfileName(value || '');
46
+ }
47
+ catch (error) {
48
+ console.error(`[ztc] ${error.message}`);
49
+ process.exit(2);
50
+ }
51
+ }
52
+ try {
53
+ validateProfileName(process.env.ZTC_PROFILE || 'default');
54
+ }
55
+ catch (error) {
56
+ console.error(`[ztc] ${error.message}`);
57
+ process.exit(2);
58
+ }
59
+ const commandArgs = profileFlags.length === 0
60
+ ? args
61
+ : args.filter((_, index) => index !== profileFlags[0] && index !== profileFlags[0] + 1);
35
62
  // Device login must work without a TTY, provider credentials, or agent startup.
36
- if (args[0] === 'auth') {
63
+ if (commandArgs[0] === 'auth') {
37
64
  try {
38
65
  const { runRouterAuthCommand } = await import('./router_auth.js');
39
- await runRouterAuthCommand(args.slice(1));
66
+ await runRouterAuthCommand(commandArgs.slice(1));
67
+ }
68
+ catch (error) {
69
+ console.error(`[ztc] ${error.message}`);
70
+ process.exit(1);
71
+ }
72
+ process.exit(0);
73
+ }
74
+ if (commandArgs[0] === 'config' && commandArgs[1] === 'reset') {
75
+ if (commandArgs.length !== 2) {
76
+ console.error('Usage: ztc config reset [--profile <name>]');
77
+ process.exit(2);
78
+ }
79
+ try {
80
+ const { configStore } = await import('./config.js');
81
+ const { createInterface } = await import('node:readline/promises');
82
+ const path = configStore.getConfigPath();
83
+ console.log(`Factory reset settings and ZRouter sign-in for ${path}. This deletes the API keys, OpenAI-compatible endpoint, account registrations, voice keys and MCP servers saved there. Sessions and managed account directories will be kept.`);
84
+ const input = createInterface({ input: process.stdin, output: process.stdout });
85
+ let answer;
86
+ try {
87
+ answer = await input.question('Type RESET to confirm: ');
88
+ }
89
+ finally {
90
+ input.close();
91
+ }
92
+ if (answer !== 'RESET') {
93
+ console.log('Factory reset cancelled.');
94
+ process.exit(1);
95
+ }
96
+ await configStore.factoryReset();
97
+ console.log(`Factory reset complete: ${path}`);
40
98
  }
41
99
  catch (error) {
42
100
  console.error(`[ztc] ${error.message}`);
@@ -177,6 +235,7 @@ ZTC - Zerg Terminal Client v${version}
177
235
 
178
236
  Usage: ztc [options] [prompt]
179
237
  ztc auth <login|status|logout>
238
+ ztc config reset [--profile <name>]
180
239
  ztc resume [id]
181
240
  ztc --branch <workspace-name> [options]
182
241
 
@@ -239,6 +298,8 @@ Options:
239
298
  --bench-workflow <id>
240
299
  Run a bench workflow after opening the workspace
241
300
  --provider <name> LLM provider override (anthropic, claude_code, openai, codex_app_server, codex_legacy, gemini, inception, kimi, kimi_code, etc.; codex is a legacy alias)
301
+ --profile <name> Use named settings in ~/.ztc/profiles/<name>/config.json
302
+ (default: ~/.ztc/config.json). Sessions stay shared.
242
303
  --model <name> LLM model override (e.g., claude-sonnet-4-20250514, gpt-4o)
243
304
  --effort <level> Override saved reasoning effort for this process
244
305
  --thinking <on|off> Enable/disable reasoning when the model supports it
@@ -889,6 +950,27 @@ async function main() {
889
950
  // any non-colony-child process.
890
951
  if (installColonyParentWatchdog())
891
952
  debugLog('[main] Colony parent watchdog armed');
953
+ // Resolve a saved session's profile before loading config, credentials,
954
+ // skills, or MCP. An explicit --profile or ZTC_PROFILE always wins.
955
+ if (!profileExplicitAtLaunch && !explicitSessionFile) {
956
+ const lookup = resumeSessionId || explicitSessionId || nameFlag;
957
+ if (lookup) {
958
+ const { loadMostRecentSession, loadSessionByIdOrName } = await import('./session.js');
959
+ const saved = lookup === 'most-recent'
960
+ ? await loadMostRecentSession(process.cwd())
961
+ : await loadSessionByIdOrName(lookup, process.cwd());
962
+ if (saved?.metadata.configProfile) {
963
+ try {
964
+ process.env.ZTC_PROFILE = validateProfileName(saved.metadata.configProfile);
965
+ }
966
+ catch (error) {
967
+ console.error(`[ztc] Saved session has an invalid config profile: ${error.message}`);
968
+ process.exitCode = 2;
969
+ return;
970
+ }
971
+ }
972
+ }
973
+ }
892
974
  const { configStore, shouldRefreshSuperuserStatus } = await import('./config.js');
893
975
  // Raw (pre-load) config read, same as the historical module-scope read; the
894
976
  // --max-iterations flag parsed at module scope wins over the config value.
@@ -950,6 +1032,7 @@ async function main() {
950
1032
  // Config-level opt-in mirrors the --colony flag (which set ZTC_COLONY_MCP).
951
1033
  if (configStore.get().mcp?.colonyAutoAttach && !process.env.ZTC_COLONY_MCP) {
952
1034
  process.env.ZTC_COLONY_MCP = '1';
1035
+ process.env.ZTC_COLONY_MCP_CONFIG_OWNED = '1';
953
1036
  }
954
1037
  // Only load the MCP client (SDK + zod tree) when there is actually something
955
1038
  // to connect: configured servers or a colony opt-in. initMcpServers with an