mixdog 0.9.32 → 0.9.34

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 (51) hide show
  1. package/package.json +4 -2
  2. package/scripts/compact-trigger-migration-smoke.mjs +37 -31
  3. package/scripts/provider-toolcall-test.mjs +535 -36
  4. package/src/agents/heavy-worker/AGENT.md +3 -4
  5. package/src/agents/worker/AGENT.md +2 -3
  6. package/src/repl.mjs +9 -1
  7. package/src/rules/shared/01-tool.md +4 -2
  8. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +22 -0
  9. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +21 -2
  10. package/src/runtime/agent/orchestrator/providers/gemini.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +15 -9
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -18
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -12
  15. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +131 -0
  16. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +102 -26
  17. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +194 -9
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -2
  19. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -1
  20. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/session/compact.mjs +3 -0
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +39 -6
  23. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +15 -23
  24. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +28 -0
  25. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +5 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +25 -0
  27. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +16 -8
  28. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +50 -4
  29. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +53 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -2
  31. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +270 -47
  32. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -2
  33. package/src/runtime/channels/lib/output-forwarder.mjs +7 -1
  34. package/src/runtime/channels/lib/owned-runtime.mjs +5 -2
  35. package/src/runtime/memory/lib/embedding-worker.mjs +18 -3
  36. package/src/runtime/memory/lib/memory-embed.mjs +89 -8
  37. package/src/session-runtime/context-status.mjs +7 -6
  38. package/src/session-runtime/mcp-glue.mjs +5 -5
  39. package/src/session-runtime/plugin-mcp.mjs +36 -1
  40. package/src/session-runtime/resource-api.mjs +14 -9
  41. package/src/session-runtime/runtime-core.mjs +0 -10
  42. package/src/tui/app/extension-pickers.mjs +1 -1
  43. package/src/tui/app/model-picker.mjs +48 -12
  44. package/src/tui/app/route-pickers.mjs +4 -0
  45. package/src/tui/app/slash-dispatch.mjs +6 -1
  46. package/src/tui/app/use-transcript-window.mjs +13 -1
  47. package/src/tui/components/StatusLine.jsx +5 -5
  48. package/src/tui/dist/index.mjs +46 -15
  49. package/src/ui/statusline.mjs +4 -10
  50. package/src/vendor/statusline/bin/statusline-route.mjs +4 -5
  51. package/src/vendor/statusline/src/gateway/route-meta.mjs +3 -2
@@ -100,11 +100,11 @@ export function createMcpGlue({
100
100
  async function applyMcpServerConnection(name, enabled) {
101
101
  const target = clean(name);
102
102
  if (!target) return;
103
- const { servers, sources } = resolveEffectiveMcpServers();
104
- // A project-local `.mcp.json` entry WINS over config for this name, so the
105
- // config enable/disable flag doesn't change what's actually connected.
106
- // Only act when the effective entry is config-sourced (or absent).
107
- if (sources[target] && sources[target] !== 'config') return;
103
+ const { servers } = resolveEffectiveMcpServers();
104
+ // The toggle is applied to whichever source drives this name: project
105
+ // `.mcp.json` entries persist their `enabled` flag in that file (project >
106
+ // config precedence) and config entries in mixdog-config, so acting on the
107
+ // effective entry here keeps live state in sync with the durable flag.
108
108
  // Changing this server's state clears any stale failure record for it.
109
109
  if (Array.isArray(state.mcpFailures)) {
110
110
  state.mcpFailures = state.mcpFailures.filter((row) => row.name !== target);
@@ -1,5 +1,5 @@
1
1
  // Plugin/project MCP server discovery + normalization, and skill-file counting.
2
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { clean } from './session-text.mjs';
5
5
  import { readJsonSafe } from './fs-utils.mjs';
@@ -45,6 +45,41 @@ export function readProjectMcpServers(cwd) {
45
45
  return out;
46
46
  }
47
47
 
48
+ // Persist an enable/disable flag for a project-local `.mcp.json` server, in
49
+ // place, preserving the file's shape. Accepts both the standard
50
+ // `{ mcpServers: {...} }` wrapper and a bare name->cfg map. Writes pretty JSON
51
+ // (2-space indent + trailing newline) so the file stays valid + diff-friendly.
52
+ // Throws if the file is missing/unparseable or the server isn't defined there.
53
+ export function setProjectMcpServerEnabled(cwd, name, enabled) {
54
+ const path = join(cwd || '.', '.mcp.json');
55
+ const key = clean(name);
56
+ if (!key) throw new Error('MCP server name is required');
57
+ let raw;
58
+ try {
59
+ raw = JSON.parse(readFileSync(path, 'utf8'));
60
+ } catch (error) {
61
+ throw new Error(`cannot update ${path}: ${error?.message || String(error)}`);
62
+ }
63
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
64
+ throw new Error(`unexpected .mcp.json shape at ${path}`);
65
+ }
66
+ const usesWrapper = raw.mcpServers && typeof raw.mcpServers === 'object' && !Array.isArray(raw.mcpServers);
67
+ const map = usesWrapper ? raw.mcpServers : raw;
68
+ const entryKey = Object.prototype.hasOwnProperty.call(map, key)
69
+ ? key
70
+ : Object.keys(map).reverse().find((candidate) => clean(candidate) === key);
71
+ if (!map || typeof map !== 'object' || Array.isArray(map) || !entryKey) {
72
+ throw new Error(`MCP server not defined in ${path}: ${key}`);
73
+ }
74
+ const entry = map[entryKey];
75
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
76
+ throw new Error(`MCP server is not an object in ${path}: ${key}`);
77
+ }
78
+ map[entryKey] = { ...entry, enabled: enabled !== false };
79
+ writeFileSync(path, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
80
+ return raw;
81
+ }
82
+
48
83
  function isPlainObject(value) {
49
84
  return value !== null && typeof value === 'object' && !Array.isArray(value);
50
85
  }
@@ -17,6 +17,7 @@ import {
17
17
  pluginRawMcpServers,
18
18
  pluginMcpEnableScript,
19
19
  resolveContainedPluginPath,
20
+ setProjectMcpServerEnabled,
20
21
  } from './plugin-mcp.mjs';
21
22
 
22
23
  // MCP servers, skills, plugins, hooks, and memory/recall surfaces. Extracted
@@ -129,6 +130,17 @@ export function createResourceApi(deps) {
129
130
  setMcpServerEnabled(name, enabled) {
130
131
  const serverName = clean(name);
131
132
  if (!serverName) throw new Error('MCP server name is required');
133
+ const want = enabled !== false;
134
+ // A project-local `.mcp.json` entry WINS over config for this name, so the
135
+ // durable toggle must land in whichever file actually drives the server.
136
+ // For project-sourced servers, persist the `enabled` flag into `.mcp.json`
137
+ // (the mtime bump invalidates the project cache), then run the same
138
+ // background connect/recreate chain used for config servers.
139
+ const shadowRow = mcpStatus().servers.find((s) => s.name === serverName);
140
+ if (shadowRow && shadowRow.source === 'project') {
141
+ setProjectMcpServerEnabled(getCurrentCwd(), serverName, want);
142
+ return scheduleMcpToggle(serverName, want);
143
+ }
132
144
  const nextConfig = { ...getConfig() };
133
145
  const current = nextConfig.mcpServers && typeof nextConfig.mcpServers === 'object'
134
146
  ? { ...nextConfig.mcpServers }
@@ -136,20 +148,13 @@ export function createResourceApi(deps) {
136
148
  if (!Object.prototype.hasOwnProperty.call(current, serverName)) {
137
149
  throw new Error(`MCP server not configured: ${serverName}`);
138
150
  }
139
- // A project-local `.mcp.json` entry WINS over config for this name, so a
140
- // config enable/disable flag would persist but never change live state.
141
- // Surface that to the caller instead of reporting a silent success.
142
- const shadowRow = mcpStatus().servers.find((s) => s.name === serverName);
143
- if (shadowRow && shadowRow.source === 'project') {
144
- throw new Error(`'${serverName}' is defined by project .mcp.json — config toggle has no effect`);
145
- }
146
151
  // Adopt + persist config synchronously (fast) so intent is durable, then
147
152
  // hand the heavy connect/close/recreate to the per-server background
148
153
  // chain. Return that chain's promise so callers can settle the picker on
149
154
  // completion, but the store no longer blocks on it.
150
- current[serverName] = { ...(current[serverName] || {}), enabled: enabled !== false };
155
+ current[serverName] = { ...(current[serverName] || {}), enabled: want };
151
156
  saveConfigAndAdopt({ ...nextConfig, mcpServers: current });
152
- return scheduleMcpToggle(serverName, enabled !== false);
157
+ return scheduleMcpToggle(serverName, want);
153
158
  },
154
159
  skillsStatus() {
155
160
  return skillsStatus();
@@ -81,16 +81,6 @@ import {
81
81
  removePlugin as registryRemovePlugin,
82
82
  updatePlugin as registryUpdatePlugin,
83
83
  } from '../standalone/plugin-admin.mjs';
84
- import {
85
- estimateMessagesTokens,
86
- estimateRequestReserveTokens,
87
- estimateTranscriptContextUsage,
88
- estimateToolSchemaTokens,
89
- resolveCompactBufferTokens,
90
- resolveCompactTriggerTokens,
91
- summarizeContextMessages,
92
- } from '../runtime/agent/orchestrator/session/context-utils.mjs';
93
-
94
84
  import {
95
85
  sessionMessageText,
96
86
  messageContextText,
@@ -64,7 +64,7 @@ export function createExtensionPickers({
64
64
  markerColor: enabled ? theme.success : theme.inactive,
65
65
  description: pending
66
66
  ? `${optimistic.enabled ? 'enabling' : 'disabling'}… · ${server.transport || 'unknown'}`
67
- : `${server.status || 'unknown'} · ${server.transport || 'unknown'} · ${server.toolCount || 0} tools${server.error ? ` · ${server.error}` : ''}`,
67
+ : `${server.source || 'config'} · ${server.status || 'unknown'} · ${server.transport || 'unknown'} · ${server.toolCount || 0} tools${server.error ? ` · ${server.error}` : ''}`,
68
68
  _action: 'server',
69
69
  _server: server,
70
70
  _enabled: enabled,
@@ -20,6 +20,12 @@ import {
20
20
  buildProviderModelItems,
21
21
  } from './model-options.mjs';
22
22
 
23
+ // Cached picker opens stay instant, but a catalog older than this is treated as
24
+ // stale: cached rows render immediately and a background force refresh updates
25
+ // the picker in place. Avoids the "stale /model & /agents catalog" without
26
+ // paying a remote provider-list round-trip on every open.
27
+ const MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
28
+
23
29
  export function createModelPicker({
24
30
  store,
25
31
  getState,
@@ -35,6 +41,7 @@ export function createModelPicker({
35
41
  modelSwitchNotice,
36
42
  openProviderSetupPicker,
37
43
  }) {
44
+ let providerModelsTtlRefreshPromise = null;
38
45
  const openModelPicker = async (options = {}) => {
39
46
  const state = getState();
40
47
  setProviderPrompt(null);
@@ -61,6 +68,7 @@ export function createModelPicker({
61
68
  : [];
62
69
  let refreshModelsPromise = null;
63
70
  let renderedQuickModels = false;
71
+ let renderActiveProviderModels = null;
64
72
  if (!providerModels.length || options.refreshModels === true) {
65
73
  setPicker({
66
74
  title: options.title || 'Model',
@@ -88,6 +96,17 @@ export function createModelPicker({
88
96
  }
89
97
  }
90
98
 
99
+ // Served straight from a non-empty UI cache: if that cache is older than the
100
+ // TTL, render the cached rows now and quietly force a background refresh so
101
+ // the catalog can't drift stale. Never applies to the search cache (its own
102
+ // quick paths refresh differently) or explicit refreshModels opens.
103
+ const cacheAt = Number(cacheRef.current.at) || 0;
104
+ const cacheIsStale = providerModels.length > 0
105
+ && options.refreshModels !== true
106
+ && options.cacheRef !== 'search'
107
+ && !refreshModelsPromise
108
+ && (Date.now() - cacheAt) > MODEL_CACHE_TTL_MS;
109
+
91
110
  if (!providerModels || providerModels.length === 0) {
92
111
  store.pushNotice(options.emptyNotice || 'no provider models available; open /providers to sign in', 'warn');
93
112
  void openProviderSetupPicker({
@@ -112,9 +131,11 @@ export function createModelPicker({
112
131
  }
113
132
  const highlightProvider = renderOptions.highlightProvider || providerListHighlightProvider || null;
114
133
  activeModelProvider = null;
134
+ renderActiveProviderModels = null;
115
135
  const openProviderModelsPicker = (provider) => {
116
136
  if (!provider) return;
117
137
  activeModelProvider = provider;
138
+ renderActiveProviderModels = () => openProviderModelsPicker(provider);
118
139
  const providerModels = models.filter((model) => model.provider === provider);
119
140
  const preferredEffort = (values = []) => {
120
141
  const allowed = values.filter(Boolean);
@@ -345,19 +366,34 @@ export function createModelPicker({
345
366
  };
346
367
 
347
368
  renderModelPicker();
369
+ const applyFreshModels = (freshModels) => {
370
+ if (!isActiveModelPicker()) return;
371
+ if (!Array.isArray(freshModels) || freshModels.length === 0) return;
372
+ providerModels = freshModels;
373
+ models = normalizeModelOptions(providerModels);
374
+ cacheRef.current = { models: providerModels, at: Date.now() };
375
+ if (activeModelProvider === null) {
376
+ renderModelPicker();
377
+ } else if (typeof renderActiveProviderModels === 'function') {
378
+ renderActiveProviderModels();
379
+ }
380
+ };
348
381
  if (renderedQuickModels && refreshModelsPromise) {
349
- void refreshModelsPromise
350
- .then((freshModels) => {
351
- if (!isActiveModelPicker()) return;
352
- if (!Array.isArray(freshModels) || freshModels.length === 0) return;
353
- providerModels = freshModels;
354
- models = normalizeModelOptions(providerModels);
355
- cacheRef.current = { models: providerModels, at: Date.now() };
356
- if (activeModelProvider === null) {
357
- renderModelPicker();
358
- }
359
- })
360
- .catch(() => {});
382
+ void refreshModelsPromise.then(applyFreshModels).catch(() => {});
383
+ } else if (cacheIsStale) {
384
+ if (!providerModelsTtlRefreshPromise) {
385
+ providerModelsTtlRefreshPromise = Promise.resolve(loadModels({ force: true }))
386
+ .then((freshModels) => {
387
+ if (Array.isArray(freshModels) && freshModels.length > 0) {
388
+ cacheRef.current = { models: freshModels, at: Date.now() };
389
+ }
390
+ return freshModels;
391
+ })
392
+ .finally(() => {
393
+ providerModelsTtlRefreshPromise = null;
394
+ });
395
+ }
396
+ void providerModelsTtlRefreshPromise.then(applyFreshModels).catch(() => {});
361
397
  }
362
398
  };
363
399
 
@@ -74,6 +74,9 @@ export function createRoutePickers({
74
74
  store.pushNotice(`could not list agents: ${e?.message || e}`, 'error');
75
75
  return;
76
76
  }
77
+ // /agents refresh: force the nested model picker to reload the provider
78
+ // catalog on the next agent open (the agents list itself is always fresh).
79
+ const refreshModels = options.refreshModels === true;
77
80
  const routeOverrides = options.routeOverrides && typeof options.routeOverrides === 'object' ? options.routeOverrides : {};
78
81
  const initialAgentId = clean(options.initialAgentId || '');
79
82
  const items = agents.map((agent) => ({
@@ -104,6 +107,7 @@ export function createRoutePickers({
104
107
  void openModelPicker({
105
108
  title: `${agent.label} Model`,
106
109
  providerDescription: 'Choose a provider for this agent.',
110
+ refreshModels,
107
111
  currentRoute: agent.route || null,
108
112
  returnTo: () => openAgentsPicker(),
109
113
  onImmediateSelect: (routeInput) => {
@@ -71,6 +71,11 @@ export function createSlashDispatch({
71
71
  openModelPicker();
72
72
  return true;
73
73
  }
74
+ if (arg.trim().toLowerCase() === 'refresh') {
75
+ // Explicit catalog reload: force a fresh remote provider list.
76
+ openModelPicker({ refreshModels: true });
77
+ return true;
78
+ }
74
79
  void store.setModel(arg)
75
80
  .then(ok => store.pushNotice(ok ? modelSwitchNotice() : 'Model switch is already running.', ok ? 'info' : 'warn'))
76
81
  .catch((e) => store.pushNotice(`Couldn’t switch model: ${e?.message || e}`, 'error'));
@@ -92,7 +97,7 @@ export function createSlashDispatch({
92
97
  openSearchPicker();
93
98
  return true;
94
99
  case 'agents':
95
- openAgentsPicker();
100
+ openAgentsPicker(arg.trim().toLowerCase() === 'refresh' ? { refreshModels: true } : {});
96
101
  return true;
97
102
  case 'workflow':
98
103
  if (!arg) {
@@ -595,7 +595,19 @@ export function useTranscriptWindow({
595
595
  toolExpanded: toolExpandedFlag,
596
596
  variantKey,
597
597
  });
598
- if (!isStreamingAssistant) changed = true;
598
+ if (!isStreamingAssistant) {
599
+ // First mount (no prior entry): this frame's row index already used the
600
+ // ESTIMATE for this item (measuredTranscriptRows returned null). If Yoga
601
+ // measured exactly what the estimate predicted, the geometry is already
602
+ // correct — bumping measuredRowsVersion would re-run the row-index/window
603
+ // memos for a no-op and repaint the card one frame later (the "settle"
604
+ // jump). Only bump when the measurement actually corrects the estimate.
605
+ // A CHANGED height (prev existed but differs) always bumps.
606
+ const estimateRows = prev
607
+ ? -1
608
+ : estimateTranscriptItemRowsCached(item, frameColumns, toolOutputExpanded);
609
+ if (prev || measured !== estimateRows) changed = true;
610
+ }
599
611
  }
600
612
  if (changed) {
601
613
  // `changed` only flips true when a height actually differs from the
@@ -158,11 +158,11 @@ function localContextPct({
158
158
  : (localNum(contextWindow) > 0
159
159
  ? localNum(contextWindow)
160
160
  : (localNum(rawContextWindow) > 0 ? localNum(rawContextWindow) : 200_000)));
161
- // Trigger-as-denominator (mirrors ui/statusline.mjs resolveContextUsedPct):
162
- // a sub-boundary compaction trigger is the display denominator so the gauge
163
- // reads 100% exactly when auto-compact fires, instead of stalling at ~90%.
164
- const trigger = localNum(autoCompactTokenLimit);
165
- const window = trigger > 0 && trigger < baseWindow ? trigger : baseWindow;
161
+ // Boundary-only denominator (mirrors ui/statusline.mjs resolveContextUsedPct):
162
+ // context % is always measured against the single compaction boundary value,
163
+ // never a separate auto-compact trigger statusline and gateway stay on the
164
+ // same denominator with no before/after trigger semantics.
165
+ const window = baseWindow;
166
166
  const s = stats && typeof stats === 'object' ? stats : {};
167
167
  const source = String(s.currentContextSource || '').toLowerCase();
168
168
  const estimated = localNum(s.currentEstimatedContextTokens);
@@ -3910,8 +3910,7 @@ function localContextPct({
3910
3910
  autoCompactTokenLimit = 0
3911
3911
  } = {}) {
3912
3912
  const baseWindow = localNum(compactBoundaryTokens) > 0 ? localNum(compactBoundaryTokens) : localNum(displayContextWindow) > 0 ? localNum(displayContextWindow) : localNum(contextWindow) > 0 ? localNum(contextWindow) : localNum(rawContextWindow) > 0 ? localNum(rawContextWindow) : 2e5;
3913
- const trigger = localNum(autoCompactTokenLimit);
3914
- const window = trigger > 0 && trigger < baseWindow ? trigger : baseWindow;
3913
+ const window = baseWindow;
3915
3914
  const s = stats && typeof stats === "object" ? stats : {};
3916
3915
  const source = String(s.currentContextSource || "").toLowerCase();
3917
3916
  const estimated = localNum(s.currentEstimatedContextTokens);
@@ -11944,7 +11943,10 @@ function useTranscriptWindow({
11944
11943
  toolExpanded: toolExpandedFlag,
11945
11944
  variantKey
11946
11945
  });
11947
- if (!isStreamingAssistant) changed = true;
11946
+ if (!isStreamingAssistant) {
11947
+ const estimateRows = prev ? -1 : estimateTranscriptItemRowsCached(item, frameColumns, toolOutputExpanded);
11948
+ if (prev || measured !== estimateRows) changed = true;
11949
+ }
11948
11950
  }
11949
11951
  if (changed) {
11950
11952
  setMeasuredRowsVersion((v) => (v + 1) % 1e6);
@@ -13122,7 +13124,7 @@ function createExtensionPickers({
13122
13124
  label: server.name,
13123
13125
  marker: enabled ? "\u25CF" : "\u25CB",
13124
13126
  markerColor: enabled ? theme2.success : theme2.inactive,
13125
- description: pending ? `${optimistic.enabled ? "enabling" : "disabling"}\u2026 \xB7 ${server.transport || "unknown"}` : `${server.status || "unknown"} \xB7 ${server.transport || "unknown"} \xB7 ${server.toolCount || 0} tools${server.error ? ` \xB7 ${server.error}` : ""}`,
13127
+ description: pending ? `${optimistic.enabled ? "enabling" : "disabling"}\u2026 \xB7 ${server.transport || "unknown"}` : `${server.source || "config"} \xB7 ${server.status || "unknown"} \xB7 ${server.transport || "unknown"} \xB7 ${server.toolCount || 0} tools${server.error ? ` \xB7 ${server.error}` : ""}`,
13126
13128
  _action: "server",
13127
13129
  _server: server,
13128
13130
  _enabled: enabled
@@ -15690,6 +15692,7 @@ function createChannelPickers({
15690
15692
  }
15691
15693
 
15692
15694
  // src/tui/app/model-picker.mjs
15695
+ var MODEL_CACHE_TTL_MS = 5 * 60 * 1e3;
15693
15696
  function createModelPicker({
15694
15697
  store,
15695
15698
  getState,
@@ -15705,6 +15708,7 @@ function createModelPicker({
15705
15708
  modelSwitchNotice: modelSwitchNotice2,
15706
15709
  openProviderSetupPicker
15707
15710
  }) {
15711
+ let providerModelsTtlRefreshPromise = null;
15708
15712
  const openModelPicker = async (options = {}) => {
15709
15713
  const state = getState();
15710
15714
  setProviderPrompt(null);
@@ -15729,6 +15733,7 @@ function createModelPicker({
15729
15733
  let providerModels = Array.isArray(cacheRef.current.models) ? cacheRef.current.models : [];
15730
15734
  let refreshModelsPromise = null;
15731
15735
  let renderedQuickModels = false;
15736
+ let renderActiveProviderModels = null;
15732
15737
  if (!providerModels.length || options.refreshModels === true) {
15733
15738
  setPicker({
15734
15739
  title: options.title || "Model",
@@ -15755,6 +15760,8 @@ function createModelPicker({
15755
15760
  return;
15756
15761
  }
15757
15762
  }
15763
+ const cacheAt = Number(cacheRef.current.at) || 0;
15764
+ const cacheIsStale = providerModels.length > 0 && options.refreshModels !== true && options.cacheRef !== "search" && !refreshModelsPromise && Date.now() - cacheAt > MODEL_CACHE_TTL_MS;
15758
15765
  if (!providerModels || providerModels.length === 0) {
15759
15766
  store.pushNotice(options.emptyNotice || "no provider models available; open /providers to sign in", "warn");
15760
15767
  void openProviderSetupPicker({
@@ -15778,9 +15785,11 @@ function createModelPicker({
15778
15785
  }
15779
15786
  const highlightProvider = renderOptions.highlightProvider || providerListHighlightProvider || null;
15780
15787
  activeModelProvider = null;
15788
+ renderActiveProviderModels = null;
15781
15789
  const openProviderModelsPicker = (provider) => {
15782
15790
  if (!provider) return;
15783
15791
  activeModelProvider = provider;
15792
+ renderActiveProviderModels = () => openProviderModelsPicker(provider);
15784
15793
  const providerModels2 = models.filter((model) => model.provider === provider);
15785
15794
  const preferredEffort = (values = []) => {
15786
15795
  const allowed = values.filter(Boolean);
@@ -15999,17 +16008,33 @@ ${model?.id || ""}`;
15999
16008
  });
16000
16009
  };
16001
16010
  renderModelPicker();
16011
+ const applyFreshModels = (freshModels) => {
16012
+ if (!isActiveModelPicker()) return;
16013
+ if (!Array.isArray(freshModels) || freshModels.length === 0) return;
16014
+ providerModels = freshModels;
16015
+ models = normalizeModelOptions(providerModels);
16016
+ cacheRef.current = { models: providerModels, at: Date.now() };
16017
+ if (activeModelProvider === null) {
16018
+ renderModelPicker();
16019
+ } else if (typeof renderActiveProviderModels === "function") {
16020
+ renderActiveProviderModels();
16021
+ }
16022
+ };
16002
16023
  if (renderedQuickModels && refreshModelsPromise) {
16003
- void refreshModelsPromise.then((freshModels) => {
16004
- if (!isActiveModelPicker()) return;
16005
- if (!Array.isArray(freshModels) || freshModels.length === 0) return;
16006
- providerModels = freshModels;
16007
- models = normalizeModelOptions(providerModels);
16008
- cacheRef.current = { models: providerModels, at: Date.now() };
16009
- if (activeModelProvider === null) {
16010
- renderModelPicker();
16011
- }
16012
- }).catch(() => {
16024
+ void refreshModelsPromise.then(applyFreshModels).catch(() => {
16025
+ });
16026
+ } else if (cacheIsStale) {
16027
+ if (!providerModelsTtlRefreshPromise) {
16028
+ providerModelsTtlRefreshPromise = Promise.resolve(loadModels({ force: true })).then((freshModels) => {
16029
+ if (Array.isArray(freshModels) && freshModels.length > 0) {
16030
+ cacheRef.current = { models: freshModels, at: Date.now() };
16031
+ }
16032
+ return freshModels;
16033
+ }).finally(() => {
16034
+ providerModelsTtlRefreshPromise = null;
16035
+ });
16036
+ }
16037
+ void providerModelsTtlRefreshPromise.then(applyFreshModels).catch(() => {
16013
16038
  });
16014
16039
  }
16015
16040
  };
@@ -16616,6 +16641,7 @@ function createRoutePickers({
16616
16641
  store.pushNotice(`could not list agents: ${e?.message || e}`, "error");
16617
16642
  return;
16618
16643
  }
16644
+ const refreshModels = options.refreshModels === true;
16619
16645
  const routeOverrides = options.routeOverrides && typeof options.routeOverrides === "object" ? options.routeOverrides : {};
16620
16646
  const initialAgentId = clean3(options.initialAgentId || "");
16621
16647
  const items = agents.map((agent) => ({
@@ -16646,6 +16672,7 @@ function createRoutePickers({
16646
16672
  void openModelPicker({
16647
16673
  title: `${agent.label} Model`,
16648
16674
  providerDescription: "Choose a provider for this agent.",
16675
+ refreshModels,
16649
16676
  currentRoute: agent.route || null,
16650
16677
  returnTo: () => openAgentsPicker(),
16651
16678
  onImmediateSelect: (routeInput) => {
@@ -17441,6 +17468,10 @@ function createSlashDispatch({
17441
17468
  openModelPicker();
17442
17469
  return true;
17443
17470
  }
17471
+ if (arg.trim().toLowerCase() === "refresh") {
17472
+ openModelPicker({ refreshModels: true });
17473
+ return true;
17474
+ }
17444
17475
  void store.setModel(arg).then((ok) => store.pushNotice(ok ? modelSwitchNotice2() : "Model switch is already running.", ok ? "info" : "warn")).catch((e) => store.pushNotice(`Couldn\u2019t switch model: ${e?.message || e}`, "error"));
17445
17476
  return true;
17446
17477
  case "remote": {
@@ -17453,7 +17484,7 @@ function createSlashDispatch({
17453
17484
  openSearchPicker();
17454
17485
  return true;
17455
17486
  case "agents":
17456
- openAgentsPicker();
17487
+ openAgentsPicker(arg.trim().toLowerCase() === "refresh" ? { refreshModels: true } : {});
17457
17488
  return true;
17458
17489
  case "workflow":
17459
17490
  if (!arg) {
@@ -210,16 +210,10 @@ function resolveContextUsedPct({
210
210
  autoCompactTokenLimit,
211
211
  compact,
212
212
  });
213
- // Trigger-as-denominator: when a sub-boundary compaction trigger
214
- // (boundary - buffer) is known, context % is measured against IT so the
215
- // gauge reads 100% exactly when auto-compact fires instead of stalling at
216
- // ~90% of the boundary window. The gateway's own pct is computed against
217
- // the boundary denominator, so it is bypassed in that case.
218
- const trigger = num(autoCompactTokenLimit);
219
- const triggerDenominator = trigger > 0 && (!(boundary > 0) || trigger < boundary);
220
- if (triggerDenominator && numerator > 0) {
221
- return (numerator / trigger) * 100;
222
- }
213
+ // Boundary-only denominator: context % is always measured against the single
214
+ // compaction boundary value (shared with the gateway), never against a
215
+ // separate auto-compact trigger. This keeps the statusline and gateway
216
+ // display on the same denominator with no before/after trigger semantics.
223
217
  const gatewayRawPct = gatewayStatus?.contextUsedPct;
224
218
  if (
225
219
  gatewayStatus
@@ -11,7 +11,10 @@ import {
11
11
  readLatestGatewayHostRoute,
12
12
  readGatewaySessionRoute,
13
13
  } from '../src/gateway/session-routes.mjs';
14
- import { compactBoundaryDenominator } from '../src/gateway/route-meta.mjs';
14
+ import {
15
+ compactBoundaryDenominator,
16
+ defaultEffectiveContextWindowPercent,
17
+ } from '../src/gateway/route-meta.mjs';
15
18
 
16
19
  function positiveInt(value) {
17
20
  const n = parseInt(String(value || ''), 10);
@@ -189,10 +192,6 @@ function boundedPercent(value, fallback = null) {
189
192
  return fallback;
190
193
  }
191
194
 
192
- function defaultEffectiveContextWindowPercent(_provider) {
193
- return 90;
194
- }
195
-
196
195
  function routeContextMeta(provider, info = {}, inherited = {}) {
197
196
  const rawContextWindow = firstPositiveWindow(
198
197
  inherited.rawContextWindow,
@@ -9,6 +9,7 @@ import {
9
9
  estimateMessagesTokens,
10
10
  estimateRequestReserveTokens,
11
11
  } from '../../../../runtime/agent/orchestrator/session/context-utils.mjs';
12
+ import { DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT } from '../../../../runtime/agent/orchestrator/session/compact/constants.mjs';
12
13
  import { CLAUDE_CURRENT_MODE } from './claude-current.mjs';
13
14
 
14
15
  const GATEWAY_USAGE_FILE = 'gateway-usage.local.json';
@@ -98,12 +99,12 @@ function boundedPercent(value, fallback = null) {
98
99
  return fallback;
99
100
  }
100
101
 
101
- function defaultEffectiveContextWindowPercent(_provider) {
102
+ export function defaultEffectiveContextWindowPercent(_provider) {
102
103
  // Gateway-routed models use catalog/provider context metadata (LiteLLM,
103
104
  // models.dev, or native provider catalogs). Reserve a small universal
104
105
  // headroom for output/tool/system tokens while keeping the raw model window
105
106
  // visible separately.
106
- return 90;
107
+ return DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT;
107
108
  }
108
109
 
109
110
  function effectiveContextWindowPercent(provider, info = {}, seed = {}) {