agentbox-app 0.1.11 → 0.1.13

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 (239) hide show
  1. package/README.md +24 -16
  2. package/bin/agentbox.mjs +18 -9
  3. package/briefs/message-rules.md +15 -4
  4. package/main/account-tooling.mjs +2 -1
  5. package/main/acp.mjs +296 -0
  6. package/main/agent-homes.mjs +158 -0
  7. package/main/agent-sessions.mjs +57 -20
  8. package/main/agent-updates.mjs +9 -20
  9. package/main/analytics.mjs +30 -0
  10. package/main/approvals.mjs +14 -5
  11. package/main/claude-bin.mjs +45 -310
  12. package/main/claude-input.mjs +9 -3
  13. package/main/claude-launch-env.mjs +54 -0
  14. package/main/claude-models.mjs +39 -7
  15. package/main/claude-plan.mjs +14 -2
  16. package/main/claude-usage.mjs +82 -25
  17. package/main/codex-bin.mjs +51 -248
  18. package/main/codex-launch-env.mjs +31 -0
  19. package/main/codex-session.mjs +38 -3
  20. package/main/codex-threads.mjs +41 -8
  21. package/main/codex-watch.mjs +67 -3
  22. package/main/config.mjs +40 -13
  23. package/main/corner-tag.mjs +489 -0
  24. package/main/crash-report.mjs +26 -3
  25. package/main/demo.mjs +17 -14
  26. package/main/engine-bin.mjs +595 -0
  27. package/main/engine-setup.mjs +27 -27
  28. package/main/feedback.mjs +11 -6
  29. package/main/fresh-user.mjs +1 -1
  30. package/main/git-change.mjs +8 -2
  31. package/main/harnesses/claude-stream.mjs +208 -0
  32. package/main/harnesses/claude.mjs +84 -0
  33. package/main/harnesses/codex.mjs +65 -0
  34. package/main/harnesses/common.mjs +78 -0
  35. package/main/harnesses/instructions-file.mjs +116 -0
  36. package/main/harnesses/opencode.mjs +234 -0
  37. package/main/harnesses.mjs +65 -0
  38. package/main/importable-threads.mjs +38 -0
  39. package/main/instruction-settings.mjs +47 -11
  40. package/main/ipc.mjs +175 -22
  41. package/main/local-app-frames.mjs +82 -0
  42. package/main/machine.mjs +1 -1
  43. package/main/main.mjs +120 -5
  44. package/main/memory-gate-history.mjs +51 -12
  45. package/main/memory-gate-server.mjs +67 -11
  46. package/main/memory-gate.mjs +49 -8
  47. package/main/memory-log.mjs +124 -0
  48. package/main/notify.mjs +48 -5
  49. package/main/opencode-server.mjs +271 -0
  50. package/main/opencode.mjs +287 -0
  51. package/main/phone-link.mjs +551 -0
  52. package/main/refresh-agent-models.mjs +2 -1
  53. package/main/relay-address.mjs +28 -0
  54. package/main/relay-client.mjs +246 -0
  55. package/main/row-label.mjs +8 -7
  56. package/main/runs-on.mjs +90 -0
  57. package/main/serve.mjs +124 -10
  58. package/main/settings.mjs +171 -11
  59. package/main/shell-path.mjs +86 -0
  60. package/main/ship-queue.mjs +25 -7
  61. package/main/sign-in-files.mjs +3 -7
  62. package/main/source-updater.mjs +27 -1
  63. package/main/start-hold.mjs +73 -0
  64. package/main/store/home.mjs +37 -5
  65. package/main/store/project.mjs +15 -19
  66. package/main/store.mjs +10 -4
  67. package/main/supervisor.mjs +1030 -447
  68. package/main/task-commands.mjs +13 -17
  69. package/main/task-folders-offthread.mjs +6 -1
  70. package/main/task-folders.mjs +491 -82
  71. package/main/task-terminals.mjs +49 -8
  72. package/main/team/index.mjs +2 -2
  73. package/main/team/projects.mjs +1 -1
  74. package/main/updater.mjs +71 -9
  75. package/main/worktree-include.mjs +494 -0
  76. package/mcp/core/page.mjs +1 -1
  77. package/mcp/core/work.mjs +34 -3
  78. package/mcp/tools.mjs +2 -1
  79. package/package.json +10 -1
  80. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  81. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  82. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  83. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  84. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  85. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  86. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  87. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  88. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  89. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  90. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  91. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  92. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  93. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  94. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  95. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  96. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  97. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  98. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  99. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  100. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  101. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  102. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  103. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  104. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  105. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  106. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  107. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  108. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  109. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  110. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  111. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  112. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  113. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  114. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  115. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  116. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  117. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  118. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  119. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  120. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  121. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  122. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  123. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  124. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  125. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  126. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  127. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  128. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  129. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  130. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  131. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  132. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  133. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  134. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  135. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  136. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  137. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  138. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  139. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  140. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  141. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  142. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  143. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  144. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  145. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  146. package/renderer/dist/corner-tag.html +13 -0
  147. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  148. package/renderer/dist/emulator/data/compression/README.md +11 -0
  149. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  150. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  151. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  152. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  153. package/renderer/dist/emulator/data/cores/README.md +22 -0
  154. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  155. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  156. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  157. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  158. package/renderer/dist/emulator/data/cores/package.json +65 -0
  159. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  160. package/renderer/dist/emulator/data/emulator.css +1614 -0
  161. package/renderer/dist/emulator/data/loader.js +170 -0
  162. package/renderer/dist/emulator/data/localization/README.md +63 -0
  163. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  164. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  165. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  166. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  167. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  168. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  169. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  170. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  171. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  172. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  173. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  174. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  175. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  176. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  177. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  178. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  179. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  180. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  181. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  182. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  183. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  184. package/renderer/dist/emulator/data/src/compression.js +142 -0
  185. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  186. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  187. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  188. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  189. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  190. package/renderer/dist/emulator/data/src/storage.js +125 -0
  191. package/renderer/dist/emulator/data/version.json +5 -0
  192. package/renderer/dist/emulator/host.js +297 -0
  193. package/renderer/dist/emulator/index.html +28 -0
  194. package/renderer/dist/emulator/provenance.json +6 -0
  195. package/renderer/dist/index.html +16 -3
  196. package/scripts/after-install.mjs +14 -0
  197. package/scripts/approval-server.sh +31 -7
  198. package/scripts/before-publish.mjs +12 -10
  199. package/shared/agent-import.mjs +3 -1
  200. package/shared/agents.d.mts +1 -0
  201. package/shared/bridge-map.mjs +19 -0
  202. package/shared/claude-commands.generated.mjs +8 -6
  203. package/shared/claude-models.generated.mjs +10 -8
  204. package/shared/codex-modes.mjs +1 -1
  205. package/shared/corner-tag.d.mts +14 -0
  206. package/shared/corner-tag.mjs +141 -0
  207. package/shared/demo-world.mjs +1 -1
  208. package/shared/engines.mjs +20 -5
  209. package/shared/fresh-user-home.mjs +12 -1
  210. package/shared/harness-definitions.mjs +103 -0
  211. package/shared/instruction-defaults.json +1 -1
  212. package/shared/notify-rules.d.mts +25 -0
  213. package/shared/notify-rules.mjs +19 -0
  214. package/shared/project-folder-check.mjs +3 -3
  215. package/shared/provider-commands.mjs +10 -2
  216. package/shared/rank.d.mts +2 -0
  217. package/shared/rank.mjs +50 -0
  218. package/shared/relay-address.mjs +6 -0
  219. package/shared/relay-crypto.mjs +165 -0
  220. package/shared/runs-on.d.mts +10 -0
  221. package/shared/runs-on.mjs +55 -0
  222. package/shared/shortcuts.mjs +11 -5
  223. package/shared/spawn-trouble.mjs +58 -7
  224. package/shared/terminal-input.d.mts +1 -0
  225. package/shared/terminal-input.mjs +30 -0
  226. package/shared/terminal-keys.d.mts +1 -0
  227. package/shared/terminal-keys.mjs +16 -0
  228. package/shared/terminal-size.d.mts +4 -0
  229. package/shared/terminal-size.mjs +29 -0
  230. package/shared/usage.d.mts +3 -0
  231. package/shared/usage.mjs +7 -0
  232. package/shared/where-it-runs-from.d.mts +43 -0
  233. package/shared/where-it-runs-from.mjs +155 -0
  234. package/shared/work-items.mjs +28 -2
  235. package/shared/work-lines.mjs +1 -1
  236. package/shared/xterm-invalid-range.d.mts +2 -0
  237. package/shared/xterm-invalid-range.mjs +58 -0
  238. package/renderer/dist/assets/index-B-2Az952.css +0 -32
  239. package/renderer/dist/assets/index-D6qncgai.js +0 -1168
@@ -0,0 +1,124 @@
1
+ // A LINE A MINUTE: HOW SHORT MEMORY IS, AND WHICH TASK IS HOLDING IT (w-ec27ff1a51).
2
+ //
3
+ // 2026-10-11 the Mac ran out of memory with Agentbox at 22 GB. By the time
4
+ // anyone looked it had calmed down, and nothing had been written down, so which
5
+ // task had been holding the memory could only be guessed. This writes one JSON
6
+ // line a minute to `memory-<day>.jsonl` in the app's own folder, kept a week:
7
+ // the kernel's reading, the agents running and the limit, whether new agents
8
+ // were being held (main/start-hold.mjs), the app's own processes, every task's
9
+ // programs with what they hold, and the biggest programs that are not ours.
10
+ //
11
+ // It stays on this computer. Nothing here is sent anywhere.
12
+ //
13
+ // A program belongs to the task of the nearest process at or above it that
14
+ // carries ZERO_ITEM (main/leftovers.mjs reads the tag), which is what catches a
15
+ // dev server that renamed itself and hid its environment.
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { listProcesses } from './leftovers.mjs';
20
+ import { readMacPressure } from './memory-gate-server.mjs';
21
+ import { pressureFrom } from './memory-gate.mjs';
22
+
23
+ /** Days of files kept, today included. */
24
+ export const KEEP_DAYS = 7;
25
+ const FILE = /^memory-(\d{4}-\d{2}-\d{2})\.jsonl$/;
26
+
27
+ const name = (exe) => String(exe ?? '').split('/').filter(Boolean).pop() || String(exe ?? '');
28
+ const top = (list, n) => [...list].sort((a, b) => b.mb - a.mb).slice(0, n).map((p) => ({ name: name(p.exe), mb: Math.round(p.mb) }));
29
+
30
+ /** One minute's line, from one listing. Pure. */
31
+ export function summarizeMemory(procs, { appPid = process.pid, at = Date.now(), reading = {} } = {}) {
32
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
33
+ const owner = new Map();
34
+ const ownerOf = (p) => {
35
+ if (owner.has(p.pid)) return owner.get(p.pid);
36
+ let found = null;
37
+ const seen = new Set();
38
+ for (let q = p; q && !seen.has(q.pid); q = byPid.get(q.ppid)) {
39
+ seen.add(q.pid);
40
+ if (q.item) { found = { item: q.item, product: q.product ?? null }; break; }
41
+ if (q.pid === appPid) { found = 'app'; break; }
42
+ }
43
+ owner.set(p.pid, found);
44
+ return found;
45
+ };
46
+ const tasks = new Map();
47
+ const app = [];
48
+ const others = [];
49
+ for (const p of procs) {
50
+ const o = ownerOf(p);
51
+ if (o === 'app') app.push(p);
52
+ else if (o) {
53
+ const t = tasks.get(o.item) ?? { item: o.item, product: o.product, list: [] };
54
+ t.product ??= o.product;
55
+ t.list.push(p);
56
+ tasks.set(o.item, t);
57
+ } else others.push(p);
58
+ }
59
+ const sum = (list) => Math.round(list.reduce((n, p) => n + (Number(p.mb) || 0), 0));
60
+ const level = Number.isFinite(reading?.level) ? reading.level : null;
61
+ const freePct = Number.isFinite(reading?.freePct) ? reading.freePct : null;
62
+ return {
63
+ at: new Date(at).toISOString(),
64
+ pressure: level === null && freePct === null ? null : pressureFrom({ level, freePct }),
65
+ level,
66
+ freePct,
67
+ totalMb: sum(procs),
68
+ app: { mb: sum(app), procs: app.length },
69
+ tasks: [...tasks.values()]
70
+ .map((t) => ({ item: t.item, product: t.product, mb: sum(t.list), procs: t.list.length, top: top(t.list, 3) }))
71
+ .sort((a, b) => b.mb - a.mb),
72
+ others: top(others, 5),
73
+ };
74
+ }
75
+
76
+ const dayOf = (ms) => {
77
+ const d = new Date(ms);
78
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
79
+ };
80
+
81
+ export class MemoryLog {
82
+ constructor({
83
+ dir, appPid = process.pid, list = listProcesses, readPressure = readMacPressure,
84
+ now = Date.now, facts = () => ({}), keepDays = KEEP_DAYS,
85
+ }) {
86
+ Object.assign(this, { dir, appPid, list, readPressure, now, facts, keepDays });
87
+ this._pruned = null;
88
+ }
89
+
90
+ /** Writes this minute's line. Never throws: a log that breaks the app is worse than a gap in the log. */
91
+ async tick() {
92
+ if (this._busy) return;
93
+ this._busy = true;
94
+ try {
95
+ const [procs, reading] = await Promise.all([
96
+ Promise.resolve().then(() => this.list()).catch(() => []),
97
+ Promise.resolve().then(() => this.readPressure()).catch(() => ({})),
98
+ ]);
99
+ const at = this.now();
100
+ let facts = {};
101
+ try { facts = this.facts() ?? {}; } catch { /* the line goes without them */ }
102
+ const line = { ...summarizeMemory(procs, { appPid: this.appPid, at, reading }), ...facts };
103
+ fs.mkdirSync(this.dir, { recursive: true });
104
+ fs.appendFileSync(path.join(this.dir, `memory-${dayOf(at)}.jsonl`), `${JSON.stringify(line)}\n`);
105
+ this._prune(at);
106
+ } catch (err) {
107
+ console.warn('zero: memory log:', err.message);
108
+ } finally {
109
+ this._busy = false;
110
+ }
111
+ }
112
+
113
+ /** Once a day: removes this log's own files older than `keepDays`, and nothing else. */
114
+ _prune(at) {
115
+ const today = dayOf(at);
116
+ if (this._pruned === today) return;
117
+ this._pruned = today;
118
+ const oldest = dayOf(new Date(at).setDate(new Date(at).getDate() - (this.keepDays - 1)));
119
+ for (const f of fs.readdirSync(this.dir)) {
120
+ const m = f.match(FILE);
121
+ if (m && m[1] < oldest) fs.rmSync(path.join(this.dir, f), { force: true });
122
+ }
123
+ }
124
+ }
package/main/notify.mjs CHANGED
@@ -13,16 +13,46 @@
13
13
  // AND ONCE, NOT TWENTY TIMES. One at a time is not once. `spoke` below is the
14
14
  // fix, and shared/notify-rules.mjs carries the rule.
15
15
 
16
+ import path from 'node:path';
16
17
  import { atTheApp, banner, shouldSpeak } from '../shared/notify-rules.mjs';
17
18
  import { NAME } from '../shared/product-name.mjs';
18
19
 
20
+ // THE MARK THE BANNER WEARS, and why it is a png and not the .icns every other
21
+ // part of this repo uses.
22
+ //
23
+ // macOS draws the app's OWN bundle icon down the left of a banner, and no app
24
+ // can override that. From a source checkout the bundle being asked is the
25
+ // Electron.app in node_modules, which scripts/brand-electron.mjs renames and
26
+ // re-icons, and macOS still drew a generic grey square: "it shows a plain
27
+ // placeholder", looking at a real one, 2026-10-07.
28
+ //
29
+ // An image handed to the notification is the half we DO control; macOS attaches
30
+ // it to the banner. Measured the same day in the Electron the app runs from:
31
+ // nativeImage.createFromPath on build/icon.icns comes back empty, 0x0, so the
32
+ // icns is not a candidate however convenient it would be. The png beside it,
33
+ // drawn from the same art, reads 256x256.
34
+ //
35
+ // AN EMPTY IMAGE IS NOT A MARK. createFromPath answers a missing or unreadable
36
+ // file with an empty image rather than an error, so "it returned something" is
37
+ // the wrong question, and handing that something to macOS is how this goes
38
+ // quietly back to a grey square. Returns null when there is nothing to show,
39
+ // and the banner then looks exactly as it did before any of this.
40
+ export const MARK_FILE = path.join('build', 'notification-icon.png');
41
+
42
+ export function markFor({ root, createFromPath }) {
43
+ try {
44
+ const img = createFromPath(path.join(root, MARK_FILE));
45
+ return img && !img.isEmpty() ? img : null;
46
+ } catch { return null; }
47
+ }
48
+
19
49
  // A BURST IS ONE PIECE OF NEWS. Four workers finishing inside the same
20
50
  // supervisor tick is the ordinary case, and it arrives as four pushes over a
21
51
  // second or two. Waiting a beat before speaking turns that into one banner
22
52
  // with one sound instead of four rewrites and four chimes.
23
53
  const COALESCE_MS = 1500;
24
54
 
25
- export function createNotifier({ window, Notification, powerMonitor, coalesceMs = COALESCE_MS }) {
55
+ export function createNotifier({ window, Notification, powerMonitor, icon = null, coalesceMs = COALESCE_MS }) {
26
56
  // Everything she has not seen since she last had the window, keyed by id so
27
57
  // a repeated push is idempotent. Insertion order is arrival order.
28
58
  const unseen = new Map();
@@ -81,6 +111,9 @@ export function createNotifier({ window, Notification, powerMonitor, coalesceMs
81
111
  title: say.title,
82
112
  body: say.body,
83
113
  silent: false,
114
+ // Omitted rather than passed empty when there is nothing to show, so a
115
+ // banner with no mark is byte for byte the banner that shipped before.
116
+ ...(icon ? { icon } : {}),
84
117
  });
85
118
  } catch { return; }
86
119
  // A banner she can act on. Clicking it brings Agentbox forward and opens the
@@ -104,7 +137,7 @@ export function createNotifier({ window, Notification, powerMonitor, coalesceMs
104
137
  // two-minute diagnosis.
105
138
  n.on('failed', (_e, err) => {
106
139
  live = null;
107
- console.warn(`zero: macOS refused the notification (${err}). Check ${NAME} in System Settings > Notifications.`);
140
+ console.warn(`zero: the system refused the notification (${err}). Check ${NAME} in the system notification settings.`);
108
141
  });
109
142
  live = n;
110
143
  try {
@@ -138,18 +171,28 @@ export function createNotifier({ window, Notification, powerMonitor, coalesceMs
138
171
  };
139
172
  }
140
173
 
141
- export function installNotifier({ app, window, Notification, powerMonitor, ipcMain }) {
174
+ // `onArrivals` hears every arrival too, for the phone's alerts
175
+ // (main/phone-link.mjs), which make their own call about speaking.
176
+ export function installNotifier({ app, window, Notification, powerMonitor, ipcMain, nativeImage, onArrivals = () => {} }) {
142
177
  if (Notification.isSupported && !Notification.isSupported()) {
178
+ ipcMain.handle('zero:notify', (_e, payload = {}) => { try { onArrivals(Array.isArray(payload.arrivals) ? payload.arrivals : []); } catch {} });
143
179
  return { add() {}, seen() {} };
144
180
  }
145
- const notifier = createNotifier({ window, Notification, powerMonitor });
181
+ // app.getAppPath() is the repo from source and the asar inside the bundle
182
+ // when it is packaged, and the mark sits at the same place in both.
183
+ const icon = nativeImage
184
+ ? markFor({ root: app.getAppPath(), createFromPath: (p) => nativeImage.createFromPath(p) })
185
+ : null;
186
+ const notifier = createNotifier({ window, Notification, powerMonitor, icon });
146
187
  window.on('focus', () => notifier.seen());
147
188
  // Unlocking with Agentbox already the front app never fires 'focus' (it never
148
189
  // lost it), so without this the banner that fired while the screen was
149
190
  // locked would sit in Notification Center after she is back at the row.
150
191
  try { powerMonitor.on('unlock-screen', () => { if (window.isFocused()) notifier.seen(); }); } catch {}
151
192
  ipcMain.handle('zero:notify', (_e, payload = {}) => {
152
- notifier.add(Array.isArray(payload.arrivals) ? payload.arrivals : []);
193
+ const arrivals = Array.isArray(payload.arrivals) ? payload.arrivals : [];
194
+ try { onArrivals(arrivals); } catch {}
195
+ notifier.add(arrivals);
153
196
  });
154
197
  return notifier;
155
198
  }
@@ -0,0 +1,271 @@
1
+ // OPENCODE'S OWN TRANSPORT, AND WHY IT IS THE SERVER AND NOT THE CLI.
2
+ //
3
+ // Measured against a real OpenCode 1.18.35 on 2026-10-07. `opencode run` is
4
+ // the obvious road -- it is non-interactive, `--format json` prints one JSON
5
+ // object per line, and that is nearly the shape main/harnesses/claude-stream
6
+ // already reads. It is also the wrong road, and the measurement says so in one
7
+ // line. Run with `permission: { bash: "ask" }` in config, the CLI printed:
8
+ //
9
+ // ! permission requested: bash (echo probe-run-mode); auto-rejecting
10
+ //
11
+ // and handed the model "The user rejected permission to use this specific tool
12
+ // call." With no permission config at all it ran the command with no card.
13
+ // `--auto` approves everything not explicitly denied. So the CLI offers
14
+ // approve-everything or refuse-everything, and an Agentbox card is neither.
15
+ //
16
+ // The HTTP server does carry a real approval. `opencode serve` raises
17
+ // `permission.asked` on its `/event` stream with the command in
18
+ // `metadata.command`, and `POST /session/:id/permissions/:id` with
19
+ // `{ response: "once" }` releases it while `{ response: "reject" }` refuses it.
20
+ // Both measured end to end against the real binary, both answers, 200 each
21
+ // time, the tool running in the first case and coming back
22
+ // "The user rejected permission to use this specific tool call." in the second.
23
+ // That is the same card contract main/codex-approvals.mjs already fills, which
24
+ // is why this file exists and a second stream parser does not.
25
+ //
26
+ // AND IT IS WHY WE DO NOT PASS `--auto` ANYWHERE. A grep for it is a test
27
+ // (tests/opencode-asks-before-it-runs-a-command), because the flag would make
28
+ // every run quietly approve its own file writes and shell commands.
29
+ import { EventEmitter } from 'node:events';
30
+ import { Name } from '../shared/product-name.mjs';
31
+
32
+ /** How long a single HTTP call to the local server may take. */
33
+ const CALL_MS = 60_000;
34
+
35
+ /**
36
+ * A client for one `opencode serve`.
37
+ *
38
+ * `password` is the server's own basic-auth secret, which Agentbox generates
39
+ * per server and which OpenCode reads from `OPENCODE_SERVER_PASSWORD`. It is
40
+ * NOT a provider credential: OpenCode loads those itself, from its own
41
+ * `~/.local/share/opencode/auth.json`. Agentbox never has one to pass, which
42
+ * is the rule in CLAUDE.md and the reason this harness can exist at all.
43
+ */
44
+ export function openCodeClient({ baseUrl, password = null, username = 'opencode', fetchImpl = globalThis.fetch } = {}) {
45
+ if (!baseUrl) throw Error('openCodeClient needs the address of a running OpenCode server');
46
+ const root = String(baseUrl).replace(/\/+$/, '');
47
+ const authorization = password ? `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` : null;
48
+
49
+ const request = async (path, { method = 'GET', body, signal, stream = false } = {}) => {
50
+ const response = await fetchImpl(root + path, {
51
+ method,
52
+ headers: {
53
+ ...(authorization ? { authorization } : {}),
54
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
55
+ ...(stream ? { accept: 'text/event-stream' } : {}),
56
+ },
57
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
58
+ signal: signal ?? AbortSignal.timeout(CALL_MS),
59
+ });
60
+ if (!response.ok) throw Error(`OpenCode answered ${response.status} for ${method} ${path}`);
61
+ if (stream) return response;
62
+ const text = await response.text();
63
+ if (!text) return null;
64
+ try { return JSON.parse(text); } catch { return text; }
65
+ };
66
+
67
+ return {
68
+ url: root,
69
+ request,
70
+ createSession: (body = {}) => request('/session', { method: 'POST', body }),
71
+ prompt: (sessionId, body) => request(`/session/${encodeURIComponent(sessionId)}/prompt_async`, { method: 'POST', body }),
72
+ abort: sessionId => request(`/session/${encodeURIComponent(sessionId)}/abort`, { method: 'POST', body: {} }),
73
+ // `{ response }` and not `{ action }`: the field name is off the real
74
+ // OpenAPI document the running server serves at /doc.
75
+ replyPermission: (sessionId, permissionId, response) =>
76
+ request(`/session/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(permissionId)}`, { method: 'POST', body: { response } }),
77
+ summarize: (sessionId, model) =>
78
+ request(`/session/${encodeURIComponent(sessionId)}/summarize`, { method: 'POST', body: splitModel(model) }),
79
+ runCommand: (sessionId, body) => request(`/session/${encodeURIComponent(sessionId)}/command`, { method: 'POST', body }),
80
+ providers: () => request('/config/providers'),
81
+ commands: () => request('/command'),
82
+ config: () => request('/config'),
83
+ health: () => request('/global/health'),
84
+ events: ({ signal } = {}) => eventStream(request, signal),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * `provider/model` as the two fields OpenCode's API wants.
90
+ *
91
+ * Its model ids are provider-qualified and the slash is part of the id people
92
+ * see (`opencode/big-pickle`, `anthropic/claude-sonnet-4-5`), but every server
93
+ * endpoint takes `providerID` and `modelID` apart. A model with no slash is not
94
+ * guessed at: it is left for the server's own default to answer, because
95
+ * inventing a provider here is how a run dies on a model nobody chose.
96
+ */
97
+ export function splitModel(model) {
98
+ const text = typeof model === 'string' ? model.trim() : '';
99
+ const at = text.indexOf('/');
100
+ if (at <= 0 || at === text.length - 1) return {};
101
+ return { providerID: text.slice(0, at), modelID: text.slice(at + 1) };
102
+ }
103
+
104
+ /** The `/event` stream, as an async iterable of `{ type, properties }`. */
105
+ function eventStream(request, outerSignal) {
106
+ const controller = new AbortController();
107
+ outerSignal?.addEventListener('abort', () => controller.abort(), { once: true });
108
+ const opened = request('/event', { stream: true, signal: controller.signal });
109
+
110
+ return {
111
+ close: () => controller.abort(),
112
+ async *[Symbol.asyncIterator]() {
113
+ const response = await opened;
114
+ const reader = response.body.getReader();
115
+ const decoder = new TextDecoder();
116
+ let buffer = '';
117
+ try {
118
+ while (true) {
119
+ const { value, done } = await reader.read();
120
+ if (done) return;
121
+ buffer += decoder.decode(value, { stream: true });
122
+ let index;
123
+ while ((index = buffer.indexOf('\n')) >= 0) {
124
+ const line = buffer.slice(0, index).trim();
125
+ buffer = buffer.slice(index + 1);
126
+ if (!line.startsWith('data:')) continue;
127
+ const payload = line.slice(5).trim();
128
+ if (!payload) continue;
129
+ try { yield JSON.parse(payload); } catch { /* a half-written frame is not news */ }
130
+ }
131
+ }
132
+ } finally {
133
+ try { await reader.cancel(); } catch { /* already gone */ }
134
+ }
135
+ },
136
+ };
137
+ }
138
+
139
+ /** The permission events, old and new, that mean "a card is wanted". */
140
+ const ASKED = new Set(['permission.asked', 'permission.v2.asked']);
141
+ /** What the reply endpoint calls yes and no. */
142
+ const ALLOW = 'once';
143
+ const DENY = 'reject';
144
+
145
+ /**
146
+ * ONE OPENCODE RUN, SHAPED LIKE A CHILD PROCESS.
147
+ *
148
+ * The supervisor's spawn path wants something with `stderr`, `on('event')` and
149
+ * one `exit`, because that is what a Claude Code child and a Codex worker both
150
+ * look like from there (main/supervisor.mjs, `_spawnWorker`). This is the third
151
+ * one, and it owns nothing the other two own: its events are OpenCode's own
152
+ * names, passed through untranslated, exactly as the Codex adapter passes its
153
+ * notifications through rather than pretending to be Claude's JSON lines.
154
+ *
155
+ * DENY IS THE ANSWER NOBODY HAS TO CHOOSE. A card that is never answered, a
156
+ * handler that throws, a spool that has gone away, a worker killed mid-question
157
+ * -- every one of those refuses the tool call. The opposite default would mean
158
+ * a crashed inbox silently approves shell commands, which is the one failure
159
+ * here that cannot be taken back.
160
+ */
161
+ export function createOpenCodeWorker({
162
+ client,
163
+ plan = {},
164
+ cwd = null,
165
+ resumeSessionId = null,
166
+ onApproval = null,
167
+ title = null,
168
+ } = {}) {
169
+ if (!client) throw Error('createOpenCodeWorker needs a client for a running OpenCode server');
170
+
171
+ const worker = new EventEmitter();
172
+ worker.stderr = new EventEmitter();
173
+ worker.stdout = new EventEmitter();
174
+ worker.sessionId = resumeSessionId ?? null;
175
+
176
+ const say = text => {
177
+ const line = String(text ?? '').trim();
178
+ if (line) worker.stderr.emit('data', line);
179
+ };
180
+
181
+ let ended = false;
182
+ let stream = null;
183
+ const answered = new Set();
184
+
185
+ const stop = async () => {
186
+ if (ended) return;
187
+ ended = true;
188
+ try { stream?.close(); } catch { /* already closed */ }
189
+ if (worker.sessionId) { try { await client.abort(worker.sessionId); } catch { /* the run is over either way */ } }
190
+ };
191
+
192
+ /**
193
+ * One card, and the reply that releases or refuses the tool.
194
+ *
195
+ * The id is remembered BEFORE the card is raised, so a request the stream
196
+ * repeats -- which it does, `permission.asked` can arrive again for a request
197
+ * still open -- costs one card and not two.
198
+ */
199
+ const decide = async (type, properties) => {
200
+ const id = properties?.id;
201
+ const session = properties?.sessionID;
202
+ if (!id || session !== worker.sessionId || answered.has(id)) return;
203
+ answered.add(id);
204
+
205
+ let allowed = false;
206
+ try {
207
+ allowed = onApproval ? await onApproval(type, properties) === true : false;
208
+ } catch (error) {
209
+ say(`${Name} could not ask about ${String(properties?.metadata?.command ?? 'a tool call')}: ${error?.message ?? error}`);
210
+ allowed = false;
211
+ }
212
+ if (ended) allowed = false;
213
+
214
+ try {
215
+ await client.replyPermission(session, id, allowed ? ALLOW : DENY);
216
+ } catch (error) {
217
+ say(`OpenCode did not take the answer to that question: ${error?.message ?? error}`);
218
+ }
219
+ };
220
+
221
+ const run = async () => {
222
+ try {
223
+ if (!worker.sessionId) {
224
+ const session = await client.createSession({ ...(title ? { title } : {}), ...(cwd ? { directory: cwd } : {}) });
225
+ worker.sessionId = session?.id ?? null;
226
+ if (!worker.sessionId) throw Error('OpenCode did not name the session it opened');
227
+ }
228
+
229
+ // The stream is opened BEFORE the prompt. Opening it after is a race the
230
+ // fast answers win: `permission.asked` for the first tool call can beat
231
+ // our own subscription, and nothing would ever answer that card.
232
+ stream = client.events();
233
+ const reading = (async () => {
234
+ for await (const event of stream) {
235
+ if (ended) return;
236
+ const type = String(event?.type ?? '');
237
+ const properties = event?.properties ?? {};
238
+ worker.emit('event', type, properties);
239
+ if (ASKED.has(type)) { await decide(type, properties); continue; }
240
+ if (properties?.sessionID && properties.sessionID !== worker.sessionId) continue;
241
+ if (type === 'session.error') {
242
+ worker.error = properties?.error ?? 'OpenCode reported an error';
243
+ return;
244
+ }
245
+ if (type === 'session.idle') return;
246
+ }
247
+ })();
248
+
249
+ await client.prompt(worker.sessionId, {
250
+ ...splitModel(plan.model),
251
+ ...(plan.agent ? { agent: plan.agent } : {}),
252
+ parts: [{ type: 'text', text: String(plan.prompt ?? '') }],
253
+ });
254
+
255
+ await reading;
256
+ } catch (error) {
257
+ worker.error = error?.message ?? String(error);
258
+ say(worker.error);
259
+ } finally {
260
+ const already = ended;
261
+ ended = true;
262
+ try { stream?.close(); } catch { /* already closed */ }
263
+ if (!already && worker.sessionId) { try { await client.abort(worker.sessionId); } catch { /* nothing left to stop */ } }
264
+ worker.emit('exit', worker.error ? 1 : 0, null);
265
+ }
266
+ };
267
+
268
+ worker.kill = () => { void stop(); };
269
+ worker.done = run();
270
+ return worker;
271
+ }