synabun 2.0.5 → 2.0.7

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 (233) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/shared.mjs +6 -3
  6. package/lib/child-path.js +303 -0
  7. package/lib/claude-hooks.js +2 -1
  8. package/lib/data-home-migration.js +2 -0
  9. package/lib/desktop-app.js +265 -0
  10. package/lib/external-tools.js +7 -3
  11. package/lib/log-redact.js +102 -0
  12. package/lib/packaged-runtime.js +138 -11
  13. package/mcp-server/dist/http.d.ts +3 -1
  14. package/mcp-server/dist/http.d.ts.map +1 -1
  15. package/mcp-server/dist/http.js +15 -4
  16. package/mcp-server/dist/http.js.map +1 -1
  17. package/mcp-server/dist/index.d.ts.map +1 -1
  18. package/mcp-server/dist/index.js +9 -6
  19. package/mcp-server/dist/index.js.map +1 -1
  20. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  21. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  22. package/mcp-server/dist/services/budget-judgments.js +153 -0
  23. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  24. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  25. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  26. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  27. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  28. package/mcp-server/dist/services/identity.d.ts +11 -0
  29. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  30. package/mcp-server/dist/services/identity.js +24 -0
  31. package/mcp-server/dist/services/identity.js.map +1 -1
  32. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  33. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  34. package/mcp-server/dist/services/neural-interface.js +34 -2
  35. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  36. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  37. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  38. package/mcp-server/dist/services/typesafe-config.js +4 -1
  39. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  40. package/mcp-server/dist/tools/agents.d.ts +10 -7
  41. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  42. package/mcp-server/dist/tools/agents.js +34 -13
  43. package/mcp-server/dist/tools/agents.js.map +1 -1
  44. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  45. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  46. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  47. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  48. package/mcp-server/dist/tui.js +2 -1
  49. package/mcp-server/dist/tui.js.map +1 -1
  50. package/mcp-server/dist/version.d.ts +5 -0
  51. package/mcp-server/dist/version.d.ts.map +1 -0
  52. package/mcp-server/dist/version.js +21 -0
  53. package/mcp-server/dist/version.js.map +1 -0
  54. package/mcp-server/package-lock.json +2 -2
  55. package/mcp-server/package.json +1 -1
  56. package/mcp-server/run.mjs +15 -9
  57. package/mcp-server/src/http.ts +18 -3
  58. package/mcp-server/src/index.ts +9 -6
  59. package/mcp-server/src/services/budget-judgments.ts +183 -0
  60. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  61. package/mcp-server/src/services/identity.ts +27 -0
  62. package/mcp-server/src/services/neural-interface.ts +34 -2
  63. package/mcp-server/src/services/typesafe-config.ts +4 -1
  64. package/mcp-server/src/tools/agents.ts +33 -14
  65. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  66. package/mcp-server/src/tui.ts +2 -1
  67. package/mcp-server/src/version.ts +22 -0
  68. package/neural-interface/i18n/en.json +627 -57
  69. package/neural-interface/i18n/pt-BR.json +626 -56
  70. package/neural-interface/lib/account-usage.js +163 -0
  71. package/neural-interface/lib/assistant-api.js +255 -52
  72. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  73. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  74. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  75. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  76. package/neural-interface/lib/assistant-budget-split.js +408 -0
  77. package/neural-interface/lib/assistant-budget.js +94 -95
  78. package/neural-interface/lib/assistant-config.js +62 -14
  79. package/neural-interface/lib/assistant-dispatch.js +432 -125
  80. package/neural-interface/lib/assistant-jev.js +26 -3
  81. package/neural-interface/lib/assistant-persona.js +68 -11
  82. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  83. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  84. package/neural-interface/lib/assistant-route-gate.js +19 -3
  85. package/neural-interface/lib/assistant-router.js +373 -42
  86. package/neural-interface/lib/assistant-runtime.js +307 -47
  87. package/neural-interface/lib/assistant-user-writer.js +4 -2
  88. package/neural-interface/lib/augmented-path.js +317 -53
  89. package/neural-interface/lib/claude-accounts.js +342 -31
  90. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  91. package/neural-interface/lib/claude-executable.js +6 -1
  92. package/neural-interface/lib/claude-model-catalog.js +3 -3
  93. package/neural-interface/lib/claude-panel-auth.js +221 -0
  94. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  95. package/neural-interface/lib/cli-install-api.js +137 -0
  96. package/neural-interface/lib/cli-install.js +797 -0
  97. package/neural-interface/lib/cli-path.js +130 -0
  98. package/neural-interface/lib/cli-resolver.js +744 -0
  99. package/neural-interface/lib/cli-update-planner.js +143 -7
  100. package/neural-interface/lib/codex-account-auth.js +178 -0
  101. package/neural-interface/lib/codex-accounts.js +489 -0
  102. package/neural-interface/lib/codex-capabilities.js +2 -1
  103. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  104. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  105. package/neural-interface/lib/codex-panel-auth.js +62 -0
  106. package/neural-interface/lib/codex-session-settings.js +1 -1
  107. package/neural-interface/lib/codex-settings-schema.js +120 -0
  108. package/neural-interface/lib/desktop/config.js +3 -0
  109. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  110. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  111. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  112. package/neural-interface/lib/git-bash/install.js +529 -0
  113. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  114. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  115. package/neural-interface/lib/git-bash/manager.js +268 -0
  116. package/neural-interface/lib/git-bash/pins.js +206 -0
  117. package/neural-interface/lib/git-bash/pins.json +34 -0
  118. package/neural-interface/lib/git-bash/resolver.js +194 -0
  119. package/neural-interface/lib/git-read.js +34 -2
  120. package/neural-interface/lib/install-details.js +145 -0
  121. package/neural-interface/lib/mcp-installer.js +14 -2
  122. package/neural-interface/lib/native-loop-providers.js +16 -3
  123. package/neural-interface/lib/native-loop-runtime.js +9 -4
  124. package/neural-interface/lib/os-path.js +319 -0
  125. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  126. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  127. package/neural-interface/lib/pty-host/core.js +2 -1
  128. package/neural-interface/lib/pty-host/manager.js +27 -2
  129. package/neural-interface/lib/remote-policy.js +20 -11
  130. package/neural-interface/lib/session-title-generator.js +4 -3
  131. package/neural-interface/lib/system-about.js +340 -0
  132. package/neural-interface/lib/system-activity.js +66 -0
  133. package/neural-interface/lib/terminal-env.js +11 -3
  134. package/neural-interface/lib/terminal-path.js +55 -0
  135. package/neural-interface/lib/virtual-socket.js +12 -1
  136. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  137. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  138. package/neural-interface/lib/whatsapp/commands.js +13 -14
  139. package/neural-interface/lib/whatsapp/config.js +134 -20
  140. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  141. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  142. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  143. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  144. package/neural-interface/lib/whatsapp/manager.js +92 -32
  145. package/neural-interface/lib/whatsapp/paths.js +11 -1
  146. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  147. package/neural-interface/lib/whatsapp/redact.js +63 -0
  148. package/neural-interface/lib/whatsapp/service.js +343 -91
  149. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  150. package/neural-interface/package-lock.json +2 -2
  151. package/neural-interface/package.json +1 -1
  152. package/neural-interface/public/index.html +2 -0
  153. package/neural-interface/public/index2d.html +2 -0
  154. package/neural-interface/public/offline.html +16 -1
  155. package/neural-interface/public/onboarding.html +96 -2
  156. package/neural-interface/public/shared/account-i18n.js +7 -0
  157. package/neural-interface/public/shared/account-menu.js +539 -0
  158. package/neural-interface/public/shared/account-usage.js +19 -0
  159. package/neural-interface/public/shared/account-view.js +335 -0
  160. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  161. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  162. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  163. package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
  164. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  165. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  166. package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
  167. package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
  168. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  169. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  170. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  171. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  172. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  173. package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
  174. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  175. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  176. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  177. package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
  178. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  179. package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
  180. package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
  181. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  182. package/neural-interface/public/shared/claude-account-view.js +3 -0
  183. package/neural-interface/public/shared/cli-install-view.js +392 -0
  184. package/neural-interface/public/shared/cli-status.js +282 -22
  185. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  186. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  187. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  188. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  189. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  190. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  191. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  192. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  193. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  194. package/neural-interface/public/shared/git-bash-view.js +145 -0
  195. package/neural-interface/public/shared/html-shell.js +1 -0
  196. package/neural-interface/public/shared/i18n.js +19 -1
  197. package/neural-interface/public/shared/install-details-view.js +86 -0
  198. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
  199. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  200. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
  201. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  202. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  203. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  204. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  205. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  206. package/neural-interface/public/shared/storage.js +30 -3
  207. package/neural-interface/public/shared/styles.css +278 -31
  208. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  209. package/neural-interface/public/shared/ui-claude-panel.js +212 -213
  210. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  211. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  212. package/neural-interface/public/shared/ui-judgments.js +10 -1
  213. package/neural-interface/public/shared/ui-loading.js +30 -6
  214. package/neural-interface/public/shared/ui-menubar.js +13 -3
  215. package/neural-interface/public/shared/ui-navbar.js +3 -1
  216. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  217. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  218. package/neural-interface/public/shared/ui-sessions.js +9 -3
  219. package/neural-interface/public/shared/ui-settings.js +55 -3
  220. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  221. package/neural-interface/public/shared/ui-sync.js +6 -0
  222. package/neural-interface/public/shared/ui-terminal.js +17 -0
  223. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  224. package/neural-interface/public/shared/ui-update.js +594 -5
  225. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  226. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  227. package/neural-interface/public/shared/worker-end.js +2 -0
  228. package/neural-interface/public/sw.js +1 -1
  229. package/neural-interface/public/variant/2d/main.js +10 -1
  230. package/neural-interface/public/variant/3d/main.js +10 -1
  231. package/neural-interface/server.js +1219 -608
  232. package/package.json +4 -1
  233. package/setup.js +9 -8
@@ -12,6 +12,8 @@
12
12
  // success, error once, when a turn that did real work ends (then idle)
13
13
  // watch while you type: eyes on the caret column, look() at most
14
14
  // every 120 ms; back 1.5 s after the last key, at once on send
15
+ // project/budget attention owned by this panel's empty-state chip/menu;
16
+ // commits react once; a menu prevents idle sleep
15
17
  // sleep the hero only, after 90 s with no input while nothing runs;
16
18
  // the rig wakes itself on a pointer move or a key
17
19
  //
@@ -28,6 +30,7 @@
28
30
  export const MASCOT_TIMING = Object.freeze({
29
31
  watchEveryMs: 120,
30
32
  watchIdleMs: 1500,
33
+ controlDwellMs: 1500,
31
34
  sleepAfterMs: 90_000,
32
35
  realWorkMs: 3000,
33
36
  oneShotMs: Object.freeze({ success: 1000, error: 900 }),
@@ -67,6 +70,9 @@ export function lookToward(box, point, { horizontal = false } = {}) {
67
70
  * getTarget() → the renderer's visible mascot { kind: 'hero'|'stage'|'working',
68
71
  * rig, el, parked?(), hold(on), restore() } or null; caret() → the caret's
69
72
  * client point or null.
73
+ * Control API: attend(owner, { pose, rectOf, options, open }), leave(owner),
74
+ * controlCommitted({ pose, options, reaction }); type(present, pointOf?) also
75
+ * watches a budget amount field using the same throttling as the composer.
70
76
  * env: { now, setTimeout, clearTimeout, input(fn) → unsubscribe (page pointer
71
77
  * and key input), lastInputAt() }.
72
78
  * All of its state is its own: one director per panel, any number at once.
@@ -83,6 +89,9 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
83
89
  let holding = false; // hold(true) is in force on `target`
84
90
  let shown = ''; // the pose (and then) set on `target`
85
91
  let typing = false;
92
+ let typingPoint = null;
93
+ let attention = null; // { owner, pose, rectOf, options, open }; this panel's empty-state control
94
+ let attentionTimer = 0;
86
95
  let typingTimer = 0;
87
96
  let lookTimer = 0;
88
97
  let lookedAt = -Infinity;
@@ -106,9 +115,16 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
106
115
  const point = card && card.width ? { x: card.left + Math.min(card.width * 0.3, 160), y: card.top + Math.min(card.height / 2, 28) } : null;
107
116
  return { pose: 'blocked', look: lookToward(boxOf(t), point) };
108
117
  }
109
- if (oneShot && oneShot.target === t) return { pose: oneShot.pose, opts: { then: 'idle' } };
110
- if (typing) return { pose: 'watch', look: lookToward(boxOf(t), safe(caret), { horizontal: true }) };
118
+ const attentionLook = () => {
119
+ const rect = attention && safe(attention.rectOf);
120
+ return lookToward(boxOf(t), rect && { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
121
+ };
122
+ if (oneShot && oneShot.target === t) return { pose: oneShot.pose, opts: oneShot.opts || { then: 'idle' }, ...(attention ? { look: attentionLook() } : {}) };
123
+ if (typing) return { pose: 'watch', look: lookToward(boxOf(t), safe(typingPoint || caret), { horizontal: true }) };
111
124
  if (sleeping && t.kind === 'hero' && !safe(() => t.parked?.())) return { pose: 'sleep' };
125
+ if (attention && t.kind === 'hero' && !safe(() => t.parked?.())) {
126
+ return { pose: attention.pose, opts: safe(attention.options) || {}, look: attentionLook() };
127
+ }
112
128
  return null;
113
129
  }
114
130
 
@@ -136,7 +152,7 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
136
152
  return;
137
153
  }
138
154
  if (!holding) { holding = true; safe(() => t.hold(true)); }
139
- const key = `${want.pose}|${want.opts?.then || ''}`;
155
+ const key = `${want.pose}|${want.opts?.then || ''}|${!!want.opts?.unlimited}`;
140
156
  if (key !== shown) { shown = key; safe(() => t.rig.setPose(want.pose, want.opts)); }
141
157
  if (want.look) safe(() => t.rig.look(want.look.x, want.look.y));
142
158
  }
@@ -155,7 +171,7 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
155
171
 
156
172
  function canSleep() {
157
173
  const t = target;
158
- return !destroyed && !!t && t.kind === 'hero' && !safe(() => t.parked?.()) && !turnActive && !typing && !blocked && !offline && !oneShot;
174
+ return !destroyed && !!t && t.kind === 'hero' && !safe(() => t.parked?.()) && !turnActive && !typing && !attention?.open && !blocked && !offline && !oneShot;
159
175
  }
160
176
 
161
177
  function armSleep() {
@@ -177,17 +193,20 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
177
193
  if (lookTimer) { cancel(lookTimer); lookTimer = 0; }
178
194
  if (!typing) return;
179
195
  typing = false;
196
+ typingPoint = null;
180
197
  apply();
181
198
  armSleep();
182
199
  }
183
200
 
184
201
  /** The draft changed: `present` false (an empty draft) ends the watch. */
185
- function type(present = true) {
202
+ function type(present = true, pointOf = null) {
186
203
  if (destroyed) return;
187
204
  noteInput();
188
205
  if (!present) { stopTyping(); return; }
206
+ if (oneShot?.control) dropOneShot(); // a new edit resumes watching after an acknowledgement
189
207
  const first = !typing;
190
208
  typing = true;
209
+ typingPoint = typeof pointOf === 'function' ? pointOf : null;
191
210
  if (sleepTimer) { cancel(sleepTimer); sleepTimer = 0; }
192
211
  if (typingTimer) cancel(typingTimer);
193
212
  typingTimer = later(() => { typingTimer = 0; stopTyping(); }, MASCOT_TIMING.watchIdleMs);
@@ -221,13 +240,53 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
221
240
  armSleep();
222
241
  }
223
242
 
243
+ function clearAttention() {
244
+ if (attentionTimer) { cancel(attentionTimer); attentionTimer = 0; }
245
+ attention = null;
246
+ }
247
+
224
248
  return {
225
249
  /** The draft changed (typing): the mascot watches the caret column. */
226
250
  type,
227
251
  /** Blur, or the draft went away. */
228
252
  stopTyping,
253
+ /** Chip previews relax after a short dwell; only open menus hold attention and block sleep. */
254
+ attend(owner, spec) {
255
+ if (destroyed || (attention?.open && attention.owner !== owner && !spec.open)) return;
256
+ noteInput();
257
+ clearAttention();
258
+ attention = { owner, ...spec };
259
+ if (!attention.open) attentionTimer = later(() => {
260
+ clearAttention();
261
+ apply();
262
+ armSleep();
263
+ }, MASCOT_TIMING.controlDwellMs);
264
+ apply();
265
+ armSleep();
266
+ },
267
+ leave(owner) {
268
+ if (attention?.owner !== owner) return;
269
+ clearAttention();
270
+ apply();
271
+ armSleep();
272
+ },
273
+ /** Only acknowledged user commits call this; loads, live updates and keystrokes do not. */
274
+ controlCommitted({ pose, options = {}, reaction = 'nod' }) {
275
+ if (destroyed) return;
276
+ const t = safe(getTarget);
277
+ if (!t || t.kind !== 'hero' || safe(() => t.parked?.()) || turnActive || offline || blocked) return;
278
+ stopTyping();
279
+ noteInput();
280
+ dropOneShot();
281
+ const shot = { pose, opts: options, target: t, timer: 0, control: true };
282
+ shot.timer = later(() => { if (oneShot !== shot) return; oneShot = null; apply(); armSleep(); }, 900);
283
+ oneShot = shot;
284
+ apply();
285
+ safe(() => t.rig.react(reaction));
286
+ armSleep();
287
+ },
229
288
  /** A prompt went out: the watch hands over at once (the renderer gives the hero to the "Thinking" row). */
230
- sent() { stopTyping(); },
289
+ sent() { clearAttention(); dropOneShot(); stopTyping(); apply(); armSleep(); },
231
290
  /** A card waits on the user (`rectOf()` → its box), or none (null). */
232
291
  setBlocked(rectOf) {
233
292
  blocked = typeof rectOf === 'function' ? rectOf : null;
@@ -248,6 +307,7 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
248
307
  },
249
308
  turnStarted() {
250
309
  turnActive = true;
310
+ clearAttention();
251
311
  dropOneShot();
252
312
  if (sleeping) sleeping = false;
253
313
  apply();
@@ -260,14 +320,73 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
260
320
  /** The user is here (focus, a click): a sleeping hero wakes, the quiet starts over. */
261
321
  touch() { if (!destroyed) noteInput(); },
262
322
  /** For tests and devtools: what the director holds right now. */
263
- state: () => ({ target: target?.kind || null, pose: shown.split('|')[0] || null, typing, blocked: !!blocked, offline, sleeping, oneShot: oneShot?.pose || null }),
323
+ state: () => ({ target: target?.kind || null, pose: shown.split('|')[0] || null, typing, attention: attention?.pose || null, menuOpen: !!attention?.open, blocked: !!blocked, offline, sleeping, oneShot: oneShot?.pose || null }),
264
324
  destroy() {
265
325
  if (destroyed) return;
266
326
  if (target && holding) safe(() => target.hold(false));
267
327
  destroyed = true;
328
+ clearAttention();
268
329
  for (const id of [typingTimer, lookTimer, sleepTimer]) if (id) cancel(id);
269
330
  dropOneShot();
270
331
  if (stopInput) { safe(stopInput); stopInput = null; }
271
332
  },
272
333
  };
273
334
  }
335
+
336
+ /** Wire one empty-state chip and its body-level menu to its panel's director. No rig or clock here. */
337
+ export function createMascotControl(anchor, { director = () => null, pose, options = () => ({}) } = {}) {
338
+ const owner = {};
339
+ let menu = null, preview = null, watched = false, destroyed = false;
340
+ const listeners = [];
341
+ const listen = (node, event, fn) => { node.addEventListener(event, fn); listeners.push(() => node.removeEventListener(event, fn)); };
342
+ function attend(node = anchor) {
343
+ if (destroyed) return;
344
+ preview = node;
345
+ director()?.attend(owner, { pose, options, open: !!menu, rectOf: () => node.getBoundingClientRect() });
346
+ }
347
+ const leave = () => { if (!menu) { preview = null; director()?.leave(owner); } };
348
+ listen(anchor, 'pointerenter', () => attend(menu || anchor));
349
+ listen(anchor, 'focus', () => attend(menu || anchor));
350
+ listen(anchor, 'pointerleave', () => { if (document.activeElement !== anchor) leave(); });
351
+ listen(anchor, 'blur', leave);
352
+ let stopMenu = () => {};
353
+ return {
354
+ open(node) {
355
+ if (destroyed || !node) return;
356
+ stopMenu();
357
+ menu = node;
358
+ const follow = e => {
359
+ const option = e.target.closest?.('.asst-dd-item, input, label, button');
360
+ if (option && node.contains(option)) attend(option);
361
+ };
362
+ const type = e => {
363
+ if (!e.target.matches?.('.asst-budget-input')) return;
364
+ watched = true;
365
+ director()?.type(true, () => {
366
+ const r = e.target.getBoundingClientRect();
367
+ return { x: r.left + Math.min(r.width - 12, 12 + e.target.value.length * 7), y: r.top + r.height / 2 };
368
+ });
369
+ };
370
+ node.addEventListener('pointerover', follow);
371
+ node.addEventListener('focusin', follow);
372
+ node.addEventListener('input', type);
373
+ stopMenu = () => {
374
+ node.removeEventListener('pointerover', follow);
375
+ node.removeEventListener('focusin', follow);
376
+ node.removeEventListener('input', type);
377
+ };
378
+ attend(node.contains(document.activeElement) ? document.activeElement : node);
379
+ },
380
+ close() {
381
+ stopMenu(); menu = null; preview = null;
382
+ if (watched) { director()?.stopTyping(); watched = false; }
383
+ director()?.leave(owner);
384
+ },
385
+ commit(reaction) { if (!destroyed) director()?.controlCommitted({ pose, options: options(), reaction }); },
386
+ refresh() { if (preview) attend(preview); },
387
+ destroy() {
388
+ this.close(); destroyed = true;
389
+ for (const off of listeners) off();
390
+ },
391
+ };
392
+ }
@@ -288,7 +288,8 @@ export function openMenu(anchor, opts = {}) {
288
288
  closeMenu({ restoreFocus: !!item.restoreFocus });
289
289
  item.onSelect?.(item, node);
290
290
  };
291
- buildItems(list, items, { role, onActivate: activate });
291
+ if (opts.content) list.appendChild(opts.content);
292
+ else buildItems(list, items, { role, onActivate: activate });
292
293
  if (width) menu.style.minWidth = opts.repositionOnResize ? `min(${width}px, calc(100vw - 16px))` : `${width}px`;
293
294
  if (width && opts.repositionOnResize) menu.style.width = `min(${width}px, calc(100vw - 16px))`;
294
295
  document.body.appendChild(menu);
@@ -311,6 +312,15 @@ export function openMenu(anchor, opts = {}) {
311
312
  const nodes = focusables(menu);
312
313
  const idx = nodes.indexOf(document.activeElement);
313
314
  if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeMenu({ restoreFocus: true }); return; }
315
+ if (role === 'dialog') {
316
+ if (e.key === 'Tab') {
317
+ const fields = [...menu.querySelectorAll('button, input')].filter(node => !node.disabled && !node.hidden);
318
+ const index = fields.indexOf(document.activeElement);
319
+ if (e.shiftKey && index <= 0) { e.preventDefault(); fields.at(-1)?.focus(); }
320
+ else if (!e.shiftKey && (index < 0 || index === fields.length - 1)) { e.preventDefault(); fields[0]?.focus(); }
321
+ }
322
+ return; // native number/switch keys and Enter belong to the form
323
+ }
314
324
  if (e.key === 'Tab') { closeMenu(); return; }
315
325
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
316
326
  e.preventDefault(); e.stopPropagation();
@@ -347,12 +357,13 @@ export function openMenu(anchor, opts = {}) {
347
357
  const onResize = () => opts.repositionOnResize ? positionMenu(menu, anchor, placement) : closeMenu();
348
358
  const onBlur = () => closeMenu();
349
359
  document.addEventListener('mousedown', onDocDown, true);
350
- document.addEventListener('keydown', onKey, true);
360
+ const keyTarget = role === 'dialog' ? window : document;
361
+ keyTarget.addEventListener('keydown', onKey, true);
351
362
  window.addEventListener('resize', onResize);
352
363
  window.addEventListener('blur', onBlur);
353
364
  const cleanup = () => {
354
365
  document.removeEventListener('mousedown', onDocDown, true);
355
- document.removeEventListener('keydown', onKey, true);
366
+ keyTarget.removeEventListener('keydown', onKey, true);
356
367
  window.removeEventListener('resize', onResize);
357
368
  window.removeEventListener('blur', onBlur);
358
369
  };
@@ -360,6 +371,7 @@ export function openMenu(anchor, opts = {}) {
360
371
  _open = { menu, anchor, onClose, cleanup };
361
372
  if (anchor.getAttribute('aria-haspopup')) anchor.setAttribute('aria-expanded', 'true');
362
373
  anchor.setAttribute('aria-controls', menu.id);
374
+ opts.onOpen?.(menu);
363
375
 
364
376
  const selected = list.querySelector('.asst-dd-item[data-checked="1"]:not([disabled])');
365
377
  const first = focusables(menu).find(n => n.classList.contains('asst-dd-item')) || focusables(menu)[0];
@@ -29,6 +29,9 @@ import { emit, on, state as appState } from '../state.js';
29
29
  import { storage } from '../storage.js';
30
30
  import { KEYS } from '../constants.js';
31
31
  import { t } from '../i18n.js';
32
+ // The account words of the brain picker and the accounts manager (account-view.js) in the page's language.
33
+ // Here, not in those modules: node tests load them, and i18n.js reads the page's storage.
34
+ import '../account-i18n.js';
32
35
  import { forgetGreeting, mountGreeting } from '../greeting/greeting-card.js';
33
36
  import { notify, NOTIF_TYPE } from '../ui-notifications.js';
34
37
  import { getProviderMeta } from '../provider-icons.js';
@@ -51,7 +54,7 @@ import { createAgentsDock } from './asst-agents-dock.js';
51
54
  import { createUsageGauge } from './asst-usage.js';
52
55
  import { closeMenu, isMenuOpen, openMenu } from './asst-menu.js';
53
56
  import { createRouteModeControl, openModelsManager, openRoutesEditor, refreshModelsManager, renderRouteCard, routeModeLabel } from './asst-route.js';
54
- import { budgetChipText, budgetTone, openBudgetEditor } from './asst-budget.js';
57
+ import { budgetChipText, budgetSpendText, budgetTone, closeBudgetEditor, createBudgetController, mountBudgetChip, openBudgetEditor } from './asst-budget.js';
55
58
  import { computerRemoteView, createComputerPanel, createComputerToggle, normalizeComputerRemote, openFrameLightbox, closeFrameLightbox, requestDesktopStop } from './asst-computer.js';
56
59
  import {
57
60
  applyLimit, applyLimitPacket, applyUsagePacket, brainLabel, createRenameWriter, createTabState, DEFAULT_ROUTE_MODE, deserializeTabState, fmtCost, fmtRunLine, limitExpiresIn, limitNoticeText, modelShortName,
@@ -365,11 +368,11 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
365
368
  }
366
369
 
367
370
  function renderCost() {
368
- // The server's budget view (brain + agents, the caps) when it sends one.
369
- const budget = st.budget && Number.isFinite(Number(st.budget.totalUsd)) ? st.budget : null;
370
- const total = budget ? Number(budget.totalUsd) : totalCost();
371
- costEl.textContent = fmtCost(total);
372
- costEl.hidden = !(total > 0);
371
+ // The confirmed SessionBudget includes both brain and worker spend.
372
+ const budget = st.budget && Number.isFinite(Number(st.budget.spentUsd)) ? st.budget : null;
373
+ const total = budget ? Number(budget.spentUsd) : totalCost();
374
+ costEl.textContent = budget ? budgetSpendText(budget, { t }) : fmtCost(total);
375
+ costEl.hidden = !budget && !(total > 0);
373
376
  const tone = budgetTone(budget);
374
377
  if (tone && tone !== 'ok') costEl.dataset.tone = tone;
375
378
  else delete costEl.dataset.tone;
@@ -379,6 +382,7 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
379
382
  ? tt('assistant.topbar.costSplit', 'Session {session} · agents {agents}', { session: fmtCost(st.costUsd || 0), agents: fmtCost(st.dispatchUsd) })
380
383
  : tt('assistant.topbar.cost', 'Session cost');
381
384
  costEl.setAttribute('data-tooltip', tip);
385
+ costEl.setAttribute('aria-label', `${tt('assistant.budget.title', 'Budget')}: ${tip}`);
382
386
  }
383
387
 
384
388
  function brainModelLabel() {
@@ -441,6 +445,12 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
441
445
  });
442
446
 
443
447
  // ── Renderer ──
448
+ let emptyBudgetChip = null;
449
+ const budgetController = createBudgetController({ t, sessionId, onSaved: (data) => {
450
+ if (destroyed) return;
451
+ if (data?.session) st.budget = data.session;
452
+ renderCost();
453
+ } });
444
454
  const renderer = createRenderer(transcriptEl, {
445
455
  t,
446
456
  providerIcon,
@@ -463,6 +473,16 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
463
473
  emptySuggestions: () => emptySuggestions(),
464
474
  // "Choose a project" in the empty state is the toolbar's Working project field (asst-brain-picker.js).
465
475
  mountEmptyProject: (slot) => picker.mountEmptyProject(slot),
476
+ mountEmptyBudget: (slot) => {
477
+ emptyBudgetChip?.destroy();
478
+ emptyBudgetChip = mountBudgetChip(slot, budgetController, { t, mascot: () => director });
479
+ return emptyBudgetChip.el;
480
+ },
481
+ unmountEmptyControls: () => {
482
+ picker.mountEmptyProject(null);
483
+ emptyBudgetChip?.destroy();
484
+ emptyBudgetChip = null;
485
+ },
466
486
  onSuggestion: (template) => composer.fillTemplate(template),
467
487
  heroOrigin: () => { try { return host.takeHeroOrigin?.() || null; } catch { return null; } },
468
488
  onMascotChange: () => director?.retarget(),
@@ -549,7 +569,9 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
549
569
  // Model · effort · permission mode · account sit in the composer row next to attach; project and MCP in the toolbar.
550
570
  const picker = createBrainPicker({ brain: composer.el.querySelector('.asst-composer-brain'), context: barGroups.context }, {
551
571
  t,
572
+ mascot: () => director,
552
573
  getBrain: () => st.brain,
574
+ getRunning: () => st.running,
553
575
  onChange: (next, meta) => applyBrain(next, meta),
554
576
  onAddAccount: (provider) => openAccountsManager({ provider, ...accountHooks() }).openAdd(),
555
577
  onManageAccounts: (provider) => openAccountsManager({ provider, ...accountHooks() }),
@@ -625,7 +647,10 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
625
647
  function fetchUsage() {
626
648
  const seq = ++usageFetchSeq;
627
649
  getAssistantSessionUsage(sessionId).then((data) => {
628
- if (!destroyed && seq === usageFetchSeq && data?.ok && data.usage) applyUsage(data.usage);
650
+ if (!destroyed && seq === usageFetchSeq && data?.ok) {
651
+ if (data.budget) budgetController.receiveSession(data.budget);
652
+ if (data.usage) applyUsage(data.usage);
653
+ }
629
654
  }).catch(() => { /* older servers do not expose usage */ });
630
655
  }
631
656
  function applyUsage(packet) {
@@ -813,6 +838,9 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
813
838
  case 'assistant:mailbox':
814
839
  renderer.appendMailbox(packet.items || packet.events || packet.mailbox || []);
815
840
  return;
841
+ case 'assistant:budget':
842
+ budgetController.receiveSession(packet.budget);
843
+ return;
816
844
  case 'assistant:cost':
817
845
  applyCost(packet);
818
846
  return;
@@ -885,7 +913,7 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
885
913
  if (session != null) st.costUsd = session;
886
914
  else if (!packet?.runId && num(packet?.costUsd) != null) st.costUsd = num(packet.costUsd);
887
915
  if (num(packet?.dispatchUsd) != null) st.dispatchUsd = num(packet.dispatchUsd);
888
- if (packet?.budget && typeof packet.budget === 'object') st.budget = packet.budget;
916
+ if (packet?.budget && typeof packet.budget === 'object') budgetController.receiveSession(packet.budget);
889
917
  if (typeof packet?.brainMetered === 'boolean') st.brainMetered = packet.brainMetered;
890
918
  if (packet?.runId && num(packet.costUsd) != null) dock.patchRun(packet.runId, { costUsd: num(packet.costUsd) });
891
919
  if (session != null) st.serverCost = true;
@@ -905,7 +933,7 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
905
933
  const next = normalizeBrain({ ...st.brain, provider: meta.brain, model: meta.model ?? st.brain.model, effort: meta.effort ?? st.brain.effort, accountId: meta.accountId ?? st.brain.accountId, mcpProfile: meta.mcpProfile ?? st.brain.mcpProfile, project: meta.cwd ?? st.brain.project });
906
934
  if (JSON.stringify(next) !== JSON.stringify(st.brain)) { st.brain = next; picker.render(); host.setBrain?.(next); persist(); }
907
935
  }
908
- if (meta.budget && typeof meta.budget === 'object') st.budget = meta.budget;
936
+ if (meta.budget && typeof meta.budget === 'object') budgetController.receiveSession(meta.budget);
909
937
  if (Number.isFinite(Number(meta.costUsd))) { st.costUsd = Number(meta.costUsd); renderCost(); }
910
938
  if (meta.providerSessionId) st.providerSessionId = meta.providerSessionId;
911
939
  if (meta.routing && typeof meta.routing === 'object') {
@@ -1468,13 +1496,13 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
1468
1496
  if (meta.silent) return;
1469
1497
  if (meta.requiresSwitch) {
1470
1498
  const b = st.brain;
1471
- sock.send({ type: 'switch_brain', brain: b.provider, model: b.model || undefined, effort: b.effort || undefined, accountId: b.accountId || undefined, mcpProfile: b.mcpProfile || undefined, cwd: b.project || undefined, agent: b.agent || undefined, permissionMode: b.permissionMode, planMode: b.planMode === true });
1499
+ if (!meta.restartBrain) sock.send({ type: 'switch_brain', brain: b.provider, model: b.model || undefined, effort: b.effort || undefined, accountId: b.accountId || undefined, mcpProfile: b.mcpProfile || undefined, cwd: b.project || undefined, agent: b.agent || undefined, permissionMode: b.permissionMode, planMode: b.planMode === true });
1472
1500
  renderer.appendDivider(`${tt('assistant.status.brain', 'brain')} → ${brainLabel(b)}`);
1473
1501
  renderer.forgetSeen(); // the new brain's ids start over
1474
1502
  } else if (before.permissionMode !== st.brain.permissionMode || !!before.planMode !== !!st.brain.planMode) {
1475
1503
  sock.send({ type: 'set_permission_mode', mode: st.brain.permissionMode, planMode: st.brain.planMode === true });
1476
1504
  }
1477
- patchAssistantSession(sessionId, { brain: st.brain }).catch((err) => {
1505
+ patchAssistantSession(sessionId, { brain: st.brain, ...(meta.restartBrain ? { restartBrain: true } : {}) }).catch((err) => {
1478
1506
  // Refused (a model disabled in Assistant → Models): undo, so the refused
1479
1507
  // brain is neither shown nor saved as the last used one — the next start
1480
1508
  // would ask for it again. A brain switch already got the error on the socket.
@@ -1653,7 +1681,7 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
1653
1681
  items.push({ id: 'compact', label: tt('assistant.topbar.compact', 'Compact context'), onSelect: () => compact() });
1654
1682
  if (routingAvailable()) items.push({ id: 'routes', label: tt('assistant.routes.editor', 'Model routes…'), icon: ICON_ROUTE, onSelect: () => openRoutes() });
1655
1683
  items.push({ id: 'models', label: tt('assistant.models.menu', 'Models…'), onSelect: () => openModels() });
1656
- items.push({ id: 'budget', label: tt('assistant.budget.menu', 'Budget…'), desc: st.budget ? `${fmtCost(st.budget.totalUsd) || '$0.00'} / ${fmtCost(st.budget.hardUsd)}` : '', onSelect: () => openBudget() });
1684
+ items.push({ id: 'budget', label: tt('assistant.budget.menu', 'Budget…'), desc: budgetSpendText(st.budget, { t }), onSelect: () => openBudget() });
1657
1685
  items.push({ id: 'clear', label: tt('assistant.topbar.clear', 'Clear transcript'), onSelect: () => clearTranscript() });
1658
1686
  items.push({ id: 'help', label: tt('assistant.topbar.helpShortcuts', 'Help & shortcuts'), onSelect: () => openHelp() });
1659
1687
  items.push({ kind: 'separator' });
@@ -1707,12 +1735,12 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
1707
1735
  }
1708
1736
  }
1709
1737
 
1710
- // Assistant → Budget: the money caps (applied live by the server).
1738
+ // Assistant → Budget: one saved amount plus the current session's live split.
1711
1739
  function openBudget() {
1712
1740
  openBudgetEditor({
1713
1741
  t,
1714
1742
  sessionId,
1715
- brainMetered: st.brainMetered !== false && st.brain?.provider !== 'codex',
1743
+ controller: budgetController,
1716
1744
  onToast: toast,
1717
1745
  onSaved: (res) => { if (res?.session) { st.budget = res.session; renderCost(); } },
1718
1746
  }).catch(() => {});
@@ -1821,6 +1849,8 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
1821
1849
 
1822
1850
  // ── Bus ──
1823
1851
  unsubs.push(on('sync:assistant:session-updated', (msg) => { const s = msg?.session || msg; if (s?.id === sessionId) applySessionMeta(s); }));
1852
+ unsubs.push(on('sync:assistant:budget', (msg) => { if ((msg?.assistantSessionId || msg?.sessionId) === sessionId) budgetController.receiveSession(msg.budget); }));
1853
+ unsubs.push(on('sync:assistant:budget-changed', () => budgetController.load()));
1824
1854
  unsubs.push(on('sync:assistant:cost', (msg) => { if (!msg?.assistantSessionId || msg.assistantSessionId === sessionId) applyCost(msg); }));
1825
1855
  unsubs.push(on('sync:assistant:mailbox', (msg) => { if (msg?.assistantSessionId === sessionId) renderer.appendMailbox(msg.items || msg.events || []); }));
1826
1856
  unsubs.push(on('sync:assistant:permission-request', (msg) => {
@@ -1891,6 +1921,7 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
1891
1921
  sock.connect();
1892
1922
  });
1893
1923
  fetchUsage();
1924
+ budgetController.load();
1894
1925
 
1895
1926
  const controller = {
1896
1927
  sessionId,
@@ -1934,6 +1965,9 @@ export function mountAssistant(viewport, { sessionId, brain, host = {}, session
1934
1965
  if (persistTimer) { clearTimeout(persistTimer); persistTimer = null; }
1935
1966
  clearTimeout(announceTimer);
1936
1967
  if (replay) { clearTimeout(replay.timer); replay = null; }
1968
+ closeBudgetEditor(budgetController);
1969
+ emptyBudgetChip?.destroy();
1970
+ budgetController.destroy();
1937
1971
  director.destroy();
1938
1972
  usage.destroy();
1939
1973
  clearTimeout(limitTimer);
@@ -228,6 +228,7 @@ export function updateRunCard(card, run, opts = {}) {
228
228
  const r = run.route;
229
229
  const routeBits = [r.taskClassLabel || r.taskClass || t('assistant.routes.task', 'task')];
230
230
  if (r.status === 'pending') routeBits.push(t('assistant.routes.state.pending', 'waiting for your choice'));
231
+ else if (r.decidedBy === 'budget' || r.source === 'budget') routeBits.push(t('assistant.routes.budgetDecision', 'budget re-route'));
231
232
  else if (r.decidedBy === 'user') routeBits.push(t('assistant.routes.youChose', 'you chose'));
232
233
  else if (r.remembered || r.decidedBy === 'remembered') routeBits.push(t('assistant.routes.remembered', 'your saved choice'));
233
234
  else if (Number.isFinite(Number(r.confidence))) routeBits.push(`${Math.round(Number(r.confidence) <= 1 ? Number(r.confidence) * 100 : Number(r.confidence))}%`);
@@ -548,8 +549,10 @@ export function createRenderer(transcriptEl, hooks = {}) {
548
549
 
549
550
  /** A menu opened from the empty state's project picker does not outlive it. */
550
551
  function closeEmptyMenu() {
551
- const btn = emptyEl?.querySelector('.asst-empty-project-btn');
552
- if (btn && isMenuOpen(btn)) closeMenu();
552
+ for (const btn of emptyEl?.querySelectorAll('.asst-empty-project-btn') || []) {
553
+ if (isMenuOpen(btn)) closeMenu();
554
+ }
555
+ hooks.unmountEmptyControls?.();
553
556
  }
554
557
 
555
558
  // ── The hero ──
@@ -2689,7 +2692,12 @@ export function createRenderer(transcriptEl, hooks = {}) {
2689
2692
 
2690
2693
  function upsertRunCard(run, { result = null } = {}) {
2691
2694
  if (!run?.runId) return null;
2692
- runInfo.set(run.runId, { ...runInfo.get(run.runId), title: run.title || run.task || runInfo.get(run.runId)?.title, state: run.status || runInfo.get(run.runId)?.state, provider: run.provider });
2695
+ const prev = runInfo.get(run.runId);
2696
+ const status = run.status || prev?.state;
2697
+ const tone = status ? runTone({ ...run, status }) : '';
2698
+ const routeId = run.route?.routeId || prev?.routeId || '';
2699
+ runInfo.set(run.runId, { ...prev, title: run.title || run.task || prev?.title, state: status, provider: run.provider, routeId, working: tone === 'running' || tone === 'queued' });
2700
+ if (routeId) syncRouteLive(routeId);
2693
2701
  const cardOpts = runCardOpts(result);
2694
2702
  let card = runCards.get(run.runId);
2695
2703
  if (card && card.isConnected) {
@@ -2745,21 +2753,42 @@ export function createRenderer(transcriptEl, hooks = {}) {
2745
2753
  return { phase, t: hooks.t, providerIcon: hooks.providerIcon, brainLabel: hooks.brainLabel, onChangeRoutes: hooks.onChangeRoutes };
2746
2754
  }
2747
2755
 
2748
- /** Create or update the line for `route.routeId` in place. */
2756
+ /** Create or update the line for `route.routeId` in place. Only a new line enters (never on replay). */
2749
2757
  function upsertRouteLine(route, phase = 'decided') {
2750
2758
  const routeId = route?.routeId;
2751
2759
  if (!routeId) return null;
2752
2760
  const existing = routeLines.get(routeId);
2753
- if (existing && existing.el.isConnected) { existing.update(route, phase); return existing; }
2761
+ if (existing && existing.el.isConnected) { existing.update(route, phase); syncRouteLive(routeId); return existing; }
2754
2762
  const line = createRouteLine(route, routeLineOpts(phase));
2755
2763
  routeLines.set(routeId, line);
2756
2764
  append(line.el);
2765
+ syncRouteLive(routeId);
2766
+ enterRow(line.el);
2757
2767
  return line;
2758
2768
  }
2759
2769
 
2760
2770
  /** Adopt a line created by a collapsing route card so later events update it. */
2761
2771
  function registerRouteLine(routeId, line) {
2762
- if (routeId && line) routeLines.set(routeId, line);
2772
+ if (!routeId || !line) return;
2773
+ const fresh = routeLines.get(routeId) !== line;
2774
+ routeLines.set(routeId, line);
2775
+ syncRouteLive(routeId);
2776
+ if (fresh && line.el?.isConnected) enterRow(line.el);
2777
+ }
2778
+
2779
+ /** The line is live while a run it dispatched is queued or working (run cards keep runInfo current). */
2780
+ function syncRouteLive(routeId) {
2781
+ const line = routeLines.get(routeId);
2782
+ if (!line?.setRunning) return;
2783
+ let working = false;
2784
+ for (const info of runInfo.values()) if (info.routeId === routeId && info.working) { working = true; break; }
2785
+ line.setRunning(working);
2786
+ }
2787
+
2788
+ /** A new row's entrance: the rack ledger's (4px rise and fade, --asst-dur-fast, --asst-ease-enter); none when calm. */
2789
+ function enterRow(node) {
2790
+ if (calm() || typeof node?.animate !== 'function') return;
2791
+ node.animate([{ opacity: 0, transform: 'translateY(4px)' }, { opacity: 1, transform: 'none' }], { duration: 140, easing: cssEase('--asst-ease-enter', 'cubic-bezier(0,0,.38,.9)') });
2763
2792
  }
2764
2793
 
2765
2794
  function removeRouteLine(routeId) {
@@ -2899,7 +2928,9 @@ export function createRenderer(transcriptEl, hooks = {}) {
2899
2928
  const projectSlot = emptyEl.querySelector('.asst-empty-project');
2900
2929
  let picker = null;
2901
2930
  try { picker = typeof hooks.mountEmptyProject === 'function' ? hooks.mountEmptyProject(projectSlot) : null; } catch { picker = null; }
2902
- projectSlot.hidden = !picker;
2931
+ let budget = null;
2932
+ try { budget = hooks.mountEmptyBudget?.(projectSlot); } catch { /* unavailable */ }
2933
+ projectSlot.hidden = !picker && !budget;
2903
2934
  renderSuggestions();
2904
2935
  $msgs.appendChild(emptyEl);
2905
2936
  // The host's "Starting…" character flies into place (not on replay or reduced motion).