@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.0

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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +928 -2
  3. package/THIRD_PARTY_NOTICES.md +2467 -0
  4. package/bin/vibe-os.mjs +13 -0
  5. package/dist/web/apple-touch-icon.png +0 -0
  6. package/dist/web/assets/index-B77Qk8B5.js +35 -0
  7. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  8. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  9. package/dist/web/assets/index-CH5Em-Mo.css +1 -0
  10. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  11. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
  12. package/dist/web/icon-192.png +0 -0
  13. package/dist/web/icon-512.png +0 -0
  14. package/dist/web/icon-app.svg +19 -0
  15. package/dist/web/icon-maskable-512.png +0 -0
  16. package/dist/web/icon-maskable.svg +13 -0
  17. package/dist/web/icon.svg +15 -0
  18. package/dist/web/index.html +36 -0
  19. package/dist/web/ssh.wasm +0 -0
  20. package/dist/web/ssh.wasm.br +0 -0
  21. package/dist/web/ssh.wasm.gz +0 -0
  22. package/dist/web/sw.js +29 -0
  23. package/dist/web/wasm_exec.js +575 -0
  24. package/dist/web/wasm_exec.js.br +0 -0
  25. package/dist/web/wasm_exec.js.gz +0 -0
  26. package/package.json +82 -4
  27. package/scripts/compile.ts +62 -0
  28. package/scripts/compress.mjs +54 -0
  29. package/scripts/fetch-wasm.mjs +242 -0
  30. package/scripts/gen-assets.ts +76 -0
  31. package/scripts/lib/walk.ts +18 -0
  32. package/scripts/notices.ts +189 -0
  33. package/server/acme.ts +182 -0
  34. package/server/api.ts +515 -0
  35. package/server/assets.generated.ts +53 -0
  36. package/server/attach.ts +524 -0
  37. package/server/auth.ts +194 -0
  38. package/server/bridge.ts +145 -0
  39. package/server/browser.ts +519 -0
  40. package/server/cli.ts +843 -0
  41. package/server/config.ts +362 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +966 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +654 -0
  46. package/server/http.ts +53 -0
  47. package/server/index.ts +469 -0
  48. package/server/log.ts +40 -0
  49. package/server/main.ts +7 -0
  50. package/server/mcp.ts +291 -0
  51. package/server/preflight.ts +87 -0
  52. package/server/profiles.ts +238 -0
  53. package/server/projects.ts +721 -0
  54. package/server/reaper.ts +303 -0
  55. package/server/runtime.ts +64 -0
  56. package/server/session.ts +330 -0
  57. package/server/ssh-ca.ts +368 -0
  58. package/server/static.ts +147 -0
  59. package/server/wallpapers.ts +248 -0
  60. package/server/windows.ts +237 -0
  61. package/shared/args.ts +91 -0
  62. package/shared/grid.ts +32 -0
  63. package/shared/wire.ts +226 -0
@@ -0,0 +1,519 @@
1
+ // The one browser.
2
+ //
3
+ // A single Google Chrome, on a virtual display, running for as long as the box
4
+ // does. It exists so a browser extension — Claude in Chrome is the one this was
5
+ // built for — has somewhere to live on a machine with no screen, and so you can
6
+ // take that browser over from anywhere rather than from one laptop.
7
+ //
8
+ // Three processes, in a chain, because each needs the one before it:
9
+ //
10
+ // Xtigervnc :99 an X server whose framebuffer is also a VNC server
11
+ // └── openbox a window manager
12
+ // └── chrome the browser itself
13
+ //
14
+ // ── Why an X server with VNC built in ────────────────────────────────────────
15
+ // The usual recipe is Xvfb plus x11vnc. Xtigervnc is both in one process, which
16
+ // removes a daemon and a failure mode, and it resizes the framebuffer on a
17
+ // client's request, which Xvfb cannot do without an xrandr dance.
18
+ //
19
+ // ── Why a window manager ─────────────────────────────────────────────────────
20
+ // Without one, nothing can move, resize or stack a window, and every dialog
21
+ // Chrome opens outside its own frame lands at 0,0 undecorated.
22
+ //
23
+ // ── Why not headless, or CDP screencasting ───────────────────────────────────
24
+ // Chrome's remote debugging protocol can stream the page and take clicks back,
25
+ // with no X server at all. It captures the *page* though, and an extension's
26
+ // side panel, toolbar and popups are browser UI, not page content. The whole
27
+ // point here is that browser UI, so pixels from a real display are the only
28
+ // thing that answers.
29
+ //
30
+ // The debug port is still opened, on loopback. Not for the view — for the
31
+ // watchdog, which is the only cheap way to tell a wedged browser from a
32
+ // healthy one.
33
+
34
+ import os from "node:os";
35
+
36
+ /** Google publishes no arm64 Linux build of Chrome, and Chromium is not it. */
37
+ export const BROWSER_ARCH = "x64";
38
+
39
+ export interface BrowserOptions {
40
+ /** The unix user the browser runs as. Never root: Chrome refuses. */
41
+ user: string;
42
+ home: string;
43
+ /** X display number. 99 by convention, to stay clear of a real session. */
44
+ display: number;
45
+ geometry: string;
46
+ vncPort: number;
47
+ cdpPort: number;
48
+ /**
49
+ * A systemd OnCalendar expression, or null for no nightly restart.
50
+ *
51
+ * Include the timezone: systemd evaluates a bare time in UTC, and "2am" is
52
+ * being asked for in somebody's actual timezone.
53
+ */
54
+ restartAt: string | null;
55
+ /** Where Chrome's profile lives. Losing it means signing in again. */
56
+ profileDir: string;
57
+ /**
58
+ * A TigerVNC password file, or null for no authentication at all.
59
+ *
60
+ * Null is the default and is not the hole it reads as: the display listens on
61
+ * loopback, so SSH is the authentication step, and anyone who can open that
62
+ * tunnel has a shell here anyway.
63
+ *
64
+ * It is set for one reason. macOS Screen Sharing refuses a server offering no
65
+ * authentication and asks for a password that does not exist, so a box driven
66
+ * from a Mac needs VncAuth to be reachable at all. VNC authentication is
67
+ * DES-based and truncates at 8 characters, which makes it a second layer
68
+ * behind SSH and never the thing keeping the box shut.
69
+ */
70
+ vncPasswordFile: string | null;
71
+ }
72
+
73
+ export const DEFAULT_DISPLAY = 99;
74
+ export const DEFAULT_GEOMETRY = "1600x900";
75
+ export const DEFAULT_VNC_PORT = 5900;
76
+ export const DEFAULT_CDP_PORT = 9222;
77
+ /**
78
+ * 2am in the box's own timezone, written out by name. systemd would read a bare
79
+ * time the same way, but naming the zone puts it in the unit and in the install
80
+ * summary, where someone whose night is not the box's can see to change it.
81
+ */
82
+ export const defaultRestartAt = () =>
83
+ `*-*-* 02:00:00 ${Intl.DateTimeFormat().resolvedOptions().timeZone}`;
84
+
85
+ /** VNC authentication truncates silently past this, so generate exactly this. */
86
+ export const VNC_PASSWORD_LENGTH = 8;
87
+
88
+ export const vncPasswordPath = (home: string) => `${home}/.vibe-os/vncpasswd`;
89
+
90
+ export function defaultBrowserOptions(
91
+ user: string,
92
+ home: string,
93
+ ): BrowserOptions {
94
+ return {
95
+ user,
96
+ home,
97
+ display: DEFAULT_DISPLAY,
98
+ geometry: DEFAULT_GEOMETRY,
99
+ vncPort: DEFAULT_VNC_PORT,
100
+ cdpPort: DEFAULT_CDP_PORT,
101
+ restartAt: defaultRestartAt(),
102
+ profileDir: `${home}/.vibe-os/chrome`,
103
+ vncPasswordFile: null,
104
+ };
105
+ }
106
+
107
+ export interface Size {
108
+ width: number;
109
+ height: number;
110
+ }
111
+
112
+ /**
113
+ * Splits "1600x900".
114
+ *
115
+ * Chrome wants the two numbers separately for --window-size, and a geometry
116
+ * that reaches the unit file malformed is a service that fails to start on
117
+ * every boot rather than an error anyone sees.
118
+ */
119
+ export function parseGeometry(geometry: string): Size | null {
120
+ const match = /^(\d{2,5})x(\d{2,5})$/.exec(geometry.trim());
121
+ if (!match) return null;
122
+ const width = Number(match[1]);
123
+ const height = Number(match[2]);
124
+ if (width < 320 || height < 240) return null;
125
+ return { width, height };
126
+ }
127
+
128
+ export type VncExposure = "loopback" | "exposed" | "absent";
129
+
130
+ /**
131
+ * Where the VNC port is listening, from `ss -ltnH`.
132
+ *
133
+ * This matters more than it looks. The display is served with no VNC password,
134
+ * on the argument that only loopback can reach it and anything on loopback
135
+ * already has a shell. Bound to a real interface, that same argument becomes an
136
+ * unauthenticated desktop, signed in to Claude, for anyone who can route to the
137
+ * box. So the check is not "is VNC running" but "is it only on loopback".
138
+ */
139
+ export function vncExposure(ssOutput: string, port: number): VncExposure {
140
+ let found = false;
141
+ for (const line of ssOutput.split("\n")) {
142
+ const fields = line.trim().split(/\s+/);
143
+ // ss -ltnH columns: State Recv-Q Send-Q Local:Port Peer:Port
144
+ const local = fields[3];
145
+ if (!local) continue;
146
+ const colon = local.lastIndexOf(":");
147
+ if (colon === -1) continue;
148
+ if (Number(local.slice(colon + 1)) !== port) continue;
149
+ found = true;
150
+ // Strip the brackets of an IPv6 literal and the %iface of a scoped address.
151
+ const addr = local
152
+ .slice(0, colon)
153
+ .replace(/^\[/, "")
154
+ .replace(/\]$/, "")
155
+ .replace(/%.*$/, "");
156
+ const loopback = addr === "::1" || /^127\./.test(addr);
157
+ if (!loopback) return "exposed";
158
+ }
159
+ return found ? "loopback" : "absent";
160
+ }
161
+
162
+ export interface Unit {
163
+ name: string;
164
+ contents: string;
165
+ }
166
+
167
+ /**
168
+ * Where the units are written, named once.
169
+ *
170
+ * `install-browser` writes them and three readers go looking: doctor, the Hermes
171
+ * discovery in `harness.ts`, and `connect-hermes`. They had a path literal each
172
+ * until the third one wanted the same file, which is one copy past the point
173
+ * where they can be trusted to stay in step.
174
+ */
175
+ export const UNIT_DIR = "/etc/systemd/system";
176
+ export const CHROME_UNIT_PATH = `${UNIT_DIR}/vibe-os-chrome.service`;
177
+ export const XVNC_UNIT_PATH = `${UNIT_DIR}/vibe-os-xvnc.service`;
178
+
179
+ /**
180
+ * The units, in the order they must be written.
181
+ *
182
+ * Returned rather than written so the shape can be tested without a machine to
183
+ * install onto, and so `install-browser` has one place to look for what it is
184
+ * about to put in /etc.
185
+ */
186
+ export function browserUnits(opts: BrowserOptions): Unit[] {
187
+ const size = parseGeometry(opts.geometry);
188
+ if (!size) throw new Error(`unusable geometry: ${opts.geometry}`);
189
+
190
+ const d = opts.display;
191
+ const security = opts.vncPasswordFile
192
+ ? `-SecurityTypes VncAuth -PasswordFile ${opts.vncPasswordFile}`
193
+ : "-SecurityTypes None";
194
+ const units: Unit[] = [
195
+ {
196
+ name: "vibe-os-xvnc.service",
197
+ contents: `[Unit]
198
+ Description=vibe-os virtual display (TigerVNC on :${d})
199
+ After=network.target
200
+
201
+ [Service]
202
+ Type=simple
203
+ User=${opts.user}
204
+ # A killed X server leaves these behind, and the next start refuses the display
205
+ # number rather than reusing it. Leading '-' so a first run does not fail here.
206
+ ExecStartPre=-/bin/rm -f /tmp/.X${d}-lock /tmp/.X11-unix/X${d}
207
+ ExecStart=/usr/bin/Xtigervnc :${d} -geometry ${opts.geometry} -depth 24 \\
208
+ -localhost ${security} -rfbport ${opts.vncPort} -AlwaysShared
209
+ Restart=always
210
+ RestartSec=2
211
+
212
+ [Install]
213
+ WantedBy=multi-user.target
214
+ `,
215
+ },
216
+ {
217
+ name: "vibe-os-wm.service",
218
+ contents: `[Unit]
219
+ Description=vibe-os window manager (openbox)
220
+ Requires=vibe-os-xvnc.service
221
+ After=vibe-os-xvnc.service
222
+
223
+ [Service]
224
+ Type=simple
225
+ User=${opts.user}
226
+ Environment=DISPLAY=:${d}
227
+ # After= orders the start and nothing more: it does not wait for the X server to
228
+ # begin accepting clients, and openbox exits immediately if it cannot connect.
229
+ ExecStartPre=/bin/sh -c 'for i in $(seq 1 60); do /usr/bin/xdpyinfo >/dev/null 2>&1 && exit 0; sleep 0.5; done; exit 1'
230
+ ExecStart=/usr/bin/openbox
231
+ Restart=always
232
+ RestartSec=2
233
+
234
+ [Install]
235
+ WantedBy=multi-user.target
236
+ `,
237
+ },
238
+ {
239
+ name: "vibe-os-chrome.service",
240
+ contents: `[Unit]
241
+ Description=vibe-os browser (Google Chrome)
242
+ Requires=vibe-os-wm.service
243
+ After=vibe-os-wm.service
244
+
245
+ [Service]
246
+ Type=simple
247
+ User=${opts.user}
248
+ Environment=DISPLAY=:${d}
249
+ Environment=HOME=${opts.home}
250
+ # systemd owns the singleton, so a lock left behind by a killed Chrome is always
251
+ # stale, and leaving it there costs a start.
252
+ ExecStartPre=-/bin/rm -f ${opts.profileDir}/SingletonLock ${opts.profileDir}/SingletonSocket ${opts.profileDir}/SingletonCookie
253
+ ExecStart=/usr/bin/google-chrome \\
254
+ --user-data-dir=${opts.profileDir} \\
255
+ --no-first-run --no-default-browser-check \\
256
+ --hide-crash-restore-bubble --disable-session-crashed-bubble \\
257
+ --password-store=basic \\
258
+ --remote-debugging-port=${opts.cdpPort} \\
259
+ --window-position=0,0 --window-size=${size.width},${size.height} \\
260
+ about:blank
261
+ Restart=always
262
+ RestartSec=5
263
+ # Chrome is the main process, deliberately. Wrapping it in dbus-run-session puts
264
+ # something else there instead, and that wrapper does not pass SIGTERM on, so
265
+ # every stop became a 20-second timeout and a SIGKILL.
266
+ #
267
+ # Chrome still records exit_type "Crashed" on a SIGTERM stop — it writes
268
+ # "Normal" only when quit from its own menu — which is why both crash-restore
269
+ # flags above are set rather than trusted to be unnecessary.
270
+ KillMode=mixed
271
+ TimeoutStopSec=20
272
+
273
+ [Install]
274
+ WantedBy=multi-user.target
275
+ `,
276
+ },
277
+ {
278
+ // Restart=always covers a browser that died. It cannot see one that is
279
+ // running and wedged, which is the failure that actually strands you,
280
+ // because the window keeps rendering whatever it last painted.
281
+ name: "vibe-os-chrome-watchdog.service",
282
+ contents: `[Unit]
283
+ Description=vibe-os browser liveness probe
284
+
285
+ [Service]
286
+ Type=oneshot
287
+ ExecStart=/bin/sh -c 'curl -sf --max-time 5 http://127.0.0.1:${opts.cdpPort}/json/version >/dev/null || { echo "chrome not answering CDP, restarting"; systemctl restart vibe-os-chrome.service; }'
288
+ `,
289
+ },
290
+ {
291
+ name: "vibe-os-chrome-watchdog.timer",
292
+ contents: `[Unit]
293
+ Description=probe the vibe-os browser every minute
294
+
295
+ [Timer]
296
+ OnBootSec=2min
297
+ OnUnitActiveSec=1min
298
+
299
+ [Install]
300
+ WantedBy=timers.target
301
+ `,
302
+ },
303
+ ];
304
+
305
+ if (opts.restartAt) {
306
+ units.push(
307
+ {
308
+ // A restart, not a tab sweep: it returns the memory a day of browsing
309
+ // took as well as the tabs, and the profile carries the sign-in across.
310
+ name: "vibe-os-chrome-restart.service",
311
+ contents: `[Unit]
312
+ Description=vibe-os nightly browser restart
313
+
314
+ [Service]
315
+ Type=oneshot
316
+ ExecStart=/usr/bin/systemctl restart vibe-os-chrome.service
317
+ `,
318
+ },
319
+ {
320
+ name: "vibe-os-chrome-restart.timer",
321
+ contents: `[Unit]
322
+ Description=bounce the vibe-os browser nightly
323
+
324
+ [Timer]
325
+ OnCalendar=${opts.restartAt}
326
+ # The point is a clean start each night, not catching up on a missed one.
327
+ Persistent=false
328
+
329
+ [Install]
330
+ WantedBy=timers.target
331
+ `,
332
+ },
333
+ );
334
+ }
335
+
336
+ return units;
337
+ }
338
+
339
+ /** The units that should be enabled, in dependency order. */
340
+ export function browserServices(opts: BrowserOptions): string[] {
341
+ return [
342
+ "vibe-os-xvnc.service",
343
+ "vibe-os-wm.service",
344
+ "vibe-os-chrome.service",
345
+ "vibe-os-chrome-watchdog.timer",
346
+ ...(opts.restartAt ? ["vibe-os-chrome-restart.timer"] : []),
347
+ ];
348
+ }
349
+
350
+ /**
351
+ * The commands this box is missing.
352
+ *
353
+ * Named separately from the doctor checks because install-browser needs the
354
+ * same answer before it writes a unit that could never start.
355
+ */
356
+ export const BROWSER_COMMANDS = [
357
+ {
358
+ command: "Xtigervnc",
359
+ package: "tigervnc-standalone-server",
360
+ why: "the virtual display",
361
+ },
362
+ { command: "openbox", package: "openbox", why: "the window manager" },
363
+ {
364
+ command: "xdpyinfo",
365
+ package: "x11-utils",
366
+ why: "waiting for the display to accept clients",
367
+ },
368
+ {
369
+ command: "google-chrome",
370
+ package: "google-chrome-stable",
371
+ why: "the browser itself",
372
+ },
373
+ ] as const;
374
+
375
+ /**
376
+ * Whether this machine could run the browser at all.
377
+ *
378
+ * Chrome for Linux is x86_64 only. Saying so here beats an install that writes
379
+ * five units and then fails to start one of them for a reason nothing explains.
380
+ */
381
+ export function archSupported(arch: string = os.arch()): boolean {
382
+ return arch === BROWSER_ARCH;
383
+ }
384
+
385
+ /**
386
+ * The ports an installed unit actually uses.
387
+ *
388
+ * doctor is given flags, not a unit file, and it has no way to know what
389
+ * install-browser was told months ago. Reading them back out of what is on
390
+ * disk is the difference between checking the real ports and checking the
391
+ * defaults while claiming to have checked the real ones.
392
+ */
393
+ export function portsFromUnits(units: {
394
+ xvnc?: string | null;
395
+ chrome?: string | null;
396
+ }): { vncPort: number; cdpPort: number } {
397
+ const rfb = units.xvnc ? /-rfbport\s+(\d{1,5})\b/.exec(units.xvnc) : null;
398
+ const cdp = units.chrome
399
+ ? /--remote-debugging-port=(\d{1,5})\b/.exec(units.chrome)
400
+ : null;
401
+ return {
402
+ vncPort: rfb ? Number(rfb[1]) : DEFAULT_VNC_PORT,
403
+ cdpPort: cdp ? Number(cdp[1]) : DEFAULT_CDP_PORT,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * The password file an installed unit is already using, if any.
409
+ *
410
+ * Read before writing, so a reinstall cannot quietly turn authentication off on
411
+ * a box that had it. Losing a setting you asked for is bad; losing it silently,
412
+ * on the one command whose whole job is to rewrite these units, is worse.
413
+ */
414
+ export function vncPasswordFileFromUnit(
415
+ unit: string | null | undefined,
416
+ ): string | null {
417
+ if (!unit) return null;
418
+ const match = /-PasswordFile\s+(\S+)/.exec(unit);
419
+ return match ? match[1] : null;
420
+ }
421
+
422
+ /**
423
+ * What Hermes has to be told so its browser tools drive this Chrome.
424
+ *
425
+ * Hermes reads its CDP target from `browser.cdp_url` in `~/.hermes/config.yaml`
426
+ * and offers no flag for it, so this is set once for the box rather than per
427
+ * profile. `browser-use` is named explicitly even though it is already the
428
+ * default, because the default is conditional on the CLI being runnable and a
429
+ * config that says what it means survives someone else reading it.
430
+ *
431
+ * `cloud_provider: local` is what stops a Browserbase or Firecrawl key, set
432
+ * later for something else, from quietly taking the browser back.
433
+ *
434
+ * Returned as pairs rather than written here: they are handed to
435
+ * `hermes config set`, which owns that file and knows where a secret goes.
436
+ */
437
+ export function hermesBrowserSettings(
438
+ cdpPort: number,
439
+ ): { key: string; value: string }[] {
440
+ return [
441
+ { key: "browser.cdp_url", value: `http://127.0.0.1:${cdpPort}` },
442
+ { key: "browser.backend", value: "browser-use" },
443
+ { key: "browser.cloud_provider", value: "local" },
444
+ ];
445
+ }
446
+
447
+ /**
448
+ * Whether a configured `cdp_url` names this box's Chrome.
449
+ *
450
+ * Written by hand as often as by `connect-hermes`, so the comparison is on what
451
+ * the URL means rather than on its spelling: `localhost` and `127.0.0.1` are the
452
+ * same host, and `ws://` and `http://` are the same endpoint to a CDP client.
453
+ * A bare `127.0.0.1:9222` is accepted too, because it is what people type.
454
+ *
455
+ * The port has to be explicit. A URL with no port is not a CDP endpoint that
456
+ * would ever have worked, and inferring 80 from `http://` would report a
457
+ * misconfigured box as connected.
458
+ */
459
+ export function cdpUrlMatches(
460
+ configured: string | null | undefined,
461
+ cdpPort: number,
462
+ ): boolean {
463
+ const raw = configured?.trim();
464
+ if (!raw) return false;
465
+ // A scheme is optional in what people write but not in what URL will parse.
466
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
467
+ ? raw
468
+ : `http://${raw}`;
469
+ let url: URL;
470
+ try {
471
+ url = new URL(withScheme);
472
+ } catch {
473
+ return false;
474
+ }
475
+ if (!/^(https?|wss?):$/.test(url.protocol)) return false;
476
+ if (url.port !== String(cdpPort)) return false;
477
+ // Chrome binds the debug port to loopback, so anything else is a different
478
+ // browser on a different machine and saying "connected" would be a guess.
479
+ const host = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
480
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
481
+ }
482
+
483
+ export interface MemInfo {
484
+ totalKb: number;
485
+ swapKb: number;
486
+ }
487
+
488
+ /** MemTotal and SwapTotal out of /proc/meminfo. */
489
+ export function parseMemInfo(text: string): MemInfo | null {
490
+ const total = /^MemTotal:\s+(\d+)\s+kB/m.exec(text);
491
+ if (!total) return null;
492
+ const swap = /^SwapTotal:\s+(\d+)\s+kB/m.exec(text);
493
+ return {
494
+ totalKb: Number(total[1]),
495
+ swapKb: swap ? Number(swap[1]) : 0,
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Whether Chrome has room to run here.
501
+ *
502
+ * Chrome idles around half a gigabyte and grows with every tab. On a small box
503
+ * with no swap the first thing the OOM killer reaps is whatever it finds, which
504
+ * presents as terminal sessions dying for no visible reason rather than as a
505
+ * browser problem. Two gigabytes of headroom, counting swap, is the line.
506
+ */
507
+ export function memoryHeadroom(mem: MemInfo): {
508
+ ok: boolean;
509
+ detail: string;
510
+ } {
511
+ const gib = (kb: number) => (kb / 1024 / 1024).toFixed(1);
512
+ const totalGib = `${gib(mem.totalKb)}GiB RAM`;
513
+ const swapNote = mem.swapKb === 0 ? "no swap" : `${gib(mem.swapKb)}GiB swap`;
514
+ const headroomKb = mem.totalKb + mem.swapKb;
515
+ return {
516
+ ok: headroomKb >= 4 * 1024 * 1024 || mem.swapKb > 0,
517
+ detail: `${totalGib}, ${swapNote}`,
518
+ };
519
+ }