agentbox-app 0.1.7 → 0.1.11

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 (244) hide show
  1. package/README.md +209 -55
  2. package/bin/agentbox.mjs +6 -0
  3. package/briefs/message-rules.md +13 -3
  4. package/briefs/worker.md +63 -49
  5. package/briefs/writing-rules.md +90 -131
  6. package/main/account-discovery.mjs +21 -20
  7. package/main/account-tooling.mjs +9 -10
  8. package/main/agent-activity.mjs +45 -5
  9. package/main/agent-schedule.mjs +14 -14
  10. package/main/agent-sessions.mjs +94 -10
  11. package/main/agent-updates.mjs +114 -0
  12. package/main/agents.mjs +52 -46
  13. package/main/analytics.mjs +27 -0
  14. package/main/approval-prompt-server.mjs +2 -2
  15. package/main/artifact-path.mjs +11 -0
  16. package/main/claude-bin.mjs +31 -5
  17. package/main/claude-input.mjs +45 -3
  18. package/main/claude-plan.mjs +11 -11
  19. package/main/code-change.mjs +3 -3
  20. package/main/codex-account.mjs +1 -1
  21. package/main/codex-bin.mjs +17 -0
  22. package/main/codex-memory-gate.mjs +260 -0
  23. package/main/codex-models.mjs +12 -7
  24. package/main/codex-threads.mjs +46 -23
  25. package/main/codex-usage-file.mjs +7 -7
  26. package/main/codex-usage.mjs +4 -4
  27. package/main/codex-watch.mjs +12 -12
  28. package/main/config.mjs +68 -8
  29. package/main/dev-window.mjs +9 -9
  30. package/main/doc-scheme.mjs +2 -0
  31. package/main/engine-setup.mjs +193 -0
  32. package/main/feedback.mjs +55 -0
  33. package/main/folder-pictures.mjs +48 -0
  34. package/main/frame-scrollbars.mjs +90 -0
  35. package/main/fresh-user.mjs +6 -3
  36. package/main/git-change-offthread.mjs +70 -0
  37. package/main/git-change-thread.mjs +11 -0
  38. package/main/git-change.mjs +1 -1
  39. package/main/img-scheme.mjs +70 -1
  40. package/main/instruction-settings.mjs +92 -54
  41. package/main/ipc.mjs +316 -42
  42. package/main/leftovers.mjs +518 -0
  43. package/main/live-replies.mjs +43 -5
  44. package/main/machine.mjs +47 -0
  45. package/main/main.mjs +119 -15
  46. package/main/memory-gate-history.mjs +254 -0
  47. package/main/memory-gate-server.mjs +461 -0
  48. package/main/memory-gate.mjs +206 -0
  49. package/main/message-priority.mjs +87 -0
  50. package/main/rail-note.mjs +2 -2
  51. package/main/refresh-agent-models.mjs +20 -0
  52. package/main/repeats.mjs +4 -4
  53. package/main/row-label.mjs +114 -51
  54. package/main/serve.mjs +67 -5
  55. package/main/settings.mjs +223 -26
  56. package/main/ship-queue.mjs +279 -0
  57. package/main/sign-in-files.mjs +39 -0
  58. package/main/source-updater.mjs +298 -0
  59. package/main/store/home.mjs +47 -2
  60. package/main/store/work-items.mjs +150 -6
  61. package/main/store.mjs +235 -9
  62. package/main/supervisor.mjs +1444 -406
  63. package/main/task-commands.mjs +8 -10
  64. package/main/task-folders.mjs +74 -13
  65. package/main/task-remote-control.mjs +3 -3
  66. package/main/task-terminals.mjs +2 -1
  67. package/main/team/index.mjs +440 -0
  68. package/main/team/memory-cloud.mjs +247 -0
  69. package/main/team/projects.mjs +162 -0
  70. package/main/team/session.mjs +225 -0
  71. package/main/team/sign-in.mjs +106 -0
  72. package/main/team/supabase-backend.mjs +231 -0
  73. package/main/team/sync.mjs +163 -0
  74. package/main/updater.mjs +15 -5
  75. package/main/write-before-reload.mjs +50 -0
  76. package/mcp/core/page.mjs +9 -11
  77. package/mcp/core/work.mjs +91 -25
  78. package/mcp/tools.mjs +12 -6
  79. package/package.json +21 -9
  80. package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
  81. package/renderer/dist/assets/index-B-2Az952.css +32 -0
  82. package/renderer/dist/assets/index-D6qncgai.js +1168 -0
  83. package/renderer/dist/index.html +2 -2
  84. package/scripts/memory-gate-hook.sh +59 -0
  85. package/shared/agent-import.mjs +38 -15
  86. package/shared/agents.d.mts +26 -8
  87. package/shared/agents.mjs +100 -108
  88. package/shared/analytics-events.mjs +19 -2
  89. package/shared/answers.mjs +12 -0
  90. package/shared/bridge-map.d.mts +12 -0
  91. package/shared/bridge-map.mjs +37 -1
  92. package/shared/claude-commands.generated.mjs +2 -2
  93. package/shared/claude-models.generated.mjs +4 -4
  94. package/shared/claude-usage.mjs +6 -6
  95. package/shared/codex-import.mjs +7 -7
  96. package/shared/codex-modes.d.mts +50 -0
  97. package/shared/codex-usage.mjs +7 -8
  98. package/shared/demo-world.mjs +5 -5
  99. package/shared/engines.mjs +27 -10
  100. package/shared/feedback.d.mts +14 -0
  101. package/shared/feedback.mjs +44 -0
  102. package/shared/first-run-examples.mjs +4 -4
  103. package/shared/first-run-practice.d.mts +16 -0
  104. package/shared/first-run-practice.mjs +176 -70
  105. package/shared/fresh-user-home.mjs +22 -3
  106. package/shared/instruction-defaults.json +2 -2
  107. package/shared/product-name.mjs +4 -2
  108. package/shared/project-folder-check.mjs +6 -0
  109. package/shared/rank.d.mts +1 -0
  110. package/shared/rank.mjs +22 -0
  111. package/shared/referenced-files.mjs +1 -1
  112. package/shared/schemes.d.mts +11 -0
  113. package/shared/schemes.mjs +1 -1
  114. package/shared/shortcuts.d.mts +41 -0
  115. package/shared/shortcuts.mjs +236 -0
  116. package/shared/spawn-trouble.d.mts +2 -0
  117. package/shared/spawn-trouble.mjs +39 -18
  118. package/shared/team-rules.d.mts +17 -0
  119. package/shared/team-rules.mjs +230 -0
  120. package/shared/team-status.d.mts +11 -0
  121. package/shared/team-status.mjs +90 -0
  122. package/shared/terminal-links.d.mts +2 -0
  123. package/shared/terminal-links.mjs +55 -0
  124. package/shared/terminal-state.d.mts +1 -0
  125. package/shared/terminal-state.mjs +16 -0
  126. package/shared/thread-cards.d.mts +19 -0
  127. package/shared/thread-cards.mjs +254 -0
  128. package/shared/usage.d.mts +1 -1
  129. package/shared/usage.mjs +6 -7
  130. package/shared/work-items.mjs +255 -27
  131. package/shared/work-lines.mjs +393 -16
  132. package/worker-permissions.json +1 -1
  133. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  134. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  135. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  136. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  137. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  138. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  139. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  140. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  141. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  142. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  143. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  144. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  145. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  146. package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
  147. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  148. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  149. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  150. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  151. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  152. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  153. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  154. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  155. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  156. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  157. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  158. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  159. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  160. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  161. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  162. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  163. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  164. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  165. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  166. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  167. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  168. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  169. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  170. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  171. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  172. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  173. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  174. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  175. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  176. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  177. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  178. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  179. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  180. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  181. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  182. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  183. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  184. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  185. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  186. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  187. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  188. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  189. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  190. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  191. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  192. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  193. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  194. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  195. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  196. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  197. package/renderer/dist/emulator/audio-rules.mjs +0 -74
  198. package/renderer/dist/emulator/data/compression/README.md +0 -11
  199. package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
  200. package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
  201. package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
  202. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  203. package/renderer/dist/emulator/data/cores/README.md +0 -22
  204. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  205. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  206. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  207. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  208. package/renderer/dist/emulator/data/cores/package.json +0 -65
  209. package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
  210. package/renderer/dist/emulator/data/emulator.css +0 -1614
  211. package/renderer/dist/emulator/data/loader.js +0 -170
  212. package/renderer/dist/emulator/data/localization/README.md +0 -63
  213. package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
  214. package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
  215. package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
  216. package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
  217. package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
  218. package/renderer/dist/emulator/data/localization/en-US.json +0 -339
  219. package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
  220. package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
  221. package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
  222. package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
  223. package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
  224. package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
  225. package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
  226. package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
  227. package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
  228. package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
  229. package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
  230. package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
  231. package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
  232. package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
  233. package/renderer/dist/emulator/data/src/GameManager.js +0 -464
  234. package/renderer/dist/emulator/data/src/compression.js +0 -142
  235. package/renderer/dist/emulator/data/src/emulator.js +0 -6158
  236. package/renderer/dist/emulator/data/src/gamepad.js +0 -167
  237. package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
  238. package/renderer/dist/emulator/data/src/shaders.js +0 -236
  239. package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
  240. package/renderer/dist/emulator/data/src/storage.js +0 -125
  241. package/renderer/dist/emulator/data/version.json +0 -5
  242. package/renderer/dist/emulator/host.js +0 -299
  243. package/renderer/dist/emulator/index.html +0 -28
  244. package/renderer/dist/emulator/provenance.json +0 -6
@@ -0,0 +1,236 @@
1
+ // PURE. EVERY KEY AGENTBOX HAS, IN THE ORDER A PERSON MEETS THEM.
2
+ //
3
+ // A shortcuts page in settings, because onboarding a non-technical tester
4
+ // meant saying each key out loud, one at a time: the app had a switch for
5
+ // whether it WHISPERS its keys and no page anywhere that says what they are.
6
+ //
7
+ // WHY THIS IS A FILE AND NOT MARKUP IN THE COMPONENT. A page that prints a key
8
+ // the handler does not have is worse than no page at all — that is the rule
9
+ // `list-rules.ts` already keeps for the hint on a row, and the reason it holds
10
+ // even harder here is that a hint is read for a second and a reference page is
11
+ // believed. So the list is a value, and
12
+ // `tests/the-shortcuts-page-lists-keys-that-work.test.mjs` reads every entry
13
+ // below against the branch in App.tsx (or main/main.mjs, or Compose.tsx) that
14
+ // actually runs it. A key added here without a handler fails the suite.
15
+ //
16
+ // WHY IT IS IN shared/ AND PLAIN ESM (w-7ec8553e23, 2026-10-04). It lived in
17
+ // renderer/src as TypeScript until an agent told the user to "Press S on this
18
+ // task" three days after S stopped scheduling: nothing a session is handed
19
+ // said what the keys are, so it went by an old memory. The supervisor now
20
+ // hands every run `keysForAgents()`, read off this same list, and main cannot
21
+ // import TypeScript. Types are in shortcuts.d.mts beside it.
22
+ //
23
+ // ONE SENTENCE A ROW, AND IT IS SHORT (w-1bc916a880). Every
24
+ // sentence here is now under seventy characters and the test enforces it. A row
25
+ // that wants a second idea becomes its own row, or it does not go on the page.
26
+ //
27
+ // AND NO KEY HIDES INSIDE A SENTENCE.
28
+ //
29
+ // HOW IT IS GROUPED, and it is not how the code is arranged. The code has one
30
+ // switch for the list, one branch for an open task, a chord block above both
31
+ // and a main-process handler under all of it. Nobody thinks in those. Four
32
+ // headings, and every key sits under the one that matches when she would want
33
+ // it: in your inbox, in a task you have opened, closing and scheduling and
34
+ // undoing, anywhere in the app.
35
+ //
36
+ // EVERY HEADING IS LITERAL AND THERE IS NO SUBHEADING AT ALL (w-1bc916a880,
37
+ // second round).
38
+ //
39
+ // The plainest point first: that heading named one of the two
40
+ // directions its own rows draw. Three of the four were figures of speech, and a
41
+ // figure of speech is a second thing to decode on a page whose only job is to
42
+ // be looked up. So a heading now says WHEN the keys under it work, in the words
43
+ // the app itself uses for those places, and the one group that cannot be named
44
+ // that way is named by what its keys do instead, because all four of its keys
45
+ // work in both places.
46
+ //
47
+ // What the three of them carried, and where it went:
48
+ //
49
+ // "In Scheduled, E brings a task back instead of closing it." This was the
50
+ // most confusing one. It is also the same class of thing as G, B and
51
+ // the import letters below: a behaviour that only happens in one view. The
52
+ // page's existing rule is that those stay off it, so this now obeys the rule
53
+ // the rest of the file already kept.
54
+ //
55
+ // The two ↵ rows say what ↵ does when it does something.
56
+ //
57
+ // "Anything without a key of its own is in ⌘K by name." The lede at the top
58
+ // of the page already says this, so it was the same sentence twice.
59
+ //
60
+ // WHAT IS DELIBERATELY NOT HERE, so nobody adds it back as an oversight:
61
+ //
62
+ // ⌘1..4, for PRIORITY or for anything else. Priority was removed deliberately
63
+ // and that handler no longer fires. The chord then went to a section of the
64
+ // sidebar until 2026-10-02, and that is gone as well: Tab moves along the tabs
65
+ // now and is listed above.
66
+ //
67
+ // Y, N and I on a row in the list. Real since w-9741ed0e0b, but they answer
68
+ // a Codex conversation's import row and are dead on every other row in the
69
+ // app. A reference page that lists a key which does nothing on nineteen
70
+ // screens out of twenty teaches somebody that the app ignores them.
71
+ //
72
+ // ⇥ inside the new task card, which changes the project it goes to. It is
73
+ // gated to that one card, the card draws its projects where she can see
74
+ // them, and it used to ride along inside the C row's sentence, which is the
75
+ // fault the NO KEY HIDES INSIDE A SENTENCE block above is about.
76
+ //
77
+ // ⇧⇥ in the reply box, which cycles Claude Code's permission modes. Real,
78
+ // and it belongs to the reply box rather than to the app; it is also the one
79
+ // key here that cannot be said without the word "permissions", and Settings
80
+ // already has a whole group explaining those.
81
+ //
82
+ // ⌘R, ⌘=, ⌘- and ⌘0. The Mac's own chords, answered in main.mjs so they work
83
+ // over the game. Nobody needs to be taught reload and zoom.
84
+ //
85
+ // EVERY SENTENCE IS PLAIN. A tester does not know what a modal, a pane, a
86
+ // binary or a view is. No row uses a word the app does not print on screen
87
+ // somewhere she can see it. THE SIDEBAR ROW IS WHY THAT NOW MATTERS TWICE: it
88
+ // used to name the sections, "Inbox, Scheduled, In progress, Closed", and
89
+ // that went wrong in both halves. The real order is Inbox, In
90
+ // progress, Scheduled, Closed (`workspaceDestinations`), and Scheduled is
91
+ // drawn only when something is actually scheduled. A reference
92
+ // page that recites a list the sidebar can change goes stale without anybody
93
+ // touching it, so the row names no sections at all.
94
+
95
+ import { NAME } from './product-name.mjs';
96
+
97
+ /** @type {import('./shortcuts.d.mts').ShortcutGroup[]} */
98
+ export const SHORTCUTS = [
99
+ {
100
+ // App.tsx, the `switch (e.key)` at the foot of the key handler: this is the
101
+ // list with nothing open, which is where anybody starts.
102
+ //
103
+ // "IN YOUR INBOX", BECAUSE INBOX IS A WORD THE APP PRINTS AND TASK LIST IS
104
+ // NOT (w-1bc916a880, third round).
105
+ //
106
+ // The mistake is worth naming because it is the same one
107
+ // the round before was meant to fix. "Task list" was reached for to cover
108
+ // the fact that these keys work in all four sidebar sections rather than
109
+ // only the first, which is an accurate thing about the code and a phrase
110
+ // nobody sees on a screen. Inbox is the first row of the
111
+ // sidebar. The heading claims these keys work there; it does not claim they
112
+ // work ONLY there, and one of its rows moves between the sections anyway.
113
+ label: 'In your inbox',
114
+ keys: [
115
+ // case 'j': case 'J': case 'ArrowDown' → selected + 1
116
+ { keys: ['J', '↓'], join: 'or', what: 'Move down the list.' },
117
+ // case 'k': case 'K': case 'ArrowUp' → selected - 1
118
+ { keys: ['K', '↑'], join: 'or', what: 'Move up the list.' },
119
+ // case 'ArrowLeft': case 'ArrowRight' → boardSideways (w-23fc91bff5).
120
+ // "Board" is the word the View and filters menu prints.
121
+ { keys: ['←', '→'], what: 'Move to the next column on the board.' },
122
+ // case 'Enter' → setFocused(pointed)
123
+ { keys: ['↵'], what: 'Open the task you are on.' },
124
+ // App.tsx, `if (e.key === 'Tab')`: nextTab(stateTabOrder, view, shiftKey)
125
+ // walks the tabs along the top of the inbox, wrapping. It replaced ⌘1 to
126
+ // ⌘4 on 2026-10-02 (w-914b16eab6), which jumped through the sidebar's
127
+ // sections before those became these tabs. Shift-Tab goes back and is
128
+ // left unlisted on her word: "just show the Next tab".
129
+ { keys: ['⇥'], what: 'Move to the next tab along the top.' },
130
+ // case 'b': case 'B' → setInboxDisplay(flipView(inboxDisplay)), asked
131
+ // for by name (w-58c8f466e7). "List" and "Board" are the words the View
132
+ // and filters menu prints, so the sentence uses those.
133
+ { keys: ['B'], what: 'Switch between the list and the board.' },
134
+ // Escape in the focused branch: doc → task → list. In the list switch it
135
+ // clears a selection and closes search.
136
+ { keys: ['esc'], what: 'Go back one step: a file, then the task, then the list.' },
137
+ ],
138
+ },
139
+ {
140
+ // App.tsx, the `if (focused)` branch: a task open in front of her. NOT
141
+ // "answering an agent" any more, because ⌘J opens its terminal and that is
142
+ // not an answer; the heading says when the keys work, which is the scheme.
143
+ label: 'In a task you have opened',
144
+ keys: [
145
+ // focused branch: 'r' → setModal('reply'); list switch: 'r' in the inbox
146
+ { keys: ['R'], what: 'Write back to the agent.' },
147
+ // Compose.tsx onKeyDown: (meta||ctrl) && Enter → send; Focus.tsx the same
148
+ { keys: ['⌘↵'], what: 'Send what you have typed.' },
149
+ // focused branch: ArrowDown / ArrowUp walk opts
150
+ { keys: ['↑', '↓'], what: 'Move through the options an agent has offered.' },
151
+ // focused branch: Enter sends optionSel. Its own row rather than a clause
152
+ // at the end of the one above, so somebody scanning for ↵ finds it.
153
+ { keys: ['↵'], what: 'Send the option you have selected.' },
154
+ // focused branch: /^[1-9]$/ → pickOption(focused, Number(e.key))
155
+ { keys: ['1', '9'], join: 'to', what: 'Pick an option by its number.' },
156
+ // ⌘J, above the inInput gate in App.tsx: dispatches task-terminal-toggle,
157
+ // and toasts "Open a task to use its terminal" when there is none
158
+ // (w-ef92663cb1). It landed after this list last changed, so it was
159
+ // missing here at first.
160
+ { keys: ['⌘J'], what: 'Show or hide this task’s terminal.' },
161
+ // threads/Summary.tsx, `useSummaryShortcut`: it listens in the capture
162
+ // phase and stops the key there, so S belongs to the summary on a thread
163
+ // that has one. It is under THIS heading and not the one below because it
164
+ // is the one key in the app that works only inside an open thread.
165
+ { keys: ['S'], what: 'Show or hide the summary.' },
166
+ ],
167
+ },
168
+ {
169
+ // The one group that cannot be named by WHERE she is, because all four of
170
+ // its keys work both in the list and inside an open task. So it is named by
171
+ // what the keys do, literally and in the order they are drawn.
172
+ label: 'Closing, scheduling and undoing',
173
+ keys: [
174
+ // focused branch: 'e' → markDone. List switch: inbox → markDone,
175
+ // snoozed → unsnooze. It never approves anything.
176
+ { keys: ['E'], what: 'Close the task and take it out of your inbox.' },
177
+ // focused branch and list switch: 'l' → openSnooze. IT WAS S UNTIL
178
+ // 2026-10-01 AND THAT IS THE WHOLE OF WHY IT MOVED. S had come to mean
179
+ // the summary inside a thread and still meant the schedule picker in the
180
+ // list, so this page taught one letter for two different things and an
181
+ // office manager testing the app met both inside a minute. One meaning
182
+ // per letter: S is the summary, L is later.
183
+ { keys: ['L'], what: 'Put the task off until a time you pick.' },
184
+ // focused branch and list switch: 'z' → undo
185
+ { keys: ['Z'], what: 'Undo the last thing you did.' },
186
+ // (meta||ctrl) && 'a' && !inInput && !modal → tick the whole list
187
+ { keys: ['⌘A'], what: 'Tick every task in the list. Press it again to untick.' },
188
+ ],
189
+ },
190
+ {
191
+ label: 'Anywhere in the app',
192
+ keys: [
193
+ // (meta||ctrl) && 'k' → the palette, above every other branch, and
194
+ // forwarded from inside an open file by main.mjs as well.
195
+ { keys: ['⌘K'], what: `The command bar: everything ${NAME} can do, by name.` },
196
+ // 'n' in both branches → setModal('compose'). C does the same and is
197
+ // left off: N is the default (w-fb9051e597).
198
+ // "Threads", not tasks, since w-ec62ab6b38.
199
+ { keys: ['N'], what: 'Start a new thread.' },
200
+ // '/' above the focused branch → openSearch
201
+ { keys: ['/'], what: 'Search your threads.' },
202
+ // '\\' above the focused branch → togglePanel
203
+ { keys: ['\\'], what: 'Show or hide the left sidebar.' },
204
+ // main/main.mjs before-input-event: ⌘Y / ⌘N answer the oldest approval.
205
+ // Its own card prints "⌘Y / ⌘N" beside Allow and Deny, so the words here
206
+ // are the card's words. It lives under this heading rather than under an
207
+ // open task because it is answered from anywhere at all.
208
+ { keys: ['⌘Y', '⌘N'], join: 'or', what: 'Allow or deny a card asking to run something.' },
209
+ ],
210
+ },
211
+ ];
212
+
213
+ /** Every cap drawn on the page, flattened. The test walks this. */
214
+ export function everyKey(groups = SHORTCUTS) {
215
+ return groups.flatMap((g) => g.keys.flatMap((k) => k.keys));
216
+ }
217
+
218
+ // WHAT AN AGENT IS TOLD, and it is this page and nothing else, so a key that
219
+ // moves here has moved for every agent on its next run. The closing line is
220
+ // the point: an agent's own saved memory is read before anything we hand it,
221
+ // and on 2026-10-04 one said "press S" three days after S stopped scheduling.
222
+ /** The keys as one block of plain text, for an agent's system prompt. */
223
+ export function keysForAgents(groups = SHORTCUTS) {
224
+ const lines = [
225
+ `The keys in ${NAME}, the app this person reads you in, as they are today.`,
226
+ 'When you tell them to press something, use these. Keys have moved before,',
227
+ 'so a key you remember from an earlier session, a memory or a note that',
228
+ 'disagrees with this list is out of date. Anything without a key is in ⌘K',
229
+ 'by name.',
230
+ ];
231
+ for (const g of groups) {
232
+ lines.push('', `${g.label}:`);
233
+ for (const k of g.keys) lines.push(`- ${k.keys.join(k.join ? ` ${k.join} ` : ' ')}: ${k.what}`);
234
+ }
235
+ return lines.join('\n');
236
+ }
@@ -6,3 +6,5 @@
6
6
  // main and by the tests, which are both plain JS and need nothing here.
7
7
 
8
8
  export function strandedTitle(count: number): string;
9
+ /** The line in a thread where a run stopped, in our words; `raw` is the tool's own refusal. */
10
+ export function runStoppedLine(raw: string): string;
@@ -6,11 +6,11 @@
6
6
  // across the top of her inbox. Her three complaints were all correct. There is
7
7
  // no OAuth in Agentbox, so the sentence names a thing that does not exist here.
8
8
  // It is ugly. And it was on screen while agents were working perfectly well,
9
- // because it was about one of her two Claude accounts and not about the app.
9
+ // because it was about one Claude account among several and not about the app.
10
10
  //
11
11
  // So the raw text never reaches the renderer again. It goes in the session's
12
12
  // own trace log, which is where a person debugging wants it, and what she is
13
- // shown is one sentence of ours written in her words.
13
+ // shown is one sentence of ours written in plain words.
14
14
  //
15
15
  // Every sentence here has to survive the same test: read it out loud to
16
16
  // somebody who has never heard of a subprocess, and they know what happened
@@ -24,7 +24,7 @@
24
24
  // CODEX. It is right about Codex anyway, and that is measured rather than
25
25
  // assumed. On 2026-09-05 `codex app-server` (codex-cli 0.148.0) was driven the
26
26
  // way main/codex-app-server.mjs drives it, with CODEX_HOME pointed at an empty
27
- // directory so there was no auth.json and her real ~/.codex login was neither
27
+ // directory so there was no auth.json and the real ~/.codex login was neither
28
28
  // read nor written. The turn retried ten times and failed with
29
29
  //
30
30
  // unexpected status 401 Unauthorized: Missing bearer or basic authentication in
@@ -41,18 +41,18 @@
41
41
 
42
42
  // AN ACCOUNT THAT IS SIGNED IN AND NOT ALLOWED TO RUN.
43
43
  //
44
- // She was. Her second login was intact and her browser sign-in succeeded every
45
- // time she did it; the WORKSPACE that account belongs to has Claude Code turned
46
- // off. Run by hand on her Mac, this is the whole of what the CLI says:
44
+ // It happens. A login can be intact, with the browser sign-in succeeding every
45
+ // time, while the WORKSPACE that account belongs to has Claude Code turned
46
+ // off. Run by hand, this is the whole of what the CLI says:
47
47
  //
48
48
  // Your organization has disabled Claude subscription access for Claude Code ·
49
49
  // Use an Anthropic API key instead, or ask your admin to enable access
50
50
  //
51
51
  // "subscription access" is in the list below, so every one of those was filed
52
- // as a login that had run out and she was told to type /login. She did, twice
53
- // in one afternoon; both logins worked; nothing changed, because no login can
54
- // change an admin's policy. FOUR dead runs on one row that day, all four
55
- // carrying "Sign that account back in and it picks straight up."
52
+ // as a login that had run out and the person was told to type /login. Doing
53
+ // it again and again changes nothing, because no login can change an admin's
54
+ // policy, and every dead run on the row carried "Sign that account back in
55
+ // and it picks straight up."
56
56
  //
57
57
  // TESTED BEFORE SIGNED_OUT, which is the whole of the fix: these lines say
58
58
  // "subscription" and "access" and mean something else entirely.
@@ -188,9 +188,8 @@ export function troubleCause(raw) {
188
188
  //
189
189
  // carries the one thing she actually wants to know, and we were throwing it
190
190
  // away and then telling her the vaguer "when the limit resets". The timezone
191
- // is dropped rather than shown: this Mac is set to America/Los_Angeles and
192
- // every limit line in her store prints that same zone, so naming it would only
193
- // add a word she has to read. Null when there is no time to quote, and then
191
+ // is dropped rather than shown: the limit line prints the zone the Mac is
192
+ // already set to, so naming it would only add a word she has to read. Null when there is no time to quote, and then
194
193
  // the sentence falls back to the wording that has always been there.
195
194
  export function limitResetsAt(raw) {
196
195
  // Both wordings are real: the CLI's own line is "resets 6pm" and the API's
@@ -202,14 +201,14 @@ export function limitResetsAt(raw) {
202
201
  const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
203
202
 
204
203
  // THE SAME HOUR AS A MOMENT, so the fleet can wait exactly until it
205
- // (2026-09-22: a limit that reset at 6:50pm held every agent until 7:03pm,
204
+ // (a limit that reset at, say, 6:50pm could hold every agent until 7:03pm,
206
205
  // because the app waited a fixed thirty minutes instead of reading this line).
207
206
  // The next local occurrence of that clock time at or after `from`; null when
208
207
  // the line names no hour.
209
208
  export function limitResetMoment(raw, from = Date.now()) {
210
209
  // CODEX NAMES A DAY, NOT AN HOUR: "try again at Sep 28th, 2026 1:05 PM"
211
210
  // (codex-cli 0.153.4, 2026-09-24). A weekly limit read as "thirty minutes"
212
- // put the capped login back in the pool every half hour for four days.
211
+ // put the capped login back in the pool every half hour for days.
213
212
  const dated = /try again at ([a-z]{3,9})\.? (\d{1,2})(?:st|nd|rd|th)?,? (\d{4}),? (\d{1,2}):(\d{2})\s*(am|pm)/i.exec(String(raw ?? ''));
214
213
  if (dated) {
215
214
  const month = MONTHS.indexOf(dated[1].slice(0, 3).toLowerCase());
@@ -343,8 +342,8 @@ export function engineTroubleNote(cause, engineWord = 'Codex') {
343
342
  // The count is the point, and it is why this is a different sentence from
344
343
  // `troubleSentence` above rather than a rewording of it. That one is about the
345
344
  // FLEET, and it can only ever be true when every account on the Mac is down at
346
- // once. That is why her screen stayed quiet: four Codex tasks and four on a
347
- // signed-out login sat there all day while her other agents worked, so the
345
+ // once. That is why the screen could stay quiet: Codex tasks and tasks on a
346
+ // signed-out login could sit there all day while the other agents worked, so the
348
347
  // fleet was never down and the one line the app owns was structurally unable to
349
348
  // speak. The subject here is her TASKS, which is the thing she went looking for
350
349
  // and the thing that was actually stuck.
@@ -443,6 +442,28 @@ const DEAD_RUN_OPENERS = {
443
442
  unknown: 'stopped before it did anything, so this task has not been started.',
444
443
  };
445
444
 
445
+ /**
446
+ * THE LINE IN A THREAD WHERE A RUN STOPPED, in our words.
447
+ *
448
+ * The CLI types its own refusal into the run as if the agent had said it
449
+ * ("Failed to authenticate: OAuth session expired..."), and the thread drew it
450
+ * as the agent's message. This is about one run, not the task: a run that
451
+ * stops mid-thread has usually done plenty before it, so it says nothing about
452
+ * what has or has not been done.
453
+ */
454
+ const RUN_STOPPED = {
455
+ 'signed-out': 'This run stopped because the account was signed out.',
456
+ 'org-blocked': 'This run stopped because that account is switched off by its organization.',
457
+ 'at-limit': 'This run stopped because the account hit its usage limit.',
458
+ workspace: "This run stopped because it would not start in this project's folder.",
459
+ interrupted: 'This run was cut off in the middle.',
460
+ unknown: 'This run stopped before it finished.',
461
+ };
462
+
463
+ export function runStoppedLine(raw) {
464
+ return RUN_STOPPED[troubleCause(raw)] ?? RUN_STOPPED.unknown;
465
+ }
466
+
446
467
  export function deadRunSentence({ engineWord = 'The agent', cause = 'unknown', runs = 1, resetsAt = null } = {}) {
447
468
  const opener = `${engineWord} ${DEAD_RUN_OPENERS[cause] ?? DEAD_RUN_OPENERS.unknown}`;
448
469
 
@@ -468,7 +489,7 @@ export function deadRunSentence({ engineWord = 'The agent', cause = 'unknown', r
468
489
  // WHAT GOES ON HER ROW WHEN THE RUN WAS FINE AND HER ANSWER STILL WENT NOWHERE.
469
490
  //
470
491
  // `settleDelivery` is two independent tests joined with an AND: did the process
471
- // exit cleanly, and did an agent leave a word on the row since she wrote. A run
492
+ // exit cleanly, and did an agent leave a word on the row since the user wrote. A run
472
493
  // can pass the first and fail the second, and several ordinary ones do -- a
473
494
  // Codex turn that talks in its thread and never calls a store tool, a worker
474
495
  // that claims a row and finishes without writing, any session on an install
@@ -0,0 +1,17 @@
1
+ // Types for shared/team-rules.mjs, read by the renderer.
2
+ type Row = { createdBy?: string | null; assignee?: string; runner?: string; people?: string[]; status?: string };
3
+ type ProductLike = { team?: { projectId?: string; sharedBy?: string | null } | null } | null | undefined;
4
+
5
+ export function isShared(product: ProductLike): boolean;
6
+ export function runnerOf(item: Row, product: ProductLike): string | null;
7
+ export function heldByAPerson(item: Row): boolean;
8
+ export function mayRunHere(item: Row, product: ProductLike, me: string | null): boolean;
9
+ export function inMyInbox(item: Row, product: ProductLike, me: string | null): boolean;
10
+ export function lastSpeaker(item: Row): string | null;
11
+ export function iSpokeLast(item: Row, product: ProductLike, me: string | null): boolean;
12
+ export function handedOnByReply(item: Row, product: ProductLike, me: string | null): string | null;
13
+ /** One thread you are in where somebody spoke after you (w-920461cbe6). */
14
+ export interface ThreadWaiting { uid: string; mine: boolean; fresh: number; people: string[]; text: string; last: string; lastBy: string }
15
+ /** What in a conversation waits on you; null on a row from before threads. */
16
+ export interface Waits { chat: number; threads: ThreadWaiting[] }
17
+ export function whatWaits(item: Row & { talk?: unknown }, me: string | null): Waits | null;
@@ -0,0 +1,230 @@
1
+ // THE TEAM'S RULES ABOUT ROWS, IN ONE PLACE. Pure and browser-safe: the
2
+ // supervisor asks them before it runs anything, and the window asks them
3
+ // before it puts a row in somebody's inbox, so the two can never disagree about
4
+ // whose a row is.
5
+ //
6
+ // A project is shared when its record carries a team id (main/team/projects.mjs).
7
+ // Everything here leaves a private project exactly as it always was.
8
+ import { pickFields } from './work-items.mjs';
9
+
10
+ export const isShared = (product) => !!product?.team?.projectId;
11
+
12
+ // THE PERSON WHOSE MAC RUNS AGENTS ON A ROW: whoever it was handed to, else
13
+ // whoever started it, else (a row from before anyone signed in) whoever shared
14
+ // the project.
15
+ export function runnerOf(item, product) {
16
+ return item?.runner || item?.createdBy || product?.team?.sharedBy || null;
17
+ }
18
+
19
+ // A row a person has been given waits for that person, not for an agent.
20
+ export const heldByAPerson = (item) => !!item?.assignee && item.assignee !== 'agent';
21
+
22
+ // May a worker run on this row on this Mac, signed in as `me`?
23
+ export function mayRunHere(item, product, me) {
24
+ if (!isShared(product)) return true;
25
+ if (!me) return false;
26
+ // A MESSAGE BETWEEN TWO PEOPLE NEVER RUNS AN AGENT, on either Mac. Turning
27
+ // one into work is @-mentioning an agent in the reply box, which starts a new
28
+ // thread of your own in the project the mention names; nothing a teammate
29
+ // writes into the shared record can start one here.
30
+ if (product?.team?.direct) return false;
31
+ if (heldByAPerson(item)) return false;
32
+ // ONLY YOU PUT YOUR MAC IN CHARGE OF A ROW. A runner naming you that somebody
33
+ // else's line wrote is a teammate trying to start an agent on your Mac with
34
+ // their words, so it does not count.
35
+ const runnerBy = item?.wrote?.runner?.by;
36
+ if (item?.runner === me && runnerBy && runnerBy !== me) return false;
37
+ // NOR DOES A TEAMMATE'S WORD START ONE. Lines pulled from teammates may not
38
+ // set these fields at all (whatATeammateMaySet below); this is the second
39
+ // wall, for a ledger that already holds such a line from before that rule.
40
+ // A field with no writer is from before anyone signed in, and is yours.
41
+ for (const field of STARTS_A_RUN) {
42
+ const by = item?.wrote?.[field]?.by;
43
+ if (by && by !== me) return false;
44
+ }
45
+ return runnerOf(item, product) === me;
46
+ }
47
+ const STARTS_A_RUN = ['body', 'answer', 'status', 'runAt'];
48
+
49
+ // WHOSE INBOX A SHARED ROW IS IN: one person's at a time, whoever has to act.
50
+ // A row given to a person is theirs until it is done; an agent's row is its
51
+ // runner's. True leaves the row to the inbox's ordinary rules, which is what a
52
+ // private row, or any row on a Mac nobody is signed in on, always gets.
53
+ // WHO SPOKE LAST IN A CONVERSATION: the writer of its newest message.
54
+ export function lastSpeaker(item) {
55
+ const a = item?.wrote?.answer;
56
+ const b = item?.wrote?.body;
57
+ const latest = a && (!b || (a.ts ?? 0) >= (b.ts ?? 0)) ? a : b;
58
+ return latest?.by ?? item?.createdBy ?? null;
59
+ }
60
+
61
+ /**
62
+ * WHAT IN A CONVERSATION IS WAITING ON YOU (w-920461cbe6). Asked the day
63
+ * threads shipped: in a busy chat, with three people answering your thread and
64
+ * others carrying on in the chat, what reaches your inbox? Not a row per
65
+ * message, and not a thread you are not in; and a thread waiting on you must
66
+ * not be lost because you said something in the chat.
67
+ *
68
+ * So: `chat` is how many messages in the main chat came after your own newest
69
+ * one there, and `threads` is every thread you are IN (you wrote the message it
70
+ * hangs off, or replied in it) where somebody else spoke after you, newest
71
+ * first, with how many replies are new, who wrote them, what the thread is
72
+ * about and its newest words. Null for a row with no record of who spoke where
73
+ * (one from before threads), which then reads by `lastSpeaker` as it always did.
74
+ */
75
+ export function whatWaits(item, me) {
76
+ const talk = item?.talk;
77
+ if (!talk || !me) return null;
78
+ const since = (list) => { let n = 0; for (let k = list.length - 1; k >= 0 && list[k].by !== me; k -= 1) n += 1; return n; };
79
+ const threads = [];
80
+ for (const [uid, t] of Object.entries(talk.threads ?? {})) {
81
+ const replies = t.replies ?? [];
82
+ if (t.by !== me && !replies.some((r) => r.by === me)) continue;
83
+ const fresh = since(replies);
84
+ if (!fresh) continue;
85
+ const news = replies.slice(-fresh);
86
+ threads.push({
87
+ uid, mine: t.by === me, fresh,
88
+ people: [...new Set(news.map((r) => r.by))],
89
+ text: t.text, last: news[news.length - 1].text, lastBy: news[news.length - 1].by,
90
+ at: news[news.length - 1].ts,
91
+ });
92
+ }
93
+ threads.sort((a, b) => b.at - a.at);
94
+ return { chat: since(talk.chat ?? []), threads: threads.map(({ at, ...t }) => t) };
95
+ }
96
+
97
+ export function inMyInbox(item, product, me) {
98
+ if (!isShared(product) || !me) return true;
99
+ // A CONVERSATION NEEDS WHOEVER DID NOT SPEAK LAST (2026-10-01), so a message
100
+ // to three people is in all three inboxes until one of them answers, and the
101
+ // answer puts it back in everyone else's. One person to one works the same.
102
+ // WITH THREADS (w-920461cbe6) the same rule is read per place: the chat, and
103
+ // each thread you are in. One row, whichever of them is waiting.
104
+ if (product?.team?.direct) {
105
+ if (item.status === 'done') return false;
106
+ const waits = whatWaits(item, me);
107
+ if (waits) return waits.chat > 0 || waits.threads.length > 0;
108
+ const by = lastSpeaker(item);
109
+ return by ? by !== me : item.assignee === me;
110
+ }
111
+ if (heldByAPerson(item)) return item.assignee === me && item.status !== 'done';
112
+ return runnerOf(item, product) === me;
113
+ }
114
+
115
+ // A CONVERSATION WHERE YOU SPOKE LAST IS DONE FOR YOU (w-57a202a968), until
116
+ // the other person writes again and `inMyInbox` brings it back. Without it a
117
+ // message you answered was on no tab and no column: out of your inbox by the
118
+ // rule above, never In progress, and not Done because its status is not done.
119
+ // Her words: "It's not supposed to leave the board; it's supposed to go in Done."
120
+ export function iSpokeLast(item, product, me) {
121
+ if (!me || !product?.team?.direct || item?.status === 'done') return false;
122
+ // Done for you means nothing in it waits on you: the chat or any thread.
123
+ const waits = whatWaits(item, me);
124
+ if (waits) return waits.chat === 0 && waits.threads.length === 0;
125
+ return lastSpeaker(item) === me;
126
+ }
127
+
128
+ // A REPLY HANDS A PERSON-TO-PERSON ROW TO THE OTHER PERSON, so a conversation
129
+ // goes back and forth between two inboxes instead of sitting in both. Returns
130
+ // who it goes to, or null when a reply changes nothing about whose it is.
131
+ export function handedOnByReply(item, product, me) {
132
+ if (!isShared(product) || !me || !heldByAPerson(item) || item.assignee !== me) return null;
133
+ const people = Array.isArray(item.people) ? item.people : [];
134
+ return people.find((p) => p !== me) || (item.createdBy && item.createdBy !== me ? item.createdBy : null);
135
+ }
136
+
137
+ // WHAT A TEAMMATE'S LINE MAY SET ON THIS MAC. Found by review 2026-10-01: a
138
+ // teammate's line with source "founder" could set the body, the answer, the
139
+ // status, the schedule or the model of any row in a shared project, including
140
+ // a row whose runner is you, and the supervisor then started an agent here on
141
+ // their words. So a pulled line keeps only the summary (which the board shows
142
+ // anyway), who has to act next, and, in a message between two people (which
143
+ // never runs an agent), the message itself. Everything else is dropped before
144
+ // it reaches the disk, claims and epochs included. Returns null when nothing
145
+ // is left.
146
+ const A_TEAMMATE_MAY_SET = ['problem', 'progress', 'solution', 'blockedBy', 'blocks', 'assignee'];
147
+ // The status rides too: a new message reopens a conversation put away, and a
148
+ // message record never starts an agent (mayRunHere).
149
+ //
150
+ // AND A REACTION, which is the chips under a message (w-560647d4db). It is on
151
+ // this list rather than the one above because it is only ever drawn in a
152
+ // conversation, and because an unrecognised delta on an ordinary shared task
153
+ // would be a teammate writing a field nothing there reads. It cannot start an
154
+ // agent: `STARTS_A_RUN` above names the four fields that can, and a reaction
155
+ // touches none of them, so a chip never puts the row in anybody's inbox.
156
+ //
157
+ // AND WHICH MESSAGE A REPLY ANSWERS (w-920461cbe6), for the same two reasons:
158
+ // only a conversation draws a thread, and the mark rides beside an `answer`
159
+ // this list already lets through. Without it a teammate's thread reply would
160
+ // land in the middle of your chat instead of in the thread.
161
+ const IN_A_MESSAGE_ALSO = ['title', 'body', 'answer', 'people', 'status', 'react', 'inReplyTo'];
162
+ // AND THE LEVEL THE SENDER PICKED, ON THE OPENING LINE ALONE (w-7ba439c883).
163
+ // A message now carries how urgent its sender thought it was, and that number
164
+ // decides where it sits in the inbox it lands in, so it has to survive the
165
+ // pull. It may not become a way to re-rank a row afterwards, though: the level
166
+ // rides the line that MAKES the row and no other.
167
+ //
168
+ // `kind` is how that line is known. Only a row's first line sets it, and a
169
+ // teammate may not set it here at all, so it is a marker rather than a field:
170
+ // it is read, then dropped with everything else that is not allowed.
171
+ //
172
+ // WHAT THIS DOES NOT STOP, said plainly: a teammate who forges a line carrying
173
+ // a kind can still re-rank the one conversation the two of you share. They can
174
+ // already rewrite that row's title, body, answer and status (above), so this
175
+ // opens no row that was closed to them, and nothing outside a conversation is
176
+ // reachable either way.
177
+ const WHEN_A_MESSAGE_OPENS = ['priority'];
178
+ const opensARow = (patch) => 'kind' in patch;
179
+ export function whatATeammateMaySet(line, { direct = false } = {}) {
180
+ if (!line || typeof line !== 'object' || !line.patch || typeof line.patch !== 'object') return null;
181
+ const allowed = direct
182
+ ? [...A_TEAMMATE_MAY_SET, ...IN_A_MESSAGE_ALSO, ...(opensARow(line.patch) ? WHEN_A_MESSAGE_OPENS : [])]
183
+ : A_TEAMMATE_MAY_SET;
184
+ const kept = {};
185
+ for (const field of allowed) if (field in line.patch) kept[field] = line.patch[field];
186
+ const patch = pickFields(kept);
187
+ if (!patch) return null;
188
+ return { id: line.id, ts: line.ts, source: line.source, by: line.by, uid: line.uid, patch };
189
+ }
190
+
191
+ // A LEASE BELONGS TO THE MAC THAT TOOK IT. A claim, an epoch, a release or a
192
+ // heartbeat is a worker's hold on one Mac, so it never rides a line across to
193
+ // another one — not even to a second copy of the app that is also yours.
194
+ export function withoutALease(line) {
195
+ const out = { ...line };
196
+ delete out.claim; delete out.epoch; delete out.release; delete out.heartbeat;
197
+ return out;
198
+ }
199
+
200
+ // A LINE AS IT COMES BACK FROM THE CLOUD, the same on both backends. Who wrote
201
+ // it and when are the database's (by_person, created_at), never the copy in
202
+ // the body, and a teammate's line carries no lease (above).
203
+ export function asPulled(body, { by, at, me }) {
204
+ const line = { ...body, by };
205
+ if (by === me) return line;
206
+ const kept = withoutALease(line);
207
+ if (Number.isFinite(at)) kept.ts = at;
208
+ return kept;
209
+ }
210
+
211
+ // YOUR OWN LINE, COMING BACK FROM A SECOND COPY OF THE APP SIGNED IN AS YOU.
212
+ //
213
+ // Reported 2026-10-04 (w-2fce569057): a message sent to a teammate never came
214
+ // back. It had been sent from a second copy. It reached the cloud and it
215
+ // reached the teammate's app; what it never reached was the everyday copy,
216
+ // because the pull skipped every line whose writer was you, on the reasoning
217
+ // that a line you wrote is already here. True of the copy that wrote it, false
218
+ // of every other copy you own, and a conversation missing your own half reads
219
+ // as a message that never sent.
220
+ //
221
+ // ONLY IN A MESSAGE RECORD, where no agent ever runs (mayRunHere). On an
222
+ // ordinary shared row your own answer, body or status is exactly what starts
223
+ // one, and `mayRunHere` believes a line whose writer is you, because it is you:
224
+ // carrying those across would start a second agent on the same row on your
225
+ // other Mac. So nothing crosses there, and your own words stay where you typed
226
+ // them. Returns null when the line is not yours to bring back.
227
+ export function whatYourOtherCopyMaySet(line, { direct = false } = {}) {
228
+ if (!direct || !line || typeof line !== 'object') return null;
229
+ return withoutALease(line);
230
+ }
@@ -0,0 +1,11 @@
1
+ export interface PersonStatus { text: string; until: number | null }
2
+ export type HoldId = 'today' | 'tomorrow' | 'week' | 'open';
3
+
4
+ export const STATUS_MAX: number;
5
+ export const HOLDS: { id: HoldId; label: string }[];
6
+
7
+ export function cleanStatus(text: unknown): string | null;
8
+ export function liveStatus(status: PersonStatus | null | undefined, now?: number): PersonStatus | null;
9
+ export function hasLapsed(status: PersonStatus | null | undefined, now?: number): boolean;
10
+ export function holdsUntil(until: number | null | undefined, now?: number): string;
11
+ export function holdEnds(id: HoldId | string, now?: number): number | null;