@zergai/ztc 0.4.0-beta.1 → 0.4.1-dev.36919234045

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 (92) hide show
  1. package/README.md +8 -1
  2. package/THIRD_PARTY_NOTICES.md +4 -0
  3. package/dist/App.js +2 -2
  4. package/dist/agent/agent.js +41 -5
  5. package/dist/agent/backends/anthropic.js +5 -10
  6. package/dist/agent/backends/gemini.js +2 -0
  7. package/dist/agent/backends/index.d.ts +1 -0
  8. package/dist/agent/backends/index.js +10 -1
  9. package/dist/agent/backends/openai.d.ts +0 -6
  10. package/dist/agent/backends/openai.js +5 -2
  11. package/dist/agent/backends/openai_compatible.d.ts +2 -0
  12. package/dist/agent/backends/openai_compatible.js +18 -8
  13. package/dist/agent/backends/provider_ids.d.ts +1 -0
  14. package/dist/agent/backends/provider_ids.js +4 -0
  15. package/dist/agent/backends/types.d.ts +1 -0
  16. package/dist/agent/backends/zrouter_stream.d.ts +5 -1
  17. package/dist/agent/backends/zrouter_stream.js +141 -37
  18. package/dist/agent/commands/compact.js +6 -5
  19. package/dist/agent/commands/config.js +4 -1
  20. package/dist/agent/commands/context.js +6 -1
  21. package/dist/agent/commands/effort.js +14 -6
  22. package/dist/agent/commands/model.js +12 -2
  23. package/dist/agent/commands/profile.js +21 -2
  24. package/dist/agent/commands/provider_selection.js +14 -1
  25. package/dist/agent/commands/settings.js +45 -7
  26. package/dist/agent/commands/types.d.ts +19 -0
  27. package/dist/agent/factory.js +14 -68
  28. package/dist/agent/subagent.d.ts +0 -10
  29. package/dist/agent/subagent.js +22 -0
  30. package/dist/agent/tool_response_store.d.ts +1 -1
  31. package/dist/agent/tool_response_store.js +4 -2
  32. package/dist/agent/tools/pane.d.ts +0 -7
  33. package/dist/agent/tools/pane.js +5 -4
  34. package/dist/app/command_context.js +33 -10
  35. package/dist/app/model_catalog_cache.d.ts +5 -0
  36. package/dist/app/model_catalog_cache.js +138 -0
  37. package/dist/app/restart.d.ts +1 -1
  38. package/dist/app/restart.js +16 -5
  39. package/dist/app/selection_completion.d.ts +17 -2
  40. package/dist/app/selection_completion.js +74 -6
  41. package/dist/app/session_manager.js +13 -8
  42. package/dist/cli_args.js +2 -16
  43. package/dist/cli_main.js +25 -6
  44. package/dist/cli_ratatui.js +70 -33
  45. package/dist/config/defaults.d.ts +2 -1
  46. package/dist/config/defaults.js +9 -2
  47. package/dist/config/resolved.d.ts +2 -2
  48. package/dist/config.d.ts +35 -3
  49. package/dist/config.js +227 -29
  50. package/dist/headless.js +2 -0
  51. package/dist/onboarding/first_run.js +5 -1
  52. package/dist/panes/model_catalog.d.ts +3 -3
  53. package/dist/panes/model_catalog.js +28 -4
  54. package/dist/panes/pane_registry.js +6 -2
  55. package/dist/panes/types.d.ts +2 -0
  56. package/dist/panes/ztc_launch.js +12 -0
  57. package/dist/session.d.ts +6 -2
  58. package/dist/session.js +65 -10
  59. package/dist/ui/views/completion_menu.js +10 -5
  60. package/dist/ui/views/status_bar.js +9 -5
  61. package/dist/ui/widgets/resume_panel.d.ts +2 -0
  62. package/dist/ui/widgets/resume_panel.js +17 -15
  63. package/dist/utils/compaction.d.ts +1 -1
  64. package/dist/utils/compaction.js +2 -2
  65. package/dist/utils/context.d.ts +4 -4
  66. package/dist/utils/context.js +14 -12
  67. package/dist/utils/deepseek.d.ts +2 -2
  68. package/dist/utils/deepseek.js +13 -5
  69. package/dist/utils/model_cache.d.ts +15 -0
  70. package/dist/utils/model_cache.js +143 -0
  71. package/dist/utils/models.d.ts +4 -2
  72. package/dist/utils/models.js +12 -16
  73. package/dist/utils/reasoning.d.ts +8 -1
  74. package/dist/utils/reasoning.js +31 -11
  75. package/dist/utils/router_catalog.d.ts +47 -0
  76. package/dist/utils/router_catalog.js +222 -0
  77. package/dist/zlm-embed.mjs +1180 -514
  78. package/node_modules/@zerg/model-contracts/LICENSE.md +375 -0
  79. package/node_modules/@zerg/model-contracts/README.md +78 -0
  80. package/node_modules/@zerg/model-contracts/index.d.ts +18 -0
  81. package/node_modules/@zerg/model-contracts/index.js +220 -0
  82. package/node_modules/@zerg/model-contracts/package.json +8 -0
  83. package/node_modules/glob/node_modules/brace-expansion/index.js +78 -22
  84. package/node_modules/glob/node_modules/brace-expansion/package.json +1 -1
  85. package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
  86. package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
  87. package/package.json +9 -6
  88. package/packages/model-contracts/LICENSE.md +375 -0
  89. package/packages/model-contracts/README.md +78 -0
  90. package/packages/model-contracts/index.d.ts +18 -0
  91. package/packages/model-contracts/index.js +220 -0
  92. package/packages/model-contracts/package.json +8 -0
@@ -1,3 +1,5 @@
1
+ import type { ReasoningWireSurface } from '@zerg/model-contracts';
2
+ import type { ReasoningContract } from '../../utils/reasoning.js';
1
3
  import { AgentState, Message, FormFieldDef, type TodoStatus } from '../../types.js';
2
4
  import { ZTCConfig, ZTCAuthMode, ZTCConfigScope, OpenAICompatibleSettings, ZTCPermissionProfile, IntentLayerSettings, WorkflowSettings, CodexReasoningEffort } from '../../config/types.js';
3
5
  import type { AuthContext, ResolvedLLMConfig } from '../../config/resolved.js';
@@ -9,6 +11,11 @@ import type { BenchController } from '../../bench/controller.js';
9
11
  import type { SlashUsage } from './usage_spec.js';
10
12
  import type { KeyEvent } from '../../ui/widgets/types.js';
11
13
  export interface ConfigController {
14
+ getRouterCatalogConnection?: () => import('../../utils/router_catalog.js').RouterCatalogConnection | undefined;
15
+ getRoutedModelCatalog?: () => import('../../utils/router_catalog.js').RouterCatalog | undefined;
16
+ adoptRouterCatalog?(catalog: import('../../utils/router_catalog.js').RouterCatalog): Promise<void>;
17
+ getRouterCatalog?: () => import('../../utils/router_catalog.js').RouterCatalog | undefined;
18
+ getModelContextWindow?: (model?: string, provider?: string) => number | null | undefined;
12
19
  locationLabel?: string;
13
20
  get: () => ZTCConfig;
14
21
  set: <K extends keyof ZTCConfig>(key: K, value: ZTCConfig[K]) => void;
@@ -49,6 +56,8 @@ export interface ConfigController {
49
56
  getModel?: () => string;
50
57
  setModelEffort: (model: string, level: CodexReasoningEffort | null) => void;
51
58
  /** Effort chosen by a startup `--effort` for this process only. */
59
+ getReasoningWireSurface?: (provider?: string, model?: string) => ReasoningWireSurface;
60
+ getReasoningContract?: (provider?: string, model?: string) => ReasoningContract;
52
61
  getCodexEffortOverride?: () => CodexReasoningEffort | undefined;
53
62
  /** Drop the process-only effort override after an explicit in-session choice. */
54
63
  clearCodexEffortOverride?: () => void;
@@ -93,9 +102,13 @@ export interface ClipboardController {
93
102
  writeText: (text: string) => Promise<void>;
94
103
  }
95
104
  export interface ModelsController {
105
+ catalog?: (signal?: AbortSignal, options?: {
106
+ refresh?: boolean;
107
+ }) => Promise<import('../../utils/router_catalog.js').RouterCatalog>;
96
108
  list: (provider?: string, options?: {
97
109
  signal?: AbortSignal;
98
110
  authMode?: 'direct' | 'zrouter';
111
+ refresh?: boolean;
99
112
  }) => Promise<string[]>;
100
113
  }
101
114
  export interface SkillsController {
@@ -293,6 +306,12 @@ export interface CommandContext {
293
306
  }) => void;
294
307
  /** Reconfigure this running session from another config profile. */
295
308
  switchConfigProfile?: (name: string) => Promise<void>;
309
+ /**
310
+ * Copy a profile's settings into a new one and switch to it, returning the
311
+ * new profile's name. Optional exactly as `switchConfigProfile` is: a
312
+ * renderer that cannot switch in place cannot duplicate either.
313
+ */
314
+ duplicateProfile?: (name: string, source: string) => Promise<string>;
296
315
  /**
297
316
  * Apply a provider/model selection to the currently running process. This
298
317
  * updates any session-local runtime override before recreating the agent, so
@@ -1,5 +1,6 @@
1
+ import { readRouterCatalog } from '../utils/router_catalog.js';
1
2
  import { Agent } from './agent.js';
2
- import { OpenAICompatibleBackend, GeminiBackend, ZergAPIBackend, ZRouterStreamBackend, createBackendForProvider } from './backends/index.js';
3
+ import { ZergAPIBackend, ZRouterStreamBackend, createBackendForProvider } from './backends/index.js';
3
4
  import { CODEX_AUTH_SENTINEL } from './backends/codex_auth.js';
4
5
  import { CLAUDE_CODE_AUTH_SENTINEL } from './backends/claude_cli.js';
5
6
  import { KIMI_CODE_AUTH_SENTINEL } from './backends/kimi_code_auth.js';
@@ -232,7 +233,17 @@ export function resolveAgentBackendFromConfig(options) {
232
233
  runtimeDiag('error', '[ztc] Modal requires Zerg Router authentication. Run ztc auth login and use authMode zrouter.');
233
234
  return null;
234
235
  }
235
- if (isCodexAppServerProvider(provider)) {
236
+ if (useProxy) {
237
+ // Route selection is resolved before any provider-specific backend.
238
+ apiKey = proxyToken;
239
+ const connection = configStore.getRouterCatalogConnection(proxyUrl);
240
+ const workspaceId = connection?.token === apiKey ? connection.workspaceId : undefined;
241
+ runtimeDiag('info', `[ztc] routing LLM via ZergRouter proxy ${JSON.stringify({ proxy_url: proxyUrl, provider, has_token: Boolean(proxyToken) })}`);
242
+ backend = new ZRouterStreamBackend({ apiKey, proxyUrl: proxyUrl, provider, conversationId: options.sessionId,
243
+ workspaceId,
244
+ catalog: readRouterCatalog({ origin: proxyUrl, token: apiKey, workspaceId }) });
245
+ }
246
+ else if (isCodexAppServerProvider(provider)) {
236
247
  try {
237
248
  backend = createBackendForProvider(provider, {
238
249
  apiKey: '',
@@ -297,71 +308,6 @@ export function resolveAgentBackendFromConfig(options) {
297
308
  }
298
309
  apiKey = KIMI_CODE_AUTH_SENTINEL;
299
310
  }
300
- else if (useProxy) {
301
- // Proxy mode: stream through the platform ZRouter proxy.
302
- //
303
- // ZRouter emits a UNIFIED custom SSE ({type:delta|usage|done}) for ALL
304
- // providers — on /v1/messages (anthropic) AND /v1/chat/completions (openai
305
- // et al.), NOT the provider-native SSE. So the SDK-shaped backends
306
- // (AnthropicBackend / OpenAICompatibleBackend) can't parse the proxy stream
307
- // (they expect native SSE) and would render empty. ZRouterStreamBackend
308
- // speaks the unified format for every provider, so route all proxy arms
309
- // through it. The one exception is a user-supplied direct Gemini key, which
310
- // hits Google natively and skips the proxy.
311
- apiKey = proxyToken;
312
- // Proof-chain log: confirms the agent chose the router (visible in the
313
- // container's logs via ZergCloud /container-logs). Never logs the token.
314
- runtimeDiag('info', `[ztc] routing LLM via ZergRouter proxy ${JSON.stringify({
315
- proxy_url: proxyUrl,
316
- provider,
317
- has_token: Boolean(proxyToken),
318
- })}`);
319
- // Router forwards this to Modal as a conversation pin, so the turns of one
320
- // conversation keep reusing one container's cached prompt prefix. It is the
321
- // ZTC session id, so it survives `--resume`; the per-process client session
322
- // Router already receives does not. Undefined when the caller has no session
323
- // (the backend then omits the header).
324
- const conversationId = options.sessionId;
325
- switch (provider) {
326
- case 'openai':
327
- backend = new ZRouterStreamBackend({ apiKey, proxyUrl: proxyUrl, provider: 'openai', conversationId });
328
- break;
329
- case 'gemini': {
330
- // Auto mode prefers the user's direct Gemini key; forced Router mode
331
- // keeps its authenticated proxy route. Otherwise
332
- // stream through ZRouter's gemini path. Use the resolver's already-computed
333
- // directApiKey — re-reading the env-first singleton would bypass the
334
- // options.config snapshot / apiKey override the resolver honors.
335
- const geminiDirectKey = directApiKey;
336
- if (geminiDirectKey && authMode !== 'zrouter') {
337
- backend = new GeminiBackend({ apiKey: geminiDirectKey });
338
- apiKey = geminiDirectKey;
339
- }
340
- else {
341
- backend = new ZRouterStreamBackend({ apiKey, proxyUrl: proxyUrl, provider: 'gemini', conversationId });
342
- }
343
- break;
344
- }
345
- case 'inception':
346
- // ZRouter has no inception provider path; keep the OpenAI-compatible shim.
347
- backend = new OpenAICompatibleBackend({ apiKey, baseUrl: `${proxyUrl}/v1`, extraBody: { provider: 'inception' } });
348
- break;
349
- case 'openai_compatible':
350
- backend = new ZRouterStreamBackend({ apiKey, proxyUrl: proxyUrl, provider: 'openai_compatible', conversationId });
351
- break;
352
- case 'kimi':
353
- case 'moonshot':
354
- backend = new ZRouterStreamBackend({ apiKey, proxyUrl: proxyUrl, provider: 'kimi', conversationId });
355
- break;
356
- case 'modal':
357
- case 'deepseek':
358
- backend = new ZRouterStreamBackend({ apiKey, proxyUrl: proxyUrl, provider, conversationId });
359
- break;
360
- case 'anthropic':
361
- default:
362
- backend = new ZRouterStreamBackend({ apiKey, proxyUrl: proxyUrl, provider: 'anthropic', conversationId });
363
- }
364
- }
365
311
  else if (useDirectProvider) {
366
312
  // Use direct provider with user's API key — delegate to shared factory.
367
313
  // For openai_compatible we pass full settings (headers / apiPath) so
@@ -734,7 +680,7 @@ export function createAgentFromConfig(options) {
734
680
  tracer: options.tracer,
735
681
  hooks,
736
682
  approvalRequester: options.hookApprovalRequester,
737
- codexEffort: resolveCodexEffort(options.config, provider, effectiveModel),
683
+ codexEffort: resolveCodexEffort(options.config, provider, effectiveModel, backend.getModelCapabilities?.(effectiveModel)),
738
684
  codexFastMode: options.config.codexFastMode,
739
685
  // Codex panes never defer: dynamicTools are registered once at thread/start,
740
686
  // so a schema hidden behind tool_search would be unreachable for the whole
@@ -1,13 +1,3 @@
1
- /**
2
- * SubAgent - Isolated agent for context-heavy operations
3
- *
4
- * Spawns a separate agent with its own context to handle tasks like:
5
- * - Exploring codebases
6
- * - Searching for patterns
7
- * - Reading multiple files
8
- *
9
- * Only the final summary is returned to the main agent, keeping the main context clean.
10
- */
11
1
  import { AgentBackend } from './backends/index.js';
12
2
  import type { Tool } from './tools/types.js';
13
3
  import type { Policy } from './runtime/policy.js';
@@ -1,3 +1,5 @@
1
+ import { ZRouterStreamBackend } from './backends/zrouter_stream.js';
2
+ import { readRouterCatalog } from '../utils/router_catalog.js';
1
3
  /**
2
4
  * SubAgent - Isolated agent for context-heavy operations
3
5
  *
@@ -44,6 +46,18 @@ function isToolUseBlock(block) {
44
46
  function getSubAgentModel(provider, preferredModel) {
45
47
  if (preferredModel)
46
48
  return preferredModel;
49
+ if (configStore.resolveLLMBackendConfig({ provider }).useProxy) {
50
+ if (configStore.getProvider() === provider)
51
+ return configStore.getModel();
52
+ const catalog = configStore.getRouterCatalog();
53
+ const rows = catalog?.models.filter(row => row.owned_by === provider && (catalog.version === 0 || row.capabilities?.chat));
54
+ const selected = rows?.find(row => row.is_default) ?? rows?.[0];
55
+ if (selected)
56
+ return selected.id;
57
+ if (!configStore.getRouterCatalog() || configStore.getRouterCatalog()?.version === 0)
58
+ return getDefaultModel(provider, 'cheap');
59
+ throw new Error('Select a Router model for this subagent.');
60
+ }
47
61
  return getDefaultModel(provider, 'cheap');
48
62
  }
49
63
  /**
@@ -57,6 +71,12 @@ function getSubAgentModel(provider, preferredModel) {
57
71
  */
58
72
  function createBackend(provider, apiKey) {
59
73
  const config = configStore.get();
74
+ const resolved = configStore.resolveLLMBackendConfig({ provider, apiKeyOverride: apiKey });
75
+ if (resolved.useProxy) {
76
+ return new ZRouterStreamBackend({ apiKey: resolved.authToken, proxyUrl: resolved.proxyUrl, provider,
77
+ workspaceId: configStore.getRouterCatalogConnection()?.workspaceId,
78
+ catalog: readRouterCatalog({ origin: resolved.proxyUrl, token: resolved.authToken, workspaceId: configStore.getRouterCatalogConnection()?.workspaceId }) });
79
+ }
60
80
  const accountOpts = accountBackendOptions(config, provider);
61
81
  const rotationOpts = subagentShouldRotate(resolveRotation(config))
62
82
  ? buildAccountRotation(config) ?? {}
@@ -109,6 +129,8 @@ export class SubAgent {
109
129
  * @param opts Optional overrides (abort signal, custom system prompt, tools)
110
130
  */
111
131
  async run(task, opts) {
132
+ if (this.backend.getModelCapabilities?.(this.model)?.tools === false)
133
+ throw new Error('Chat-only models cannot run tool-dependent subagents.');
112
134
  const signal = opts?.signal;
113
135
  const startTime = Date.now();
114
136
  const timeoutMs = this.config.timeoutMs || 120000; // 2 minute default
@@ -72,7 +72,7 @@ export declare class ToolResponseStore {
72
72
  * fraction of the active model's context window so a small-context model
73
73
  * (gpt-4 8k → 2k effective) doesn't get crushed by a Claude-tuned default.
74
74
  */
75
- effectiveBudget(model: string): number;
75
+ effectiveBudget(model: string, windowOverride?: number | null): number;
76
76
  private cacheBudgetTokens;
77
77
  /**
78
78
  * Swap to a new session directory. Preloads only `meta` from each record
@@ -173,9 +173,11 @@ export class ToolResponseStore {
173
173
  * fraction of the active model's context window so a small-context model
174
174
  * (gpt-4 8k → 2k effective) doesn't get crushed by a Claude-tuned default.
175
175
  */
176
- effectiveBudget(model) {
176
+ effectiveBudget(model, windowOverride) {
177
177
  const configured = this.config.configuredInlineBudgetTokens;
178
- const window = getContextWindowSize(model);
178
+ const window = getContextWindowSize(model, windowOverride);
179
+ if (window === 0)
180
+ return configured;
179
181
  return Math.min(configured, Math.floor(window * PER_MODEL_BUDGET_FRACTION));
180
182
  }
181
183
  cacheBudgetTokens() {
@@ -1,10 +1,3 @@
1
- /**
2
- * Terminal pane management tools for zmux mode.
3
- *
4
- * These tools allow the agent to create, read, write to, close,
5
- * and list terminal panes when running inside zmux.
6
- * They communicate with the zmux Rust binary via the IPC socket.
7
- */
8
1
  import { Tool } from './types.js';
9
2
  import type { PaneCommandTransport } from '../../ui/ratatui/pane_relay.js';
10
3
  import type { RustEvent } from '../../ui/ratatui/protocol.js';
@@ -1,3 +1,4 @@
1
+ import { configStore } from '../../config.js';
1
2
  /**
2
3
  * Terminal pane management tools for zmux mode.
3
4
  *
@@ -440,8 +441,8 @@ export function buildChildZtcPaneLaunch(extraArgs = [], childSessionId = generat
440
441
  i++;
441
442
  }
442
443
  }
443
- const resolved = model ? resolveModelAndProvider(model, provider) : { model: undefined, provider };
444
- const knownProvider = isPaneProvider(resolved.provider) ? resolved.provider : undefined;
444
+ const resolved = model ? resolveModelAndProvider(model, provider, configStore.getResolvedAuthMode()) : { model: undefined, provider };
445
+ const knownProvider = isPaneProvider(resolved.provider, configStore.getResolvedAuthMode()) ? resolved.provider : undefined;
445
446
  const fallbackArgs = [];
446
447
  if (provider && !knownProvider)
447
448
  fallbackArgs.push('--provider', provider);
@@ -681,7 +682,7 @@ export const createZtcPaneTool = {
681
682
  let provider = args.provider ? String(args.provider) : undefined;
682
683
  let model = args.model ? String(args.model) : undefined;
683
684
  if (model) {
684
- const resolved = resolveModelAndProvider(model, provider);
685
+ const resolved = resolveModelAndProvider(model, provider, configStore.getResolvedAuthMode());
685
686
  model = resolved.model;
686
687
  provider = resolved.provider;
687
688
  }
@@ -725,7 +726,7 @@ export const createZtcPaneTool = {
725
726
  sessionMode: 'new',
726
727
  cwd,
727
728
  title,
728
- providerOverride: isPaneProvider(provider) ? provider : undefined,
729
+ providerOverride: isPaneProvider(provider, configStore.getResolvedAuthMode()) ? provider : undefined,
729
730
  modelOverride: model,
730
731
  extraArgs: filterChildZtcArgs(allExtraArgs),
731
732
  });
@@ -1,10 +1,5 @@
1
- /**
2
- * CommandContext Factory
3
- *
4
- * Shared helpers for building CommandContext objects used by command handlers.
5
- * Eliminates duplication between TUI (cli_ratatui.ts) and headless (headless.ts) renderers.
6
- */
7
- import { listModels } from '../utils/models.js';
1
+ import { discoverRouterCatalog, readRouterCatalog, routerModelIds, routerCatalogScope } from '../utils/router_catalog.js';
2
+ import { loadModelCatalog } from './model_catalog_cache.js';
8
3
  import { getSkills } from '../skills/registry.js';
9
4
  import { runShellCommand } from '../utils/shell.js';
10
5
  import { configStore } from '../config.js';
@@ -44,18 +39,46 @@ export function routeUserInput(text) {
44
39
  */
45
40
  export function buildModelsController(config) {
46
41
  return {
42
+ catalog: async (signal, options) => {
43
+ try {
44
+ const connection = config.getRouterCatalogConnection?.();
45
+ if (!connection)
46
+ throw new Error('Router discovery requires a Router login. Run ztc auth login.');
47
+ const scope = routerCatalogScope(connection);
48
+ signal?.throwIfAborted();
49
+ const previous = readRouterCatalog(connection);
50
+ const age = previous === undefined ? Infinity : Date.now() - previous.fetchedAt;
51
+ const catalog = !options?.refresh && previous && !previous.authRequired && age >= 0 && age < 30_000
52
+ ? previous : await discoverRouterCatalog(connection, signal);
53
+ signal?.throwIfAborted();
54
+ const active = config.getRouterCatalogConnection?.();
55
+ if (!active || routerCatalogScope(active) !== scope)
56
+ throw new Error('Router discovery context changed. Retry model discovery.');
57
+ await config.adoptRouterCatalog?.(catalog);
58
+ return catalog;
59
+ }
60
+ catch (error) {
61
+ const cached = config.getRouterCatalog?.();
62
+ if (!signal?.aborted && cached)
63
+ return { ...cached, ...(error instanceof Error && /authentication|expired|login|origin/i.test(error.message) ? { authRequired: true } : {}) };
64
+ throw error;
65
+ }
66
+ },
47
67
  list: async (provider, options) => {
68
+ if ((options?.authMode ?? config.getAuthMode?.()) === 'zrouter') {
69
+ const controller = buildModelsController(config);
70
+ return routerModelIds(await controller.catalog(options?.signal, options), provider ?? config.getProvider());
71
+ }
48
72
  const llm = config.resolveLLMBackendConfig({ provider,
49
73
  config: options?.authMode ? { ...config.get(), authMode: options.authMode } : undefined });
50
- // Preserve discovery failures so callers can distinguish an unavailable
51
- // catalog from a successful provider response with zero supported models.
52
- return listModels({
74
+ return loadModelCatalog({
53
75
  signal: options?.signal,
54
76
  provider: llm.provider,
55
77
  apiKey: llm.apiKey,
56
78
  baseUrl: llm.provider === 'openai_compatible' ? llm.compatBaseUrl : undefined,
57
79
  proxyUrl: llm.useProxy ? llm.proxyUrl : undefined,
58
80
  authToken: llm.useProxy ? llm.authToken : undefined,
81
+ workspaceId: llm.useProxy ? config.getRouterCatalogConnection?.()?.workspaceId : undefined,
59
82
  codexAppServerCommand: llm.codexAppServerCommand,
60
83
  codexAppServerArgs: llm.codexAppServerArgs,
61
84
  codexHome: llm.codexHome,
@@ -0,0 +1,5 @@
1
+ import { listModels } from '../utils/models.js';
2
+ type ListModelsOptions = Parameters<typeof listModels>[0];
3
+ /** Shared discovery for all renderers. Disk rows are previews, not entitlements. */
4
+ export declare function loadModelCatalog(options: ListModelsOptions, onVerified?: (models: string[]) => void): Promise<string[]>;
5
+ export {};
@@ -0,0 +1,138 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { profileConfigDir } from '../config/profiles.js';
3
+ import { listModels } from '../utils/models.js';
4
+ import { clearCachedModels, readCachedModels, readCachedModelsFetchedAt, writeCachedModels } from '../utils/model_cache.js';
5
+ const FRESH_MS = 30_000;
6
+ const OFFLINE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
7
+ const MAX_SCOPES = 64;
8
+ const MAX_CACHED_MODELS = 512;
9
+ const catalogs = new Map();
10
+ let writes = Promise.resolve();
11
+ function scopeFor(options) {
12
+ // Never persist raw credentials. Profiles, endpoints and credential identities
13
+ // are separate catalogs; switching any of them must bypass the old entry.
14
+ const identity = [profileConfigDir(), options.provider, options.proxyUrl?.replace(/\/$/, ''),
15
+ options.authToken, options.workspaceId, options.baseUrl?.replace(/\/$/, ''), options.apiKey,
16
+ options.codexAppServerCommand, options.codexAppServerArgs, options.codexHome,
17
+ options.codexAuthFilePath, options.kimiCredsFilePath];
18
+ return `${options.provider}:${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}`;
19
+ }
20
+ function persist(profile, action) {
21
+ writes = writes.then(async () => {
22
+ // A response can arrive after a profile switch. Do not write that response
23
+ // into the new profile's cache, even under an unreachable hashed scope.
24
+ if (profileConfigDir() === profile)
25
+ await action();
26
+ }).catch(() => { });
27
+ }
28
+ function joinRequest(entry, pending, signal) {
29
+ signal?.throwIfAborted();
30
+ entry.waiters++;
31
+ return new Promise((resolve, reject) => {
32
+ let finished = false;
33
+ const finish = () => {
34
+ if (finished)
35
+ return false;
36
+ finished = true;
37
+ signal?.removeEventListener('abort', abort);
38
+ entry.waiters--;
39
+ if (!entry.waiters && !entry.background)
40
+ entry.controller?.abort();
41
+ return true;
42
+ };
43
+ const abort = () => { if (finish())
44
+ reject(signal.reason); };
45
+ signal?.addEventListener('abort', abort, { once: true });
46
+ pending.then(models => { if (finish())
47
+ resolve([...models]); }, error => { if (finish())
48
+ reject(error); });
49
+ });
50
+ }
51
+ function freshModels(entry) {
52
+ if (entry.models === undefined || entry.checkedAt === null)
53
+ return undefined;
54
+ const age = Date.now() - entry.checkedAt;
55
+ return age >= 0 && age < FRESH_MS ? [...entry.models] : undefined;
56
+ }
57
+ /** Shared discovery for all renderers. Disk rows are previews, not entitlements. */
58
+ export async function loadModelCatalog(options, onVerified) {
59
+ options.signal?.throwIfAborted();
60
+ const profile = profileConfigDir();
61
+ const scope = scopeFor(options);
62
+ let entry = catalogs.get(scope);
63
+ if (entry?.controller?.signal.aborted) {
64
+ catalogs.delete(scope);
65
+ entry = undefined;
66
+ }
67
+ if (!entry) {
68
+ if (catalogs.size >= MAX_SCOPES) {
69
+ const oldest = catalogs.keys().next().value;
70
+ catalogs.get(oldest).controller?.abort();
71
+ catalogs.delete(oldest);
72
+ }
73
+ entry = { checkedAt: null, waiters: 0, background: false, diskChecked: false };
74
+ catalogs.set(scope, entry);
75
+ }
76
+ // Keep the map in access order for bounded eviction.
77
+ catalogs.delete(scope);
78
+ catalogs.set(scope, entry);
79
+ const fresh = freshModels(entry);
80
+ if (fresh !== undefined) {
81
+ onVerified?.([...fresh]);
82
+ return fresh;
83
+ }
84
+ if (entry.models === undefined && !entry.pending && !entry.diskChecked) {
85
+ entry.diskChecked = true;
86
+ const [cached, fetchedAt] = await Promise.all([readCachedModels(scope), readCachedModelsFetchedAt(scope)]);
87
+ options.signal?.throwIfAborted();
88
+ const age = fetchedAt === undefined ? Infinity : Date.now() - fetchedAt;
89
+ if (entry.models === undefined && cached.length && age >= 0 && age < OFFLINE_MAX_AGE_MS)
90
+ entry.models = cached;
91
+ }
92
+ if (catalogs.get(scope) !== entry)
93
+ throw new DOMException('Model discovery was superseded', 'AbortError');
94
+ const afterRead = freshModels(entry);
95
+ if (afterRead !== undefined) {
96
+ onVerified?.([...afterRead]);
97
+ return afterRead;
98
+ }
99
+ const current = entry;
100
+ const refresh = () => {
101
+ if (current.pending)
102
+ return current.pending;
103
+ const controller = new AbortController();
104
+ current.controller = controller;
105
+ const pending = listModels({ ...options, signal: controller.signal }).then(models => {
106
+ controller.signal.throwIfAborted();
107
+ const cacheable = models.length <= MAX_CACHED_MODELS
108
+ && models.every(id => id.length <= 256 && !/[\r\n\0]/.test(id));
109
+ current.models = cacheable ? [...models] : undefined;
110
+ current.checkedAt = Date.now();
111
+ persist(profile, () => models.length && cacheable ? writeCachedModels(scope, models) : clearCachedModels(scope));
112
+ return models;
113
+ }).catch(error => {
114
+ // A denial invalidates the preview. Temporary transport errors retain it.
115
+ if (error instanceof Error && /\((401|403)\)/.test(error.message)) {
116
+ current.models = undefined;
117
+ persist(profile, () => clearCachedModels(scope));
118
+ }
119
+ throw error;
120
+ }).finally(() => {
121
+ if (current.pending === pending) {
122
+ current.pending = undefined;
123
+ current.controller = undefined;
124
+ current.background = false;
125
+ }
126
+ });
127
+ current.pending = pending;
128
+ return pending;
129
+ };
130
+ if (current.models !== undefined) {
131
+ current.background = true;
132
+ void refresh().then(models => onVerified?.([...models])).catch(() => { });
133
+ return [...current.models];
134
+ }
135
+ const models = await joinRequest(current, refresh(), options.signal);
136
+ onVerified?.([...models]);
137
+ return models;
138
+ }
@@ -69,7 +69,7 @@ export declare function reduceDesktopUpdateOwnerHandshake(phase: DesktopUpdateOw
69
69
  */
70
70
  export declare function prepareDesktopUpdateAuthorization(persistSession: (force: true) => Promise<void>, send: (command: DesktopUpdateRestartCommand) => boolean): Promise<DesktopUpdatePreparation>;
71
71
  export declare function buildHotRestartArgs(argv: readonly string[], sessionId: string): string[];
72
- export declare function buildHotRestartLaunch(argv: readonly string[], sessionId: string, execPath: string): HotRestartLaunch;
72
+ export declare function buildHotRestartLaunch(argv: readonly string[], sessionId: string, execPath: string, cwd?: string): HotRestartLaunch;
73
73
  export declare function buildDesktopUpdateRelaunchContext(argv: readonly string[], sessionId: string, execPath: string, cwd: string, ownerPid: number): string;
74
74
  export declare function requestColonyCellRestart(env?: Record<string, string | undefined>, fetchImpl?: typeof fetch): Promise<void>;
75
75
  export {};
@@ -1,3 +1,5 @@
1
+ import { createRequire } from 'node:module';
2
+ import { pathToFileURL } from 'node:url';
1
3
  const RESTART_SESSION_FLAGS = new Set(['--resume', '--continue', '-c', '--session-id']);
2
4
  /**
3
5
  * Reduce native updater protocol events and local persistence outcomes into
@@ -113,14 +115,23 @@ export function buildHotRestartArgs(argv, sessionId) {
113
115
  }
114
116
  return [...next, '--resume', sessionId];
115
117
  }
116
- export function buildHotRestartLaunch(argv, sessionId, execPath) {
118
+ export function buildHotRestartLaunch(argv, sessionId, execPath, cwd) {
117
119
  const relaunchArgs = buildHotRestartArgs(argv, sessionId);
120
+ // The picker can switch workspaces. Override a launcher's original --cwd;
121
+ // cli_main deliberately uses the last occurrence of that flag.
122
+ if (cwd)
123
+ relaunchArgs.push('--cwd', cwd);
118
124
  const entryScript = relaunchArgs[0] || '';
119
- const isTsEntry = entryScript.endsWith('.ts');
125
+ const isTsEntry = /\.[cm]?tsx?$/.test(entryScript);
126
+ // Resolve from this installation, not the selected session's working dir.
127
+ // Direct argv also preserves spaces/metacharacters without a shell or npx.
128
+ const loaderArgs = isTsEntry
129
+ ? ['--import', pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href]
130
+ : [];
120
131
  return {
121
- command: isTsEntry ? 'npx' : execPath,
122
- args: isTsEntry ? ['tsx', ...relaunchArgs] : relaunchArgs,
123
- shell: isTsEntry,
132
+ command: execPath,
133
+ args: [...loaderArgs, ...relaunchArgs],
134
+ shell: false,
124
135
  };
125
136
  }
126
137
  export function buildDesktopUpdateRelaunchContext(argv, sessionId, execPath, cwd, ownerPid) {
@@ -1,3 +1,6 @@
1
+ import { type RouterCatalog } from '../utils/router_catalog.js';
2
+ import { type ReasoningContract } from '../utils/reasoning.js';
3
+ import type { CodexReasoningEffort } from '../config/types.js';
1
4
  import { type SlashCommandUsage } from '../agent/commands/usage_spec.js';
2
5
  export interface SelectionCandidate {
3
6
  value: string;
@@ -8,7 +11,9 @@ export interface SelectionCandidate {
8
11
  needsArgument?: boolean;
9
12
  }
10
13
  export interface SelectionCompletionView {
11
- kind?: 'command' | 'provider' | 'model' | 'usage';
14
+ kind?: 'command' | 'provider' | 'model' | 'usage' | 'reasoning';
15
+ command?: 'effort' | 'thinking';
16
+ model?: string;
12
17
  query?: string;
13
18
  usage?: string;
14
19
  provider?: string;
@@ -22,6 +27,13 @@ export interface SelectionCompletionView {
22
27
  unavailable?: boolean;
23
28
  visibleRows?: number;
24
29
  }
30
+ export interface ReasoningCompletionContext {
31
+ catalog?: RouterCatalog;
32
+ current?: CodexReasoningEffort;
33
+ contract?: ReasoningContract;
34
+ /** Opaque account/profile identity; invalidate definitions when it changes. */
35
+ scope?: string;
36
+ }
25
37
  export declare function fuzzySelections(candidates: SelectionCandidate[], query: string): SelectionCandidate[];
26
38
  export declare function modelSelections(provider: string, discovered?: string[]): SelectionCandidate[];
27
39
  /** Static preview for renderers without a completion controller. Explicit null hides it. */
@@ -49,8 +61,10 @@ export declare class SelectionCompletion {
49
61
  private requestController;
50
62
  private commands;
51
63
  private currentModel?;
64
+ private routerCatalog?;
65
+ private reasoning;
52
66
  constructor(changed?: () => void);
53
- update(text: string, provider: string, loadModels?: (signal: AbortSignal, provider: string, authMode?: 'direct' | 'zrouter') => Promise<string[]>, commands?: SlashCommandUsage[], currentModel?: string): void;
67
+ update(text: string, provider: string, loadModels?: (signal: AbortSignal, provider: string, authMode?: 'direct' | 'zrouter') => Promise<string[]>, commands?: SlashCommandUsage[], currentModel?: string, loadCatalog?: (signal: AbortSignal) => Promise<RouterCatalog>, activeRouted?: boolean, reasoning?: ReasoningCompletionContext): void;
54
68
  private filter;
55
69
  private revealSelection;
56
70
  setVisibleRows(rows: number): void;
@@ -73,6 +87,7 @@ export declare class SelectionCompletion {
73
87
  }): {
74
88
  handled: boolean;
75
89
  text?: string;
90
+ submit?: boolean;
76
91
  };
77
92
  }
78
93
  export declare const SELECTION_VISIBLE_ROWS = 4;