agentbox-app 0.1.6 → 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-CwiDv5bL.css +0 -32
  147. package/renderer/dist/assets/index-D9hCAVPl.js +0 -684
  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
@@ -66,7 +66,7 @@ export const CLAUDE_PERMISSION_MODES = [
66
66
  // `answerMode` used to live on the ledger: what one reply is allowed to do on
67
67
  // the founder's Mac, turned straight into `--permission-mode` on a real process
68
68
  // by spawnPlan. A guard here stripped it off any line that did not claim to be
69
- // hers, which closed the STRUCTURED path, the MCP and the app.
69
+ // the founder's, which closed the STRUCTURED path, the MCP and the app.
70
70
  //
71
71
  // IT COULD NOT CLOSE THE FILE, AND NOTHING WRITTEN HERE EVER COULD. `source` is
72
72
  // a plain string in a line and nothing checks it. A worker with Bash could
@@ -78,8 +78,8 @@ export const CLAUDE_PERMISSION_MODES = [
78
78
  // own storage now, written by main and by nothing else (main/answer-modes.mjs),
79
79
  // which is the convention Claude Code uses for its standing policy. Everything
80
80
  // on the ledger today is a claim about the WORK, and a forged line can still
81
- // fake her archive or her answer — true of any append-only log nobody signs, and
82
- // filed for her rather than papered over here. What it can no longer do is hand
81
+ // fake the founder's archive or answer, which is true of any append-only log
82
+ // nobody signs, and is filed as a known issue rather than papered over here. What it can no longer do is hand
83
83
  // anything a permission.
84
84
  //
85
85
  // If a future field is a grant rather than an opinion, the answer is the same
@@ -90,29 +90,96 @@ export const CLAUDE_PERMISSION_MODES = [
90
90
  // state is worse than one that visibly does nothing.
91
91
  // `label` IS THE NAME ON THE ROW, AND IT EXISTS BECAUSE `title` CANNOT BE ONE.
92
92
  //
93
- // A message she dictates becomes an item whose title is its first line,
94
- // verbatim (message-split.ts). Measured on her Agentbox store, 2026-09-03: 214
95
- // items are hers, median title 84 characters, 145 of them over 60, 37 ending in
96
- // an ellipsis mid sentence, and 76 opening with "I", "I've been", "Can you" or
97
- // "We". Eleven of those in a column is eleven rows that look like one row.
93
+ // A dictated message becomes an item whose title is its first line, verbatim
94
+ // (message-split.ts). Such titles run long, are often cut off mid sentence with
95
+ // an ellipsis, and many open with "I", "I've been", "Can you" or "We". Eleven
96
+ // of those in a column is eleven rows that look like one row.
98
97
  //
99
98
  // The obvious fix, having a session write a better title, does not work and
100
- // never did: `beats` below makes her outrank an agent per field, so 208 of those
101
- // 214 titles are hers and an agent's rewrite of one is accepted here and then
102
- // dropped on the floor, silently. That is the whole reason this is a second
103
- // field rather than a better-behaved writer.
99
+ // never did: `beats` below makes the founder outrank an agent per field, so
100
+ // nearly every such title is the founder's and an agent's rewrite of one is
101
+ // accepted here and then dropped on the floor, silently. That is the whole
102
+ // reason this is a second field rather than a better-behaved writer.
104
103
  //
105
- // So `title` stays exactly what she typed and nothing about who owns it changes.
106
- // `label` is the short written name a session puts beside it, and the LIST
107
- // prefers it (`rowTitle`, list-rules.ts). Everything else still reads the title,
108
- // so her own words are what she finds when she opens the row, and search matches
109
- // either name.
104
+ // So `title` stays exactly what the user typed and nothing about who owns it
105
+ // changes. `label` is the short written name a session puts beside it, and the
106
+ // LIST prefers it (`rowTitle`, list-rules.ts). Everything else still reads the
107
+ // title, so the user's own words are what they find when they open the row, and
108
+ // search matches either name.
110
109
  export const WORK_ITEM_FIELDS = [
111
110
  'title', 'label', 'body', 'kind', 'labels', 'priority', 'parent',
112
111
  'status', 'result', 'note', 'answer', 'product', 'runAt', 'answeredThrough',
113
112
  'engine', 'model', 'effort',
113
+ // WHETHER THIS HAS BEEN STARTED AT ALL. 'later' is a thread written down and
114
+ // deliberately not begun: it waits for a person, not for a clock, and
115
+ // `isDue` below is where that becomes true of the whole app. 'now' is how it
116
+ // is started, and a thread without the field at all is an ordinary one that
117
+ // started the moment it was sent.
118
+ 'start',
119
+ // THE TEAM FIELDS. All four are about PEOPLE on a shared project, and a
120
+ // private project never sets them, so a row without them reads as before.
121
+ // assignee the person who has to act next, or 'agent' once a person has
122
+ // handed it back to the agents
123
+ // runner the person whose Mac runs agents on this row; without one it
124
+ // is whoever started it (`createdBy`)
125
+ // due a calendar day, YYYY-MM-DD, or 'none' to clear one
126
+ // people who is on the conversation
127
+ 'assignee', 'runner', 'due', 'people',
128
+ // THE THREAD'S OWN FIELDS (the team version, approved 2026-10-01).
129
+ // visibility 'team' (the default), 'people' or 'private': whether every
130
+ // teammate sees this thread's summary on the Team board, only
131
+ // the people named in `visibleTo`, or nobody but you
132
+ // visibleTo the people a 'people' thread reaches, by id. Ignored on the
133
+ // other two, and a 'people' thread naming nobody reaches
134
+ // nobody (shared/thread-cards.mjs says why it fails closed)
135
+ // problem, progress, solution
136
+ // the SUMMARY, one short sentence each (an agent's is held to
137
+ // SUMMARY_WORDS, shared/thread-cards.mjs). The agent keeps it
138
+ // current and the person can edit it in place, so these are
139
+ // the fields where the later write wins whoever wrote it
140
+ // (SHARED_FIELDS below)
141
+ // blockedBy, blocks
142
+ // other threads' ids, the summary's Linked part
143
+ 'visibility', 'visibleTo', 'problem', 'progress', 'solution', 'blockedBy', 'blocks',
144
+ // A REACTION, AND IT IS THE ONE FIELD ON THIS LIST THAT IS A DELTA RATHER
145
+ // THAN A VALUE: `{ on, emoji }` to put one on, `{ on, emoji, off: true }` to
146
+ // take yours back, where `on` is the uid of the line the message was written
147
+ // as. Every other field here keeps the latest write and drops the rest, which
148
+ // for a reaction would mean your 👀 erasing the one a teammate put on the same
149
+ // message a second earlier. The fold accumulates these per person instead and
150
+ // reads them back as `reactions` (see foldWorkItems); nothing ever reads a
151
+ // bare `react` off an item.
152
+ 'react',
153
+ // A REPLY IN A THREAD (w-920461cbe6): the uid of the message line it answers,
154
+ // written on the same line as the reply's own `answer`. Like `react` it is a
155
+ // mark on ONE line rather than a value the row holds, so the fold skips it;
156
+ // the chat reads it per line (renderer/src/thread-history.ts).
157
+ 'inReplyTo',
114
158
  ];
115
159
 
160
+ // THE SUMMARY IS SHARED BETWEEN THE PERSON AND THE AGENT. Everywhere else the
161
+ // founder's word outranks an agent's forever, which here would mean one edit
162
+ // of hers freezes the summary the agent is meant to keep up to date. So on
163
+ // these fields the later write wins, whoever made it, and the source is still
164
+ // recorded so the panel can say who touched it last.
165
+ const SHARED_FIELDS = new Set(['problem', 'progress', 'solution', 'blockedBy', 'blocks']);
166
+ const MAX_SUMMARY = 1200;
167
+
168
+ // A person id or a line id: short, plain, never an object a writer slipped in.
169
+ const MAX_PERSON_CHARS = 64;
170
+ function shortId(value) {
171
+ return typeof value === 'string' && value && value.length <= MAX_PERSON_CHARS ? value : null;
172
+ }
173
+ const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
174
+ function calendarDay(value) {
175
+ if (value === 'none') return 'none';
176
+ const m = typeof value === 'string' ? DAY_RE.exec(value) : null;
177
+ if (!m) return undefined;
178
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
179
+ const date = new Date(Date.UTC(y, mo - 1, d));
180
+ return date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d ? value : undefined;
181
+ }
182
+
116
183
  // How long a claim survives without a heartbeat. The MCP server heartbeats on a
117
184
  // timer while it holds any claim, so this is a liveness signal about the PROCESS
118
185
  // (session dies, child dies, heartbeats stop) rather than anything the model has
@@ -133,6 +200,9 @@ const MAX_SHORT = 200;
133
200
  // see. Four fields at this size still leave a line at half the append ceiling.
134
201
  export const MAX_TEXT_BYTES = 32 * 1024;
135
202
  const MAX_LABELS = 20;
203
+ // How long one emoji may be, in code points. 👩🏽‍🚀 is five; eight leaves room
204
+ // for the longest sequence anyone picks and refuses a sentence pasted in.
205
+ const MAX_EMOJI = 8;
136
206
  const MAX_ID_CHARS = 64;
137
207
  // A single line longer than this is treated as torn or hostile and skipped
138
208
  // rather than parsed. Matches the ceiling records.mjs already uses.
@@ -235,6 +305,11 @@ export function normalizeLine(line) {
235
305
  id: raw.id,
236
306
  ts: Number.isFinite(raw.ts) ? raw.ts : 0,
237
307
  source,
308
+ // WHO WROTE IT and the line's own id. Stamped where a line reaches the
309
+ // disk on a signed-in Mac (main/store/work-items.mjs), absent everywhere
310
+ // else, and a line without them reads exactly as it always did.
311
+ by: shortId(raw.by),
312
+ uid: shortId(raw.uid),
238
313
  // A founder line is never fenced, so its epoch is ignored even if present.
239
314
  epoch: source === 'agent' && Number.isFinite(raw.epoch) ? raw.epoch : null,
240
315
  claim: raw.claim && typeof raw.claim === 'object' ? raw.claim : null,
@@ -321,6 +396,10 @@ function coerceField(field, value) {
321
396
  // that coerces to undefined is dropped by the fold, so without a falsy
322
397
  // sentinel a schedule could be set and never cleared.
323
398
  case 'runAt': return Number.isFinite(value) ? Math.trunc(value) : undefined;
399
+ // 'later' or 'now', and 'now' is the falsy sentinel for the same reason
400
+ // runAt keeps 0: a field that coerces to undefined is dropped by the fold,
401
+ // so without a real value for "started" nothing could ever clear it.
402
+ case 'start': return value === 'later' || value === 'now' ? value : undefined;
324
403
  // WHICH ANSWER A SESSION HAS ALREADY ACTED ON, as that answer's own ts.
325
404
  //
326
405
  // An answer had two states and the ledger recorded one. A worker finishes
@@ -340,6 +419,40 @@ function coerceField(field, value) {
340
419
  const labels = value.map((l) => str(l, MAX_SHORT)).filter(Boolean).slice(0, MAX_LABELS);
341
420
  return labels.length ? labels : undefined;
342
421
  }
422
+ case 'assignee': case 'runner': return shortId(value) ?? undefined;
423
+ case 'due': return calendarDay(value);
424
+ case 'people': {
425
+ if (!Array.isArray(value)) return undefined;
426
+ const people = [...new Set(value.map(shortId).filter(Boolean))].slice(0, MAX_LABELS);
427
+ return people.length ? people : undefined;
428
+ }
429
+ // WHICH MESSAGE AND WHICH EMOJI. `on` is a line uid, bounded like any other
430
+ // id; the emoji is counted in CHARACTERS rather than bytes, because a flag
431
+ // or a person with a skin tone is several code points joined and cutting one
432
+ // mid-sequence stores a glyph nobody chose.
433
+ case 'react': {
434
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
435
+ const on = shortId(value.on);
436
+ const emoji = typeof value.emoji === 'string' ? [...value.emoji.trim()].slice(0, MAX_EMOJI).join('') : '';
437
+ if (!on || !emoji) return undefined;
438
+ return value.off === true ? { on, emoji, off: true } : { on, emoji };
439
+ }
440
+ case 'inReplyTo': return shortId(value) ?? undefined;
441
+ case 'visibility': return value === 'private' || value === 'team' || value === 'people' ? value : undefined;
442
+ // An empty list is a real value: it is how the last chosen person comes
443
+ // off a thread, and the thread then reaches nobody until she names one.
444
+ case 'visibleTo': {
445
+ if (!Array.isArray(value)) return undefined;
446
+ return [...new Set(value.map(shortId).filter(Boolean))].slice(0, MAX_LABELS);
447
+ }
448
+ // An empty string is a real value here: it is how a person clears a line
449
+ // of the summary, and a field that coerces to undefined cannot be cleared.
450
+ case 'problem': case 'progress': case 'solution':
451
+ return typeof value === 'string' ? value.trim().slice(0, MAX_SUMMARY) : undefined;
452
+ case 'blockedBy': case 'blocks': {
453
+ if (!Array.isArray(value)) return undefined;
454
+ return [...new Set(value.map(shortId).filter(Boolean))].slice(0, MAX_LABELS);
455
+ }
343
456
  default: return undefined;
344
457
  }
345
458
  }
@@ -355,10 +468,9 @@ function coerceField(field, value) {
355
468
  // WHO An agent's `done` and the founder's archive are the same word for
356
469
  // opposite events. Her own ask, ended by an agent, is an answer she has
357
470
  // not received; ended by her, it is filed. the app's inbox could not tell
358
- // them apart, so every answer to a question she asked went straight to
359
- // the archive and she reasonably concluded the work never happened
360
- // (2026-08-06: she asked the same question three times in six hours,
361
- // and all three had been answered within twenty minutes).
471
+ // them apart, so every answer to a question the founder asked went
472
+ // straight to the archive, and it reasonably looked as though the work
473
+ // never happened, so the same question got asked again and again.
362
474
  // WHEN Two identical nudges are two asks. A delivery keyed on the words
363
475
  // alone treats the second as already handled and swallows it.
364
476
  //
@@ -367,6 +479,22 @@ function coerceField(field, value) {
367
479
  // 2. skip an agent line fenced out by a newer epoch (the zombie writer)
368
480
  // 3. apply the claim, if this line carries one
369
481
  // 4. apply the patch field by field, founder outranking agent
482
+ // THE CHIPS UNDER A MESSAGE, out of the presses the ledger holds. Message by
483
+ // message, emoji by emoji, the people still on it — oldest press first, so the
484
+ // chips do not reorder themselves under somebody's pointer as a teammate's
485
+ // lines arrive. Returns null when every press has been taken back, which is
486
+ // what leaves a row with no `reactions` at all.
487
+ function rollUpReactions(presses) {
488
+ const live = [...presses.values()].filter((p) => !p.off).sort((a, b) => a.ts - b.ts);
489
+ if (!live.length) return null;
490
+ const out = {};
491
+ for (const press of live) {
492
+ const message = out[press.on] ?? (out[press.on] = {});
493
+ (message[press.emoji] ?? (message[press.emoji] = [])).push(press.who);
494
+ }
495
+ return out;
496
+ }
497
+
370
498
  export function foldWorkItems(lines, now = Date.now()) {
371
499
  const items = new Map();
372
500
 
@@ -379,10 +507,15 @@ export function foldWorkItems(lines, now = Date.now()) {
379
507
  item = {
380
508
  id: line.id, status: 'open', title: '', kind: '', labels: [],
381
509
  priority: 0, epoch: 0, claim: null, createdAt: line.ts, updatedAt: line.ts,
382
- wrote: {},
510
+ createdBy: line.by, wrote: {},
383
511
  };
384
512
  items.set(line.id, item);
385
513
  }
514
+ // Who started the row is whoever wrote its EARLIEST line, not the first
515
+ // line this Mac happened to receive: a teammate's lines arrive late.
516
+ if (line.ts < item.createdAt || (line.ts === item.createdAt && !item.createdBy)) {
517
+ if (line.by) item.createdBy = line.by;
518
+ }
386
519
 
387
520
  // Fencing. An agent line written under a superseded epoch is the whole
388
521
  // reason this field exists: it is a live worker whose lease lapsed, and
@@ -392,7 +525,9 @@ export function foldWorkItems(lines, now = Date.now()) {
392
525
  if (line.claim) {
393
526
  // A claim at or above the current epoch takes the item. A heartbeat only
394
527
  // extends a lease it still legitimately holds.
395
- if (line.epoch >= item.epoch) {
528
+ const renewsHeldClaim = !line.heartbeat || (item.claim
529
+ && line.epoch === item.epoch && line.claim.holder === item.claim.holder);
530
+ if (line.epoch >= item.epoch && renewsHeldClaim) {
396
531
  item.epoch = line.epoch;
397
532
  item.claim = {
398
533
  holder: String(line.claim.holder ?? ''),
@@ -412,16 +547,48 @@ export function foldWorkItems(lines, now = Date.now()) {
412
547
  // are already in her ledger and will be there forever.
413
548
  const patch = line.heartbeat ? {} : line.patch;
414
549
 
550
+ // WHO SAID WHAT, AND WHERE, for a conversation between people
551
+ // (w-920461cbe6). Every person's message, kept in a list of its own because
552
+ // the fold below keeps only the newest answer, and a busy chat needs to know
553
+ // who spoke last in the chat itself and in each thread. Only a person's own
554
+ // words count (a line with a writer), and only rows with people on them keep
555
+ // the result (see the end of the fold).
556
+ const words = typeof patch.answer === 'string' && patch.answer !== WITHDRAWN ? patch.answer
557
+ : typeof patch.body === 'string' ? patch.body : null;
558
+ if (line.by && words && words.trim()) {
559
+ (item.said ??= []).push({ uid: line.uid ?? null, by: line.by, ts: line.ts, text: words.trim().slice(0, TALK_TEXT), inReplyTo: patch.inReplyTo ?? null, body: typeof patch.answer !== 'string' });
560
+ }
561
+
415
562
  for (const [field, value] of Object.entries(patch)) {
563
+ // A REACTION IS ACCUMULATED, NOT HELD. Every other field below keeps one
564
+ // value and the newest writer owns it; a reaction belongs to the person
565
+ // who pressed it, so two people on one message have to survive each other.
566
+ // The key is the message, the emoji and the person, and within that one
567
+ // key the newest press wins — by its own timestamp, because a page pulled
568
+ // from the cloud is not promised in the order anybody pressed anything.
569
+ if (field === 'react') {
570
+ if (!item.reacts) item.reacts = new Map();
571
+ const who = line.by || 'you';
572
+ const key = `${value.on}\u0000${value.emoji}\u0000${who}`;
573
+ const was = item.reacts.get(key);
574
+ if (!was || line.ts >= was.ts) {
575
+ item.reacts.set(key, { ts: line.ts, off: value.off === true, who, on: value.on, emoji: value.emoji });
576
+ }
577
+ continue;
578
+ }
579
+ // WHICH MESSAGE A REPLY ANSWERS belongs to that reply's line alone. Held
580
+ // as a value, the row would carry the newest reply's parent forever.
581
+ if (field === 'inReplyTo') continue;
416
582
  const held = item.wrote[field];
417
583
  // There used to be an exception here, for machinery spending a one-off
418
584
  // permission mode over the founder's own grant. That field is not on the
419
585
  // ledger any more (see the note beside WORK_ITEM_FIELDS), so
420
586
  // the plain rule is the only rule: a weaker writer never overwrites a
421
587
  // stronger one's value.
422
- if (held && !beats(line, held)) continue;
588
+ if (held && !beats(line, held, field)) continue;
423
589
  item[field] = value;
424
590
  item.wrote[field] = { ts: line.ts, source: authorityOf(line, field, value) };
591
+ if (line.by) item.wrote[field].by = line.by;
425
592
  }
426
593
 
427
594
  item.updatedAt = Math.max(item.updatedAt, line.ts);
@@ -440,18 +607,68 @@ export function foldWorkItems(lines, now = Date.now()) {
440
607
  for (const item of items.values()) {
441
608
  item.claimExpired = !!(item.claim && item.claim.leaseUntil < now);
442
609
  if (item.status === 'claimed' && (!item.claim || item.claimExpired)) item.status = 'open';
610
+ if (item.reacts) {
611
+ const chips = rollUpReactions(item.reacts);
612
+ delete item.reacts;
613
+ if (chips) item.reactions = chips;
614
+ }
615
+ if (item.said) {
616
+ const said = item.said;
617
+ delete item.said;
618
+ if (Array.isArray(item.people) && item.people.length) item.talk = talkOf(said);
619
+ }
443
620
  }
444
621
 
445
622
  return items;
446
623
  }
447
624
 
625
+ // A reply taken back reads as this, and is nobody's news.
626
+ const WITHDRAWN = '(withdrawn)';
627
+ // How much of a message the record keeps: enough for a row's one line.
628
+ const TALK_TEXT = 280;
629
+ // How many of the newest messages the record keeps, in the chat and in each
630
+ // thread. The counts it answers stop at your own newest word, which in a chat
631
+ // you are part of is never this far back.
632
+ const TALK_KEEP = 100;
633
+
634
+ /**
635
+ * THE CHAT AND ITS THREADS, AS WHO SPOKE WHERE (w-920461cbe6): `chat` is the
636
+ * main conversation, oldest first, and `threads` the replies under each message
637
+ * they answer, with who wrote that message and its words. Pure bookkeeping for
638
+ * `whatWaits` (shared/team-rules.mjs) and the row's one line; the messages
639
+ * themselves are read off the ledger by the thread view, as before.
640
+ */
641
+ function talkOf(said) {
642
+ const lines = [...said].sort((a, b) => a.ts - b.ts);
643
+ const chat = [];
644
+ const threads = {};
645
+ const parents = new Map();
646
+ const bodies = new Set();
647
+ for (const s of lines) {
648
+ // A rename that sends the same opening words again is not a new message.
649
+ if (s.body) { if (bodies.has(s.text)) continue; bodies.add(s.text); }
650
+ const said1 = { by: s.by, ts: s.ts, text: s.text };
651
+ const parent = s.inReplyTo ? parents.get(s.inReplyTo) : null;
652
+ if (parent) {
653
+ (threads[s.inReplyTo] ??= { by: parent.by, text: parent.text, replies: [] }).replies.push(said1);
654
+ continue;
655
+ }
656
+ // A reply to a message this chat does not hold is drawn in the chat, so it
657
+ // counts there too.
658
+ chat.push(said1);
659
+ if (s.uid) parents.set(s.uid, said1);
660
+ }
661
+ for (const t of Object.values(threads)) t.replies = t.replies.slice(-TALK_KEEP);
662
+ return { chat: chat.slice(-TALK_KEEP), threads };
663
+ }
664
+
448
665
  /* -------------------------------- the thread ----------------------------- */
449
666
  // EVERY TURN OF ONE ROW, OLDEST FIRST. The fold answers "what does this item
450
667
  // say now"; this answers "what has been said here", which is a different
451
668
  // question and nothing could ask it before.
452
669
  //
453
- // She was describing a real hole. Every reply she wrote spawned a fresh session
454
- // holding one field, `answer`, with no sight of her earlier answers on the same
670
+ // There was a real hole here. Every reply the user wrote spawned a fresh session
671
+ // holding one field, `answer`, with no sight of their earlier answers on the same
455
672
  // row and no sight of the work it was replying about. One session built
456
673
  // thirty-nine images off a bare line asking for more options, because cold that
457
674
  // is what the line says. The whole exchange was on disk the entire time: the
@@ -512,7 +729,8 @@ function authorityOf(line, field, value) {
512
729
 
513
730
  // Does the incoming line outrank the one already holding this field? The founder
514
731
  // wins over any agent regardless of time; between equals, later wins.
515
- function beats(incoming, held) {
732
+ function beats(incoming, held, field) {
733
+ if (SHARED_FIELDS.has(field)) return incoming.ts >= held.ts;
516
734
  if (held.source === 'founder' && incoming.source !== 'founder') return false;
517
735
  if (incoming.source === 'founder' && held.source !== 'founder') return true;
518
736
  return incoming.ts >= held.ts;
@@ -530,10 +748,20 @@ function beats(incoming, held) {
530
748
  // the whole reason this is a predicate over the clock rather than a timer, and
531
749
  // it is what makes a schedule survive an app restart, a reboot, and a store
532
750
  // restored onto another machine (the founder's requirement, 2026-08-06).
751
+ // AND A THREAD IN LATER WAITS FOR A PERSON RATHER THAN A CLOCK (w-afb66e6661).
752
+ // "Add it to Later" on the card writes `start: 'later'`, which means exactly
753
+ // what a future moment means and never arrives on its own: not claimable, no
754
+ // session, not in the inbox. Pressing start on the thread writes 'now'.
533
755
  export function isDue(item, now = Date.now()) {
756
+ if (item?.start === 'later') return false;
534
757
  return !item?.runAt || item.runAt <= now;
535
758
  }
536
759
 
760
+ /** Written down and deliberately not begun. */
761
+ export function notStarted(item) {
762
+ return item?.start === 'later';
763
+ }
764
+
537
765
  export function isClaimable(item, now = Date.now()) {
538
766
  if (!item) return false;
539
767
  if (!PULLABLE.includes(item.status)) return false;