agentbox-app 0.1.13 → 0.1.16

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 (278) hide show
  1. package/README.md +24 -9
  2. package/bin/agentbox.mjs +23 -1
  3. package/briefs/message-rules.md +36 -8
  4. package/briefs/worker.md +46 -23
  5. package/main/account-discovery.mjs +122 -30
  6. package/main/account-tooling.mjs +29 -1
  7. package/main/account-usage.mjs +46 -0
  8. package/main/agent-sessions.mjs +282 -268
  9. package/main/agents.mjs +179 -466
  10. package/main/analytics.mjs +12 -2
  11. package/main/answer-modes.mjs +103 -37
  12. package/main/approval-prompt-core.mjs +197 -0
  13. package/main/approval-prompt-server.mjs +4 -138
  14. package/main/approvals.mjs +101 -297
  15. package/main/artifact-path.mjs +47 -18
  16. package/main/claude-input.mjs +48 -4
  17. package/main/claude-plan.mjs +48 -0
  18. package/main/claude-session-open.mjs +28 -0
  19. package/main/claude-usage-process.mjs +40 -0
  20. package/main/claude-usage.mjs +18 -40
  21. package/main/cloud/account.mjs +179 -0
  22. package/main/cloud/aws.mjs +127 -0
  23. package/main/cloud/browser-return.mjs +58 -0
  24. package/main/cloud/clients.mjs +15 -0
  25. package/main/cloud/codex-cloud.mjs +53 -0
  26. package/main/cloud/config.mjs +107 -0
  27. package/main/cloud/docs.mjs +219 -0
  28. package/main/cloud/exec.mjs +27 -0
  29. package/main/cloud/host.mjs +165 -0
  30. package/main/cloud/kit.mjs +80 -0
  31. package/main/cloud/logins.mjs +543 -0
  32. package/main/cloud/managed.mjs +115 -0
  33. package/main/cloud/marker.mjs +104 -0
  34. package/main/cloud/org.mjs +53 -0
  35. package/main/cloud/reuse.mjs +208 -0
  36. package/main/cloud/run-cli.mjs +177 -0
  37. package/main/cloud/run.mjs +1934 -0
  38. package/main/cloud/usage.mjs +76 -0
  39. package/main/cloud-permission.mjs +51 -0
  40. package/main/codex-account.mjs +78 -0
  41. package/main/codex-app-server.mjs +276 -409
  42. package/main/codex-approvals.mjs +148 -433
  43. package/main/codex-models.mjs +20 -2
  44. package/main/codex-session.mjs +212 -507
  45. package/main/codex-threads-worker.mjs +12 -0
  46. package/main/codex-threads.mjs +71 -3
  47. package/main/codex-usage-file.mjs +5 -3
  48. package/main/codex-usage.mjs +56 -7
  49. package/main/codex-watch.mjs +17 -3
  50. package/main/codex.mjs +95 -363
  51. package/main/config.mjs +11 -1
  52. package/main/corner-tag.mjs +340 -19
  53. package/main/dev-window.mjs +1 -1
  54. package/main/engine-bin.mjs +232 -282
  55. package/main/engine-setup.mjs +21 -9
  56. package/main/feedback.mjs +3 -25
  57. package/main/folder-pictures.mjs +2 -2
  58. package/main/freeze-recorder-thread.mjs +86 -0
  59. package/main/freeze-recorder.mjs +146 -0
  60. package/main/fresh-user.mjs +20 -9
  61. package/main/git-change-offthread.mjs +17 -3
  62. package/main/git-change-thread.mjs +5 -2
  63. package/main/harnesses/claude.mjs +32 -11
  64. package/main/harnesses/codex.mjs +9 -2
  65. package/main/importable-threads.mjs +64 -6
  66. package/main/ipc.mjs +362 -111
  67. package/main/leftovers.mjs +117 -25
  68. package/main/liquid-glass.mjs +26 -0
  69. package/main/live-replies.mjs +10 -4
  70. package/main/main-went-red.mjs +330 -0
  71. package/main/main.mjs +199 -84
  72. package/main/memory-gate-history.mjs +36 -10
  73. package/main/memory-gate-server.mjs +62 -0
  74. package/main/memory-gate.mjs +14 -1
  75. package/main/menu.mjs +4 -2
  76. package/main/message-priority.mjs +2 -2
  77. package/main/mic.mjs +38 -0
  78. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  79. package/main/new-chats-watch.mjs +110 -0
  80. package/main/new-user-window.mjs +25 -0
  81. package/main/notify.mjs +24 -8
  82. package/main/phone-alerts.mjs +117 -0
  83. package/main/phone-inbox.mjs +625 -0
  84. package/main/phone-link.mjs +29 -9
  85. package/main/phone-uploads.mjs +476 -0
  86. package/main/picture-shape.mjs +114 -0
  87. package/main/quit-sequence.mjs +143 -0
  88. package/main/refresh-agent-models.mjs +15 -2
  89. package/main/relay-client.mjs +69 -1
  90. package/main/repeats.mjs +10 -4
  91. package/main/row-label.mjs +12 -41
  92. package/main/run-change.mjs +13 -0
  93. package/main/run-ledger.mjs +135 -0
  94. package/main/save-attachments.mjs +41 -0
  95. package/main/serve.mjs +45 -3
  96. package/main/settings.mjs +220 -44
  97. package/main/shell-path.mjs +185 -37
  98. package/main/ship-conflict-brief.mjs +119 -0
  99. package/main/ship-queue.mjs +115 -19
  100. package/main/source-updater.mjs +18 -10
  101. package/main/stage-attachment.mjs +68 -0
  102. package/main/stall-watch.mjs +50 -0
  103. package/main/store/home.mjs +87 -232
  104. package/main/store/paths.mjs +11 -0
  105. package/main/store/teammates-files.mjs +99 -0
  106. package/main/store/work-items.mjs +231 -13
  107. package/main/store.mjs +226 -52
  108. package/main/supervisor.mjs +3103 -341
  109. package/main/task-folders.mjs +430 -30
  110. package/main/task-terminals.mjs +110 -34
  111. package/main/team/index.mjs +114 -10
  112. package/main/team/memory-cloud.mjs +183 -1
  113. package/main/team/room-hours.mjs +48 -0
  114. package/main/team/room-setup.mjs +31 -0
  115. package/main/team/rooms-memory.mjs +454 -0
  116. package/main/team/rooms-shape.mjs +42 -0
  117. package/main/team/rooms-supabase.mjs +324 -0
  118. package/main/team/rooms.mjs +575 -0
  119. package/main/team/session.mjs +96 -26
  120. package/main/team/supabase-backend.mjs +180 -0
  121. package/main/team/sync.mjs +109 -1
  122. package/main/teammates.mjs +580 -0
  123. package/main/terminal-host.mjs +143 -0
  124. package/main/terminal-hosts.mjs +261 -0
  125. package/main/transcript-search.mjs +80 -0
  126. package/main/webview-guard.mjs +45 -0
  127. package/main/worktree-include.mjs +90 -219
  128. package/mcp/core/documents.mjs +3 -1
  129. package/mcp/core/repeats.mjs +88 -0
  130. package/mcp/core/teammates.mjs +222 -0
  131. package/mcp/core/work.mjs +76 -4
  132. package/mcp/stdio.mjs +24 -11
  133. package/mcp/tools.mjs +116 -8
  134. package/package.json +21 -5
  135. package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
  136. package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
  137. package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
  138. package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
  139. package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
  140. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  141. package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
  142. package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
  143. package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
  144. package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
  145. package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
  146. package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
  147. package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
  148. package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
  149. package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
  150. package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
  151. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  152. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  153. package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
  154. package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
  155. package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
  156. package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
  157. package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
  158. package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
  159. package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
  160. package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
  161. package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
  162. package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
  163. package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
  164. package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
  165. package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
  166. package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
  167. package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
  168. package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
  169. package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
  170. package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
  171. package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
  172. package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
  173. package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
  174. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  175. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  176. package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
  177. package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
  178. package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
  179. package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
  180. package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
  181. package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
  182. package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
  183. package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
  184. package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
  185. package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
  186. package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
  187. package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
  188. package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
  189. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  190. package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
  191. package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
  192. package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
  193. package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
  194. package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
  195. package/renderer/dist/corner-tag.html +3 -3
  196. package/renderer/dist/index.html +3 -3
  197. package/scripts/after-install.mjs +30 -8
  198. package/scripts/cloud-shim.pl +57 -0
  199. package/shared/account-names.d.mts +6 -0
  200. package/shared/account-names.mjs +15 -0
  201. package/shared/agent-hours.mjs +136 -0
  202. package/shared/agent-import.mjs +60 -34
  203. package/shared/agents.mjs +126 -522
  204. package/shared/analytics-events.mjs +12 -0
  205. package/shared/approval-card.mjs +36 -0
  206. package/shared/bridge-map.mjs +27 -1
  207. package/shared/claude-code-shape.mjs +6 -0
  208. package/shared/claude-usage.d.mts +1 -0
  209. package/shared/claude-usage.mjs +42 -1
  210. package/shared/cloud-hand-back.d.mts +7 -0
  211. package/shared/cloud-hand-back.mjs +20 -0
  212. package/shared/cloud-kit.d.mts +219 -0
  213. package/shared/cloud-kit.mjs +341 -0
  214. package/shared/cloud-login-name.mjs +4 -0
  215. package/shared/cloud-start.mjs +17 -0
  216. package/shared/cloud-versions.mjs +23 -0
  217. package/shared/codex-import.mjs +2 -2
  218. package/shared/codex-modes.d.mts +22 -9
  219. package/shared/codex-modes.mjs +67 -11
  220. package/shared/codex-usage.mjs +108 -2
  221. package/shared/corner-tag.d.mts +23 -1
  222. package/shared/corner-tag.mjs +284 -29
  223. package/shared/dead-run-trace.mjs +19 -2
  224. package/shared/engines.mjs +70 -309
  225. package/shared/feedback.d.mts +7 -0
  226. package/shared/feedback.mjs +25 -0
  227. package/shared/first-run-practice.mjs +17 -24
  228. package/shared/harness-definitions.mjs +8 -0
  229. package/shared/instruction-defaults.json +2 -2
  230. package/shared/key-caps.d.mts +14 -0
  231. package/shared/key-caps.mjs +68 -0
  232. package/shared/message-edits.d.mts +7 -0
  233. package/shared/message-edits.mjs +35 -0
  234. package/shared/move-thread.d.mts +24 -0
  235. package/shared/move-thread.mjs +112 -0
  236. package/shared/named-threads.mjs +72 -0
  237. package/shared/not-shipped.d.mts +22 -0
  238. package/shared/not-shipped.mjs +89 -0
  239. package/shared/notify-rules.d.mts +6 -0
  240. package/shared/notify-rules.mjs +36 -8
  241. package/shared/phone-files.mjs +28 -0
  242. package/shared/phone-settings.mjs +118 -0
  243. package/shared/phone-snapshot.d.mts +16 -0
  244. package/shared/phone-snapshot.mjs +348 -0
  245. package/shared/picture-formats.d.mts +1 -0
  246. package/shared/picture-formats.mjs +13 -0
  247. package/shared/rank.d.mts +2 -1
  248. package/shared/rank.mjs +32 -19
  249. package/shared/repeats.d.mts +8 -0
  250. package/shared/repeats.mjs +33 -2
  251. package/shared/room-cards.mjs +167 -0
  252. package/shared/room-links.d.mts +4 -0
  253. package/shared/room-links.mjs +21 -0
  254. package/shared/runs-on.d.mts +3 -3
  255. package/shared/runs-on.mjs +8 -5
  256. package/shared/shipped-note.d.mts +4 -0
  257. package/shared/shipped-note.mjs +6 -0
  258. package/shared/shortcuts.mjs +14 -6
  259. package/shared/spawn-trouble.d.mts +4 -0
  260. package/shared/spawn-trouble.mjs +443 -40
  261. package/shared/team-rules.mjs +9 -2
  262. package/shared/teammates.d.mts +19 -0
  263. package/shared/teammates.mjs +756 -0
  264. package/shared/terminal-focus.d.mts +1 -0
  265. package/shared/terminal-focus.mjs +13 -0
  266. package/shared/thread-cards.mjs +4 -1
  267. package/shared/usage.d.mts +9 -0
  268. package/shared/usage.mjs +49 -3
  269. package/shared/websites.d.mts +15 -0
  270. package/shared/websites.mjs +61 -0
  271. package/shared/work-items.mjs +127 -30
  272. package/shared/work-lines.mjs +92 -244
  273. package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
  274. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
  275. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
  276. package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
  277. package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
  278. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
@@ -125,6 +125,7 @@ export function windowSpan(minutes) {
125
125
  */
126
126
  export function codexLimits(snapshot) {
127
127
  const out = [];
128
+ const refused = reachedWindow(snapshot);
128
129
  for (const key of WINDOWS) {
129
130
  const window = snapshot?.[key];
130
131
  if (!plain(window)) continue;
@@ -133,11 +134,18 @@ export function codexLimits(snapshot) {
133
134
  const minutes = field(window, 'windowDurationMins', 'window_minutes');
134
135
  const [span, name] = windowSpan(minutes) ?? FALLBACK[key];
135
136
  const resets = field(window, 'resetsAt', 'resets_at');
137
+ const reached = refused === key;
136
138
  out.push({
137
139
  span,
138
140
  qualifier: null,
139
141
  name,
140
- percent: Math.max(0, Math.min(100, Math.round(percent))),
142
+ percent: drawnPercent(percent),
143
+ // WHETHER THIS WINDOW IS ACTUALLY FINISHED, which a percentage cannot say
144
+ // (w-36c51443df). Only the provider knows: a window can be refused below
145
+ // a hundred by a spend control or a per-account limit, and one sitting at
146
+ // a hundred may still be serving. So this is read off the payload and
147
+ // never derived from `percent`.
148
+ reached,
141
149
  resetsText: null,
142
150
  resetsOn: null,
143
151
  resetsAt: resets === null ? null : resets * 1000,
@@ -147,6 +155,78 @@ export function codexLimits(snapshot) {
147
155
  return out;
148
156
  }
149
157
 
158
+ /**
159
+ * WHICH WINDOW THE PROVIDER SAYS IS SPENT, or null when it says nothing.
160
+ *
161
+ * `rateLimitReachedType` NAMES a window (`primary` / `secondary`), measured on a
162
+ * live push 2026-10-09, so it belongs on that window's own line rather than on
163
+ * the reading as a whole. A name for a window that is not in the payload is
164
+ * ignored rather than guessed at: marking a line we were not told about is how
165
+ * a confident wrong number gets drawn.
166
+ */
167
+ function reachedWindow(snapshot) {
168
+ const named = snapshot?.rateLimitReachedType ?? snapshot?.rate_limit_reached_type;
169
+ return WINDOWS.includes(named) ? named : null;
170
+ }
171
+
172
+ /**
173
+ * THE FIGURE TO DRAW, WHICH MAY NOT MANUFACTURE A HUNDRED (w-36c51443df).
174
+ *
175
+ * `Math.round` drew 99.6% as "100%", so the one number on the screen that
176
+ * should have meant "stop" meant "nearly". A reading nobody can act on is worse
177
+ * than a blunter one, so anything short of the whole window draws at most 99
178
+ * and 100 means a hundred.
179
+ *
180
+ * AND `reached` IS DELIBERATELY NOT AN INPUT HERE. The first draft of this fix
181
+ * forced 100 whenever the provider refused the window, which is the same sin
182
+ * one field along: a window capped at 12% by a spend control really is at 12%,
183
+ * and writing 100 over it would be inventing usage that was never reported.
184
+ * `percent` says how much has been spent; `reached` says whether it is refused.
185
+ * Two facts, two fields, and the panel reads "stop" off the second one.
186
+ *
187
+ * The clamp stays exactly as it was: a provider that over- or under-reports
188
+ * must not put an impossible number on her screen.
189
+ */
190
+ function drawnPercent(percent) {
191
+ const inRange = Math.max(0, Math.min(100, percent));
192
+ if (inRange >= 100) return 100;
193
+ return Math.min(99, Math.round(inRange));
194
+ }
195
+
196
+ /**
197
+ * THE FACTS ABOUT THE ACCOUNT RATHER THAN ABOUT ONE WINDOW (w-36c51443df).
198
+ *
199
+ * The push carries ten fields and `codexLimits` read two of them. These four
200
+ * are the rest of what a person would want to know, and the campaign that found
201
+ * this could not answer any of them from the screen:
202
+ *
203
+ * plan which subscription is paying. Measured: "prolite".
204
+ * credits whether there is anything behind the window when it
205
+ * goes. Measured: `{hasCredits:false, balance:"0"}`, so
206
+ * nothing -- and "100% used" reads very differently with
207
+ * and without that.
208
+ * spendControlReached a cap she or her organisation set, not the plan's.
209
+ * reached which window the provider refused, for any surface
210
+ * drawing the reading whole rather than line by line.
211
+ *
212
+ * NULLS RATHER THAN GUESSES, for the reason main/codex-usage.mjs gives about
213
+ * percentages: a plan nobody has reported is not a plan of none, and a reading
214
+ * that invents one is worse than a panel that stays quiet.
215
+ */
216
+ export function codexPlan(snapshot) {
217
+ const credits = plain(snapshot?.credits) ? snapshot.credits : null;
218
+ const bool = (value) => (typeof value === 'boolean' ? value : null);
219
+ return {
220
+ plan: typeof snapshot?.planType === 'string' && snapshot.planType
221
+ ? snapshot.planType
222
+ : (typeof snapshot?.plan_type === 'string' && snapshot.plan_type ? snapshot.plan_type : null),
223
+ credits: credits ? (bool(credits.hasCredits) ?? bool(credits.has_credits)) : null,
224
+ unlimitedCredits: credits ? bool(credits.unlimited) : null,
225
+ spendControlReached: bool(snapshot?.spendControlReached ?? snapshot?.spend_control_reached),
226
+ reached: reachedWindow(snapshot),
227
+ };
228
+ }
229
+
150
230
  /**
151
231
  * FOLD A ROLLING UPDATE INTO WHAT WE ALREADY HAD, WHICH IS THE PROTOCOL'S OWN
152
232
  * INSTRUCTION AND NOT A KINDNESS.
@@ -155,7 +235,7 @@ export function codexLimits(snapshot) {
155
235
  * carrying only the five hour window would take the week away, and one carrying
156
236
  * neither would take both -- and this repo already puts exactly that on the wire
157
237
  * (`{ rateLimits: {} }`, in
158
- * tests/one-codex-process-carries-many-threads-and-a-card-nobody-answers-denies.test.mjs).
238
+ * tests/one-codex-process-carries-many-threads-and-a-card-nobody-answers-waits.test.mjs).
159
239
  * The failure would look like the corner going quiet for no reason, which is the
160
240
  * complaint this whole surface started from.
161
241
  *
@@ -168,6 +248,23 @@ export function codexLimits(snapshot) {
168
248
  * Only the two windows are carried. Nothing else in the payload is drawn, and a
169
249
  * merge that kept fields nobody reads would be inviting somebody to read them.
170
250
  */
251
+ /**
252
+ * THE ACCOUNT-LEVEL FIELDS A FOLD ALSO HAS TO KEEP (w-36c51443df).
253
+ *
254
+ * The rule above was "only the two windows, because a merge that kept fields
255
+ * nobody reads would be inviting somebody to read them". That was right while
256
+ * nothing read them, and it is exactly why a cap was unreadable. `codexPlan`
257
+ * reads these now, so they have to survive a fold for the same reason the
258
+ * windows do: a push carrying only a window would otherwise drop a
259
+ * `rateLimitReachedType` that arrived a beat earlier, and the corner would fall
260
+ * back from "spent" to a percentage for no reason -- which is the failure this
261
+ * function exists to prevent, one field along.
262
+ *
263
+ * Still nothing beyond what is drawn. `limitId`, `limitName`, `normalModelSlug`
264
+ * and `individualLimit` stay out, because nothing reads them.
265
+ */
266
+ const CARRIED = ['planType', 'credits', 'spendControlReached', 'rateLimitReachedType'];
267
+
171
268
  export function mergeRateLimits(previous, update) {
172
269
  const out = {};
173
270
  for (const key of WINDOWS) {
@@ -175,5 +272,14 @@ export function mergeRateLimits(previous, update) {
175
272
  else if (plain(previous?.[key])) out[key] = previous[key];
176
273
  else out[key] = null;
177
274
  }
275
+ // `undefined` AND `null` BOTH MEAN "THIS PUSH SAID NOTHING", so either keeps
276
+ // what was already known -- the measured push carried explicit nulls for
277
+ // `spendControlReached` and `rateLimitReachedType` while they were fine, and
278
+ // treating those as news would forget a cap on the very next beat. A push
279
+ // genuinely lifting one carries `false`, which is a value and does replace.
280
+ for (const key of CARRIED) {
281
+ const said = update?.[key];
282
+ out[key] = said === undefined || said === null ? (previous?.[key] ?? null) : said;
283
+ }
178
284
  return out;
179
285
  }
@@ -5,10 +5,32 @@
5
5
  export type HideChoice = { key: '5m' | '30m' | 'today'; label: string };
6
6
  export const HIDE_CHOICES: HideChoice[];
7
7
 
8
- export function tagSays(counts: { ready?: number; working?: number }): { kind: 'ready' | 'working'; text: string } | null;
8
+ /** text is the count on the tag; label is what it says aloud ("2 waiting"). */
9
+ export function tagSays(counts: { ready?: number }): { kind: 'ready'; text: string; label: string } | null;
9
10
 
10
11
  export type ReadyLine = { id: string; title: string; says: string; since?: number; open?: string | null };
11
12
  export function cardLines<T extends ReadyLine>(ready: T[], now: number, max?: number): {
12
13
  lines: (T & { waited: string })[];
13
14
  more: number;
14
15
  };
16
+
17
+ /** The four corners that resize it, and the arrow each one draws. */
18
+ export const GRIPS: readonly ['tl', 'tr', 'bl', 'br'];
19
+ export type Grip = 'tl' | 'tr' | 'bl' | 'br';
20
+
21
+ /** The nine dots, in whole pixels, for a tag this tall. */
22
+ export const LATTICE: { box: number; dot: number; gap: number };
23
+ export function latticeGrid(box: number, dpr?: number): {
24
+ dot: number; gap: number; pitch: number; side: number; size: number;
25
+ dots: { cx: number; cy: number }[];
26
+ };
27
+
28
+ /** How much of the tag's size the list takes; it is read, not glanced at. */
29
+ export function cardScale(scale: number): number;
30
+
31
+ type Rect = { x: number; y: number; width: number; height: number };
32
+ /** A page's measurement of itself, in screen points at the zoom it is drawn at. */
33
+ export function inPoints(
34
+ m: { width: number; height: number; glass?: Rect; radius?: number },
35
+ zoom?: number,
36
+ ): { width: number; height: number; glass?: Rect; radius?: number };
@@ -1,11 +1,10 @@
1
1
  // THE CORNER TAG (w-dafae58a23): the rules, and nothing that touches a window.
2
2
  //
3
3
  // You lose track of agents once you are in another app, and the ones waiting on
4
- // you sit idle. The answer that survived eight rounds of drawings is a small tag
5
- // in a corner of the screen that says "2 ready for you" when an agent has
6
- // something for you and "20 working" when none does. Twenty agents running is
7
- // ordinary, so working agents are a count and never a list: only what is ready
8
- // for you is worth a line.
4
+ // you sit idle. The answer is a small tag in a corner of the screen: the
5
+ // nine-dot mark and how many started waiting since you last had the app in
6
+ // front. When nothing is new it is gone (w-93b3a1a796), because a tag that is
7
+ // always there is a tag nobody reads.
9
8
  //
10
9
  // It must never become a nag. So it hides for a while and comes back on its
11
10
  // own, there is no one-click way to make it gone for good (that becomes a thing
@@ -18,6 +17,17 @@
18
17
  /** Space between the tag and the edge of the screen when it rests in a corner. */
19
18
  export const MARGIN = 18;
20
19
 
20
+ /**
21
+ * HOW FAR PAST THE EDGE SHE MAY PARK IT (2026-10-13). The resting rule used to
22
+ * hold the whole tag inside the screen, so a drag to the right stopped with
23
+ * the tag flush against the edge and sprang back from anything further: "I
24
+ * can't seem to put it further to the right. It snaps back into place in this
25
+ * slot but I often want it a little bit further to the right side of the
26
+ * screen." It may now hang this far off any edge — enough to tuck it away,
27
+ * never enough to lose it.
28
+ */
29
+ export const EDGE_SLACK = 24;
30
+
21
31
  /** The ways to hide it. All of them come back; none of them is permanent. */
22
32
  export const HIDE_CHOICES = [
23
33
  { key: '5m', label: '5 min' },
@@ -37,37 +47,244 @@ export function hiddenUntil(choice, now) {
37
47
  return 0;
38
48
  }
39
49
 
40
- /** The words on the tag, or null when there is nothing to say. Short, because
41
- * "22 ready for you" in the corner of a full screen was more than it needed;
42
- * "waiting" was picked over "ready" and "need you". */
43
- export function tagSays({ ready = 0, working = 0 } = {}) {
44
- if (ready > 0) return { kind: 'ready', text: `${ready} waiting` };
45
- if (working > 0) return { kind: 'working', text: `${working} working` };
50
+ /**
51
+ * What the tag says: the mark and how many are new, or null, in which case it
52
+ * is gone. A working count kept it on screen all day and made it wallpaper
53
+ * (w-93b3a1a796), so working agents only appear in its list. "Waiting" won
54
+ * over "ready" and "need you", and is what the tag says aloud.
55
+ */
56
+ export function tagSays({ ready = 0 } = {}) {
57
+ if (ready > 0) return { kind: 'ready', text: String(ready), label: `${ready} waiting` };
46
58
  return null;
47
59
  }
48
60
 
49
61
  /**
50
- * What the tag counts and lists: everything waiting on you, newest first.
51
- * The first version put threads from weeks ago at the top of the list; the
52
- * second cut everything older than a day, and then a Needs you of 58 read
53
- * "3 waiting" in the corner (w-0961e5523b). So the count is the whole of it,
54
- * the same number the app shows, and only the order keeps the old ones out
55
- * of the way: the list shows the newest three and folds the rest into "and N
56
- * more". Something with no moment it became ready (an agent stopped on a
57
- * yes, right now) sorts as now.
62
+ * A page's measurement of itself, in screen points. A page drawn at a zoom
63
+ * other than 100% measures in its own units, and the window and the Mac's
64
+ * glass are placed in points: at 87% the glass stood out past the tag
65
+ * (w-93b3a1a796). A zoom it cannot read counts as 100%.
66
+ */
67
+ export function inPoints({ width, height, glass, radius }, zoom) {
68
+ const z = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
69
+ return {
70
+ width: width * z,
71
+ height: height * z,
72
+ glass: glass && { x: glass.x * z, y: glass.y * z, width: glass.width * z, height: glass.height * z },
73
+ radius: Number.isFinite(radius) ? radius * z : radius,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * What started waiting after you last looked, which is the last time the
79
+ * app's window was in front, and whatever a notification told you about since
80
+ * you were last at the app (`told`, { id, at }), dated from when it told you.
81
+ * Before you have ever looked, everything counts.
82
+ */
83
+ export function newSince(ready, lookedAt, told = []) {
84
+ const toldAt = new Map();
85
+ for (const t of told ?? []) if (t?.id != null) toldAt.set(t.id, Number.isFinite(t.at) ? t.at : 0);
86
+ const out = [];
87
+ for (const r of ready ?? []) {
88
+ const own = Number.isFinite(r.since) ? r.since : null;
89
+ if (toldAt.has(r.id)) out.push(own != null && own >= toldAt.get(r.id) ? r : { ...r, since: toldAt.get(r.id) });
90
+ else if (!(lookedAt > 0) || (own != null && own > lookedAt)) out.push(r);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * What is left once you have crossed some off the tray (`dismissed`, id to
97
+ * when). A thread stays off until it has something newer than that.
98
+ */
99
+ export function notDismissed(list, dismissed = {}) {
100
+ return (list ?? []).filter((r) => {
101
+ const at = dismissed?.[r.id];
102
+ return !(Number.isFinite(at) && !(r.since > at));
103
+ });
104
+ }
105
+
106
+ /**
107
+ * HOW BIG THE TAG DRAWS, from the width of the screen it rests on (2026-10-13).
108
+ * One number: the page multiplies everything in the tag and its list by it, so
109
+ * the words, the chevron, the nine-dot mark and the padding grow together.
110
+ *
111
+ * She reads the tray on a 32" screen and on a laptop, and 28px was too small on
112
+ * both, worst on the big one: "a little bigger... esp on my 32 inch screen but
113
+ * also maybe on smaller screens too". So it is a ladder off the screen's own
114
+ * width in points, not one size for everything and not a setting to find.
115
+ *
116
+ * The first ladder climbed too gently — "34 px on a 32-inch screen is still far
117
+ * too small so you need to scale it more with screen size" — so the steps are
118
+ * now 31px on a laptop, 36px, 42px, and 48px on a screen as wide as hers
119
+ * (3413 points). 1 would be the old 28px tag, which nothing asks for any more.
120
+ *
121
+ * THE STEPS ARE WHOLE PIXELS, and the scale is worked back from them. A tag
122
+ * 30.8px tall puts its bottom border on half a device pixel, where it goes
123
+ * soft and its bottom corners break up; she saw exactly that and called it
124
+ * messed up (2026-10-13). Whole heights keep every edge on a real pixel.
125
+ */
126
+ export const TAG_BASE = 28;
127
+ export const TAG_HEIGHTS = [
128
+ { from: 0, px: 31 },
129
+ { from: 1900, px: 36 },
130
+ { from: 2400, px: 42 },
131
+ { from: 3000, px: 48 },
132
+ ];
133
+
134
+ /** How tall the tag draws on a screen this many points wide, in whole pixels. */
135
+ export function tagHeight(width) {
136
+ const w = Number.isFinite(width) ? width : 0;
137
+ let px = TAG_HEIGHTS[0].px;
138
+ for (const step of TAG_HEIGHTS) if (w >= step.from) px = step.px;
139
+ return px;
140
+ }
141
+
142
+ /** The one number the page multiplies everything by. */
143
+ export function tagScale(width) {
144
+ return tagHeight(width) / TAG_BASE;
145
+ }
146
+
147
+ /**
148
+ * HER OWN SIZE, KEPT AS A MULTIPLE OF THE AUTOMATIC ONE (2026-10-13). She
149
+ * asked to be able to resize the tray, by dragging a corner of it.
150
+ *
151
+ * What she sets is not a height in pixels but how much bigger or smaller than
152
+ * the automatic size she wants it, because the automatic size is the thing
153
+ * that knows about screens: a tag she grows by a fifth on her laptop is a
154
+ * fifth bigger on the 32" too, rather than shrinking back to a laptop's
155
+ * number the moment she plugs the big screen in.
156
+ */
157
+ export const TAG_ZOOM = { min: 0.75, max: 2, step: 0.1 };
158
+ /** However far she drags, the tag stays a tag. */
159
+ export const TAG_LIMITS = { min: 24, max: 80 };
160
+
161
+ /** Her multiple, kept sane; nothing stored means the automatic size. */
162
+ export function tagZoom(zoom) {
163
+ const z = Number.isFinite(zoom) ? zoom : 1;
164
+ return clamp(Math.round(z * 100) / 100, TAG_ZOOM.min, TAG_ZOOM.max);
165
+ }
166
+
167
+ /** How tall the tag draws on this screen, with her multiple applied. */
168
+ export function tagHeightOn(width, zoom) {
169
+ return clamp(Math.round(tagHeight(width) * tagZoom(zoom)), TAG_LIMITS.min, TAG_LIMITS.max);
170
+ }
171
+
172
+ /** The scale the page is handed: the automatic size times her multiple. */
173
+ export function tagScaleOn(width, zoom) {
174
+ return tagHeightOn(width, zoom) / TAG_BASE;
175
+ }
176
+
177
+ /** The multiple a tag dragged to this height means, on this screen. */
178
+ export function zoomFor(height, width) {
179
+ const h = clamp(Number.isFinite(height) ? height : 0, TAG_LIMITS.min, TAG_LIMITS.max);
180
+ return tagZoom(h / tagHeight(width));
181
+ }
182
+
183
+ /** One notch bigger or smaller, for the tag's own menu. */
184
+ export function zoomStep(zoom, by) {
185
+ return tagZoom(tagZoom(zoom) + by * TAG_ZOOM.step);
186
+ }
187
+
188
+ /** Every corner resizes it, because one ledge in one corner was not findable. */
189
+ export const GRIPS = ['tl', 'tr', 'bl', 'br'];
190
+
191
+ /**
192
+ * How tall a drag on this corner means. The corner follows the cursor and the
193
+ * opposite one stays where it is, the way a window resizes. The tag is four
194
+ * times wider than it is tall, so sideways travel counts for about a third:
195
+ * the diagonal still feels like a diagonal without the width running away.
196
+ */
197
+ export function heightFromDrag(corner, fromHeight, dx, dy) {
198
+ const down = corner.startsWith('b') ? dy : -dy;
199
+ const out = corner.endsWith('r') ? dx : -dx;
200
+ return Math.round(fromHeight + down + out * 0.35);
201
+ }
202
+
203
+ /**
204
+ * HOW MUCH OF THE TAG'S SIZE THE LIST TAKES (2026-10-13). The tag is a glance
205
+ * from across a desk and wants to be big on a big screen; the list is read a
206
+ * line at a time and does not. At the tag's full scale on her 32" the rows came
207
+ * out "really, really big... way bigger than anything else I'm used to seeing
208
+ * on the screen", so the list follows the tag less than a third of the way and
209
+ * stops at a quarter bigger than the app's own text.
210
+ */
211
+ export function cardScale(scale) {
212
+ const s = Number.isFinite(scale) ? scale : 1;
213
+ return clamp(Math.round((1 + (s - 1) * 0.3) * 1000) / 1000, 1, 1.25);
214
+ }
215
+
216
+ /**
217
+ * THE NINE DOTS ARE ONE DRAWING, not nine boxes (2026-10-13).
218
+ *
219
+ * They were boxes in a grid, and twice the fix was to give the dot and the gap
220
+ * whole pixels. That only holds on a screen whose point is a whole device
221
+ * pixel. Hers is not: a Mac in a scaled mode draws 1.5 device pixels to the
222
+ * point, so a 3pt dot is 4.5 and a 1pt gap is 1.5, and the browser snaps every
223
+ * box on its own — 5,5,5 across with gaps of 2 and 1. Rasterised through the
224
+ * real page, 16 of 28 sizes came out uneven at that scale and none at 1x or
225
+ * 2x. "It's the same issue we've had six times before."
226
+ *
227
+ * An svg of nine circles on one pitch is rasterised in one go, so the spacing
228
+ * cannot round apart whatever the screen does, and the geometry can go back to
229
+ * the launch film's own: dots of 2.7 and gaps of 0.95 in a box of 10.
230
+ */
231
+ export const LATTICE = { box: 10, dot: 2.7, gap: 0.95 };
232
+
233
+ /**
234
+ * The mark for a box this many points across, on a screen drawing this many
235
+ * device pixels to the point.
236
+ *
237
+ * THE PITCH IS A WHOLE NUMBER OF DEVICE PIXELS. That is the whole trick. Every
238
+ * dot then sits at the same distance from a device-pixel boundary as every
239
+ * other, so all nine are rasterised identically and the spacing cannot round
240
+ * apart — however the mark's own corner happens to land. Whole POINTS were the
241
+ * old rule, and a point on her screen is 1.5 device pixels, which is why it
242
+ * kept coming back.
243
+ */
244
+ export function latticeGrid(box, dpr = 1) {
245
+ const px = Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
246
+ const want = (Number.isFinite(box) && box > 0 ? box : LATTICE.box) * px;
247
+ // Both whole numbers either side of the film's dot and gap, and the pair
248
+ // whose mark comes closest to the size asked for — a dot and a gap rounded
249
+ // on their own can leave the mark a fifth too big, which shows.
250
+ const ideal = { dot: (want * LATTICE.dot) / LATTICE.box, gap: (want * LATTICE.gap) / LATTICE.box };
251
+ const ways = [];
252
+ for (const dot of [Math.floor(ideal.dot), Math.ceil(ideal.dot)]) {
253
+ for (const gap of [Math.floor(ideal.gap), Math.ceil(ideal.gap)]) {
254
+ const d = Math.max(2, dot);
255
+ const g = Math.max(1, Math.min(gap, d - 1));
256
+ ways.push({ dot: d, gap: g, side: 3 * d + 2 * g });
257
+ }
258
+ }
259
+ // A dot five times its gap is a mark whose dots all but touch, and one twice
260
+ // its gap is a loose one; the film's is 2.84. Keep inside that band if any
261
+ // pair can, and among those take the closest size.
262
+ const inBand = ways.filter((w) => w.dot / w.gap >= 2 && w.dot / w.gap <= 4);
263
+ const pick = (inBand.length ? inBand : ways)
264
+ .sort((a, b) => Math.abs(a.side - want) - Math.abs(b.side - want)
265
+ || Math.abs(a.dot / a.gap - LATTICE.dot / LATTICE.gap) - Math.abs(b.dot / b.gap - LATTICE.dot / LATTICE.gap));
266
+ const { dot, gap, side } = pick[0];
267
+ const pitch = dot + gap;
268
+ const at = [0, 1, 2].map((i) => dot / 2 + i * pitch);
269
+ return { dot, gap, pitch, side, size: side / px, dots: at.flatMap((cy) => at.map((cx) => ({ cx, cy }))) };
270
+ }
271
+
272
+ /**
273
+ * Newest first. Something with no moment it became ready (an agent stopped on
274
+ * a yes, right now) sorts as now.
58
275
  */
59
276
  export function readyNow(ready, now) {
60
277
  return [...(ready ?? [])]
61
278
  .sort((a, b) => (Number.isFinite(b.since) ? b.since : now) - (Number.isFinite(a.since) ? a.since : now));
62
279
  }
63
280
 
64
- /** Whether the tag is on screen right now. */
65
- export function tagShows({ on, hiddenUntil: until = 0, now, inApp, ready = 0, working = 0 }) {
281
+ /** Whether the tag is on screen right now; `ready` is how many are new. */
282
+ export function tagShows({ on, hiddenUntil: until = 0, now, inApp, ready = 0 }) {
66
283
  if (on === false) return false;
67
284
  if (until > now) return false;
68
285
  // In the app itself the inbox already says all of this, bigger.
69
286
  if (inApp) return false;
70
- return !!tagSays({ ready, working });
287
+ return !!tagSays({ ready });
71
288
  }
72
289
 
73
290
  /** How long something has waited, in units. */
@@ -117,6 +334,38 @@ function inLowerHalf(r, a) { return r.y + r.height / 2 > a.y + a.height / 2; }
117
334
  * corner stays put, so "2 ready for you" turning into "20 working" does not
118
335
  * walk the tag away from the edge you parked it against.
119
336
  */
337
+ /**
338
+ * WHERE THE TAG IS PARKED, SAID IN A WAY THAT SURVIVES A CHANGE OF SCREEN
339
+ * (2026-10-13). Its spot used to be remembered as a point on the whole desktop,
340
+ * so plugging in a monitor, or unplugging one, moved every screen's
341
+ * coordinates underneath it and the tag "jumps around quite a bit". A corner
342
+ * and a gap mean the same thing on any screen: bottom right, 18 points in.
343
+ */
344
+ export function cornerPlace(bounds, area) {
345
+ const right = inRightHalf(bounds, area);
346
+ const lower = inLowerHalf(bounds, area);
347
+ return {
348
+ corner: `${lower ? 'b' : 't'}${right ? 'r' : 'l'}`,
349
+ dx: Math.round(right ? area.x + area.width - (bounds.x + bounds.width) : bounds.x - area.x),
350
+ dy: Math.round(lower ? area.y + area.height - (bounds.y + bounds.height) : bounds.y - area.y),
351
+ };
352
+ }
353
+
354
+ /** That same place on this screen, wholly on it however small it is. */
355
+ export function spotFor(place, size, area) {
356
+ if (!place?.corner) return null;
357
+ const right = place.corner.endsWith('r');
358
+ const lower = place.corner.startsWith('b');
359
+ // A gap may be negative: that is the tag tucked past the edge, which she
360
+ // asked for and which the resting rule now allows.
361
+ const dx = clamp(Number.isFinite(place.dx) ? place.dx : MARGIN, -EDGE_SLACK, Math.max(0, area.width - size.width));
362
+ const dy = clamp(Number.isFinite(place.dy) ? place.dy : MARGIN, -EDGE_SLACK, Math.max(0, area.height - size.height));
363
+ return {
364
+ x: right ? area.x + area.width - size.width - dx : area.x + dx,
365
+ y: lower ? area.y + area.height - size.height - dy : area.y + dy,
366
+ };
367
+ }
368
+
120
369
  export function keepCorner(bounds, size, area) {
121
370
  const x = inRightHalf(bounds, area) ? bounds.x + bounds.width - size.width : bounds.x;
122
371
  const y = inLowerHalf(bounds, area) ? bounds.y + bounds.height - size.height : bounds.y;
@@ -128,14 +377,20 @@ export function keepCorner(bounds, size, area) {
128
377
  * screen, so it never runs off an edge, lined up with the tag's outer edge.
129
378
  */
130
379
  export function cardPlacement(tag, card, area, gap = 8) {
131
- let above = inLowerHalf(tag, area);
132
380
  const alignRight = inRightHalf(tag, area);
133
381
  const x = clamp(alignRight ? tag.x + tag.width - card.width : tag.x, area.x, area.x + area.width - card.width);
134
- const up = tag.y - gap - card.height;
135
- const down = tag.y + tag.height + gap;
136
- // A tall list near a short screen's middle may not fit the side it prefers.
137
- if (above && up < area.y) above = false;
138
- else if (!above && down + card.height > area.y + area.height) above = true;
139
- const y = clamp(above ? up : down, area.y, area.y + area.height - card.height);
382
+ // Which side has the room. The list may never cover the tag: she adjusts the
383
+ // tray's size from this list ("you still need to see the component to be able
384
+ // to make those decisions"), and a list over the thing it is sizing is no
385
+ // use. So the side is picked by which has more room, the list is kept on
386
+ // that side of the tag, and only then is it brought onto the screen — a list
387
+ // taller than its side runs off the screen's edge rather than over the tag.
388
+ const room = { above: tag.y - area.y, below: area.y + area.height - (tag.y + tag.height) };
389
+ let above = inLowerHalf(tag, area);
390
+ if (above && room.above < card.height + gap && room.below > room.above) above = false;
391
+ else if (!above && room.below < card.height + gap && room.above > room.below) above = true;
392
+ const y = above
393
+ ? Math.min(tag.y - gap - card.height, Math.max(area.y, tag.y - gap - card.height))
394
+ : Math.max(tag.y + tag.height + gap, Math.min(area.y + area.height - card.height, tag.y + tag.height + gap));
140
395
  return { above, alignRight, card: { x, y, width: card.width, height: card.height } };
141
396
  }
@@ -34,6 +34,20 @@ const NARRATED = /^\d{2}:\d{2}:\d{2}\s{2}\S/m;
34
34
 
35
35
  const EXITED = /^# exited \((-?\d+)\)/m;
36
36
 
37
+ // Written after the exit line's time when the person stopped the run (Stop,
38
+ // Z, closing or putting off the thread): that end is not a death to report.
39
+ export const STOPPED_ON_REQUEST = ' · stopped on request';
40
+ const STOPPED = /^# exited \([^)]*\) \S+ · stopped on request$/m;
41
+
42
+ // A tool call, as every engine traces one: `HH:MM:SS [Bash] ls`. Text lines
43
+ // do not count, because the CLI types its own refusals as the agent's words,
44
+ // and neither does OpenCode's `[error]`.
45
+ const TOOL_LINE = /^\d{2}:\d{2}:\d{2}\s{2}\[(?!error\])[^\]\s]+\]/m;
46
+
47
+ export function tracesAToolCall(text) {
48
+ return TOOL_LINE.test(String(text ?? ''));
49
+ }
50
+
37
51
  // THE DIAGNOSIS IS ON THE SECOND LINE, NOT THE FIRST, and reading only the
38
52
  // prefixed line loses it entirely. What the tool prints arrives as one chunk
39
53
  // and is written with one `stderr: ` in front of the whole chunk, so a refusal
@@ -94,8 +108,9 @@ function errorResult(raw) {
94
108
 
95
109
  /**
96
110
  * @param {string} text the whole trace file
97
- * @returns {{everRan: boolean, exitCode: number|null, stderr: string}}
111
+ * @returns {{everRan: boolean, worked: boolean, exitCode: number|null, stoppedOnRequest: boolean, stderr: string}}
98
112
  * `stderr` is what the tool said about itself, from either place it says it.
113
+ * `worked` means the agent called at least one tool before it ended.
99
114
  * `everRan` false means the CLI never produced a first line: the process
100
115
  * started and died before it was a session. `exitCode` null means the trace
101
116
  * has no exit line at all, which is what a run killed with the app looks
@@ -106,7 +121,9 @@ export function readDeadRunTrace(text) {
106
121
  const exited = EXITED.exec(raw);
107
122
  return {
108
123
  everRan: NARRATED.test(raw),
124
+ worked: tracesAToolCall(raw),
109
125
  exitCode: exited ? Number(exited[1]) : null,
126
+ stoppedOnRequest: STOPPED.test(raw),
110
127
  stderr: [stderrChunks(raw), errorResult(raw)].filter(Boolean).join('\n').slice(-1000),
111
128
  };
112
129
  }
@@ -122,6 +139,6 @@ export function readDeadRunTrace(text) {
122
139
  * the supervisor has no business speaking over it.
123
140
  */
124
141
  export function traceShowsASilentDeath(trace) {
125
- if (!trace || trace.exitCode == null) return false;
142
+ if (!trace || trace.exitCode == null || trace.stoppedOnRequest) return false;
126
143
  return !trace.everRan || trace.exitCode !== 0;
127
144
  }