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,461 @@
1
+ // THE COORDINATOR EVERY WORKER'S COMMANDS ASK BEFORE THEY RUN (w-3958c3753d).
2
+ //
3
+ // main/memory-gate.mjs decides; this file is everything around the decision:
4
+ // the local socket the hook (scripts/memory-gate-hook.sh) asks on, reading the
5
+ // Mac's memory pressure, sampling what each running command actually uses, and
6
+ // writing what was learned to disk.
7
+ //
8
+ // ONE COORDINATOR PER MAC, WHICHEVER APP STARTED FIRST. Two Agentbox builds can
9
+ // run side by side (the packaged one and a dev one), and two gates each letting
10
+ // two heavy commands through is four. The socket file itself is the lock: a
11
+ // listening socket belongs to a live process and dies with it. The first app
12
+ // to listen owns it; the next finds it answering and stands by, trying again
13
+ // every `takeoverMs`, and takes it over once nobody answers. Its own workers
14
+ // meanwhile ask the owner, carrying the rank they were spawned with.
15
+ //
16
+ // NOTHING HERE STOPS A RUNNING PROGRAM. Sampling only measures. Pausing
17
+ // programs was the first design; a paused program frees no memory, trips its
18
+ // own time limits and stays frozen forever if the app dies (agreed with Codex,
19
+ // 2026-10-04), so the only lever is when a command is allowed to START.
20
+ //
21
+ // WHAT RESTARTS DO. App quit: the supervisor kills its workers anyway, and
22
+ // `stop()` answers everything still waiting with "go", so nothing is left
23
+ // holding. App crash: an idle `claude -p` exits about 2.6 s after its input
24
+ // closes (measured), a hook mid-wait sees the socket go and runs its command,
25
+ // and a standby app takes over. Mac restart or sleep: nothing is carried across
26
+ // but the learned history; a grant whose agent process is gone, or whose pid
27
+ // now belongs to a different program, is released at the next sample.
28
+
29
+ import fs from 'node:fs';
30
+ import os from 'node:os';
31
+ import net from 'node:net';
32
+ import path from 'node:path';
33
+ import http from 'node:http';
34
+ import { execFile } from 'node:child_process';
35
+ import { MemoryGate, pressureFrom } from './memory-gate.mjs';
36
+ import { CommandHistory } from './memory-gate-history.mjs';
37
+ import { etimeSeconds } from './leftovers.mjs';
38
+
39
+ export const PROTOCOL = 1;
40
+ export const DEFAULT_LOCK_PORT = 47263;
41
+
42
+ /** Where every Agentbox on this Mac meets. Short, because macOS caps a socket
43
+ * path at 104 bytes, and in the per-user temp folder, so it is this user's. */
44
+ export function defaultSocketPath() {
45
+ return path.join(os.tmpdir(), 'agentbox-memory-gate.sock');
46
+ }
47
+
48
+ /** The deny Claude Code reads from a PreToolUse hook. */
49
+ export function denyOutput(reason) {
50
+ return JSON.stringify({
51
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
52
+ });
53
+ }
54
+
55
+ function run(cmd, args) {
56
+ return new Promise((resolve) => {
57
+ execFile(cmd, args, { timeout: 5_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => resolve(err ? null : stdout));
58
+ });
59
+ }
60
+
61
+ /** `kern.memorystatus_vm_pressure_level` and `kern.memorystatus_level`, or nulls. */
62
+ export async function readMacPressure() {
63
+ const out = await run('/usr/sbin/sysctl', ['-n', 'kern.memorystatus_vm_pressure_level', 'kern.memorystatus_level']);
64
+ const [level, freePct] = String(out ?? '').trim().split(/\s+/).map(Number);
65
+ return { level: Number.isFinite(level) ? level : null, freePct: Number.isFinite(freePct) ? freePct : null };
66
+ }
67
+
68
+ export { etimeSeconds };
69
+
70
+ /**
71
+ * Every process: pid, parent, resident MB, when it started (wall clock, to
72
+ * the second) and its command line. One `ps` costs about 33 ms on a loaded
73
+ * Mac (measured 2026-10-04), and it only runs while a command is granted.
74
+ */
75
+ export async function listProcesses() {
76
+ const out = await run('/bin/ps', ['-axo', 'pid=,ppid=,rss=,etime=,args=']);
77
+ if (!out) return [];
78
+ const now = Date.now();
79
+ const list = [];
80
+ for (const line of out.split('\n')) {
81
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
82
+ if (!m) continue;
83
+ const age = etimeSeconds(m[4]);
84
+ list.push({ pid: +m[1], ppid: +m[2], mb: +m[3] / 1024, startedAt: age === null ? undefined : now - age * 1000, args: m[5].slice(0, 400) });
85
+ }
86
+ return list;
87
+ }
88
+
89
+ // The process an agent IS: Claude Code's `claude`, or Codex's app-server.
90
+ // Used only to find whose command a hook was, never to decide anything.
91
+ const AGENT = /(^|\/)(claude|codex)(\s|$)/;
92
+
93
+ export class MemoryGateServer {
94
+ constructor({
95
+ socketPath = defaultSocketPath(),
96
+ historyFile = null,
97
+ history = null,
98
+ gate = {},
99
+ scoreFor = null,
100
+ // WHOSE CODEX THREAD IS THIS (w-e5225b62ba). A Claude worker carries its row
101
+ // in its environment; one `codex app-server` serves every Codex thread of a
102
+ // login, so there is one environment for all of them and the thread has to
103
+ // say who it is. It does, by the `session_id` in its payload, measured to be
104
+ // the app-server's own `thread.id`. Answering null means "not this app's",
105
+ // and such a command is let straight through.
106
+ ownerOf = null,
107
+ readPressure = readMacPressure,
108
+ sampleProcesses = listProcesses,
109
+ pollMs = 2_000,
110
+ takeoverMs = 5_000,
111
+ // The port whose holder is the one coordinator on this Mac. Fixed so that
112
+ // every Agentbox finds the same one; a test passes its own.
113
+ lockPort = DEFAULT_LOCK_PORT,
114
+ idleReleaseMs = 30_000,
115
+ maxGrantMs = 3 * 60 * 60_000,
116
+ clock = Date.now,
117
+ log = () => {},
118
+ } = {}) {
119
+ this.socketPath = socketPath;
120
+ this.historyFile = historyFile;
121
+ this.history = history ?? loadHistory(historyFile);
122
+ this.clock = clock;
123
+ this.idleReleaseMs = idleReleaseMs;
124
+ this.maxGrantMs = maxGrantMs;
125
+ this.gate = new MemoryGate({ ...gate, history: this.history, clock });
126
+ this.scoreFor = scoreFor;
127
+ this.ownerOf = ownerOf;
128
+ this.readPressure = readPressure;
129
+ this.sampleProcesses = sampleProcesses;
130
+ this.pollMs = pollMs;
131
+ this.takeoverMs = takeoverMs;
132
+ this.lockPort = lockPort;
133
+ this.log = log;
134
+ this.role = 'stopped';
135
+ this.grants = new Map();
136
+ this.lastPids = new Set();
137
+ this.reading = { level: null, freePct: null };
138
+ // `foreign` is counted apart from `asked` so the figures on the Agents page
139
+ // stay about this app's own work: a Codex session it did not start is let
140
+ // through and is not one of its agents.
141
+ this.counts = { asked: 0, waited: 0, refused: 0, foreign: 0 };
142
+ this.dirty = false;
143
+ }
144
+
145
+ async start() {
146
+ if (this.role !== 'stopped') return this.role;
147
+ this.stopping = false;
148
+ await this._claim();
149
+ return this.role;
150
+ }
151
+
152
+ async stop() {
153
+ this.stopping = true;
154
+ clearTimeout(this.takeoverTimer);
155
+ clearInterval(this.pollTimer);
156
+ // Everything still waiting goes: the gate is off, so nothing should hold.
157
+ for (const w of this.gate.waiting.values()) for (const r of w.replies) r({ allow: true });
158
+ this.gate.waiting.clear();
159
+ if (this.server) {
160
+ const server = this.server;
161
+ this.server = null;
162
+ await new Promise((resolve) => server.close(() => resolve()));
163
+ server.closeAllConnections?.();
164
+ try { fs.unlinkSync(this.socketPath); } catch {}
165
+ }
166
+ // The lock goes last, so nobody can take over while the socket is still ours.
167
+ if (this.lock) {
168
+ const lock = this.lock;
169
+ this.lock = null;
170
+ await new Promise((resolve) => lock.close(() => resolve()));
171
+ }
172
+ this._saveHistory();
173
+ this.role = 'stopped';
174
+ }
175
+
176
+ setSlots(n) { this.gate.setSlots(n); }
177
+
178
+ status() {
179
+ const snap = this.gate.snapshot();
180
+ return { role: this.role, ...snap, freePct: this.reading.freePct, counts: { ...this.counts } };
181
+ }
182
+
183
+ /* --------------------------- owning the socket --------------------------- */
184
+
185
+ async _claim() {
186
+ if (this.stopping) return;
187
+ if (await this._listen()) {
188
+ this.role = 'owner';
189
+ this.log(`memory gate: coordinating on ${this.socketPath}`);
190
+ this._poll();
191
+ this.pollTimer = setInterval(() => this._poll(), this.pollMs);
192
+ this.pollTimer.unref?.();
193
+ return;
194
+ }
195
+ this.role = 'standby';
196
+ this.takeoverTimer = setTimeout(() => this._claim(), this.takeoverMs);
197
+ this.takeoverTimer.unref?.();
198
+ }
199
+
200
+ // WHO COORDINATES IS DECIDED BY A PORT THE KERNEL HOLDS, NOT BY THE SOCKET
201
+ // FILE (agreed with Codex, 2026-10-05). The socket used to be its own lock:
202
+ // a socket that did not answer within a second was taken for dead, unlinked
203
+ // and replaced, so a stalled owner and a new one could both be running. Now
204
+ // the owner binds 127.0.0.1:`lockPort` first and keeps it for its whole
205
+ // life; the kernel refuses a second bind (EADDRINUSE, measured on macOS) and
206
+ // frees the port only when the owner process dies. Only the holder may
207
+ // unlink and bind the socket, and an app that cannot get the port stands by
208
+ // and never touches anything.
209
+ async _listen() {
210
+ const bind = (server, where) => new Promise((resolve) => {
211
+ const onError = (err) => { server.off('listening', onListen); resolve(err); };
212
+ const onListen = () => { server.off('error', onError); resolve(null); };
213
+ server.once('error', onError);
214
+ server.once('listening', onListen);
215
+ server.listen(where);
216
+ });
217
+ const lock = net.createServer((c) => c.destroy());
218
+ const lockErr = await bind(lock, { host: '127.0.0.1', port: this.lockPort, exclusive: true });
219
+ if (lockErr) {
220
+ if (lockErr.code !== 'EADDRINUSE') this.log(`memory gate: could not take the lock: ${lockErr.message}`);
221
+ return false;
222
+ }
223
+ lock.unref?.();
224
+ const server = http.createServer((req, res) => this._handle(req, res));
225
+ let err = await bind(server, this.socketPath);
226
+ if (err?.code === 'EADDRINUSE') {
227
+ // We hold the lock, so whatever sits at the path belongs to an owner that
228
+ // is gone.
229
+ try { fs.unlinkSync(this.socketPath); } catch {}
230
+ err = await bind(server, this.socketPath);
231
+ }
232
+ if (err) {
233
+ this.log(`memory gate: could not listen: ${err.message}`);
234
+ await new Promise((r) => lock.close(() => r()));
235
+ return false;
236
+ }
237
+ try { fs.chmodSync(this.socketPath, 0o600); } catch {}
238
+ this.server = server;
239
+ this.lock = lock;
240
+ return true;
241
+ }
242
+
243
+ /* ------------------------------- requests ------------------------------- */
244
+
245
+ _handle(req, res) {
246
+ if (req.method === 'GET' && req.url === '/status') {
247
+ res.setHeader('content-type', 'application/json');
248
+ res.end(JSON.stringify(this.status()));
249
+ return;
250
+ }
251
+ let raw = '';
252
+ req.setEncoding('utf8');
253
+ req.on('data', (d) => { if (raw.length < 1_000_000) raw += d; });
254
+ req.on('end', () => {
255
+ let body;
256
+ try { body = JSON.parse(raw); } catch { res.end(''); return; }
257
+ if (req.url === '/pre') this._pre(body, req, res);
258
+ else if (req.url === '/post') { this._post(body); res.end(''); }
259
+ else res.end('');
260
+ });
261
+ }
262
+
263
+ /**
264
+ * WHOSE ROW THIS COMMAND BELONGS TO, or null for "not this app's".
265
+ *
266
+ * THE ENVIRONMENT WINS. A Claude Code worker is a process per work item and
267
+ * carries the row on it (`memoryGateEnv`), and that is the authority: reading a
268
+ * session id first would let a Codex thread id that happened to collide
269
+ * relabel a Claude worker's command.
270
+ *
271
+ * ONLY THEN THE SESSION ID, which is the Codex path and the reason this method
272
+ * exists. One `codex app-server` serves every Codex thread of a login, so its
273
+ * environment names no row and the thread names itself: the `session_id` in
274
+ * the payload was measured to be the app-server's own `thread.id`.
275
+ *
276
+ * A LOOKUP THAT THROWS IS A COMMAND WE LET RUN. The lookup reaches into the
277
+ * supervisor's live sessions, and a store mid-write there must not be able to
278
+ * stop an agent's shell command.
279
+ *
280
+ * AND AN ASK THAT NAMES NEITHER IS STILL GATED, with no row, exactly as it was
281
+ * before any of this existed. "Let it through" is reserved for a thread we
282
+ * POSITIVELY know is somebody else's — a session id we were given and do not
283
+ * recognise. Treating a nameless ask as a stranger's would have been a way for
284
+ * a payload we failed to read to walk past the gate unmeasured.
285
+ */
286
+ _whose(body, hook) {
287
+ const item = typeof body?.item === 'string' ? body.item.trim() : '';
288
+ if (item) return { item, product: body.product || null };
289
+ const session = typeof hook?.session_id === 'string' ? hook.session_id.trim() : '';
290
+ if (!session) return { item: null, product: body?.product || null };
291
+ if (typeof this.ownerOf !== 'function') return null;
292
+ let owner;
293
+ try { owner = this.ownerOf(session); } catch { return null; }
294
+ if (!owner?.item) return null;
295
+ return { item: String(owner.item), product: owner.product ?? null };
296
+ }
297
+
298
+ _pre(body, req, res) {
299
+ const hook = body?.hook ?? {};
300
+ const command = hook?.tool_input?.command;
301
+ if (typeof command !== 'string' || !command.trim()) { res.end(''); return; }
302
+ const whose = this._whose(body, hook);
303
+ // A COMMAND THIS APP DID NOT START IS NOT ITS TO HOLD. Only a Codex ask can
304
+ // land here unowned, and letting it run is the only honest answer: holding a
305
+ // stranger's command behind our queue would stall somebody's terminal with
306
+ // nothing on any screen to say why.
307
+ if (!whose) { this.counts.foreign++; res.end(''); return; }
308
+ const id = String(hook.tool_use_id || `${body.ppid}-${Date.now()}-${Math.random()}`);
309
+ const live = typeof this.scoreFor === 'function' ? this.scoreFor(whose.product, whose.item) : null;
310
+ const score = Number.isFinite(live) ? live : Number(body.score) || 0;
311
+ this.counts.asked++;
312
+ let answered = false;
313
+ this.gate.submit({
314
+ id, command, score, item: whose.item, product: whose.product,
315
+ background: !!hook?.tool_input?.run_in_background,
316
+ }, (decision) => {
317
+ answered = true;
318
+ if (decision.allow) {
319
+ const grant = this.grants.get(id) ?? {
320
+ id, ppid: Number(body.ppid) || null, anchor: null, anchorArgs: null,
321
+ baseline: this.lastPids, wallStart: Date.now(), startedAt: this.clock(), ambiguous: false, emptySince: null,
322
+ };
323
+ this.grants.set(id, grant);
324
+ res.end('');
325
+ } else {
326
+ this.counts.refused++;
327
+ res.end(denyOutput(decision.reason));
328
+ }
329
+ });
330
+ if (!answered) this.counts.waited++;
331
+ // The agent stopped waiting (killed, interrupted, timed out): forget it.
332
+ res.on('close', () => {
333
+ if (!answered) this.gate.finish(id);
334
+ });
335
+ }
336
+
337
+ _post(body) {
338
+ const hook = body?.hook ?? {};
339
+ const id = String(hook.tool_use_id ?? '');
340
+ const grant = this.grants.get(id);
341
+ this.grants.delete(id);
342
+ const was = this.gate.finish(id);
343
+ if (!was) return;
344
+ if (grant?.ambiguous) return;
345
+ this.history.record(was.command, {
346
+ peakMb: was.peakMb,
347
+ durationMs: Number(hook.duration_ms) || null,
348
+ background: was.background,
349
+ });
350
+ this.dirty = true;
351
+ }
352
+
353
+ /* ------------------------------ measuring ------------------------------ */
354
+
355
+ async _poll() {
356
+ if (this.polling) return;
357
+ this.polling = true;
358
+ try {
359
+ this.reading = await this.readPressure();
360
+ this.gate.setPressure(pressureFrom(this.reading));
361
+ // Processes are only looked at while something is granted: nothing
362
+ // else needs them, and each look costs a `ps`.
363
+ if (this.grants.size) this._sample(await this.sampleProcesses());
364
+ this.gate.tick();
365
+ if (this.dirty && (this.saves = (this.saves ?? 0) + 1) % 15 === 0) this._saveHistory();
366
+ } catch (err) {
367
+ this.log(`memory gate: poll failed: ${err.message}`);
368
+ } finally {
369
+ this.polling = false;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * One look at every process. Each grant finds the agent process it belongs to
375
+ * (the nearest `claude` or `codex` above the hook), and is measured as the
376
+ * new processes under that agent since it was granted. Two grants on one
377
+ * agent at once share what they find and teach the history nothing.
378
+ */
379
+ _sample(procs) {
380
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
381
+ const children = new Map();
382
+ for (const p of procs) {
383
+ if (!children.has(p.ppid)) children.set(p.ppid, []);
384
+ children.get(p.ppid).push(p);
385
+ }
386
+ const perAnchor = new Map();
387
+ const now = this.clock();
388
+ for (const grant of this.grants.values()) {
389
+ if (!this.gate.running.has(grant.id)) { this.grants.delete(grant.id); continue; }
390
+ // The last resort, for a grant whose agent could never be found: no
391
+ // report back and nothing to measure for three hours is over.
392
+ if (!grant.anchor && now - grant.startedAt >= this.maxGrantMs) {
393
+ this.grants.delete(grant.id);
394
+ this.gate.finish(grant.id);
395
+ continue;
396
+ }
397
+ if (!grant.anchor) {
398
+ let at = byPid.get(grant.ppid);
399
+ for (let up = 0; at && up < 8 && !AGENT.test(at.args); up++) at = byPid.get(at.ppid);
400
+ const found = at && AGENT.test(at.args) ? at : byPid.get(grant.ppid);
401
+ if (found) { grant.anchor = found.pid; grant.anchorArgs = found.args; }
402
+ }
403
+ const anchor = grant.anchor ? byPid.get(grant.anchor) : null;
404
+ // Its agent is gone, or the pid now belongs to some other program.
405
+ if (grant.anchor && (!anchor || anchor.args !== grant.anchorArgs)) {
406
+ this.grants.delete(grant.id);
407
+ this.gate.finish(grant.id);
408
+ continue;
409
+ }
410
+ if (!anchor) continue;
411
+ if (!perAnchor.has(anchor.pid)) perAnchor.set(anchor.pid, []);
412
+ perAnchor.get(anchor.pid).push(grant);
413
+ }
414
+ for (const [anchorPid, grants] of perAnchor) {
415
+ // A process is the command's when it started after the earliest grant
416
+ // on this agent. `ps` reports start to the second, so a second's margin
417
+ // errs toward counting. Without a start time, anything not seen in the
418
+ // previous look counts instead.
419
+ const since = Math.min(...grants.map((g) => g.wallStart)) - 1_000;
420
+ const baseline = grants.reduce((acc, g) => (acc && g.baseline.size < acc.size ? g.baseline : acc ?? g.baseline), null);
421
+ const ours = (p) => (typeof p.startedAt === 'number' ? p.startedAt >= since : !baseline.has(p.pid));
422
+ let mb = 0;
423
+ const stack = [...(children.get(anchorPid) ?? [])];
424
+ while (stack.length) {
425
+ const p = stack.pop();
426
+ if (ours(p)) mb += p.mb;
427
+ stack.push(...(children.get(p.pid) ?? []));
428
+ }
429
+ for (const g of grants) {
430
+ if (grants.length > 1) g.ambiguous = true;
431
+ this.gate.observe(g.id, Math.round(mb));
432
+ // NOTHING RUNNING UNDER IT FOR A WHILE: its "after" report was lost
433
+ // (the hook was killed, the agent crashed mid-call). Every running
434
+ // command has at least the shell it runs in, so an empty reading that
435
+ // lasts is a command that is over.
436
+ if (mb > 0) g.emptySince = null;
437
+ else if (g.emptySince === null) g.emptySince = now;
438
+ else if (now - g.emptySince >= this.idleReleaseMs) {
439
+ this.grants.delete(g.id);
440
+ this.gate.finish(g.id);
441
+ }
442
+ }
443
+ }
444
+ this.lastPids = new Set(procs.map((p) => p.pid));
445
+ }
446
+
447
+ _saveHistory() {
448
+ if (!this.historyFile || !this.dirty) return;
449
+ try {
450
+ const tmp = `${this.historyFile}.${process.pid}.tmp`;
451
+ fs.writeFileSync(tmp, JSON.stringify(this.history.toJSON()));
452
+ fs.renameSync(tmp, this.historyFile);
453
+ this.dirty = false;
454
+ } catch (err) { this.log(`memory gate: could not save history: ${err.message}`); }
455
+ }
456
+ }
457
+
458
+ function loadHistory(file) {
459
+ if (!file) return new CommandHistory();
460
+ try { return CommandHistory.fromJSON(JSON.parse(fs.readFileSync(file, 'utf8'))); } catch { return new CommandHistory(); }
461
+ }
@@ -0,0 +1,206 @@
1
+ // HEAVY COMMANDS WAIT THEIR TURN WHEN MEMORY IS SHORT (w-3958c3753d).
2
+ //
3
+ // THE BOTTLENECK WAS NEVER HOW MANY AGENTS ARE OPEN. On the founder's 16 GB M4,
4
+ // 2026-10-04, swap reached 10.6 GB of 11.3 and the load average 180 on ten cores
5
+ // with four or five agents running. An agent waiting on the model is 220-340 MB;
6
+ // one running a test suite or a build passes a gigabyte. Agents spend most of
7
+ // their time waiting on the model, so many of them can be open at once as long
8
+ // as only a few run something heavy at the same moment.
9
+ //
10
+ // SO EVERY SHELL COMMAND AN AGENT RUNS ASKS THIS FIRST, through a hook the
11
+ // worker carries (scripts/memory-gate-hook.sh, main/memory-gate-server.mjs).
12
+ // Asking costs about 9 ms (measured with curl over the local socket, 30 runs);
13
+ // agents ran a median of 2 commands per active minute and at most 13, against a
14
+ // median of 6.8 s of model thinking between commands. This file is the answer
15
+ // to the question, and does no I/O so every rule below is a test.
16
+ //
17
+ // THE RULES, agreed with Codex over five rounds before any of this was written:
18
+ // - A command known to be light (main/memory-gate-history.mjs) starts at once,
19
+ // unless memory is critical. A lane of `lightLane` keeps even those bounded.
20
+ // - A heavy or never-seen command needs one of `slots`, and only while memory
21
+ // is normal. A never-seen one holds its slot for `specWindowMs`, and after
22
+ // that only while it is measured at `heavyMb` or more: a dev server that
23
+ // sits at 60 MB should not block everyone's tests for an afternoon.
24
+ // - Waiting commands go in the app's own order (the supervisor's `_score`:
25
+ // the project's place, then urgent / high / medium / low), oldest first on a
26
+ // tie, and a command that has waited `agingMs` moves up one level so
27
+ // nothing low starves behind a stream of high ones.
28
+ // - NOTHING LOWER SLIPS PAST A HIGHER COMMAND WAITING FOR A SLOT. Otherwise a
29
+ // stream of small never-seen commands keeps taking the slot an urgent test
30
+ // is waiting for. Light commands are the exception: they need no slot.
31
+ // - A running command is never stopped, paused or killed. Pausing was the
32
+ // first design and Codex took it apart: a paused program frees no memory,
33
+ // trips its own time limits, and stays frozen forever if the app dies.
34
+ // - No command waits forever. After `maxWaitMs` the agent is told no, with a
35
+ // reason it can act on, and the hook's own time limit sits above that.
36
+
37
+ import { CommandHistory, HEAVY_MB } from './memory-gate-history.mjs';
38
+ import { NAME } from '../shared/product-name.mjs';
39
+
40
+ /**
41
+ * The kernel's own pressure reading (`kern.memorystatus_vm_pressure_level`:
42
+ * 1 normal, 2 warn, 4 critical) and the free share it reports
43
+ * (`kern.memorystatus_level`), as one of three words. Free memory is the early
44
+ * signal: the kernel only says "warn" once it is already compressing hard.
45
+ */
46
+ export function pressureFrom({ level, freePct } = {}, { tightPct = 12, criticalPct = 5 } = {}) {
47
+ const free = typeof freePct === 'number' && Number.isFinite(freePct) ? freePct : null;
48
+ if (level >= 4 || (free !== null && free < criticalPct)) return 'critical';
49
+ if (level >= 2 || (free !== null && free < tightPct)) return 'tight';
50
+ return 'normal';
51
+ }
52
+
53
+ /** One heavy slot per 8 GB, at least one; two on a machine it cannot read. */
54
+ export function autoSlots(memBytes) {
55
+ const gb = Math.round((Number(memBytes) || 0) / 1024 ** 3);
56
+ if (gb <= 0) return 2;
57
+ return Math.max(1, Math.floor(gb / 8));
58
+ }
59
+
60
+ export const DEFAULTS = {
61
+ slots: 2,
62
+ specWindowMs: 10_000,
63
+ heavyMb: HEAVY_MB,
64
+ lightLane: 8,
65
+ maxWaitMs: 20 * 60_000,
66
+ agingMs: 5 * 60_000,
67
+ };
68
+
69
+ export function refusal(waitedMs) {
70
+ const minutes = Math.max(1, Math.round(waitedMs / 60_000));
71
+ return `This computer is short of memory, so ${NAME} held this command for ${minutes} minute${minutes === 1 ? '' : 's'} `
72
+ + 'while heavier work ran, and it still cannot start it. Do not retry it straight away. Carry on with anything '
73
+ + 'that does not need it, or end your turn and say you are waiting for memory.';
74
+ }
75
+
76
+ export class MemoryGate {
77
+ constructor({ history = new CommandHistory(), clock = Date.now, ...opts } = {}) {
78
+ Object.assign(this, DEFAULTS, opts);
79
+ this.history = history;
80
+ this.clock = clock;
81
+ this.pressure = 'normal';
82
+ this.running = new Map();
83
+ this.waiting = new Map();
84
+ }
85
+
86
+ setPressure(p) {
87
+ if (p === this.pressure) return;
88
+ this.pressure = p;
89
+ this._pump();
90
+ }
91
+
92
+ setSlots(n) {
93
+ this.slots = Math.max(1, Math.round(Number(n) || 1));
94
+ this._pump();
95
+ }
96
+
97
+ /**
98
+ * Ask to run `req.command`. `reply` is called exactly once per call with
99
+ * `{ allow: true }` or `{ allow: false, reason }`, now or later.
100
+ */
101
+ submit(req, reply) {
102
+ const id = String(req.id);
103
+ if (this.running.has(id)) { reply({ allow: true, cls: this.running.get(id).cls }); return; }
104
+ const already = this.waiting.get(id);
105
+ if (already) { already.replies.push(reply); return; }
106
+ this.waiting.set(id, {
107
+ id,
108
+ item: req.item ?? null,
109
+ product: req.product ?? null,
110
+ command: String(req.command ?? ''),
111
+ score: Number.isFinite(Number(req.score)) ? Number(req.score) : 0,
112
+ background: !!req.background,
113
+ cls: this.history.classify(req.command),
114
+ arrived: this.clock(),
115
+ replies: [reply],
116
+ });
117
+ this._pump();
118
+ }
119
+
120
+ /** The command ended (or its asker went away). Returns what it was, or null. */
121
+ finish(id) {
122
+ const key = String(id);
123
+ const was = this.running.get(key) ?? null;
124
+ if (was) this.running.delete(key);
125
+ if (this.waiting.delete(key) || was) this._pump();
126
+ return was;
127
+ }
128
+
129
+ /** A sampled footprint of a running command, in MB. Only the peak is kept. */
130
+ observe(id, mb) {
131
+ const r = this.running.get(String(id));
132
+ if (!r || !Number.isFinite(mb)) return;
133
+ r.peakMb = Math.max(r.peakMb ?? 0, mb);
134
+ }
135
+
136
+ tick() { this._pump(); }
137
+
138
+ snapshot() {
139
+ const now = this.clock();
140
+ return {
141
+ pressure: this.pressure,
142
+ slots: this.slots,
143
+ running: [...this.running.values()].map((r) => ({
144
+ id: r.id, item: r.item, product: r.product, command: r.command, cls: r.cls,
145
+ peakMb: r.peakMb, ranMs: now - r.startedAt, holdsSlot: this._holdsSlot(r, now),
146
+ })),
147
+ waiting: this._ordered(now).map((w) => ({
148
+ id: w.id, item: w.item, product: w.product, command: w.command, cls: w.cls,
149
+ score: w.score, waitedMs: now - w.arrived,
150
+ })),
151
+ };
152
+ }
153
+
154
+ _holdsSlot(r, now) {
155
+ if ((r.peakMb ?? 0) >= this.heavyMb) return true;
156
+ if (r.cls === 'heavy') return true;
157
+ if (r.cls === 'unknown') {
158
+ if (now - r.startedAt < this.specWindowMs) return true;
159
+ // Never measured is not evidence that it is small.
160
+ return r.peakMb === null;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ _ordered(now) {
166
+ const eff = (w) => w.score + Math.floor((now - w.arrived) / this.agingMs);
167
+ return [...this.waiting.values()].sort((a, b) => (eff(b) - eff(a)) || (a.arrived - b.arrived));
168
+ }
169
+
170
+ _pump() {
171
+ const now = this.clock();
172
+ for (const w of [...this.waiting.values()]) {
173
+ if (now - w.arrived >= this.maxWaitMs) {
174
+ this.waiting.delete(w.id);
175
+ for (const r of w.replies) r({ allow: false, reason: refusal(now - w.arrived) });
176
+ }
177
+ }
178
+ if (this.pressure === 'critical') return;
179
+ let slotsUsed = 0;
180
+ let lightUsed = 0;
181
+ for (const r of this.running.values()) {
182
+ if (this._holdsSlot(r, now)) slotsUsed++;
183
+ else if (r.cls === 'light') lightUsed++;
184
+ }
185
+ let blocked = false;
186
+ for (const w of this._ordered(now)) {
187
+ if (w.cls === 'light') {
188
+ if (lightUsed < this.lightLane) { this._admit(w, now); lightUsed++; }
189
+ continue;
190
+ }
191
+ if (blocked) continue;
192
+ if (this.pressure === 'tight' || slotsUsed >= this.slots) { blocked = true; continue; }
193
+ this._admit(w, now);
194
+ slotsUsed++;
195
+ }
196
+ }
197
+
198
+ _admit(w, now) {
199
+ this.waiting.delete(w.id);
200
+ this.running.set(w.id, {
201
+ id: w.id, item: w.item, product: w.product, command: w.command, cls: w.cls,
202
+ background: w.background, startedAt: now, peakMb: null,
203
+ });
204
+ for (const r of w.replies) r({ allow: true, cls: w.cls });
205
+ }
206
+ }