agentbox-app 0.1.7 → 0.1.12

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 (255) hide show
  1. package/README.md +220 -58
  2. package/bin/agentbox.mjs +18 -3
  3. package/briefs/message-rules.md +24 -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 +11 -11
  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 +116 -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/approvals.mjs +14 -5
  16. package/main/artifact-path.mjs +11 -0
  17. package/main/claude-bin.mjs +32 -5
  18. package/main/claude-input.mjs +45 -3
  19. package/main/claude-plan.mjs +25 -13
  20. package/main/code-change.mjs +3 -3
  21. package/main/codex-account.mjs +1 -1
  22. package/main/codex-bin.mjs +18 -0
  23. package/main/codex-launch-env.mjs +31 -0
  24. package/main/codex-memory-gate.mjs +260 -0
  25. package/main/codex-models.mjs +12 -7
  26. package/main/codex-threads.mjs +46 -23
  27. package/main/codex-usage-file.mjs +7 -7
  28. package/main/codex-usage.mjs +4 -4
  29. package/main/codex-watch.mjs +12 -12
  30. package/main/config.mjs +82 -17
  31. package/main/demo.mjs +17 -14
  32. package/main/dev-window.mjs +9 -9
  33. package/main/doc-scheme.mjs +2 -0
  34. package/main/engine-setup.mjs +204 -0
  35. package/main/feedback.mjs +55 -0
  36. package/main/folder-pictures.mjs +48 -0
  37. package/main/frame-scrollbars.mjs +90 -0
  38. package/main/fresh-user.mjs +7 -4
  39. package/main/git-change-offthread.mjs +70 -0
  40. package/main/git-change-thread.mjs +11 -0
  41. package/main/git-change.mjs +9 -3
  42. package/main/img-scheme.mjs +70 -1
  43. package/main/instruction-settings.mjs +134 -60
  44. package/main/ipc.mjs +335 -42
  45. package/main/leftovers.mjs +518 -0
  46. package/main/live-replies.mjs +43 -5
  47. package/main/machine.mjs +48 -1
  48. package/main/main.mjs +138 -15
  49. package/main/memory-gate-history.mjs +254 -0
  50. package/main/memory-gate-server.mjs +461 -0
  51. package/main/memory-gate.mjs +206 -0
  52. package/main/message-priority.mjs +87 -0
  53. package/main/notify.mjs +1 -1
  54. package/main/rail-note.mjs +2 -2
  55. package/main/refresh-agent-models.mjs +21 -0
  56. package/main/repeats.mjs +4 -4
  57. package/main/row-label.mjs +115 -51
  58. package/main/runs-on.mjs +93 -0
  59. package/main/serve.mjs +77 -5
  60. package/main/settings.mjs +245 -26
  61. package/main/ship-queue.mjs +279 -0
  62. package/main/sign-in-files.mjs +39 -0
  63. package/main/source-updater.mjs +298 -0
  64. package/main/store/home.mjs +84 -7
  65. package/main/store/project.mjs +15 -19
  66. package/main/store/work-items.mjs +150 -6
  67. package/main/store.mjs +245 -13
  68. package/main/supervisor.mjs +1634 -417
  69. package/main/task-commands.mjs +8 -10
  70. package/main/task-folders.mjs +94 -16
  71. package/main/task-remote-control.mjs +3 -3
  72. package/main/task-terminals.mjs +14 -2
  73. package/main/team/index.mjs +440 -0
  74. package/main/team/memory-cloud.mjs +247 -0
  75. package/main/team/projects.mjs +162 -0
  76. package/main/team/session.mjs +225 -0
  77. package/main/team/sign-in.mjs +106 -0
  78. package/main/team/supabase-backend.mjs +231 -0
  79. package/main/team/sync.mjs +163 -0
  80. package/main/updater.mjs +15 -5
  81. package/main/write-before-reload.mjs +50 -0
  82. package/mcp/core/page.mjs +10 -12
  83. package/mcp/core/work.mjs +123 -26
  84. package/mcp/tools.mjs +13 -6
  85. package/package.json +21 -9
  86. package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
  87. package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
  88. package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
  89. package/renderer/dist/index.html +2 -2
  90. package/scripts/after-install.mjs +14 -0
  91. package/scripts/approval-server.sh +7 -2
  92. package/scripts/memory-gate-hook.sh +59 -0
  93. package/shared/agent-import.mjs +39 -16
  94. package/shared/agents.d.mts +26 -8
  95. package/shared/agents.mjs +100 -108
  96. package/shared/analytics-events.mjs +19 -2
  97. package/shared/answers.mjs +12 -0
  98. package/shared/bridge-map.d.mts +12 -0
  99. package/shared/bridge-map.mjs +38 -1
  100. package/shared/claude-commands.generated.mjs +2 -2
  101. package/shared/claude-models.generated.mjs +6 -6
  102. package/shared/claude-usage.mjs +6 -6
  103. package/shared/codex-import.mjs +7 -7
  104. package/shared/codex-modes.d.mts +50 -0
  105. package/shared/codex-modes.mjs +1 -1
  106. package/shared/codex-usage.mjs +7 -8
  107. package/shared/demo-world.mjs +6 -6
  108. package/shared/engines.mjs +27 -10
  109. package/shared/feedback.d.mts +14 -0
  110. package/shared/feedback.mjs +44 -0
  111. package/shared/first-run-examples.mjs +4 -4
  112. package/shared/first-run-practice.d.mts +16 -0
  113. package/shared/first-run-practice.mjs +176 -70
  114. package/shared/fresh-user-home.mjs +34 -4
  115. package/shared/instruction-defaults.json +2 -2
  116. package/shared/product-name.mjs +4 -2
  117. package/shared/project-folder-check.mjs +9 -3
  118. package/shared/rank.d.mts +1 -0
  119. package/shared/rank.mjs +22 -0
  120. package/shared/referenced-files.mjs +1 -1
  121. package/shared/runs-on.d.mts +20 -0
  122. package/shared/runs-on.mjs +79 -0
  123. package/shared/schemes.d.mts +11 -0
  124. package/shared/schemes.mjs +1 -1
  125. package/shared/shortcuts.d.mts +41 -0
  126. package/shared/shortcuts.mjs +236 -0
  127. package/shared/spawn-trouble.d.mts +2 -0
  128. package/shared/spawn-trouble.mjs +40 -19
  129. package/shared/team-rules.d.mts +17 -0
  130. package/shared/team-rules.mjs +230 -0
  131. package/shared/team-status.d.mts +11 -0
  132. package/shared/team-status.mjs +90 -0
  133. package/shared/terminal-links.d.mts +2 -0
  134. package/shared/terminal-links.mjs +55 -0
  135. package/shared/terminal-state.d.mts +1 -0
  136. package/shared/terminal-state.mjs +16 -0
  137. package/shared/thread-cards.d.mts +19 -0
  138. package/shared/thread-cards.mjs +254 -0
  139. package/shared/usage.d.mts +1 -1
  140. package/shared/usage.mjs +6 -7
  141. package/shared/work-items.mjs +258 -28
  142. package/shared/work-lines.mjs +393 -16
  143. package/worker-permissions.json +1 -1
  144. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  145. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  146. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  147. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  148. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  149. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  150. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  151. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  152. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  153. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  154. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  155. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  156. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  157. package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
  158. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  159. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  160. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  161. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  162. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  163. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  164. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  165. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  166. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  167. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  168. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  169. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  170. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  171. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  172. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  173. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  174. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  175. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  176. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  177. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  178. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  179. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  180. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  181. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  182. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  183. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  184. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  185. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  186. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  187. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  188. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  189. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  190. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  191. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  192. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  193. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  194. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  195. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  196. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  197. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  198. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  199. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  200. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  201. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  202. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  203. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  204. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  205. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  206. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  207. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  208. package/renderer/dist/emulator/audio-rules.mjs +0 -74
  209. package/renderer/dist/emulator/data/compression/README.md +0 -11
  210. package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
  211. package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
  212. package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
  213. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  214. package/renderer/dist/emulator/data/cores/README.md +0 -22
  215. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  216. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  217. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  218. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  219. package/renderer/dist/emulator/data/cores/package.json +0 -65
  220. package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
  221. package/renderer/dist/emulator/data/emulator.css +0 -1614
  222. package/renderer/dist/emulator/data/loader.js +0 -170
  223. package/renderer/dist/emulator/data/localization/README.md +0 -63
  224. package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
  225. package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
  226. package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
  227. package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
  228. package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
  229. package/renderer/dist/emulator/data/localization/en-US.json +0 -339
  230. package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
  231. package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
  232. package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
  233. package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
  234. package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
  235. package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
  236. package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
  237. package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
  238. package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
  239. package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
  240. package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
  241. package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
  242. package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
  243. package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
  244. package/renderer/dist/emulator/data/src/GameManager.js +0 -464
  245. package/renderer/dist/emulator/data/src/compression.js +0 -142
  246. package/renderer/dist/emulator/data/src/emulator.js +0 -6158
  247. package/renderer/dist/emulator/data/src/gamepad.js +0 -167
  248. package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
  249. package/renderer/dist/emulator/data/src/shaders.js +0 -236
  250. package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
  251. package/renderer/dist/emulator/data/src/storage.js +0 -125
  252. package/renderer/dist/emulator/data/version.json +0 -5
  253. package/renderer/dist/emulator/host.js +0 -299
  254. package/renderer/dist/emulator/index.html +0 -28
  255. package/renderer/dist/emulator/provenance.json +0 -6
@@ -0,0 +1,90 @@
1
+ // A PAGE IN THE PANE KEEPS ITS SCROLLBAR OUT OF SIGHT UNTIL YOU SCROLL.
2
+ //
3
+ // The app's own scrollbars are invisible at rest (styles.css, "SCROLLBARS ARE
4
+ // NOT FURNITURE"), but a page drawn in the document pane is its own document
5
+ // and that stylesheet stops at the frame. With a mouse plugged in, macOS draws
6
+ // classic scrollbars, and a page that does not declare itself dark gets the
7
+ // light ones: a white track the full height of the pane with a grey thumb in
8
+ // it. That was the report, with a screenshot, on 2026-10-05.
9
+ //
10
+ // THE FILE IS STILL SERVED EXACTLY AS IT WAS WRITTEN (doc-scheme.mjs). What is
11
+ // added here is the app's own chrome, from outside, once the page's document
12
+ // exists: one adopted stylesheet that touches nothing but scrollbars, inside a
13
+ // cascade layer so a page that styled its own scrollbars keeps them, and two
14
+ // passive listeners that only ever swap that sheet. Nothing a page does is
15
+ // changed or stopped, and no key or gesture of hers is listened to.
16
+ //
17
+ // A frame whose sandbox has no allow-scripts cannot be handed this (measured:
18
+ // "Script not run"). The one such frame is the small preview under a message,
19
+ // and that one is told not to scroll at all (ArtifactThumbnail.tsx).
20
+ import { DOC_SCHEMES, isScheme } from '../shared/schemes.mjs';
21
+
22
+ const RULES = (thumb) => `@layer agentbox-scrollbars {
23
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
24
+ ::-webkit-scrollbar-track { background: transparent; }
25
+ ::-webkit-scrollbar-corner { background: transparent; }
26
+ ::-webkit-scrollbar-thumb { background: ${thumb}; border-radius: 10px; border: 3px solid transparent; background-clip: content-box; }
27
+ ::-webkit-scrollbar-thumb:hover { background: rgba(150, 150, 150, 0.7); background-clip: content-box; }
28
+ }`;
29
+
30
+ /** No track and no thumb: what a page looks like while nobody is scrolling it. */
31
+ export const AT_REST = RULES('transparent');
32
+ /** A thin soft thumb that reads on a dark page and a light one alike. */
33
+ export const SHOWN = RULES('rgba(140, 140, 140, 0.45)');
34
+
35
+ /** Is this a page of ours or a file, rather than a running app or the web. */
36
+ export function wantsQuietScrollbars(url) {
37
+ let parsed;
38
+ try { parsed = new URL(String(url ?? '')); } catch { return false; }
39
+ return parsed.protocol === 'file:' || isScheme(DOC_SCHEMES, parsed.protocol);
40
+ }
41
+
42
+ /**
43
+ * Runs INSIDE the page, so it is written to be serialised: everything it uses
44
+ * comes in through its arguments. Shown while the page or anything in it
45
+ * scrolls and for a moment after, and while the pointer is at the right edge
46
+ * where the thumb would be grabbed.
47
+ */
48
+ export function quietScrollbars(page, atRest, shown) {
49
+ const doc = page.document;
50
+ if (doc.adoptedStyleSheets.some((s) => s.agentboxScrollbars)) return;
51
+ const sheet = new page.CSSStyleSheet();
52
+ sheet.agentboxScrollbars = true;
53
+ sheet.replaceSync(atRest);
54
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
55
+ let showing = false;
56
+ let atEdge = false;
57
+ let timer = null;
58
+ const show = () => { if (!showing) { sheet.replaceSync(shown); showing = true; } };
59
+ const hideSoon = () => {
60
+ page.clearTimeout(timer);
61
+ timer = page.setTimeout(() => { timer = null; if (!atEdge) { sheet.replaceSync(atRest); showing = false; } }, 900);
62
+ };
63
+ page.addEventListener('scroll', () => { show(); if (!atEdge) hideSoon(); }, { capture: true, passive: true });
64
+ page.addEventListener('mousemove', (e) => {
65
+ const edge = e.clientX >= doc.documentElement.clientWidth - 14;
66
+ if (edge === atEdge) return;
67
+ atEdge = edge;
68
+ if (edge) { page.clearTimeout(timer); timer = null; show(); } else hideSoon();
69
+ }, { passive: true });
70
+ page.addEventListener('mouseout', (e) => {
71
+ if (e.relatedTarget || !atEdge) return;
72
+ atEdge = false;
73
+ hideSoon();
74
+ }, { passive: true });
75
+ }
76
+
77
+ const SCRIPT = `(${quietScrollbars.toString()})(window, ${JSON.stringify(AT_REST)}, ${JSON.stringify(SHOWN)})`;
78
+
79
+ function quiet(frame) {
80
+ if (!frame || !wantsQuietScrollbars(frame.url)) return;
81
+ frame.executeJavaScript(SCRIPT).catch(() => { /* a sandbox without scripts, or the frame went away */ });
82
+ }
83
+
84
+ /** Every page frame in this window gets the quiet scrollbar as its document is ready. */
85
+ export function quietTheFramesScrollbars(contents) {
86
+ contents.on('frame-created', (_event, { frame }) => {
87
+ if (!frame || frame === contents.mainFrame) return;
88
+ frame.on('dom-ready', () => quiet(frame));
89
+ });
90
+ }
@@ -57,6 +57,9 @@ export function relaunchCommand({ execPath = process.execPath, argv = process.ar
57
57
  * It cannot break inbox zero: the walk shows only its own three examples. */
58
58
  export function openFreshUser({
59
59
  withAgents = true,
60
+ // False is a new user with no Claude Code or Codex of hers, who signs in
61
+ // for itself: the way to see the walk's plan question (w-9f6975906c).
62
+ withTools = true,
60
63
  execPath = process.execPath,
61
64
  argv = process.argv,
62
65
  packaged = true,
@@ -65,7 +68,7 @@ export function openFreshUser({
65
68
  } = {}) {
66
69
  let prepared;
67
70
  try {
68
- prepared = prepareHome(makeHome(now), { withAgents });
71
+ prepared = prepareHome(makeHome(now), { withAgents: withTools && withAgents, withTools });
69
72
  } catch (err) {
70
73
  return { ok: false, error: `Could not make a throwaway home under ${HOMES}: ${err?.message ?? err}` };
71
74
  }
@@ -80,7 +83,7 @@ export function openFreshUser({
80
83
  child = spawnFn(bin, args, {
81
84
  detached: true,
82
85
  stdio: 'ignore',
83
- env: freshEnv(prepared.home),
86
+ env: freshEnv(prepared.home, process.env, { ownSignIns: !withTools }),
84
87
  });
85
88
  child.unref?.();
86
89
  } catch (err) {
@@ -91,9 +94,9 @@ export function openFreshUser({
91
94
  // be missing without looking broken, and a test that quietly cannot run an
92
95
  // agent is a test that lies. They are said rather than assumed.
93
96
  const notes = [];
94
- if (!prepared.claudeBin) notes.push('Claude Code is not at ~/.local/bin/claude, so agents will not run in it.');
97
+ if (withTools && !prepared.claudeBin) notes.push('Claude Code is not at ~/.local/bin/claude, so agents will not run in it.');
95
98
  if (!prepared.keychain) notes.push('There is no ~/Library/Keychains here, so Claude Code will say it is not logged in.');
96
- if (withAgents && !prepared.agents) notes.push('There is no ~/.claude on this Mac, so it will have no agents to offer.');
99
+ if (withAgents && !prepared.agents) notes.push('There is no ~/.claude on this computer, so it will have no agents to offer.');
97
100
 
98
101
  return { ok: true, home: prepared.home, agents: prepared.agents, notes, pid: child?.pid ?? null };
99
102
  }
@@ -0,0 +1,70 @@
1
+ // THE CHECKOUT IS PHOTOGRAPHED OFF THE THREAD THAT DRAWS HER WINDOW.
2
+ //
3
+ // Measured 2026-10-01 on a test copy: one snapshot of the app took 2.56 seconds
4
+ // while an agent was starting, against a few milliseconds either side. The
5
+ // spawn ran `snapshotRepo` inline, which is four git commands and a read of
6
+ // every untracked file, and on a busy Mac each git command costs half a second.
7
+ // That is the "long delay" after pressing Send. The same shape as
8
+ // main/task-folders-offthread.mjs: main/git-change.mjs stays synchronous and
9
+ // runs whole inside a worker thread, and if the thread cannot start the job
10
+ // runs here, the old way.
11
+ import { Worker } from 'node:worker_threads';
12
+ import * as change from './git-change.mjs';
13
+
14
+ const JOBS = new Set(['snapshotRepo']);
15
+
16
+ let worker = null;
17
+ let broken = false;
18
+ let seq = 0;
19
+ const waiting = new Map();
20
+
21
+ function runHere({ name, args, resolve, reject }) {
22
+ try { resolve(change[name](...args)); } catch (error) { reject(error); }
23
+ }
24
+
25
+ function giveUp() {
26
+ broken = true;
27
+ worker = null;
28
+ const left = [...waiting.values()];
29
+ waiting.clear();
30
+ for (const job of left) runHere(job);
31
+ }
32
+
33
+ function thread() {
34
+ if (worker || broken) return worker;
35
+ try {
36
+ worker = new Worker(new URL('./git-change-thread.mjs', import.meta.url));
37
+ } catch (error) {
38
+ console.warn('zero: the checkout will be read on the main thread:', error.message);
39
+ broken = true;
40
+ return null;
41
+ }
42
+ worker.on('message', ({ id, ok, value, error }) => {
43
+ const job = waiting.get(id);
44
+ if (!job) return;
45
+ waiting.delete(id);
46
+ if (!waiting.size) worker?.unref();
47
+ if (ok) job.resolve(value); else job.reject(new Error(error));
48
+ });
49
+ worker.on('error', (error) => {
50
+ console.warn('zero: the checkout thread failed:', error.message);
51
+ giveUp();
52
+ });
53
+ worker.on('exit', () => { if (worker) giveUp(); });
54
+ worker.unref();
55
+ return worker;
56
+ }
57
+
58
+ /** Run one of git-change.mjs's exports without blocking this thread. */
59
+ export function gitJob(name, ...args) {
60
+ if (!JOBS.has(name)) return Promise.reject(new Error(`not a git job: ${name}`));
61
+ return new Promise((resolve, reject) => {
62
+ const job = { name, args, resolve, reject };
63
+ const w = thread();
64
+ if (!w) { runHere(job); return; }
65
+ const id = ++seq;
66
+ waiting.set(id, job);
67
+ w.ref();
68
+ w.postMessage({ id, name, args });
69
+ });
70
+ }
@@ -0,0 +1,11 @@
1
+ // THE OTHER END OF main/git-change-offthread.mjs. One job at a time, in order.
2
+ import { parentPort } from 'node:worker_threads';
3
+ import * as change from './git-change.mjs';
4
+
5
+ parentPort.on('message', ({ id, name, args }) => {
6
+ try {
7
+ parentPort.postMessage({ id, ok: true, value: change[name](...args) });
8
+ } catch (error) {
9
+ parentPort.postMessage({ id, ok: false, error: String(error?.message ?? error) });
10
+ }
11
+ });
@@ -5,7 +5,7 @@
5
5
  // the Edit or Write tool. Measured on this Mac 2026-08-24: of 105 runs that
6
6
  // changed a file over two days, 4 would have shown the whole change. The other
7
7
  // 101 rewrote their files with sed, a heredoc or a script, and a card drawn
8
- // from the conversation has nothing in it. That is what David was clicking.
8
+ // from the conversation has nothing in it. That is what a tester was clicking.
9
9
  //
10
10
  // SO THE CARD STOPS ASKING HOW THE AGENT TYPED. A run's checkout is
11
11
  // photographed when the session spawns and read again when it exits, and the
@@ -42,7 +42,10 @@ const MAX_ROWS_PER_FILE = 20_000;
42
42
  const NOT_CODE = /(^|\/)(node_modules|\.git)\//;
43
43
 
44
44
  function git(cwd, args, { maxBuffer = 64 * 1024 * 1024 } = {}) {
45
- return execFileSync('git', ['--no-pager', ...args], {
45
+ // A user's `diff.mnemonicPrefix` rewrites a/ and b/ into c/ i/ o/ w/.
46
+ // The reader below only knows one prefix. Turn the setting off for this
47
+ // process so a checkout reads the same on every machine.
48
+ return execFileSync('git', ['--no-pager', '-c', 'diff.mnemonicPrefix=false', ...args], {
46
49
  cwd,
47
50
  encoding: 'utf8',
48
51
  maxBuffer,
@@ -189,7 +192,10 @@ export function filesFromPatch(patch) {
189
192
  const nameOn = (line, side) => {
190
193
  const name = line.slice(4).trim();
191
194
  if (name === '/dev/null') return null;
192
- return name.replace(side === 'old' ? /^a\// : /^b\//, '');
195
+ // a/ and b/ are the ordinary prefixes. c/ i/ o/ w/ are mnemonicPrefix,
196
+ // and 1/ 2/ are `git diff --no-index` under that same setting. One
197
+ // prefix comes off; a file that is itself named `b/foo` stays `b/foo`.
198
+ return name.replace(/^[abiowc12]\//, '');
193
199
  };
194
200
  for (const line of String(patch ?? '').split('\n')) {
195
201
  if (line.startsWith('diff --git ')) { closeHunk(); file = null; oldName = null; continue; }
@@ -36,7 +36,9 @@
36
36
  // product's docs, its attachments, its designs and the `.staging` folder a
37
37
  // paste lands in) or a product's own code repo, which is where a worker
38
38
  // writes `shots/…` and then names it on a card.
39
+ import fs from 'node:fs';
39
40
  import path from 'node:path';
41
+ import { Readable } from 'node:stream';
40
42
  import { IMG_SCHEME, IMG_SCHEMES, isScheme } from '../shared/schemes.mjs';
41
43
 
42
44
  // The name is read rather than typed, and every spelling this app has used is
@@ -51,6 +53,12 @@ export const IMG_HOST = 'file';
51
53
 
52
54
  const PICTURES = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.bmp', '.ico']);
53
55
 
56
+ // AND A SOUND OR A FILM, SINCE 2026-09-29, so a voice sample or a render a
57
+ // worker names plays in the message (ArtifactMedia in Focus.tsx) instead of
58
+ // sitting there as a grey path. Same two rules, same roots: this only widens
59
+ // what counts as something to look at, to include something to listen to.
60
+ const MEDIA = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.mp4', '.mov', '.m4v', '.webm']);
61
+
54
62
  /** The url the renderer draws, for a picture already resolved on disk. */
55
63
  export function imgUrl(file) {
56
64
  if (typeof file !== 'string' || !path.isAbsolute(file)) return null;
@@ -82,9 +90,70 @@ function inside(dir, file) {
82
90
  return f === d || f.startsWith(d + path.sep);
83
91
  }
84
92
 
93
+ const MEDIA_TYPES = {
94
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg',
95
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.m4v': 'video/mp4', '.webm': 'video/webm',
96
+ };
97
+
98
+ /** The content type of a sound or a film, or null when `file` is neither. */
99
+ export function mediaType(file) {
100
+ return MEDIA_TYPES[path.extname(String(file ?? '')).toLowerCase()] ?? null;
101
+ }
102
+
103
+ // A PLAYER ASKS FOR A SOUND IN PIECES, AND net.fetch IGNORES THE ASK.
104
+ // Measured in a hidden Electron window on 2026-09-29 against one of her voice
105
+ // samples, served on this scheme through net.fetch: it loaded and reported its
106
+ // 39.36 seconds, and could not be moved. `seekable` was [0, 0] and a jump to
107
+ // the middle stayed at 0, with or without the Range header passed through,
108
+ // because a file:// fetch answers every range with the whole file and a 200.
109
+ // So a sound or a film is answered here, in the byte range the player asked
110
+ // for, with a 206.
111
+ /**
112
+ * The bytes a Range header asks for, as { start, end } inclusive, or null for
113
+ * the whole file, or false when the range cannot be served. Only one range.
114
+ */
115
+ export function byteRange(header, size) {
116
+ if (!header) return null;
117
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim());
118
+ if (!m || (m[1] === '' && m[2] === '')) return null;
119
+ let start;
120
+ let end;
121
+ if (m[1] === '') {
122
+ // The last N bytes.
123
+ start = Math.max(0, size - Number(m[2]));
124
+ end = size - 1;
125
+ } else {
126
+ start = Number(m[1]);
127
+ end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
128
+ }
129
+ if (start >= size || start > end) return false;
130
+ return { start, end };
131
+ }
132
+
133
+ /**
134
+ * A sound or a film off disk, answered in the range the player asked for.
135
+ * The caller has already decided the file may be served (`servable`).
136
+ */
137
+ export async function mediaResponse(file, rangeHeader) {
138
+ const { size } = await fs.promises.stat(file);
139
+ const range = byteRange(rangeHeader, size);
140
+ const headers = new Headers({ 'content-type': mediaType(file) ?? 'application/octet-stream', 'accept-ranges': 'bytes' });
141
+ if (range === false) {
142
+ headers.set('content-range', `bytes */${size}`);
143
+ return new Response(null, { status: 416, headers });
144
+ }
145
+ if (size === 0) return new Response(null, { status: 200, headers });
146
+ const { start, end } = range ?? { start: 0, end: size - 1 };
147
+ headers.set('content-length', String(end - start + 1));
148
+ if (range) headers.set('content-range', `bytes ${start}-${end}/${size}`);
149
+ const body = Readable.toWeb(fs.createReadStream(file, { start, end }));
150
+ return new Response(body, { status: range ? 206 : 200, headers });
151
+ }
152
+
85
153
  /** Is this a picture the window is allowed to draw? Pure, so it is testable. */
86
154
  export function servable(file, roots) {
87
155
  if (typeof file !== 'string' || !path.isAbsolute(file)) return false;
88
- if (!PICTURES.has(path.extname(file).toLowerCase())) return false;
156
+ const ext = path.extname(file).toLowerCase();
157
+ if (!PICTURES.has(ext) && !MEDIA.has(ext)) return false;
89
158
  return (roots ?? []).some((root) => inside(root, file));
90
159
  }
@@ -1,17 +1,9 @@
1
1
  // A fixed allowlist exposes the instruction files, never arbitrary paths.
2
2
  // Defaults are shipped separately so an edited live file can always be reset.
3
3
  //
4
- // THERE ARE THREE OF THEM NOW, NOT FOUR (w-3dc46f3a67, 2026-09-22). Hers, on
5
- // being shown the five prompts a person could edit: "Those five layers of
6
- // prompts that the user can adjust might be overkill. Maybe we decide not to
7
- // expose some of them, or maybe we would best aggregate others."
8
- //
9
- // A person opening that page is answering two questions, what should agents do
10
- // and how should they talk to me, so the writing rules and the finishing rules
11
- // became ONE document: `message-rules.md`, "How agents write to you". The
12
- // evidence they were one thing all along is her own rules file, where three of
13
- // four paragraphs are about how agents write to her rather than what they do.
14
- // `joinMessageRules` below carries the two old files into the new one.
4
+ // General and writing instructions are one user document (w-fb0fe5e85a).
5
+ // The old message id remains available for callers from an older renderer;
6
+ // once migrated, it reads, saves and restores the general instructions.
15
7
  import fs from 'node:fs';
16
8
  import path from 'node:path';
17
9
  import {fileURLToPath} from 'node:url';
@@ -19,56 +11,67 @@ import {fileURLToPath} from 'node:url';
19
11
  // one place that holds it. Typing it here is what left three dead names in the
20
12
  // code through two renames (tests/the-app-is-named-in-one-place).
21
13
  import {nameSlug} from '../shared/product-name.mjs';
22
- // ADHD MODE IS A FOURTH FILE, NOT A PARAGRAPH INSIDE THE THIRD (w-5737fe67cf,
23
- // 2026-09-25). Hers: "it should really just append that text to your content if
24
- // you have ADHD mode on, and then remove it when you turn it off... Maybe it
25
- // opens a new section with additional instructions that get applied." Kept in
26
- // its own file so her edits to it never mix with her own rules, and survive
27
- // while the mode is off. Whether it is on lives in her config (`adhdMode`).
14
+ // ADHD MODE IS A FOURTH FILE, NOT A PARAGRAPH INSIDE THE THIRD (w-5737fe67cf).
15
+ // Its text is appended to the instructions while the mode is on and removed when
16
+ // it is off. Kept in its own file so edits to it never mix with the user's own
17
+ // rules, and survive while the mode is off. Whether it is on lives in the
18
+ // config (`adhdMode`).
28
19
  const files = {rules:'founder.md',messages:'message-rules.md',adhd:'adhd-mode.md',system:'worker.md'};
29
20
  // The two names the joined document replaced, read only by the migration.
30
21
  const WAS_MESSAGES = ['writing-rules.md','finishing.md'];
22
+ const UNIFIED = '.unified-agent-instructions';
23
+ export function agentInstructionsUnified(appDir) {
24
+ return fs.existsSync(path.join(appDir,'briefs',UNIFIED));
25
+ }
26
+ function sectionFor(appDir,id) {
27
+ fileFor(id);
28
+ return id==='messages' && agentInstructionsUnified(appDir) ? 'rules' : id;
29
+ }
31
30
  const defaults = JSON.parse(fs.readFileSync(fileURLToPath(new URL('../shared/instruction-defaults.json',import.meta.url)),'utf8'));
32
31
  function fileFor(id) {
33
32
  if (!Object.hasOwn(files,id)) throw new Error('Unknown instruction section');
34
33
  return files[id];
35
34
  }
35
+ // WHAT THE BOX OPENS ON when the user has written nothing. The message rules
36
+ // box starts empty like the general one (w-3ec9f07978): the app's own message
37
+ // rules are not the user's to edit, because the inbox reads every message by
38
+ // them, so they ride on every run from the checkout and never sit in the box.
39
+ const boxDefault=(id)=>(id==='rules'||id==='messages') ? '' : defaults[id];
36
40
  export function readInstruction(appDir,id,shippedDir=appDir) {
41
+ id=sectionFor(appDir,id);
37
42
  const file=fileFor(id);
38
- const defaultText=id==='rules' ? '' : defaults[id];
43
+ const defaultText=boxDefault(id);
39
44
  let text=defaultText;
40
45
  try {text=fs.readFileSync(path.join(appDir,'briefs',file),'utf8');} catch(e) {if(e.code!=='ENOENT') throw e; if(id==='system') text=readSystemTemplate(shippedDir,appDir);}
41
46
  return {text,defaultText};
42
47
  }
43
48
  /**
44
49
  * RESTORE POINTS, BECAUSE A SAVE USED TO BE THE END OF THE OLD TEXT
45
- * (w-94b3af4e70, 2026-09-28).
50
+ * (w-94b3af4e70).
46
51
  *
47
- * Hers, on why the page needs this at all: "a user might change the agent
48
- * instructions and lose some critical information to the point where maybe the
49
- * app doesn't work the way they want it or they think, 'It worked better
50
- * before. I want to restore it.'"
52
+ * Why the page needs this at all: a user can edit the agent instructions, lose
53
+ * something that mattered, and want an earlier version back.
51
54
  *
52
- * Until this, the only thing the page could hand back was our shipped text, and
53
- * on her Mac that means deleting about 21,000 characters of her own writing to
54
- * recover 5,654 of ours. The box saves 400ms after a keystroke, straight over
55
+ * Until this, the only thing the page could hand back was our shipped text, so
56
+ * recovering meant throwing away everything the user had written to get the
57
+ * default. The box saves 400ms after a keystroke, straight over
55
58
  * the file, so a paragraph deleted at noon was gone at noon.
56
59
  *
57
60
  * WHAT COUNTS AS A VERSION: the text that was in the file before a save, kept
58
61
  * under the time that text was itself saved, so the list reads as the versions
59
- * she wrote rather than as a log of keystrokes. A burst of typing is one
62
+ * the user wrote rather than as a log of keystrokes. A burst of typing is one
60
63
  * version and not two hundred, because text that has been sitting untouched for
61
64
  * less than QUIET_MS is still being written and is not a place to come back to.
62
65
  *
63
66
  * It never throws into a save. Losing a restore point is a small thing and
64
- * failing to write what she just typed is not.
67
+ * failing to write what the user just typed is not.
65
68
  */
66
69
  const HISTORY='instruction-history';
67
70
  const KEEP=20;
68
71
  const QUIET_MS=10*60*1000;
69
72
  function historyDir(appDir,id) {return path.join(appDir,HISTORY,id);}
70
73
  export function listVersions(appDir,id) {
71
- fileFor(id);
74
+ id=sectionFor(appDir,id);
72
75
  const dir=historyDir(appDir,id);
73
76
  let names;
74
77
  try {names=fs.readdirSync(dir);} catch(e) {if(e.code!=='ENOENT') throw e; return [];}
@@ -79,18 +82,18 @@ export function listVersions(appDir,id) {
79
82
  }).filter(Boolean).sort((a,b)=>b.ts-a.ts);
80
83
  }
81
84
  export function readVersion(appDir,id,ts) {
82
- fileFor(id);
85
+ id=sectionFor(appDir,id);
83
86
  const at=Number(ts);
84
87
  if(!Number.isFinite(at)) throw new Error('Unknown version');
85
88
  return {text:fs.readFileSync(path.join(historyDir(appDir,id),`${at}.md`),'utf8')};
86
89
  }
87
- function keepVersion(appDir,id,now=Date.now()) {
90
+ function keepVersion(appDir,id,now=Date.now(),always=false) {
88
91
  try {
89
92
  const live=path.join(appDir,'briefs',fileFor(id));
90
93
  let stat,text;
91
94
  try {stat=fs.statSync(live); text=fs.readFileSync(live,'utf8');}
92
95
  catch(e) {if(e.code!=='ENOENT') throw e; return;}
93
- if(now-stat.mtimeMs<QUIET_MS) return;
96
+ if(!always&&now-stat.mtimeMs<QUIET_MS) return;
94
97
  const ts=Math.floor(stat.mtimeMs);
95
98
  const have=listVersions(appDir,id);
96
99
  if(have.some(v=>v.ts===ts)) return;
@@ -102,6 +105,7 @@ function keepVersion(appDir,id,now=Date.now()) {
102
105
  } catch(e) {console.warn(`${nameSlug}: could not keep a restore point for ${id}: ${e.message}`);}
103
106
  }
104
107
  export function writeInstruction(appDir,id,text,now=Date.now()) {
108
+ id=sectionFor(appDir,id);
105
109
  const file=fileFor(id);
106
110
  if(typeof text!=='string') throw new Error('Instructions must be text');
107
111
  keepVersion(appDir,id,now);
@@ -123,21 +127,20 @@ export function readSystemTemplate(appDir,dataDir=appDir) {
123
127
  }
124
128
 
125
129
  /**
126
- * HER OWN WRITING, CARRIED OUT OF THE CHECKOUT ONCE (w-3dc46f3a67, 2026-09-21).
130
+ * THE USER'S OWN WRITING, CARRIED OUT OF THE CHECKOUT ONCE (w-3dc46f3a67).
127
131
  *
128
132
  * Until this ran, the four boxes in Settings read and wrote `dataDir/briefs`,
129
133
  * and running from source `dataDir` is the git checkout the whole fleet works
130
- * in. Measured on her repo that day: briefs/founder.md had four commits, all of
131
- * them an agent's, and the working copy matched the newest exactly. Her rules
132
- * were being kept in the one folder every worker is allowed to `git checkout`.
134
+ * in. So the user's rules were being kept in the one folder every worker is
135
+ * allowed to `git checkout`, and agents had been committing to them.
133
136
  *
134
- * WHAT MOVES IS ONLY WHAT SHE WROTE, and the test for that is the shipped
137
+ * WHAT MOVES IS ONLY WHAT THE USER WROTE, and the test for that is the shipped
135
138
  * default. A file whose text IS the default is our file sitting where we put
136
- * it, and copying it into her folder would freeze it there: the next release's
137
- * worker brief would never reach her, because her "edit" would shadow it
138
- * forever. So a file is carried across only when it differs from the default,
139
- * which on her Mac is founder.md and writing-rules.md and neither of the two we
140
- * ship. Those two are the same pair package.json keeps out of the download.
139
+ * it, and copying it into the user's folder would freeze it there: the next
140
+ * release's worker brief would never reach them, because the "edit" would
141
+ * shadow it forever. So a file is carried across only when it differs from the
142
+ * default, which is typically founder.md and writing-rules.md and neither of the
143
+ * two we ship. Those two are the same pair package.json keeps out of the download.
141
144
  *
142
145
  * It is a COPY, not a move, and it never overwrites. The checkout keeps its
143
146
  * copy so that merging this cannot lose a word, and a second run finds the
@@ -151,6 +154,7 @@ export function carryHerBriefsAcross(fromDir,toDir) {
151
154
  if(!fromDir||!toDir||path.resolve(fromDir)===path.resolve(toDir)) return carried;
152
155
  for(const [id,file] of Object.entries(files)) {
153
156
  try {
157
+ if(id==='messages' && agentInstructionsUnified(toDir)) continue;
154
158
  const target=path.join(toDir,'briefs',file);
155
159
  if(fs.existsSync(target)) continue;
156
160
  let text;
@@ -165,41 +169,111 @@ export function carryHerBriefsAcross(fromDir,toDir) {
165
169
  }
166
170
 
167
171
  /**
168
- * THE TWO OLD MESSAGE FILES, JOINED INTO ONE, ONCE (w-3dc46f3a67, 2026-09-22).
172
+ * THE TWO OLD MESSAGE FILES, JOINED INTO ONE, ONCE (w-3dc46f3a67).
169
173
  *
170
- * `writing-rules.md` said how an agent writes to her during a task and
174
+ * `writing-rules.md` said how an agent writes to the user during a task and
171
175
  * `finishing.md` said how it writes the last message. They are one box now, so
172
176
  * they have to become one document, and this is where that happens: on the
173
177
  * first boot after the change, before anything reads the new name.
174
178
  *
175
- * WHAT IT DOES WITH THE HALF SHE NEVER WROTE. The writing rules ship empty, so
176
- * anything in that file is hers. The finishing rules ship with 5,794 characters
177
- * of text, so most people have never touched them. When she has the one and not
178
- * the other, the joined document takes the shipped finishing text as its second
179
- * half rather than leaving it out: her copy shadows the default from here on,
180
- * and dropping it would quietly stop every task ending the way it does today.
179
+ * WHAT IT DOES WITH THE HALF THE USER NEVER WROTE. The writing rules ship
180
+ * empty, so anything in that file is the user's. The finishing rules ship with
181
+ * 5,794 characters of text, so most people have never touched them. When the
182
+ * user has the one and not the other, the joined document takes the shipped
183
+ * finishing text as its second half rather than leaving it out: the user's copy
184
+ * shadows the default from here on, and dropping it would quietly stop every
185
+ * task ending the way it does today.
181
186
  *
182
- * AND WHEN SHE WROTE NEITHER, IT WRITES NOTHING AT ALL. That is the fresh
183
- * install, and leaving her with no file of her own is what keeps a later
184
- * release's default reaching her. Same rule as `carryHerBriefsAcross` above:
185
- * never freeze our own text into her folder.
187
+ * AND WHEN THE USER WROTE NEITHER, IT WRITES NOTHING AT ALL. That is the fresh
188
+ * install, and leaving them with no file of their own is what keeps a later
189
+ * release's default reaching them. Same rule as `carryHerBriefsAcross` above:
190
+ * never freeze our own text into the user's folder.
191
+ *
192
+ * IT READS ONLY THE USER'S FOLDER, AND ADDS NOTHING OF OURS (w-3ec9f07978).
193
+ * It used to fall back to the checkout's copies and to put the shipped ending
194
+ * under whatever it found. In this repository `writing-rules.md` ships 16,000
195
+ * characters long, so every fresh copy run from source joined our two files
196
+ * and saved them as the user's, and the box showed nothing but our machinery.
197
+ * The shipped ending rides on every run anyway now (`messageRules` in the
198
+ * supervisor), so there is nothing of ours left to carry.
186
199
  *
187
200
  * Never throws, never overwrites. Returns what it joined, or null.
188
201
  */
189
202
  export function joinMessageRules(shippedDir,toDir) {
190
203
  try {
204
+ if(agentInstructionsUnified(toDir)) return null;
191
205
  const target=path.join(toDir,'briefs',files.messages);
192
206
  if(fs.existsSync(target)) return null;
193
- const read=(dir,name)=>{try {return fs.readFileSync(path.join(dir,'briefs',name),'utf8');} catch {return null;}};
194
- const either=(name)=>read(toDir,name) ?? read(shippedDir,name);
195
- const [writing,finishing]=WAS_MESSAGES.map((name)=>(either(name) ?? '').trim());
207
+ const read=(name)=>{try {return fs.readFileSync(path.join(toDir,'briefs',name),'utf8');} catch {return null;}};
208
+ const [writing,finishing]=WAS_MESSAGES.map((name)=>(read(name) ?? '').trim());
196
209
  if(!writing && !finishing) return null;
197
- const ending=finishing || defaults.messages.trim();
198
- const joined=[writing,ending].filter(Boolean).join('\n\n');
210
+ const joined=[writing,finishing].filter(Boolean).join('\n\n');
199
211
  writeInstruction(toDir,'messages',`${joined}\n`);
200
- return {writing:writing.length,ending:ending.length,total:joined.length+1};
212
+ return {writing:writing.length,ending:finishing.length,total:joined.length+1};
201
213
  } catch(e) {
202
214
  console.warn(`${nameSlug}: could not join the message rules: ${e.message}`);
203
215
  return null;
204
216
  }
205
217
  }
218
+
219
+ /**
220
+ * A MESSAGE BOX THAT HOLDS ONLY OUR TEXT IS NOT THE USER'S, ONCE (w-3ec9f07978).
221
+ *
222
+ * The old join above wrote the checkout's writing rules and ending rules into
223
+ * the user's folder as if they had typed them, so their box opened on 21,743
224
+ * characters of the app's own rules. A file whose text is EXACTLY one of the
225
+ * things we ship, alone or joined the way the old join joined them, is set
226
+ * aside: kept as a restore point and removed, so the box opens empty and our
227
+ * rules reach the run from the checkout as they should.
228
+ *
229
+ * Exact only. One line of the user's own makes the whole file theirs, and
230
+ * nothing here tries to cut it apart. Never throws. Returns whether it acted.
231
+ */
232
+ export function setAsideShippedMessageRules(shippedDir,toDir) {
233
+ try {
234
+ if(agentInstructionsUnified(toDir)) return false;
235
+ const target=path.join(toDir,'briefs',files.messages);
236
+ if(path.resolve(shippedDir)===path.resolve(toDir)||!fs.existsSync(target)) return false;
237
+ const read=(name)=>{try {return fs.readFileSync(path.join(shippedDir,'briefs',name),'utf8').trim();} catch {return '';}};
238
+ const writing=read(WAS_MESSAGES[0]);
239
+ const endings=[...new Set([read(files.messages),defaults.messages.trim()])].filter(Boolean);
240
+ const ours=new Set([...endings,...endings.map((e)=>[writing,e].filter(Boolean).join('\n\n'))]);
241
+ const text=fs.readFileSync(target,'utf8').trim();
242
+ if(!text||!ours.has(text)) return false;
243
+ keepVersion(toDir,'messages',Date.now(),true);
244
+ fs.rmSync(target,{force:true});
245
+ return true;
246
+ } catch(e) {
247
+ console.warn(`${nameSlug}: could not set aside the shipped message rules: ${e.message}`);
248
+ return false;
249
+ }
250
+ }
251
+
252
+ // Runs after the older migrations, before the supervisor or editors read.
253
+ // Original message files and their histories remain on disk for recovery.
254
+ // The completion marker prevents later boots reimporting them after an edit
255
+ // or an intentional empty save. A failed save leaves both originals active.
256
+ export function unifyAgentInstructions(shippedDir,toDir) {
257
+ try {
258
+ if(path.resolve(shippedDir)===path.resolve(toDir)||agentInstructionsUnified(toDir)) return null;
259
+ const general=readInstruction(toDir,'rules').text;
260
+ const writing=readInstruction(toDir,'messages').text;
261
+ if(writing.trim()) {
262
+ // If the combined save succeeded but completion was interrupted, the exact
263
+ // old text is already at the end. Retrying must not append it again.
264
+ const combined=general.endsWith(writing) ? general : general ? `${general}\n\n${writing}` : writing;
265
+ keepVersion(toDir,'rules',Date.now(),true);
266
+ keepVersion(toDir,'messages',Date.now(),true);
267
+ if(combined!==general) writeInstruction(toDir,'rules',combined);
268
+ }
269
+ const marker=path.join(toDir,'briefs',UNIFIED);
270
+ fs.mkdirSync(path.dirname(marker),{recursive:true});
271
+ const temporary=`${marker}.tmp-${process.pid}`;
272
+ fs.writeFileSync(temporary,'1\n','utf8');
273
+ fs.renameSync(temporary,marker);
274
+ return {general:general.length,writing:writing.length};
275
+ } catch(e) {
276
+ console.warn(`${nameSlug}: could not combine the agent instructions: ${e.message}`);
277
+ return null;
278
+ }
279
+ }