mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -97,6 +97,7 @@ export function createSessionService({
97
97
  createSessionRuntime = null,
98
98
  sessionExists = null,
99
99
  readStoredSession = null,
100
+ subscribeExternalSessionStates = null,
100
101
  listSessions = null,
101
102
  getRemoteSessionState = null,
102
103
  desktopRuntime = null,
@@ -138,7 +139,7 @@ export function createSessionService({
138
139
  // destroying anything, this sweep is the ONLY reclaim path besides shutdown.
139
140
  const IDLE_EVICT_MS = Number(idleEvictMs) > 0
140
141
  ? Number(idleEvictMs)
141
- : Math.max(60_000, Number(process.env.MIXDOG_SESSION_IDLE_EVICT_MS) || 2 * 60_000);
142
+ : Math.max(60_000, Number(process.env.MIXDOG_SESSION_IDLE_EVICT_MS) || 5 * 60_000);
142
143
  const EVICT_SWEEP_MS = Number(evictSweepMs) > 0 ? Number(evictSweepMs) : 30_000;
143
144
  let evictTimer = null;
144
145
 
@@ -170,6 +171,7 @@ export function createSessionService({
170
171
  // the entry the moment one materializes, so its live frames start flowing
171
172
  // without a second subscribe round-trip.
172
173
  const pendingViewers = new Map(); // sessionId -> Set<clientToken>
174
+ const externalViewEntries = new Map(); // sessionId -> ordinary projected entry
173
175
 
174
176
  function trackPendingViewer(sessionId, ctx) {
175
177
  const token = subscriberToken(ctx);
@@ -216,6 +218,10 @@ export function createSessionService({
216
218
  startEvictionSweep();
217
219
  log(`session ${currentSessionId(entry) || '(creating)'} unwatched (client ${token} gone) — retained`);
218
220
  }
221
+ for (const [sessionId, entry] of [...externalViewEntries]) {
222
+ entry.subscribers?.delete(token);
223
+ if ((entry.subscribers?.size || 0) === 0) externalViewEntries.delete(sessionId);
224
+ }
219
225
  for (const service of desktopServicesById.values()) {
220
226
  service.subscribers.delete(token);
221
227
  }
@@ -428,11 +434,22 @@ export function createSessionService({
428
434
  if (entry) entry.indexedSessionId = '';
429
435
  return '';
430
436
  }
437
+ // External agent projections use the same frame machinery but are not a
438
+ // daemon execution owner. Keeping them out of sessionsById lets a later
439
+ // ordinary session materialization adopt the viewers and take authority.
440
+ if (entry.externalView === true) return nextId;
431
441
  const existing = sessionsById.get(nextId);
432
442
  if (existing && existing !== entry && !existing.disposed) {
433
443
  // Do not redirect an established address to a second session runtime. The load
434
444
  throw new Error(`duplicate session address: ${nextId}`);
435
445
  }
446
+ const external = externalViewEntries.get(nextId);
447
+ if (external) {
448
+ externalViewEntries.delete(nextId);
449
+ for (const token of external.subscribers || []) {
450
+ addSubscriber(entry, { clientToken: token });
451
+ }
452
+ }
436
453
  sessionsById.set(nextId, entry);
437
454
  entry.indexedSessionId = nextId;
438
455
  return nextId;
@@ -460,6 +477,54 @@ export function createSessionService({
460
477
  }, entry.subscribers);
461
478
  }
462
479
 
480
+ function externalEntryForView(sessionId) {
481
+ return externalViewEntries.get(String(sessionId || '')) || null;
482
+ }
483
+
484
+ function publishExternalSessionState(update) {
485
+ const sessionId = String(update?.sessionId || '');
486
+ const snapshot = update?.snapshot;
487
+ if (!sessionId || !snapshot || typeof snapshot !== 'object') return;
488
+ // A daemon-owned runtime is the canonical owner if this address was
489
+ // materialized. External agent projection frames can arrive one tick late
490
+ // after that promotion and must not overwrite it.
491
+ if (sessionOwner(sessionId)) return;
492
+ let entry = externalViewEntries.get(sessionId);
493
+ if (!entry) {
494
+ if (!pendingViewers.has(sessionId)) return;
495
+ let state = { ...snapshot, sessionId };
496
+ const runtime = {
497
+ isWireSafe: true,
498
+ getState: () => state,
499
+ setState: (next) => { state = next; },
500
+ };
501
+ entry = {
502
+ runtime,
503
+ subscribers: new Set(),
504
+ disposed: false,
505
+ timer: null,
506
+ lastPublishedAt: 0,
507
+ publishedSessionId: '',
508
+ indexedSessionId: '',
509
+ addressedSessionId: sessionId,
510
+ revision: revisionEpoch,
511
+ busy: null,
512
+ externalView: true,
513
+ };
514
+ externalViewEntries.set(sessionId, entry);
515
+ adoptPendingViewers(entry, sessionId);
516
+ } else {
517
+ entry.runtime.setState({ ...snapshot, sessionId });
518
+ }
519
+ const step = advance(entry);
520
+ if (step.changed) publishStep(entry, step);
521
+ }
522
+
523
+ const unsubscribeExternalSessionStates =
524
+ typeof subscribeExternalSessionStates === 'function'
525
+ ? subscribeExternalSessionStates(publishExternalSessionState)
526
+ : () => {};
527
+
463
528
  /** Response body for the CALLER, which announced the revision it holds. */
464
529
  function bodyForClient(step, baseRevision) {
465
530
  if (!step.changed && baseRevision === step.revision) return { revision: step.revision };
@@ -911,7 +976,7 @@ export function createSessionService({
911
976
  }
912
977
  const id = String(sessionId || '');
913
978
  if (!id) throw new TypeError('sessionId is required');
914
- const live = await liveEntryForView(id);
979
+ const live = await liveEntryForView(id) || externalEntryForView(id);
915
980
  if (live) {
916
981
  const step = advance(live);
917
982
  retainUnwatched(live, 'headless session read');
@@ -922,7 +987,7 @@ export function createSessionService({
922
987
  }
923
988
  if (typeof readStoredSession === 'function') {
924
989
  const projection = await storedSessionProjection(id, openHints);
925
- const lateOwner = sessionOwner(id);
990
+ const lateOwner = sessionOwner(id) || externalEntryForView(id);
926
991
  if (lateOwner) {
927
992
  const step = advance(lateOwner);
928
993
  retainUnwatched(lateOwner, 'headless session read');
@@ -952,7 +1017,7 @@ export function createSessionService({
952
1017
  ) {
953
1018
  const id = String(sessionId || '');
954
1019
  if (!id) throw new TypeError('sessionId is required');
955
- const live = await liveEntryForView(id);
1020
+ const live = await liveEntryForView(id) || externalEntryForView(id);
956
1021
  if (live) {
957
1022
  addSubscriber(live, ctx);
958
1023
  const step = advance(live);
@@ -964,7 +1029,7 @@ export function createSessionService({
964
1029
  // guarantees either adoption or the live re-check below.
965
1030
  trackPendingViewer(id, ctx);
966
1031
  const projection = await storedSessionProjection(id, openHints);
967
- const lateOwner = sessionOwner(id);
1032
+ const lateOwner = sessionOwner(id) || externalEntryForView(id);
968
1033
  if (lateOwner) {
969
1034
  dropPendingViewer(id, ctx);
970
1035
  addSubscriber(lateOwner, ctx);
@@ -988,7 +1053,15 @@ export function createSessionService({
988
1053
  if (!id) throw new TypeError('sessionId is required');
989
1054
  dropPendingViewer(id, ctx);
990
1055
  const owner = sessionOwner(id);
991
- if (!owner) return { sessionId: id, unsubscribed: true };
1056
+ if (!owner) {
1057
+ const external = externalEntryForView(id);
1058
+ if (external) {
1059
+ const token = subscriberToken(ctx);
1060
+ if (token) external.subscribers?.delete(token);
1061
+ if ((external.subscribers?.size || 0) === 0) externalViewEntries.delete(id);
1062
+ }
1063
+ return { sessionId: id, unsubscribed: true };
1064
+ }
992
1065
  const entry = owner;
993
1066
  const token = subscriberToken(ctx);
994
1067
  if (token) entry.subscribers?.delete(token);
@@ -1280,6 +1353,8 @@ export function createSessionService({
1280
1353
 
1281
1354
  async function stop(reason = 'service stop') {
1282
1355
  closed = true;
1356
+ try { unsubscribeExternalSessionStates(); } catch {}
1357
+ externalViewEntries.clear();
1283
1358
  if (evictTimer) { clearInterval(evictTimer); evictTimer = null; }
1284
1359
  const services = [...new Set(desktopServicesById.values())];
1285
1360
  desktopServicesById.clear();
@@ -301,6 +301,7 @@ export function renderAppView(ctx) {
301
301
  indexMode={picker.indexMode}
302
302
  visibleCount={pickerVisibleRows}
303
303
  fillHeight={expandedOptionPanel}
304
+ loading={picker.loading === true}
304
305
  themeEpoch={state.themeEpoch || 0}
305
306
  confirmBar={picker.confirmBar}
306
307
  />
@@ -39,6 +39,7 @@ export function createCoreMemoryPicker({
39
39
  // Loading state lives in the header description row, not as a fake
40
40
  // selectable menu item.
41
41
  description: 'Loading memories…',
42
+ loading: true,
42
43
  items: [],
43
44
  onSelect: () => {},
44
45
  onCancel: closeMemoryCorePicker,
@@ -113,6 +114,7 @@ export function createCoreMemoryPicker({
113
114
  paintPanel({
114
115
  title: 'Memory · List',
115
116
  description: 'Loading memories…',
117
+ loading: true,
116
118
  items: [],
117
119
  onSelect: () => {},
118
120
  onCancel: () => openMemoryCorePicker(),
@@ -121,7 +121,7 @@ export function createExtensionPickers({
121
121
  };
122
122
 
123
123
  const openMcpPicker = () => {
124
- openMcpServersPicker();
124
+ return openMcpServersPicker();
125
125
  };
126
126
 
127
127
  const skillsStatus = async () => {
@@ -158,7 +158,7 @@ export function createMaintenancePickers({
158
158
  // binds it to the previous owner's epoch, which the open transition (a
159
159
  // panel identity change) supersedes — leaving "Latest version" stuck on
160
160
  // "checking…". Esc after the paint still closes the panel for good.
161
- void Promise.resolve(render({ checking: true }))
161
+ return Promise.resolve(render({ checking: true }))
162
162
  .catch((e) => {
163
163
  store.pushNotice(`update panel failed: ${e?.message || e}`, 'error');
164
164
  return false;
@@ -308,8 +308,8 @@ export function createMaintenancePickers({
308
308
  setSettingsPrompt(null);
309
309
  own.context(null);
310
310
  closeUsagePanel();
311
- if (options.advanced === true) renderAdvanced();
312
- else render();
311
+ return Promise.resolve(options.advanced === true ? renderAdvanced() : render())
312
+ .catch((e) => store.pushNotice(`auto-clear panel failed: ${e?.message || e}`, 'error'));
313
313
  };
314
314
 
315
315
  const openProfilePicker = async (options = {}) => {
@@ -166,6 +166,7 @@ export const providerDisplayName = (provider) => {
166
166
  if (key === 'xai' || key === 'xai-api') return 'xAI API';
167
167
  if (key === 'deepseek' || key === 'deepseek-api') return 'DeepSeek API';
168
168
  if (key === 'opencode-go') return 'OpenCode Go API';
169
+ if (key === 'openrouter') return 'OpenRouter';
169
170
  if (key === 'ollama') return 'Ollama';
170
171
  if (key === 'lmstudio') return 'LM Studio';
171
172
  if (key === 'default') return 'Default';
@@ -182,6 +183,7 @@ export const providerDisplayRank = (provider) => {
182
183
  'antigravity-oauth': 31,
183
184
  'cursor-oauth': 32,
184
185
  'opencode-go': 35,
186
+ openrouter: 36,
185
187
  openai: 40,
186
188
  'openai-api': 40,
187
189
  anthropic: 50,
@@ -59,6 +59,9 @@ export function createModelPicker({
59
59
  const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
60
60
  const returnLabel = String(options.returnLabel || 'Agents');
61
61
  const returnOnNestedCancel = options.returnOnNestedCancel === true;
62
+ const handoffPanel = options.handoffPanel && typeof options.handoffPanel === 'object'
63
+ ? options.handoffPanel
64
+ : null;
62
65
  const cancelModelPicker = () => {
63
66
  modelPickerClosed = true;
64
67
  if (returnTo) returnTo();
@@ -77,6 +80,7 @@ export function createModelPicker({
77
80
  title: options.title || 'Model',
78
81
  description: options.loadingDescription || 'Loading models...',
79
82
  help: returnTo ? `↑/↓ Select · Enter Open · Esc ${returnLabel}` : '↑/↓ Select · Enter Open · Esc Back',
83
+ loading: true,
80
84
  items: [],
81
85
  onCancel: cancelModelPicker,
82
86
  });
@@ -382,7 +386,7 @@ export function createModelPicker({
382
386
  if (typeof options.onImmediateSelect === 'function') {
383
387
  options.onImmediateSelect(routeInput, selected, effort);
384
388
  } else {
385
- own.paint(null);
389
+ own.paint(handoffPanel);
386
390
  }
387
391
  // Post-ack delegation (return to the caller's panel), bound to the
388
392
  // claim AFTER this keypress's own navigation: an Esc between the
@@ -397,10 +401,13 @@ export function createModelPicker({
397
401
  afterSelect();
398
402
  return result;
399
403
  })
400
- .catch((e) => store.pushNotice(`Couldn’t save model: ${e?.message || e}`, 'error'));
404
+ .catch((e) => {
405
+ store.pushNotice(`Couldn’t save model: ${e?.message || e}`, 'error');
406
+ if (handoffPanel) afterSelect();
407
+ });
401
408
  return;
402
409
  }
403
- own.paint(null);
410
+ own.paint(handoffPanel);
404
411
  const afterSelect = own.defer(() => {
405
412
  if (typeof options.onAfterSelect === 'function') options.onAfterSelect();
406
413
  });
@@ -415,7 +422,10 @@ export function createModelPicker({
415
422
  );
416
423
  if (ok) afterSelect();
417
424
  })
418
- .catch((e) => store.pushNotice(`Couldn’t switch model: ${e?.message || e}`, 'error'));
425
+ .catch((e) => {
426
+ store.pushNotice(`Couldn’t switch model: ${e?.message || e}`, 'error');
427
+ if (handoffPanel) afterSelect();
428
+ });
419
429
  };
420
430
  const renderProviderModels = () => {
421
431
  const providerModelItems = buildProviderModelItems(models, provider, activeRoute);
@@ -0,0 +1,311 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { createModelPicker } from './model-picker.mjs';
5
+ import { shouldSupersedePanelEpoch, supersedePanelEpoch } from './panel-epoch.mjs';
6
+ import { createPanelSurface } from './panel-surface.mjs';
7
+ import { createRoutePickers } from './route-pickers.mjs';
8
+ import { createSettingsPicker } from './settings-picker.mjs';
9
+ import { createSlashDispatch } from './slash-dispatch.mjs';
10
+ import { createThemeEffortPickers } from './theme-effort-pickers.mjs';
11
+
12
+ function deferred() {
13
+ let resolve;
14
+ let reject;
15
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
16
+ return { promise, resolve, reject };
17
+ }
18
+
19
+ const flush = async () => {
20
+ await new Promise((resolve) => setTimeout(resolve, 0));
21
+ await new Promise((resolve) => setImmediate(resolve));
22
+ };
23
+
24
+ function createPanelHost() {
25
+ let current = null;
26
+ const painted = [];
27
+ const setPicker = (next) => {
28
+ const previous = current;
29
+ current = typeof next === 'function' ? next(previous) : next;
30
+ if (shouldSupersedePanelEpoch(previous, current)) supersedePanelEpoch();
31
+ painted.push(current);
32
+ };
33
+ const surface = createPanelSurface({
34
+ setPicker,
35
+ setContextPanel: () => {},
36
+ setUsagePanel: () => {},
37
+ });
38
+ return { current: () => current, painted, setPicker, surface };
39
+ }
40
+
41
+ const HANDOFF = {
42
+ title: 'Settings',
43
+ description: 'Applying selection...',
44
+ help: 'Esc Close',
45
+ indexMode: 'never',
46
+ pickerKey: 'settings-handoff',
47
+ loading: true,
48
+ items: [],
49
+ };
50
+
51
+ function settingsDeps(host, store, opened = {}) {
52
+ const noop = () => {};
53
+ return {
54
+ store: { pushNotice: noop, ...store },
55
+ state: { model: 'gpt-5', provider: 'openai', workflow: { id: 'default' } },
56
+ surface: host.surface,
57
+ setProviderPrompt: noop,
58
+ setSettingsPrompt: noop,
59
+ settingsHeavyCacheRef: { current: null },
60
+ settingsRequestRef: { current: 0 },
61
+ formatDuration: (ms) => `${ms}ms`,
62
+ displayModelName: () => 'GPT-5',
63
+ routeModelLabel: () => '(unset)',
64
+ workflowDisplayName: () => 'Default',
65
+ workflowSwitchNotice: () => 'workflow switched',
66
+ themeNotice: () => 'theme set',
67
+ openModelPicker: (options) => { opened.model = options; },
68
+ openWebSearchPicker: noop,
69
+ openAgentsPicker: noop,
70
+ openWorkflowPicker: (options) => { opened.workflow = options; },
71
+ openOutputStylePicker: (options) => { opened.outputStyle = options; },
72
+ openProviderSetupPicker: noop,
73
+ openThemePicker: (options) => { opened.theme = options; },
74
+ openAutoClearPicker: noop,
75
+ openProfilePicker: noop,
76
+ openMcpPicker: noop,
77
+ openPluginsPicker: noop,
78
+ openHooksPicker: noop,
79
+ openSkillsPicker: noop,
80
+ openMemoryCorePicker: noop,
81
+ openUpdatePicker: noop,
82
+ };
83
+ }
84
+
85
+ test('Settings paints its loading destination before the daemon snapshot settles', async () => {
86
+ supersedePanelEpoch();
87
+ const host = createPanelHost();
88
+ host.setPicker({ title: 'Theme' });
89
+ const snapshotGate = deferred();
90
+ const opened = {};
91
+ const { openSettingsPicker } = createSettingsPicker(settingsDeps(
92
+ host,
93
+ { getSettingsSnapshot: () => snapshotGate.promise },
94
+ opened,
95
+ ));
96
+
97
+ const opening = openSettingsPicker();
98
+ assert.equal(host.current()?.title, 'Settings');
99
+ assert.match(host.current()?.description || '', /Loading settings/i);
100
+ assert.notEqual(host.current(), null);
101
+
102
+ snapshotGate.resolve({});
103
+ await opening;
104
+ const settings = host.current();
105
+ assert.equal(settings?.title, 'Settings');
106
+ assert.ok(settings.items.length > 0);
107
+
108
+ for (const action of ['model', 'workflow', 'output-style', 'theme']) {
109
+ const item = settings.items.find((entry) => entry._action === action);
110
+ settings.onSelect(item.value, item);
111
+ }
112
+ for (const options of [opened.model, opened.workflow, opened.outputStyle, opened.theme]) {
113
+ assert.equal(options?.handoffPanel?.title, 'Settings');
114
+ assert.equal(options?.handoffPanel?.pickerKey, 'settings-handoff');
115
+ assert.equal(options?.handoffPanel?.loading, true);
116
+ }
117
+ });
118
+
119
+ test('Model save keeps a Settings handoff panel visible until the write settles', async () => {
120
+ supersedePanelEpoch();
121
+ const host = createPanelHost();
122
+ const saveGate = deferred();
123
+ const { openModelPicker } = createModelPicker({
124
+ store: {
125
+ pushNotice: () => {},
126
+ listProviderModels: async () => [],
127
+ setRoute: () => saveGate.promise,
128
+ },
129
+ getState: () => ({ provider: 'openai', model: 'gpt-5', effort: null, fast: false }),
130
+ surface: host.surface,
131
+ setProviderPrompt: () => {},
132
+ setSettingsPrompt: () => {},
133
+ providerModelsCacheRef: {
134
+ current: {
135
+ at: Date.now(),
136
+ models: [{ provider: 'openai', id: 'gpt-5', display: 'GPT-5' }],
137
+ },
138
+ },
139
+ webSearchModelsCacheRef: { current: { models: [], at: 0 } },
140
+ modelPickerRequestRef: { current: 0 },
141
+ clearModelCaches: () => {},
142
+ modelSwitchNotice: () => 'switched',
143
+ openProviderSetupPicker: () => {},
144
+ });
145
+
146
+ await openModelPicker({
147
+ handoffPanel: HANDOFF,
148
+ onAfterSelect: () => host.setPicker({ title: 'Settings', items: [{ value: 'done' }] }),
149
+ });
150
+ const providers = host.current();
151
+ providers.onSelect(providers.items[0].value, providers.items[0]);
152
+ const models = host.current();
153
+ models.onSelect(models.items[0].value, models.items[0]);
154
+
155
+ assert.equal(host.current(), HANDOFF);
156
+ assert.equal(host.painted.at(-1), HANDOFF);
157
+
158
+ saveGate.resolve(true);
159
+ await flush();
160
+ assert.equal(host.current()?.title, 'Settings');
161
+ assert.notEqual(host.current(), HANDOFF);
162
+ });
163
+
164
+ function routePicker(host, store) {
165
+ return createRoutePickers({
166
+ store: { pushNotice: () => {}, ...store },
167
+ state: {},
168
+ surface: host.surface,
169
+ setProviderPrompt: () => {},
170
+ setSettingsPrompt: () => {},
171
+ closeUsagePanel: () => {},
172
+ clean: (value) => value,
173
+ routeLabel: () => 'route',
174
+ agentModelParts: () => [],
175
+ agentModelProfile: () => 'profile',
176
+ workflowSwitchNotice: () => 'workflow switched',
177
+ openModelPicker: () => {},
178
+ });
179
+ }
180
+
181
+ test('Workflow and output-style saves keep the Settings handoff visible until their writes settle', async () => {
182
+ supersedePanelEpoch();
183
+ const workflowHost = createPanelHost();
184
+ const workflowGate = deferred();
185
+ const workflow = routePicker(workflowHost, {
186
+ listWorkflows: async () => [{ id: 'solo', name: 'Solo', active: true }],
187
+ setWorkflow: () => workflowGate.promise,
188
+ });
189
+ await workflow.openWorkflowPicker({
190
+ returnTo: () => workflowHost.setPicker({ title: 'Settings', items: [{ value: 'done' }] }),
191
+ handoffPanel: HANDOFF,
192
+ });
193
+ const workflowPanel = workflowHost.current();
194
+ workflowPanel.onSelect(workflowPanel.items[0].value, workflowPanel.items[0]);
195
+ assert.equal(workflowHost.current(), HANDOFF);
196
+
197
+ workflowGate.resolve({ id: 'solo' });
198
+ await flush();
199
+ assert.equal(workflowHost.current()?.title, 'Settings');
200
+
201
+ const styleHost = createPanelHost();
202
+ const styleGate = deferred();
203
+ const outputStyle = routePicker(styleHost, {
204
+ listOutputStyles: async () => ({
205
+ current: { id: 'simple' },
206
+ styles: [{ id: 'simple', label: 'Simple' }],
207
+ }),
208
+ setOutputStyle: () => styleGate.promise,
209
+ });
210
+ await outputStyle.openOutputStylePicker({
211
+ returnTo: () => styleHost.setPicker({ title: 'Settings', items: [{ value: 'done' }] }),
212
+ handoffPanel: HANDOFF,
213
+ });
214
+ const stylePanel = styleHost.current();
215
+ stylePanel.onSelect(stylePanel.items[0].value, stylePanel.items[0]);
216
+ assert.equal(styleHost.current(), HANDOFF);
217
+
218
+ styleGate.resolve({ current: { id: 'simple', label: 'Simple' } });
219
+ await flush();
220
+ assert.equal(styleHost.current()?.title, 'Settings');
221
+ });
222
+
223
+ test('Theme selection hands directly to Settings without a null panel', () => {
224
+ supersedePanelEpoch();
225
+ const host = createPanelHost();
226
+ const { openThemePicker } = createThemeEffortPickers({
227
+ state: {},
228
+ store: {
229
+ listThemes: () => [{ id: 'basic', label: 'Basic' }],
230
+ getTheme: () => 'basic',
231
+ setTheme: (id) => ({ id, label: 'Basic' }),
232
+ pushNotice: () => {},
233
+ },
234
+ surface: host.surface,
235
+ setProviderPrompt: () => {},
236
+ setSettingsPrompt: () => {},
237
+ closeUsagePanel: () => {},
238
+ clean: (value) => value,
239
+ });
240
+
241
+ openThemePicker({
242
+ returnTo: () => host.setPicker({ title: 'Settings', items: [{ value: 'done' }] }),
243
+ handoffPanel: HANDOFF,
244
+ });
245
+ const theme = host.current();
246
+ const paintedBefore = host.painted.length;
247
+ theme.onSelect(theme.items[0].value, theme.items[0]);
248
+
249
+ const transitionPaints = host.painted.slice(paintedBefore);
250
+ assert.deepEqual(transitionPaints.map((panel) => panel?.title), ['Settings', 'Settings']);
251
+ assert.ok(transitionPaints.every(Boolean));
252
+ });
253
+
254
+ function workflowSlashDispatch(host, openWorkflowPicker, notices = []) {
255
+ return createSlashDispatch({
256
+ state: { busy: false, commandBusy: false },
257
+ store: { pushNotice: (message, tone) => notices.push([message, tone]) },
258
+ normalizeSlashCommandName: (value) => value,
259
+ surface: host.surface,
260
+ closeUsagePanel: () => {},
261
+ openWorkflowPicker,
262
+ });
263
+ }
264
+
265
+ test('slash option entry keeps a loading panel until the async picker paints', async () => {
266
+ supersedePanelEpoch();
267
+ const host = createPanelHost();
268
+ const openGate = deferred();
269
+ const { runSlashCommand } = workflowSlashDispatch(host, () => {
270
+ const own = host.surface.claim();
271
+ return openGate.promise.then(() => own.paint({
272
+ title: 'Workflow',
273
+ description: 'Select active workflow.',
274
+ items: [{ value: 'solo', label: 'Solo' }],
275
+ }));
276
+ });
277
+
278
+ assert.equal(runSlashCommand('workflow'), true);
279
+ assert.equal(host.current()?._kind, 'slash-loading:workflow');
280
+ assert.equal(host.current()?.title, 'Workflow');
281
+ assert.equal(host.current()?.loading, true);
282
+
283
+ openGate.resolve();
284
+ await flush();
285
+ assert.equal(host.current()?.title, 'Workflow');
286
+ assert.equal(host.current()?._kind, undefined);
287
+ assert.ok(host.current()?.items.length > 0);
288
+ });
289
+
290
+ test('Esc on slash loading prevents the pending picker from appearing later', async () => {
291
+ supersedePanelEpoch();
292
+ const host = createPanelHost();
293
+ const openGate = deferred();
294
+ const { runSlashCommand } = workflowSlashDispatch(host, () => {
295
+ const own = host.surface.claim();
296
+ return openGate.promise.then(() => own.paint({
297
+ title: 'Workflow',
298
+ items: [{ value: 'solo', label: 'Solo' }],
299
+ }));
300
+ });
301
+
302
+ runSlashCommand('workflow');
303
+ const loading = host.current();
304
+ assert.equal(loading?._kind, 'slash-loading:workflow');
305
+ loading.onCancel();
306
+ assert.equal(host.current(), null);
307
+
308
+ openGate.resolve();
309
+ await flush();
310
+ assert.equal(host.current(), null);
311
+ });
@@ -134,6 +134,7 @@ export function createProjectPicker({
134
134
  description: 'Opening folder picker… choose a folder in the dialog window.',
135
135
  help: 'Waiting for the system folder dialog…',
136
136
  indexMode: 'never',
137
+ loading: true,
137
138
  items: [],
138
139
  onSelect: () => {},
139
140
  onCancel: () => {},