mixdog 1.0.12 → 1.1.0

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 (256) hide show
  1. package/README.md +75 -181
  2. package/package.json +4 -2
  3. package/scripts/build-shipped-history.mjs +131 -0
  4. package/scripts/lib/run-node-tests.mjs +235 -13
  5. package/scripts/lib/test-failure-records.mjs +5 -0
  6. package/scripts/lib/test-failure-summary.mjs +9 -3
  7. package/scripts/lib/test-spawn-ledger.mjs +19 -0
  8. package/scripts/prune-embedding-runtime.mjs +9 -5
  9. package/scripts/test-direct.mjs +16 -1
  10. package/src/agents/advisor/AGENT.md +38 -0
  11. package/src/agents/advisor/agent.json +6 -0
  12. package/src/agents/reviewer/AGENT.md +5 -2
  13. package/src/agents/reviewer/agent.json +1 -1
  14. package/src/defaults/shipped-history.json +381 -0
  15. package/src/defaults/skills/goal-management/SKILL.md +11 -5
  16. package/src/defaults/skills/setup/SKILL.md +5 -8
  17. package/src/defaults/skills/setup/references/actions.md +61 -31
  18. package/src/defaults/skills/setup/references/surfaces.md +12 -10
  19. package/src/lib/rules-builder.cjs +1 -1
  20. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +3 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +4 -0
  22. package/src/runtime/agent/orchestrator/config-presets.d.mts +1 -0
  23. package/src/runtime/agent/orchestrator/config-presets.mjs +44 -0
  24. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +9 -1
  25. package/src/runtime/agent/orchestrator/config.mjs +102 -21
  26. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +77 -26
  27. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +44 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +2 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +11 -2
  30. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-login.mjs +2 -0
  31. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +3 -3
  32. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +388 -0
  33. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +92 -109
  34. package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +107 -0
  35. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +51 -9
  36. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +2 -1
  37. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +14 -9
  38. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +51 -11
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +6 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +7 -5
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +12 -0
  42. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +27 -7
  43. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +12 -2
  44. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/quota-reading.mjs +70 -0
  47. package/src/runtime/agent/orchestrator/providers/registry.mjs +38 -3
  48. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +19 -35
  49. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +5 -0
  50. package/src/runtime/agent/orchestrator/runtime-core/goal-mutations.mjs +0 -17
  51. package/src/runtime/agent/orchestrator/runtime-core/goal-state.mjs +0 -3
  52. package/src/runtime/agent/orchestrator/runtime-core/goal-store.mjs +0 -1
  53. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
  55. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +1 -2
  56. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +18 -4
  57. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +7 -1
  58. package/src/runtime/agent/orchestrator/runtime-core/session-route-policy.mjs +16 -0
  59. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +7 -7
  62. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +6 -0
  63. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +28 -3
  64. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +24 -11
  65. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +2 -6
  66. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +63 -3
  67. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +4 -4
  68. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +1 -0
  69. package/src/runtime/agent/orchestrator/stall-policy.mjs +0 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +19 -20
  72. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -11
  73. package/src/runtime/agent/orchestrator/tools/builtin/file-version-test-helpers.mjs +11 -0
  74. package/src/runtime/agent/orchestrator/tools/builtin/file-version.mjs +13 -0
  75. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +8 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-task-output.mjs +25 -12
  77. package/src/runtime/agent/orchestrator/tools/builtin/raw-content-publication.mjs +17 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +136 -76
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +15 -17
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -2
  81. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +11 -10
  82. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -3
  83. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +30 -16
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +4 -11
  85. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +7 -3
  86. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-validation.mjs +16 -19
  87. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  88. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +4 -1
  89. package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +14 -4
  90. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +58 -8
  91. package/src/runtime/agent/orchestrator/tools/patch/content-guard.mjs +47 -0
  92. package/src/runtime/agent/orchestrator/tools/patch/delete-quarantine.mjs +155 -0
  93. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-proofs.mjs +6 -2
  94. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-snapshots.mjs +6 -2
  95. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +19 -16
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +13 -44
  97. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  98. package/src/runtime/agent/orchestrator/tools/patch/post-patch-excerpts.mjs +5 -2
  99. package/src/runtime/agent/orchestrator/tools/patch/replay-capture.mjs +47 -7
  100. package/src/runtime/agent/orchestrator/tools/patch/rollback-state.mjs +72 -8
  101. package/src/runtime/agent/orchestrator/tools/patch/section-coalesce.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/patch/sequence/run.mjs +9 -1
  103. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +9 -1
  104. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +6 -6
  105. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +16 -4
  106. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -14
  107. package/src/runtime/agent/orchestrator/tools/patch/wave.mjs +42 -4
  108. package/src/runtime/agent/orchestrator/tools/shell-codepage.mjs +296 -0
  109. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +75 -24
  110. package/src/runtime/channels/lib/config.mjs +3 -8
  111. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +7 -3
  112. package/src/{tui → runtime/channels}/lib/voice-setup.mjs +38 -22
  113. package/src/runtime/channels/lib/webhook/request-handler.mjs +42 -8
  114. package/src/runtime/computer-bridge/actions.mjs +2 -0
  115. package/src/runtime/effort-judge/judge-client.mjs +12 -3
  116. package/src/runtime/effort-judge/model-manifest.json +7 -7
  117. package/src/runtime/effort-judge/previous-human-request.mjs +162 -0
  118. package/src/runtime/effort-judge/verified-context-input.mjs +10 -0
  119. package/src/runtime/memory/lib/embedding-provider.mjs +12 -0
  120. package/src/runtime/memory/lib/http-router/session-routes.mjs +2 -1
  121. package/src/runtime/memory/lib/memory-config-flags.mjs +3 -2
  122. package/src/runtime/memory/lib/memory-embed.mjs +5 -0
  123. package/src/runtime/shared/agent-route-config.mjs +50 -2
  124. package/src/runtime/shared/bounded-file-read.mjs +166 -0
  125. package/src/runtime/shared/builtin-grandfathering.mjs +37 -0
  126. package/src/runtime/shared/config.mjs +24 -4
  127. package/src/runtime/shared/defaults-separation.mjs +263 -0
  128. package/src/runtime/shared/llm/anthropic-effort.mjs +4 -0
  129. package/src/runtime/shared/llm/model-catalog.mjs +23 -0
  130. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +105 -1
  131. package/src/runtime/shared/llm/usage-ledger-worker.mjs +2 -1
  132. package/src/runtime/shared/llm/usage-ledger.mjs +72 -5
  133. package/src/runtime/shared/onnx-runtime-support.mjs +6 -0
  134. package/src/runtime/shared/orchestration.mjs +14 -6
  135. package/src/runtime/shared/projects.mjs +65 -48
  136. package/src/runtime/shared/provider-accounts.mjs +47 -6
  137. package/src/runtime/shared/quota-resume-store.mjs +255 -0
  138. package/src/runtime/shared/shipped-definitions.mjs +275 -0
  139. package/src/runtime/shared/tool-card-model.golden.json +3 -3
  140. package/src/runtime/shared/tool-result-summary.mjs +86 -15
  141. package/src/runtime/shared/transcript-metadata.mjs +2 -0
  142. package/src/runtime/shared/turn-review-reply.mjs +2 -0
  143. package/src/runtime/shared/turn-snapshot.mjs +65 -6
  144. package/src/runtime/shared/turn-worktree-snapshot.mjs +202 -22
  145. package/src/session-runtime/boot/apis.mjs +19 -0
  146. package/src/session-runtime/boot/config.mjs +4 -0
  147. package/src/session-runtime/boot/facade.mjs +4 -1
  148. package/src/session-runtime/boot/infrastructure.mjs +21 -2
  149. package/src/session-runtime/boot/session.mjs +3 -0
  150. package/src/session-runtime/channel-config-api.mjs +5 -0
  151. package/src/session-runtime/config-lifecycle/config-writers.mjs +3 -0
  152. package/src/session-runtime/config-lifecycle.mjs +31 -6
  153. package/src/session-runtime/goal-facade-api.mjs +4 -0
  154. package/src/session-runtime/lifecycle/inheritance.mjs +28 -6
  155. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +3 -0
  156. package/src/session-runtime/lifecycle/session-switch/resume.mjs +7 -1
  157. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +6 -0
  158. package/src/session-runtime/lifecycle/teardown/session-close.mjs +14 -2
  159. package/src/session-runtime/media-api.mjs +32 -22
  160. package/src/session-runtime/model-recency.mjs +14 -0
  161. package/src/session-runtime/model-route/route-tuning.mjs +3 -5
  162. package/src/session-runtime/model-route/set-route.mjs +49 -21
  163. package/src/session-runtime/model-route-api.mjs +2 -1
  164. package/src/session-runtime/notification-bus.mjs +31 -6
  165. package/src/session-runtime/provider-auth/login.mjs +6 -0
  166. package/src/session-runtime/provider-models/catalog-load.mjs +3 -1
  167. package/src/session-runtime/provider-models/row-hydration.mjs +15 -14
  168. package/src/session-runtime/quick-model-rows/model-meta.mjs +26 -0
  169. package/src/session-runtime/quick-model-rows/provider-rows.mjs +40 -14
  170. package/src/session-runtime/resource-api.mjs +8 -1
  171. package/src/session-runtime/runtime-core.mjs +4 -0
  172. package/src/session-runtime/runtime-facade.mjs +20 -1
  173. package/src/session-runtime/runtime-review-api.mjs +12 -1
  174. package/src/session-runtime/services/agent-tool/helpers.mjs +4 -0
  175. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +10 -15
  176. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +11 -3
  177. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +2 -0
  178. package/src/session-runtime/services/channel-admin.mjs +87 -13
  179. package/src/session-runtime/services/provider-admin.mjs +256 -63
  180. package/src/session-runtime/services/seeds.mjs +73 -142
  181. package/src/session-runtime/services/usage-dashboard-model.mjs +29 -0
  182. package/src/session-runtime/services/usage-dashboard.mjs +7 -0
  183. package/src/session-runtime/session-address.mjs +125 -0
  184. package/src/session-runtime/session-create.mjs +57 -2
  185. package/src/session-runtime/settings-builtin-tools-api.mjs +8 -0
  186. package/src/session-runtime/settings-compaction-api.mjs +13 -0
  187. package/src/session-runtime/setup-tool/executor.mjs +92 -40
  188. package/src/session-runtime/setup-tool/extended-actions.mjs +64 -5
  189. package/src/session-runtime/setup-tool/settings-contract.mjs +44 -1
  190. package/src/session-runtime/setup-tool/tool-defs.mjs +12 -7
  191. package/src/session-runtime/tool-surface.mjs +16 -9
  192. package/src/session-runtime/turn/session-ops.mjs +3 -0
  193. package/src/session-runtime/turn/share-ops.mjs +4 -1
  194. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +6 -0
  195. package/src/session-runtime/usage-stats-api.mjs +15 -0
  196. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +31 -5
  197. package/src/session-runtime/workflow-agents-api/agent-route.mjs +4 -0
  198. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +27 -7
  199. package/src/standalone/daemon-boot-coordinator.mjs +10 -0
  200. package/src/standalone/daemon-drain-policy.mjs +9 -0
  201. package/src/standalone/daemon-stored-session-views.mjs +7 -8
  202. package/src/standalone/daemon.mjs +21 -5
  203. package/src/standalone/local-session-runtime.mjs +11 -0
  204. package/src/standalone/session-client.mjs +52 -7
  205. package/src/standalone/session-protocol.mjs +8 -0
  206. package/src/standalone/session-service/agent-tree/agent-child.mjs +2 -0
  207. package/src/standalone/session-service/entries/lifecycle.mjs +2 -0
  208. package/src/standalone/session-service/entries/session-loads.mjs +9 -0
  209. package/src/standalone/session-service/projection/revisions.mjs +28 -4
  210. package/src/standalone/session-service/projection.mjs +1 -0
  211. package/src/standalone/session-service/quota-resume.mjs +140 -0
  212. package/src/standalone/session-service/session-calls/session-action.mjs +2 -2
  213. package/src/standalone/session-service/session-calls.mjs +10 -2
  214. package/src/standalone/session-service/stored-reader.mjs +26 -1
  215. package/src/standalone/session-service.mjs +21 -0
  216. package/src/standalone/session-transport/routes.mjs +2 -0
  217. package/src/tui/app/doctor.mjs +7 -11
  218. package/src/tui/app/maintenance-pickers/auto-clear-picker.mjs +3 -5
  219. package/src/tui/app/settings-picker/settings-rows.mjs +4 -6
  220. package/src/tui/app/settings-picker/settings-toggles/store-toggles.mjs +1 -1
  221. package/src/tui/app/slash-dispatch/route-commands.mjs +3 -2
  222. package/src/tui/components/ContextPanel.jsx +5 -1
  223. package/src/tui/components/Spinner.jsx +34 -319
  224. package/src/tui/components/SpinnerByline.jsx +256 -0
  225. package/src/tui/components/use-spinner-progress.mjs +113 -0
  226. package/src/tui/dist/index.mjs +15540 -14452
  227. package/src/tui/session/agent-job-feed/execution-delivery.mjs +13 -1
  228. package/src/tui/session/agent-job-feed/notification-router.mjs +3 -0
  229. package/src/tui/session/agent-job-feed/pending-resume.mjs +14 -1
  230. package/src/tui/session/agent-job-feed.mjs +4 -0
  231. package/src/tui/session/context-state.mjs +13 -1
  232. package/src/tui/session/initial-state.mjs +6 -0
  233. package/src/tui/session/quota-resume.mjs +263 -0
  234. package/src/tui/session/session-api/intake/abort.mjs +39 -35
  235. package/src/tui/session/session-api/intake/submission.mjs +13 -21
  236. package/src/tui/session/session-api/intake.mjs +3 -3
  237. package/src/tui/session/session-api/integrations.mjs +5 -1
  238. package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
  239. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -2
  240. package/src/tui/session/session-api/lifecycle/session-reset.mjs +4 -0
  241. package/src/tui/session/session-api/lifecycle.mjs +71 -2
  242. package/src/tui/session/session-api/media.mjs +2 -2
  243. package/src/tui/session/session-api/quota-resume.mjs +21 -0
  244. package/src/tui/session/session-api/routes.mjs +2 -0
  245. package/src/tui/session/session-api/settings.mjs +1 -0
  246. package/src/tui/session/session-api.mjs +2 -0
  247. package/src/tui/session/session-flow/auto-clear.mjs +14 -6
  248. package/src/tui/session/session-flow/drain.mjs +52 -5
  249. package/src/tui/session/session-flow/queue/take-entries.mjs +2 -3
  250. package/src/tui/session/session-flow/queue.mjs +3 -0
  251. package/src/tui/session/session-flow/steering.mjs +11 -0
  252. package/src/tui/session/session-flow.mjs +1 -0
  253. package/src/tui/session/tool-approval.mjs +7 -2
  254. package/src/tui/session/transcript-intake.mjs +1 -0
  255. package/src/tui/session/turn.mjs +40 -3
  256. package/src/vendor/statusline/bin/statusline-route.mjs +3 -2
@@ -47,7 +47,8 @@ export function createSessionIntegrationsApi(bag, { oauthFlows }) {
47
47
  // though the session runtime implements it.
48
48
  return await requireRuntimeMethod('consumeCodexRateLimitResetCredit', 'Codex reset is unavailable')(options);
49
49
  },
50
- getTurnReviewDiff: (options = {}) => turnReviewReply((reviewOptions) => runtime.getTurnReviewDiff?.(reviewOptions), options),
50
+ getTurnReviewDiff: (options = {}) =>
51
+ turnReviewReply((reviewOptions) => runtime.getTurnReviewDiff?.(reviewOptions), options),
51
52
  getSessionReviewDiff: async () => {
52
53
  return (await runtime.getSessionReviewDiff?.()) ?? { supported: false, files: [], patch: '' };
53
54
  },
@@ -106,6 +107,7 @@ export function createSessionIntegrationsApi(bag, { oauthFlows }) {
106
107
  (input) => runtime.saveCustomProvider(input),
107
108
  (result) => `custom provider saved: ${result.id}`
108
109
  ),
110
+ updateCustomProviderModel: (id, modelId, patch) => runtime.updateCustomProviderModel(id, modelId, patch),
109
111
  removeCustomProvider: async (id) => {
110
112
  const result = await runtime.removeCustomProvider(id);
111
113
  refreshRouteStats();
@@ -173,5 +175,7 @@ export function createSessionIntegrationsApi(bag, { oauthFlows }) {
173
175
  (name, enabled) => runtime.setWebhookEnabled(name, enabled),
174
176
  (_result, name, enabled) => `webhook ${enabled ? 'enabled' : 'disabled'}: ${name}`
175
177
  ),
178
+ // Signed test request only; nothing changes, so no announcement.
179
+ testWebhook: (name) => runtime.testWebhook(name),
176
180
  };
177
181
  }
@@ -31,6 +31,7 @@ export function createDisposeAction(bag, { oauthFlows }) {
31
31
  clearToastTimers();
32
32
  disposeTranscriptSpill?.();
33
33
  disposeGoalContinuation?.();
34
+ bag.disposeQuotaResume?.();
34
35
  try {
35
36
  clearInterval(lifecycle.runtimePulseTimer);
36
37
  } catch {}
@@ -12,8 +12,8 @@ export function createInheritanceActions(bag, { restoreTranscriptItems }) {
12
12
  * daemon resolves actions on THIS surface, so `inheritFrom` has to live here
13
13
  * and not only on the runtime beneath it.
14
14
  */
15
- const inheritFrom = async (sourceSessionId) => {
16
- const result = await runtime.inheritFrom(sourceSessionId);
15
+ const inheritFrom = async (sourceSessionId, options = null) => {
16
+ const result = await runtime.inheritFrom(sourceSessionId, options);
17
17
  const sessionId = String(result?.sessionId || runtime.sessionId || getState().sessionId || '');
18
18
  // Only model messages travel with the conversation. Rebuild the visible
19
19
  // transcript from them right here so the heir opens showing the carried
@@ -80,6 +80,9 @@ export function createSessionResetActions(bag) {
80
80
  return {
81
81
  clear: () =>
82
82
  withResetLock(async () => {
83
+ const clearedId = getState().sessionId;
84
+ // A cleared session has no failed turn left to resume.
85
+ bag.supersedeQuotaResume?.(clearedId);
83
86
  const rollbackSnapshot = beginTuiReset();
84
87
  set(blankSessionPatch());
85
88
  try {
@@ -101,6 +104,7 @@ export function createSessionResetActions(bag) {
101
104
  if (rollbackSnapshot) restoreTuiAfterFailedSessionReset(rollbackSnapshot);
102
105
  return false;
103
106
  }
107
+ bag.supersedeQuotaResume?.(id);
104
108
  if (deletingCurrent) commitEmptySession(rollbackSnapshot, { sessionId: null, cwd: runtime.cwd });
105
109
  return true;
106
110
  } catch (error) {
@@ -12,10 +12,51 @@ import { createDisposeAction } from './lifecycle/dispose.mjs';
12
12
  export function createSessionLifecycleApi(bag, { restoreTranscriptItems, oauthFlows }) {
13
13
  const { runtime, pushNotice, removeNotice, setProgressHint, finishToolApproval } = bag;
14
14
 
15
+ // Canonical close = execution fence at the local-session boundary. The fence
16
+ // is raised synchronously BEFORE the runtime/manager close (which cancels the
17
+ // session's shells and so triggers completion notifications), so no queue
18
+ // drain, completion wake, pending-resume kick or new submission can start
19
+ // work for a conversation that is being closed. A durable close that fails
20
+ // restores admission with every accepted input intact; one that succeeds
21
+ // leaves the session terminal and settles queued submissions explicitly.
22
+ function closeCanonical(reason) {
23
+ const flags = bag.flags || {};
24
+ const previous = flags.admission || 'open';
25
+ if (previous === 'terminal') return runtime.closeCanonicalSession?.(reason) === true;
26
+ flags.admission = 'closing';
27
+ let closed = false;
28
+ try {
29
+ closed = runtime.closeCanonicalSession?.(reason) === true;
30
+ } catch (error) {
31
+ flags.admission = previous;
32
+ restoreAdmission();
33
+ throw error;
34
+ }
35
+ if (!closed) {
36
+ flags.admission = previous;
37
+ restoreAdmission();
38
+ return false;
39
+ }
40
+ flags.admission = 'terminal';
41
+ flags.admissionGeneration = (Number(flags.admissionGeneration) || 0) + 1;
42
+ bag.settleClosedQueue?.();
43
+ return true;
44
+ }
45
+
46
+ function restoreAdmission() {
47
+ void bag.drain?.();
48
+ bag.flushDeferredExecutionPendingResumeKick?.();
49
+ }
50
+
15
51
  return {
16
52
  resolveToolApproval: (id, decision = {}) => {
17
53
  const approved = decision === true || decision?.approved === true;
18
- return finishToolApproval(id, approved, decision?.reason || (approved ? 'approved by user' : 'denied by user'));
54
+ return finishToolApproval(
55
+ id,
56
+ approved,
57
+ decision?.reason || (approved ? 'approved by user' : 'denied by user'),
58
+ typeof decision?.device === 'string' ? decision.device : ''
59
+ );
19
60
  },
20
61
  pushNotice,
21
62
  removeNotice,
@@ -27,6 +68,32 @@ export function createSessionLifecycleApi(bag, { restoreTranscriptItems, oauthFl
27
68
  renameSessionTitle: (id, title) => {
28
69
  return runtime.renameSessionTitle?.(id, title) ?? false;
29
70
  },
71
+ // Quota escape hatch: re-route THIS session onto another model, in place,
72
+ // only after its latest settled turn failed on exhausted provider quota.
73
+ // Starts no turn — the caller resubmits through retryFailedTurn.
74
+ continueSessionInModel: async (sessionId, route) => {
75
+ const state = bag.getState();
76
+ if (state.busy || state.commandBusy) throw new Error('session is busy');
77
+ if (!sessionId || state.sessionId !== sessionId) throw new Error('session is not the active session');
78
+ const items = Array.isArray(state.items) ? state.items : [];
79
+ const last = items.findLast((item) => item?.kind === 'user' || item?.kind === 'turndone');
80
+ if (last?.kind !== 'turndone' || last.status !== 'failed' || !last.quota) {
81
+ throw new Error('latest turn did not fail on exhausted provider quota');
82
+ }
83
+ if (typeof runtime.continueSessionInModel !== 'function') throw new Error('runtime cannot continue in a model');
84
+ // Continuing elsewhere supersedes waiting for this provider's reset.
85
+ bag.supersedeQuotaResume?.(sessionId);
86
+ bag.set({ commandBusy: true });
87
+ try {
88
+ await runtime.continueSessionInModel(sessionId, route);
89
+ bag.set({ ...bag.routeState(), stats: { ...bag.getState().stats } });
90
+ bag.syncContextStats?.({ allowEstimated: true });
91
+ return true;
92
+ } finally {
93
+ bag.set({ commandBusy: false });
94
+ bag.flushEmitImmediate?.();
95
+ }
96
+ },
30
97
  // Desktop sidebar watcher hook: the daemon watches this directory so
31
98
  // heartbeat sidecar create/delete pushes instant working/dot updates.
32
99
  // Without it the watcher silently no-ops and the sidebar falls back to
@@ -44,7 +111,9 @@ export function createSessionLifecycleApi(bag, { restoreTranscriptItems, oauthFl
44
111
  deliverToolCompletion: (sessionId, text, meta = {}) =>
45
112
  runtime.deliverToolCompletion?.(sessionId, text, meta) === true,
46
113
 
47
- closeCanonicalSession: (reason = 'canonical-session-close') => runtime.closeCanonicalSession?.(reason) === true,
114
+ closeCanonicalSession: (reason = 'canonical-session-close') => closeCanonical(reason),
115
+
116
+ bindSessionAddress: (sessionId) => runtime.bindSessionAddress?.(sessionId) === true,
48
117
 
49
118
  ...createDisposeAction(bag, { oauthFlows }),
50
119
  };
@@ -4,7 +4,7 @@
4
4
  * claims, the TUI theme, and the media studio passthroughs.
5
5
  */
6
6
  import { listThemes, getThemeSetting, setThemeSetting } from '../../theme.mjs';
7
- import { getVoiceStatus, toggleVoice } from '../../lib/voice-setup.mjs';
7
+ import { getVoiceStatus, toggleVoice } from '../../../runtime/channels/lib/voice-setup.mjs';
8
8
 
9
9
  const AUDIO_EXTENSIONS = [
10
10
  [/ogg/i, 'ogg'],
@@ -98,7 +98,7 @@ export function createSessionMediaApi(bag) {
98
98
  const result = await toggleVoice({
99
99
  pushNotice,
100
100
  setProgressHint,
101
- enabled: typeof enabled === 'boolean' ? enabled : undefined,
101
+ enabled,
102
102
  });
103
103
  return {
104
104
  ...(await getVoiceStatus()),
@@ -0,0 +1,21 @@
1
+ /**
2
+ * quota-resume.mjs — the session object's quota auto-resume surface: the
3
+ * session-addressed controls (schedule/cancel the resume, retry a failed
4
+ * turn), the daemon scheduler's fire entry, and the global auto-resume
5
+ * setting. The lane behind them is ../quota-resume.mjs.
6
+ */
7
+ import { quotaAutoResumeEnabled, setQuotaAutoResumeEnabled } from '../../../runtime/shared/quota-resume-store.mjs';
8
+
9
+ export function createQuotaResumeApi(bag) {
10
+ return {
11
+ scheduleQuotaResume: (sessionId) => bag.scheduleQuotaResume(sessionId),
12
+ cancelQuotaResume: (sessionId) => bag.cancelQuotaResume(sessionId),
13
+ retryFailedTurn: (sessionId, turnKey) => bag.retryFailedTurn(sessionId, turnKey),
14
+ fireQuotaResume: (options) => bag.fireQuotaResume(options),
15
+ getQuotaAutoResume: () => quotaAutoResumeEnabled(),
16
+ setQuotaAutoResume: (enabled) => {
17
+ if (typeof enabled !== 'boolean') throw new TypeError('quota auto resume must be boolean');
18
+ return setQuotaAutoResumeEnabled(enabled);
19
+ },
20
+ };
21
+ }
@@ -61,6 +61,8 @@ export function createSessionRouteApi(bag) {
61
61
 
62
62
  const setRoute = async (opts) => {
63
63
  if (getState().commandBusy && !routeWrite) return false;
64
+ // Any route change supersedes waiting for the old route's quota reset.
65
+ bag.supersedeQuotaResume?.(getState().sessionId);
64
66
  const token = ++routeSequence;
65
67
  const previousWrite = routeWrite;
66
68
  const routeOpts = opts && typeof opts === 'object' ? opts : {};
@@ -35,6 +35,7 @@ export function createSessionSettingsApi(bag) {
35
35
  // acknowledge an older route.
36
36
  setModel: withCommandLock(
37
37
  async (m) => {
38
+ bag.supersedeQuotaResume?.(getState().sessionId);
38
39
  await runtime.setRoute({ model: m });
39
40
  publishRoute();
40
41
  return true;
@@ -11,6 +11,7 @@ import { createSessionSettingsApi } from './session-api/settings.mjs';
11
11
  import { createSessionControlsApi } from './session-api/controls.mjs';
12
12
  import { createSessionExtensionsApi } from './session-api/extensions.mjs';
13
13
  import { createSessionCommandsApi } from './session-api/commands.mjs';
14
+ import { createQuotaResumeApi } from './session-api/quota-resume.mjs';
14
15
 
15
16
  export function createSessionApi(bag) {
16
17
  const api = { ...createSessionApiA(bag), ...createSessionApiB(bag) };
@@ -57,5 +58,6 @@ export function createSessionApiA(bag) {
57
58
  ...createSessionControlsApi(bag),
58
59
  ...createSessionExtensionsApi(bag),
59
60
  ...createSessionCommandsApi(bag),
61
+ ...createQuotaResumeApi(bag),
60
62
  };
61
63
  }
@@ -76,16 +76,20 @@ export function createAutoClearOps(bag, { reset, kickDrain }) {
76
76
  // Never forces busy=false mid-turn: bails while a turn is in flight.
77
77
  function flushDeferredClearedSessionUi() {
78
78
  if (!flags.pendingClearedSessionUi || getState().busy) return;
79
- const { result } = flags.pendingClearedSessionUi;
79
+ const { result, sessionId } = flags.pendingClearedSessionUi;
80
80
  flags.pendingClearedSessionUi = null;
81
81
  flags.autoClearInFlight = false;
82
+ if (runtime.session?.id !== sessionId) return;
82
83
  if (applyAutoClearUi(result)) pushNotice(LATE_COMPLETION_NOTICE, 'info');
83
84
  }
84
85
 
85
- function onLateCompact(lateResult) {
86
+ function onLateCompact(lateResult, sessionId) {
87
+ // The active session changed while the abandoned compaction ran: its
88
+ // result belongs to a session that is no longer shown.
89
+ if (runtime.session?.id !== sessionId) return;
86
90
  if (getState().busy) {
87
91
  // Do not wipe items/queued or force busy=false mid-turn.
88
- flags.pendingClearedSessionUi = { result: lateResult };
92
+ flags.pendingClearedSessionUi = { result: lateResult, sessionId };
89
93
  } else {
90
94
  if (applyAutoClearUi(lateResult)) pushNotice(LATE_COMPLETION_NOTICE, 'info');
91
95
  }
@@ -94,7 +98,7 @@ export function createAutoClearOps(bag, { reset, kickDrain }) {
94
98
  // Validate both rejected calls and error results before either normal or
95
99
  // late completion can reset the UI. Never follow compact with clear:
96
100
  // that would discard conversation/execution records the compactor kept.
97
- async function compactWithinTimeout(compactTimeoutMs) {
101
+ async function compactWithinTimeout(compactTimeoutMs, sessionId) {
98
102
  const compactPromise = runtime.compact({ requireReduction: true }).then((result) => {
99
103
  if (!result) throw new Error('no active session');
100
104
  if (result.error) throw new Error(result.error);
@@ -112,7 +116,10 @@ export function createAutoClearOps(bag, { reset, kickDrain }) {
112
116
  } catch (raceError) {
113
117
  flags.autoClearInFlight = true;
114
118
  compactPromise
115
- .then(onLateCompact, () => {})
119
+ .then(
120
+ (lateResult) => onLateCompact(lateResult, sessionId),
121
+ () => {}
122
+ )
116
123
  .finally(() => {
117
124
  if (!flags.pendingClearedSessionUi) flags.autoClearInFlight = false;
118
125
  });
@@ -146,6 +153,7 @@ export function createAutoClearOps(bag, { reset, kickDrain }) {
146
153
  // results without a summary. Plain /clear remains a separate explicit wipe.
147
154
  async function performAutoClear({ compactTimeoutMs = AUTO_CLEAR_COMPACT_TIMEOUT_MS } = {}) {
148
155
  flags.autoClearRunning = true;
156
+ const sessionId = runtime.session?.id;
149
157
  const startedAt = Date.now();
150
158
  // commandBusy blocks concurrent session commands (resume/newSession/
151
159
  // setModel) AND new submits for the duration of the async clear — the
@@ -161,7 +169,7 @@ export function createAutoClearOps(bag, { reset, kickDrain }) {
161
169
  // Without this, long idle clears can look like a frozen prompt followed by
162
170
  // an already-complete status row.
163
171
  await new Promise((resolve) => setTimeout(resolve, 0));
164
- return applyAutoClearUi(await compactWithinTimeout(compactTimeoutMs));
172
+ return applyAutoClearUi(await compactWithinTimeout(compactTimeoutMs, sessionId));
165
173
  } catch (error) {
166
174
  const message = presentErrorText(error, { surface: 'compact' });
167
175
  pushItem({
@@ -12,7 +12,7 @@ import {
12
12
  mergePastedImages,
13
13
  mergePastedTexts,
14
14
  } from '../queue-helpers.mjs';
15
- import { appendTuiSteeringPersist } from '../tui-steering-persist.mjs';
15
+ import { appendTuiSteeringPersist, dropTuiSteeringPersist } from '../tui-steering-persist.mjs';
16
16
  import { parseModelVisibleCompletionWrapper } from '../agent-envelope.mjs';
17
17
  import { rewoundFailedTurnItems } from './failed-turn-rewind.mjs';
18
18
 
@@ -55,9 +55,11 @@ function renderBatchItems(batch, pushUserOrSyntheticItem) {
55
55
  const sender = String(entry.transcriptMeta?.sender || '')
56
56
  .trim()
57
57
  .toLowerCase();
58
- let itemExtras = sender ? { sender } : null;
58
+ const device = typeof entry.transcriptMeta?.device === 'string' ? entry.transcriptMeta.device : '';
59
+ const attribution = { ...(sender ? { sender } : {}), ...(device ? { device } : {}) };
60
+ let itemExtras = sender || device ? attribution : null;
59
61
  if (Array.isArray(entry.images) && entry.images.length) {
60
- itemExtras = { images: entry.images, ...(sender ? { sender } : {}) };
62
+ itemExtras = { images: entry.images, ...attribution };
61
63
  }
62
64
  pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? 'user' : 'injected', itemExtras);
63
65
  }
@@ -160,7 +162,41 @@ export function createDrainLoop(bag, { queue, steering, submissions, flushDeferr
160
162
  return pending.some((entry) => !isSlashQueuedEntry(entry));
161
163
  }
162
164
 
165
+ const admission = () => flags.admission || 'open';
166
+
167
+ // Canonical close settled: nothing queued can ever run. Every accepted
168
+ // submission is settled explicitly (rejected), never silently dropped, and
169
+ // its durable steering mirror is released so a restart cannot resurrect it
170
+ // into the closed conversation.
171
+ function settleClosedQueue() {
172
+ const entries = pending.splice(0);
173
+ pendingNotificationKeys.clear();
174
+ clearBlockedDrainRetry();
175
+ if (entries.length === 0) return;
176
+ queue.removeQueuedEntries(entries);
177
+ const mirrored = entries.filter(
178
+ (entry) => steering.shouldMirrorSteeringEntry(entry) && !entry.steeringPersistRestored
179
+ );
180
+ if (mirrored.length > 0) {
181
+ void Promise.resolve(dropTuiSteeringPersist(steering.leadSessionId(), mirrored)).catch(() => {});
182
+ }
183
+ for (const entry of entries) {
184
+ if (entry.id) submissions.forget(entry.id);
185
+ try {
186
+ entry.onSettled?.({ status: 'rejected', reason: 'session-closed', result: null, session: null });
187
+ } catch {}
188
+ }
189
+ }
190
+
163
191
  async function drain() {
192
+ // Execution admission fence: a closing session starts nothing (the close
193
+ // path re-kicks the queue if the durable close fails); a closed one
194
+ // settles what was queued.
195
+ if (admission() === 'closing') return;
196
+ if (admission() === 'terminal') {
197
+ settleClosedQueue();
198
+ return;
199
+ }
164
200
  if (flags.draining) return;
165
201
  // Bail while any session command holds commandBusy (auto-clear implies it,
166
202
  // but so do setModel/newSession/resume/etc). Running a turn concurrently
@@ -196,6 +232,7 @@ export function createDrainLoop(bag, { queue, steering, submissions, flushDeferr
196
232
  try {
197
233
  while (pending.length > 0) {
198
234
  if (flags.drainEpoch !== drainEpoch) return;
235
+ if (admission() !== 'open') return;
199
236
  // Drain one priority/mode bucket at a time (unified command queue):
200
237
  // prompt steering stays editable and task notifications stay
201
238
  // non-editable but model-visible.
@@ -230,13 +267,23 @@ export function createDrainLoop(bag, { queue, steering, submissions, flushDeferr
230
267
  if (flags.drainEpoch === drainEpoch) {
231
268
  flags.draining = false;
232
269
  flushDeferredClearedSessionUi();
233
- if (hasModelDrainablePending()) void drain();
270
+ if (admission() === 'terminal') settleClosedQueue();
271
+ else if (hasModelDrainablePending()) void drain();
234
272
  else flushDeferredExecutionPendingResumeKick();
235
273
  }
236
274
  }
237
275
  }
238
276
 
239
277
  function enqueue(text, options = {}) {
278
+ // Admission fence. While closing, only user prompts are accepted (retained
279
+ // so a failed close loses nothing); notification-driven/model-visible
280
+ // enqueues are refused. A closed session accepts nothing.
281
+ const state = admission();
282
+ const mode = options.mode || 'prompt';
283
+ if (state !== 'open' && (state === 'terminal' || mode !== 'prompt')) {
284
+ tuiDebug(`enqueue refused: session admission=${state} mode=${mode}`);
285
+ return false;
286
+ }
240
287
  const submissionId = String(options.id || '').trim();
241
288
  // Idempotent intake: a transport that re-sent this exact submission (its
242
289
  // first attempt landed but the response was lost) must not double-post.
@@ -282,5 +329,5 @@ export function createDrainLoop(bag, { queue, steering, submissions, flushDeferr
282
329
  return true;
283
330
  }
284
331
 
285
- return { drain, enqueue };
332
+ return { drain, enqueue, settleClosedQueue };
286
333
  }
@@ -79,10 +79,9 @@ export function createTakeEntriesOps({ pending, pendingNotificationKeys, removeQ
79
79
  .filter((text) => String(text || '').trim())
80
80
  .join('\n');
81
81
  const combinedText = [queuedText, String(currentText || '')].filter((text) => text.trim()).join('\n');
82
+ const verb = unreadable === 1 ? ' was' : 's were';
82
83
  return {
83
- ...(unreadable
84
- ? { notice: `${unreadable} attachment${unreadable === 1 ? ' was' : 's were'} no longer available and dropped.` }
85
- : {}),
84
+ ...(unreadable ? { notice: `${unreadable} attachment${verb} no longer available and dropped.` } : {}),
86
85
  count: queued.length,
87
86
  ids: queued.map((item) => String(item.id || '')).filter(Boolean),
88
87
  text: combinedText,
@@ -70,6 +70,9 @@ export function createQueueOps(bag, { kickDrain }) {
70
70
  pending.unshift(...restored);
71
71
  const visible = restored.filter(isQueuedEntryVisible);
72
72
  if (visible.length > 0) set({ queued: [...visible, ...getState().queued] });
73
+ // Requeued from a turn that settled after the canonical close: settle
74
+ // explicitly instead of parking work no drain will ever run.
75
+ if (bag.flags?.admission === 'terminal') bag.settleClosedQueue?.();
73
76
  return true;
74
77
  }
75
78
 
@@ -116,7 +116,12 @@ export function createSteeringOps(bag, { queue, submissions }) {
116
116
  return !isGoalQueuedEntry(entry) || bag.shouldRunGoalContinuation?.(entry) === true;
117
117
  }
118
118
 
119
+ // Pre-send steering is an admission route: a closing/closed session injects
120
+ // nothing into a model request.
121
+ const admissionOpen = () => !bag.flags?.admission || bag.flags.admission === 'open';
122
+
119
123
  function hasPendingSteering() {
124
+ if (!admissionOpen()) return false;
120
125
  return pending.some(
121
126
  (entry) =>
122
127
  isSteerableEntry(entry) &&
@@ -134,6 +139,7 @@ export function createSteeringOps(bag, { queue, submissions }) {
134
139
  else if (_sessionIdOrOptions && typeof _sessionIdOrOptions === 'object') options = _sessionIdOrOptions;
135
140
  const maxPriority = options.maxPriority || 'next';
136
141
  const out = [];
142
+ if (!admissionOpen()) return out;
137
143
  for (;;) {
138
144
  const batch = queue.dequeueQueueBatch(maxPriority, { predicate: isSteerableEntry });
139
145
  if (batch.length === 0) break;
@@ -156,6 +162,8 @@ export function createSteeringOps(bag, { queue, submissions }) {
156
162
  }
157
163
 
158
164
  async function restoreLeadSteeringFromDisk() {
165
+ // A closed/closing conversation never gets queue work restored into it.
166
+ if (!admissionOpen()) return;
159
167
  const rows = await drainTuiSteeringPersist(leadSessionId());
160
168
  if (!rows.length) return;
161
169
  const livePersistIds = new Set(pending.map((entry) => entry?.steeringPersistId).filter(Boolean));
@@ -190,6 +198,9 @@ export function createSteeringOps(bag, { queue, submissions }) {
190
198
  if (isQueuedEntryVisible(entry)) restored.push(entry);
191
199
  }
192
200
  if (restored.length > 0) set({ queued: [...getState().queued, ...restored] });
201
+ // The conversation closed while the rows were being read: settle them
202
+ // explicitly (rejected + durable mirror released), never leave them parked.
203
+ if (bag.flags?.admission === 'terminal') bag.settleClosedQueue?.();
193
204
  if (droppedDelivered > 0) {
194
205
  try {
195
206
  process.stderr.write(`[tui] skipped ${droppedDelivered} already-delivered steering row(s) on restore\n`);
@@ -36,6 +36,7 @@ export function createSessionFlow(bag) {
36
36
  dequeueQueueBatch: queue.dequeueQueueBatch,
37
37
  drain: loop.drain,
38
38
  enqueue: loop.enqueue,
39
+ settleClosedQueue: loop.settleClosedQueue,
39
40
  hasPendingSteering: steering.hasPendingSteering,
40
41
  drainPendingSteering: steering.drainPendingSteering,
41
42
  restoreLeadSteeringFromDisk: steering.restoreLeadSteeringFromDisk,
@@ -56,12 +56,17 @@ export function createToolApproval({ getState, set, nextId, getDisposed, timeout
56
56
  armToolApprovalTimeout(entry);
57
57
  set({ toolApproval: entry.request });
58
58
  }
59
- function finishToolApproval(id, approved, reason = '') {
59
+ function finishToolApproval(id, approved, reason = '', device = '') {
60
60
  const targetId = String(id || '');
61
61
  if (activeToolApproval && activeToolApproval.id === targetId) {
62
62
  const entry = activeToolApproval;
63
63
  activeToolApproval = null;
64
- set({ toolApproval: null });
64
+ // Only an answer from a known device is attributed; a timeout or an
65
+ // interrupt names nobody.
66
+ const toolApprovalResult = device
67
+ ? { id: targetId, approved: approved === true, device: String(device), at: Date.now() }
68
+ : null;
69
+ set({ toolApproval: null, toolApprovalResult });
65
70
  settleToolApproval(entry, approved === true, String(reason || ''));
66
71
  presentNextToolApproval();
67
72
  return true;
@@ -109,6 +109,7 @@ export function createTranscriptIntake({ getState, flags, pushItem, patchItem, t
109
109
  text,
110
110
  ...transcriptMeta,
111
111
  ...(extra && typeof extra.sender === 'string' && extra.sender ? { sender: extra.sender } : {}),
112
+ ...(extra && typeof extra.device === 'string' && extra.device ? { device: extra.device } : {}),
112
113
  // Byte-free attachment metadata (name/mime/size) from the queue entry —
113
114
  // lets the desktop transcript render image chips without ever carrying
114
115
  // base64 payloads through snapshots.
@@ -152,6 +152,8 @@ function promptRestoreFor(displayText, options, submittedIds, content = null) {
152
152
  onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
153
153
  restorable: options.restorable !== false,
154
154
  submittedIds,
155
+ // Which device submitted the prompt: only that device may get it back.
156
+ device: typeof options.transcriptMeta?.device === 'string' ? options.transcriptMeta.device : '',
155
157
  reclaimed: false,
156
158
  committed: false,
157
159
  requeueEntries: Array.isArray(options.requeueOnAbort) ? options.requeueOnAbort.slice() : [],
@@ -190,6 +192,9 @@ function steeringItemExtras(steeringMeta) {
190
192
  ...(typeof steeringMeta?.transcriptMeta?.sender === 'string' && steeringMeta.transcriptMeta.sender
191
193
  ? { sender: steeringMeta.transcriptMeta.sender }
192
194
  : {}),
195
+ ...(typeof steeringMeta?.transcriptMeta?.device === 'string' && steeringMeta.transcriptMeta.device
196
+ ? { device: steeringMeta.transcriptMeta.device }
197
+ : {}),
193
198
  };
194
199
  }
195
200
 
@@ -389,7 +394,7 @@ function completeTurn(ctx, result, session) {
389
394
  turn.finishedNormally = true;
390
395
  }
391
396
 
392
- function failTurn(ctx, error) {
397
+ export function failTurn(ctx, error) {
393
398
  const { turn, stream, cards, pushNotice } = ctx;
394
399
  flushStreamBatch(stream); // ensure any batched text lands before the error notice
395
400
  if (isCancelLikeError(error)) {
@@ -401,12 +406,20 @@ function failTurn(ctx, error) {
401
406
  turn.failed = true;
402
407
  cards.aggregates.finalizeToolHeaders();
403
408
  turn.usageLimited = isUsageLimitError(error);
409
+ const exhausted = error?.quotaExhausted;
410
+ if (exhausted && typeof exhausted === 'object') {
411
+ turn.quota = {
412
+ provider: String(exhausted.provider || turn.routeMeta?.provider || ''),
413
+ model: String(exhausted.model || turn.routeMeta?.model || ''),
414
+ resetAt: Number.isFinite(exhausted.resetAt) ? exhausted.resetAt : null,
415
+ };
416
+ }
404
417
  turn.failureDetail = toolErrorDisplay(error, 'turn').replace(/^Error:\s*/i, '');
405
418
  turn.failureDiagnostic = safeErrorDetails(errText(error));
406
419
  pushNotice(turn.failureDetail, 'error', { owner: 'transcript' });
407
420
  }
408
421
 
409
- function turnDoneItem({ turn, stream, cards, getState, nextId }) {
422
+ export function turnDoneItem({ turn, stream, cards, getState, nextId }) {
410
423
  return {
411
424
  kind: 'turndone',
412
425
  id: nextId(),
@@ -425,6 +438,7 @@ function turnDoneItem({ turn, stream, cards, getState, nextId }) {
425
438
  ...(turn.failureDetail ? { detail: turn.failureDetail } : {}),
426
439
  ...(turn.failureDiagnostic ? { errorDetails: turn.failureDiagnostic } : {}),
427
440
  ...turn.routeMeta,
441
+ ...(turn.failed && turn.quota ? { quota: turn.quota } : {}),
428
442
  };
429
443
  }
430
444
 
@@ -462,6 +476,14 @@ function publishTurnClose(ctx, closingItems) {
462
476
  toolMode: runtime.toolMode,
463
477
  ...agentStatusState({ force: true }),
464
478
  });
479
+ // Same frame as the failed turndone and busy=false: the resume is queued
480
+ // (and `quotaResume` published) before anything can observe the idle
481
+ // session, so a cancel at any later point finds it and wins.
482
+ if (turn.failed && turn.quota) {
483
+ try {
484
+ ctx.bag.beginQuotaSettlement?.();
485
+ } catch {}
486
+ }
465
487
  ctx.flushDeferredExecutionPendingResumeKick();
466
488
  }
467
489
 
@@ -502,8 +524,12 @@ async function settleTurn(ctx) {
502
524
  try {
503
525
  options.onSettled?.(detail);
504
526
  } catch {}
527
+ // A quota failure suspends only a Goal that was running when it struck.
528
+ const quotaFailed = turn.failed && turn.quota && isCurrentTurn();
529
+ const goalBefore = quotaFailed ? runtime.goalStatus?.() || null : null;
530
+ let settledGoal = null;
505
531
  try {
506
- await bag.onGoalTurnSettled?.({
532
+ settledGoal = await bag.onGoalTurnSettled?.({
507
533
  status: finalStatus,
508
534
  error: turn.failureDiagnostic || turn.failureDetail || null,
509
535
  usageLimited: turn.usageLimited,
@@ -520,6 +546,17 @@ async function settleTurn(ctx) {
520
546
  }),
521
547
  });
522
548
  } catch {}
549
+ if (turn.failed && turn.quota) {
550
+ const suspended =
551
+ quotaFailed &&
552
+ !flags.disposed &&
553
+ goalBefore?.status === 'active' &&
554
+ settledGoal?.status === 'usage_limited' &&
555
+ settledGoal.id === goalBefore.id;
556
+ try {
557
+ bag.finishQuotaSettlement?.({ suspendedGoalId: suspended ? settledGoal.id : '' });
558
+ } catch {}
559
+ }
523
560
  if (flags.goalSteeringAbortEpoch === turn.epoch) flags.goalSteeringAbortEpoch = null;
524
561
  ctx.tuiDebug(
525
562
  `runTurn end turn=${turn.index} status=${finalStatus} elapsedMs=${Date.now() - turn.startedAt} pending=${ctx.pending.length}`
@@ -17,6 +17,7 @@ import {
17
17
  } from '../src/gateway/route-meta.mjs';
18
18
  import { getModelMetadataSync } from '../../../runtime/agent/orchestrator/providers/model-catalog.mjs';
19
19
  import { resolveRuntimeRoot } from '../../../runtime/shared/runtime-root.mjs';
20
+ import { withDefaultPresets } from '../../../runtime/agent/orchestrator/config-presets.mjs';
20
21
 
21
22
  function positiveInt(value) {
22
23
  const n = parseInt(String(value || ''), 10);
@@ -142,7 +143,7 @@ function resolveConfiguredModelTarget(cfg, rawModel) {
142
143
  const lower = stripped.toLowerCase();
143
144
  const agent = cfg?.agent && typeof cfg.agent === 'object' ? cfg.agent : {};
144
145
  const providers = agent.providers && typeof agent.providers === 'object' ? agent.providers : {};
145
- const presets = Array.isArray(agent.presets) ? agent.presets : [];
146
+ const presets = withDefaultPresets(agent.presets);
146
147
  for (const p of presets) {
147
148
  if (!p || typeof p !== 'object' || !p.provider || !p.model) continue;
148
149
  const providerEntry = providers[p.provider];
@@ -455,7 +456,7 @@ function configuredGatewayStatus(options = {}) {
455
456
  model = aliasTarget.model;
456
457
  }
457
458
  if (!provider || !model) return null;
458
- const presets = Array.isArray(cfg?.agent?.presets) ? cfg.agent.presets : [];
459
+ const presets = withDefaultPresets(cfg?.agent?.presets);
459
460
  const preset = presets.find(p =>
460
461
  p?.provider === provider &&
461
462
  p?.model === model &&