@tsconfigdotjson/vibe-os 0.2.0 → 0.4.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 (45) hide show
  1. package/README.md +19 -847
  2. package/THIRD_PARTY_NOTICES.md +28 -0
  3. package/dist/web/assets/index-82MhMOB4.css +1 -0
  4. package/dist/web/assets/index-82MhMOB4.css.br +0 -0
  5. package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
  6. package/dist/web/assets/index-DCB2nO0u.js +36 -0
  7. package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
  8. package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
  9. package/dist/web/index.html +2 -2
  10. package/package.json +2 -1
  11. package/scripts/notices.ts +3 -1
  12. package/server/api.ts +505 -20
  13. package/server/assets.generated.ts +13 -13
  14. package/server/attach.ts +65 -16
  15. package/server/browser.ts +1 -65
  16. package/server/cli.ts +48 -150
  17. package/server/client.ts +440 -0
  18. package/server/config.ts +64 -2
  19. package/server/db.ts +19 -0
  20. package/server/doctor.ts +176 -123
  21. package/server/harness.ts +378 -590
  22. package/server/harnesses/claude.ts +248 -0
  23. package/server/harnesses/codex.ts +241 -0
  24. package/server/harnesses/cursor.ts +202 -0
  25. package/server/harnesses/hermes.ts +259 -0
  26. package/server/harnesses/util.ts +181 -0
  27. package/server/headless.ts +417 -0
  28. package/server/index.ts +47 -14
  29. package/server/memory.ts +418 -0
  30. package/server/profiles.ts +27 -15
  31. package/server/projects.ts +52 -3
  32. package/server/reaper.ts +5 -3
  33. package/server/screens.ts +392 -0
  34. package/server/session.ts +84 -95
  35. package/server/setup.ts +73 -7
  36. package/server/windows.ts +51 -1
  37. package/shared/blanks.ts +113 -0
  38. package/shared/harness.ts +375 -0
  39. package/shared/wire.ts +107 -94
  40. package/dist/web/assets/index-B77Qk8B5.js +0 -35
  41. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  42. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  43. package/dist/web/assets/index-CH5Em-Mo.css +0 -1
  44. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  45. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
package/server/doctor.ts CHANGED
@@ -25,14 +25,14 @@ import {
25
25
  vncPasswordFileFromUnit,
26
26
  } from "./browser.ts";
27
27
  import type { Config } from "./config.ts";
28
+ import { discover, harnesses, harnessProblems } from "./harness.ts";
28
29
  import {
29
- CLAUDE_INSTALL,
30
- CURSOR_INSTALL,
31
- discoverCursor,
32
- discoverHermes,
33
- HERMES_INSTALL,
34
- UV_INSTALL,
35
- } from "./harness.ts";
30
+ type MemoryLimits,
31
+ type OomState,
32
+ oomState,
33
+ type ScopeState,
34
+ scopeState,
35
+ } from "./memory.ts";
36
36
  import { findLeaks } from "./reaper.ts";
37
37
  import { IS_COMPILED } from "./runtime.ts";
38
38
  import { discoverHostKey, SshCa } from "./ssh-ca.ts";
@@ -109,7 +109,7 @@ async function firewallState(): Promise<FirewallState> {
109
109
  directUdpAllowed: false,
110
110
  };
111
111
 
112
- // ufw first: it is what the README documents, and its status output says
112
+ // ufw first: it is what docs/deploying.md documents, and its status output says
113
113
  // plainly whether it is switched on, which a raw ruleset does not.
114
114
  const ufw = await run("ufw", ["status", "verbose"], { timeout: 5000 })
115
115
  .then((r) => r.stdout)
@@ -349,114 +349,60 @@ async function browserChecks(): Promise<Check[]> {
349
349
  }
350
350
 
351
351
  /**
352
- * Hermes, and whether its browser tools land anywhere useful.
352
+ * Every harness: installed, logged in, and whatever else its adapter checks.
353
353
  *
354
- * Three questions, and the second two only make sense once the first is yes.
355
- * None of them is fatal: a box that never installs Hermes is a working box, the
356
- * same way one without Claude is.
354
+ * None of it is fatal. A box that never installs a given harness is a working
355
+ * box; only the profiles that use it are affected. The login check exists
356
+ * because its failure is quiet in exactly the way this file cares about: the
357
+ * window opens, the harness runs, and it sits at a login prompt.
357
358
  */
358
- async function hermesChecks(): Promise<Check[]> {
359
- // Cheap when it is missing — this returns as soon as the binary is not found,
360
- // which is the answer on most boxes.
361
- const hermes = await discoverHermes();
362
- if (!hermes.available) {
363
- return [
364
- bad(
365
- "hermes",
366
- "not on PATH — profiles using the Hermes harness fall back to a shell",
367
- HERMES_INSTALL,
368
- ),
369
- ];
370
- }
371
-
372
- const checks: Check[] = [
373
- ok(
374
- "hermes",
375
- `${hermes.version ?? "installed"} — the Hermes harness will run`,
359
+ async function harnessChecks(): Promise<Check[]> {
360
+ const checks: Check[] = [];
361
+ for (const { file, error } of harnessProblems())
362
+ checks.push(bad("harness file", `${file}: ${error} — skipped`));
363
+
364
+ // Concurrently: each is its own few process starts, and the slow ones go to
365
+ // the network.
366
+ const adapters = harnesses();
367
+ const found = await Promise.all(
368
+ adapters.map(
369
+ (a) => discover(a.spec.id) as NonNullable<ReturnType<typeof discover>>,
376
370
  ),
377
- ];
378
-
379
- // Without the CLI, Browser Use mode silently does not engage. Hermes keeps
380
- // working with its twelve built-in browser tools, so nothing looks wrong;
381
- // you just pay for a dozen tool schemas in every request and never find out.
382
- checks.push(
383
- hermes.browser.browserUse
384
- ? ok("browser-use", "runnable — Hermes gets the single browser_exec tool")
385
- : bad(
386
- "browser-use",
387
- "no browser-use or uvx on PATH — Hermes keeps its twelve built-in browser tools",
388
- UV_INSTALL,
389
- ),
390
371
  );
391
-
392
- // Only worth asking when there is a browser here to be pointed at. On a box
393
- // with no `install-browser`, an unset cdp_url is the correct configuration.
394
- if (hermes.browser.cdpPort !== null) {
395
- checks.push(
396
- hermes.browser.connected
397
- ? ok(
398
- "hermes browser",
399
- `driving this box's Chrome on 127.0.0.1:${hermes.browser.cdpPort}`,
400
- )
401
- : bad(
402
- "hermes browser",
403
- hermes.browser.cdpUrl
404
- ? `browser.cdp_url is ${hermes.browser.cdpUrl}, not this box's Chrome on ${hermes.browser.cdpPort}`
405
- : "browser.cdp_url is unset — Hermes will not use the browser running here",
406
- "vibe-os connect-hermes",
407
- ),
408
- );
409
- }
410
-
411
- return checks;
412
- }
413
-
414
- /**
415
- * Cursor, and whether a window opened as it would get past the login prompt.
416
- *
417
- * Two questions. Neither is fatal, for the reason the Hermes checks give: a box
418
- * that never installs Cursor is a working box. The login check exists because
419
- * its failure is quiet in exactly the way this file cares about — the window
420
- * opens, the harness runs, and it sits asking for a browser login on a box that
421
- * may not have one attached.
422
- */
423
- async function cursorChecks(): Promise<Check[]> {
424
- const cursor = await discoverCursor();
425
- if (!cursor.available) {
426
- return [
427
- bad(
428
- "cursor",
429
- "not on PATH — profiles using the Cursor harness fall back to a shell",
430
- CURSOR_INSTALL,
431
- ),
432
- ];
433
- }
434
-
435
- const checks: Check[] = [
436
- ok(
437
- "cursor",
438
- `${cursor.version ?? "installed"} — the Cursor harness will run`,
439
- ),
440
- ];
441
-
442
- if (cursor.loggedIn === false) {
443
- checks.push(
444
- bad(
445
- "cursor login",
446
- "not logged in — Cursor windows will sit at a login prompt",
447
- "cursor-agent login",
448
- ),
449
- );
450
- } else if (cursor.loggedIn === true) {
372
+ adapters.forEach((adapter, i) => {
373
+ const { spec } = adapter;
374
+ const { report, detail } = found[i];
375
+ if (!report.available) {
376
+ checks.push(
377
+ bad(
378
+ spec.id,
379
+ `not on PATH — profiles using the ${spec.label} harness open a window that closes immediately`,
380
+ spec.install,
381
+ ),
382
+ );
383
+ return;
384
+ }
451
385
  checks.push(
452
386
  ok(
453
- "cursor login",
454
- `logged in${cursor.models.length > 0 ? `, ${cursor.models.length} models offered` : ""}`,
387
+ spec.id,
388
+ `${report.version ?? "installed"} — the ${spec.label} harness will run`,
455
389
  ),
456
390
  );
457
- }
458
- // null stays silent: status output this build cannot read is not a finding.
459
-
391
+ if (spec.login && report.loggedIn === false) {
392
+ checks.push(
393
+ bad(
394
+ `${spec.id} login`,
395
+ `not logged in — ${spec.label} windows will sit at a login prompt`,
396
+ spec.login.fix,
397
+ ),
398
+ );
399
+ } else if (spec.login && report.loggedIn === true) {
400
+ checks.push(ok(`${spec.id} login`, "logged in"));
401
+ }
402
+ // null stays silent: status output this build cannot read is not a finding.
403
+ for (const c of adapter.checks?.(report, detail) ?? [])
404
+ checks.push(c.ok ? ok(c.label, c.detail) : bad(c.label, c.detail, c.fix));
405
+ });
460
406
  return checks;
461
407
  }
462
408
 
@@ -591,6 +537,110 @@ async function tailscaleAddress(): Promise<string | null> {
591
537
  return null;
592
538
  }
593
539
 
540
+ /**
541
+ * Whether one session can take the box down, and whether anything would stop
542
+ * it if one did. Pure, so every combination is testable off a real box.
543
+ */
544
+ export function memoryChecks(
545
+ user: string,
546
+ limits: MemoryLimits,
547
+ scope: ScopeState,
548
+ oom: OomState,
549
+ ): Check[] {
550
+ const checks: Check[] = [];
551
+ const set = [
552
+ limits.high !== null ? `MemoryHigh=${limits.high}` : null,
553
+ limits.max !== null ? `MemoryMax=${limits.max}` : null,
554
+ limits.swapMax !== null ? `MemorySwapMax=${limits.swapMax}` : null,
555
+ ].filter(Boolean);
556
+
557
+ if (set.length === 0) {
558
+ checks.push(
559
+ ok("memory limits", "off (--no-memory-limit): windows start unscoped"),
560
+ );
561
+ } else if (!scope.systemd) {
562
+ checks.push(
563
+ bad(
564
+ "memory limits",
565
+ "no systemd here, so windows start without their memory limits and one runaway agent can take the box down",
566
+ "run on a systemd host, or rely on the OOM daemon below",
567
+ ),
568
+ );
569
+ } else if (!scope.linger) {
570
+ checks.push(
571
+ bad(
572
+ "memory limits",
573
+ `lingering is off for ${user}, so windows start without their memory limits (a scope would end when the last login did)`,
574
+ `sudo loginctl enable-linger ${user} # or vibe-os setup`,
575
+ ),
576
+ );
577
+ } else if (!scope.cgroup2) {
578
+ checks.push(
579
+ bad(
580
+ "memory limits",
581
+ `this box uses cgroup v1, where a user's scopes cannot be limited, so ${set.join(" ")} is set but not enforced`,
582
+ "boot with systemd.unified_cgroup_hierarchy=1, or rely on the OOM daemon below",
583
+ ),
584
+ );
585
+ } else if (scope.controllers && !scope.controllers.includes("memory")) {
586
+ checks.push(
587
+ bad(
588
+ "memory limits",
589
+ `the user manager is not delegated the memory controller (it has: ${scope.controllers.join(" ") || "none"}), so ${set.join(" ")} is set but not enforced`,
590
+ "sudo systemctl edit user@.service # [Service] Delegate=pids memory cpu",
591
+ ),
592
+ );
593
+ } else {
594
+ checks.push(
595
+ ok(
596
+ "memory limits",
597
+ `each window runs in its own scope: ${set.join(" ")}`,
598
+ ),
599
+ );
600
+ }
601
+
602
+ const swap = oom.swapTotal > 0;
603
+ if (oom.oomd || oom.earlyoom === "active") {
604
+ checks.push(
605
+ ok(
606
+ "oom daemon",
607
+ `${oom.oomd ? "systemd-oomd" : "earlyoom"} is running, so a box out of memory recovers on its own`,
608
+ ),
609
+ );
610
+ } else if (oom.earlyoom === "installed") {
611
+ checks.push(
612
+ bad(
613
+ "oom daemon",
614
+ "earlyoom is installed but not running",
615
+ "sudo systemctl enable --now earlyoom",
616
+ ),
617
+ );
618
+ } else {
619
+ checks.push(
620
+ bad(
621
+ "oom daemon",
622
+ swap
623
+ ? "swap and no OOM daemon: out of memory, this box thrashes for as long as it takes someone to notice, instead of killing one process"
624
+ : "no OOM daemon: out of memory, the kernel kills something only after the box has stalled",
625
+ "sudo apt install earlyoom # or vibe-os setup",
626
+ ),
627
+ );
628
+ }
629
+ return checks;
630
+ }
631
+
632
+ /** A user's uid, or null when there is no such user. */
633
+ async function uidOf(user: string): Promise<number | null> {
634
+ if (user === os.userInfo().username) return process.getuid?.() ?? null;
635
+ try {
636
+ const { stdout } = await run("id", ["-u", user], { timeout: 5_000 });
637
+ const uid = Number(stdout.trim());
638
+ return Number.isInteger(uid) ? uid : null;
639
+ } catch {
640
+ return null;
641
+ }
642
+ }
643
+
594
644
  export async function runDoctor(config: Config): Promise<Check[]> {
595
645
  const checks: Check[] = [];
596
646
  const user = config.user;
@@ -643,19 +693,7 @@ export async function runDoctor(config: Config): Promise<Check[]> {
643
693
  ),
644
694
  );
645
695
 
646
- const claude = await onPath("claude");
647
- checks.push(
648
- claude
649
- ? ok("claude", "on PATH — the Claude harness will run")
650
- : bad(
651
- "claude",
652
- "not on PATH — profiles using the Claude harness fall back to a shell",
653
- CLAUDE_INSTALL,
654
- ),
655
- );
656
-
657
- checks.push(...(await hermesChecks()));
658
- checks.push(...(await cursorChecks()));
696
+ checks.push(...(await harnessChecks()));
659
697
 
660
698
  // ── can a browser actually log in? ───────────────────────────────────────
661
699
  const sshdUp = await probeTcp(config.sshHost, config.sshPort);
@@ -866,7 +904,7 @@ export async function runDoctor(config: Config): Promise<Check[]> {
866
904
  bad(
867
905
  "firewall",
868
906
  "could not read any firewall — needs root, or none is installed",
869
- "sudo vibe-os doctor (and see the firewall rules in the README)",
907
+ "sudo vibe-os doctor (firewall rules: https://github.com/tsconfigdotjson/vibe-os/blob/main/docs/deploying.md#firewall)",
870
908
  ),
871
909
  );
872
910
  } else if (!fw.active) {
@@ -913,6 +951,21 @@ export async function runDoctor(config: Config): Promise<Check[]> {
913
951
  // not be told about four things it does not have.
914
952
  checks.push(...(await browserChecks()));
915
953
 
954
+ // ── memory ───────────────────────────────────────────────────────────────
955
+ //
956
+ // Linux only: the scope and both daemons are systemd and /proc things, and a
957
+ // Mac running vibe-os locally has none of them to report on.
958
+ if (process.platform === "linux") {
959
+ checks.push(
960
+ ...memoryChecks(
961
+ user,
962
+ config.memory,
963
+ await scopeState(user, await uidOf(user)),
964
+ await oomState(),
965
+ ),
966
+ );
967
+ }
968
+
916
969
  // ── leaked processes ─────────────────────────────────────────────────────
917
970
  //
918
971
  // Processes that outlived their workspace or session: a cwd that is a
@@ -960,7 +1013,7 @@ export async function runDoctor(config: Config): Promise<Check[]> {
960
1013
  bad(
961
1014
  "exposure",
962
1015
  `no token and bound to every interface — Tailscale is up (${tailnet}), but so is any public address`,
963
- `bind to the tailnet only: --host ${tailnet} (and see the firewall rules in the README)`,
1016
+ `bind to the tailnet only: --host ${tailnet} (firewall rules: https://github.com/tsconfigdotjson/vibe-os/blob/main/docs/deploying.md#firewall)`,
964
1017
  ),
965
1018
  );
966
1019
  } else {