@hellcoder/companion 0.116.1-preview.20260814042234.2d329a2 → 0.117.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 (34) hide show
  1. package/dist/assets/{AgentsPage-BAalV1v6.js → AgentsPage-DJIGGU86.js} +1 -1
  2. package/dist/assets/{CronManager-B2Vo1YmB.js → CronManager-D7aAMVgs.js} +1 -1
  3. package/dist/assets/{DashboardPage-BOvDiP1W.js → DashboardPage-Bporj7Vr.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-kOXLH5KU.js → IntegrationsPage-BSY0-fsO.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-H-uUJ-ub.js → LinearOAuthSettingsPage-Cl5dP05g.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-CD_x4AGr.js → LinearSettingsPage-dwMpnpxL.js} +1 -1
  7. package/dist/assets/{MagicUIDashboard-9Jgiv4rb.js → MagicUIDashboard-Bpa4AupX.js} +90 -4
  8. package/dist/assets/{MagicUIView-BLEcS9XO.js → MagicUIView-BoHMBELi.js} +1 -1
  9. package/dist/assets/{Playground-QLrd9q16.js → Playground-By2aXWQF.js} +5 -5
  10. package/dist/assets/{PromptsPage-11eJ6ak7.js → PromptsPage-DVv0Qn3d.js} +1 -1
  11. package/dist/assets/{RunsPage-Bz3udIL2.js → RunsPage-bu3TZ6ai.js} +1 -1
  12. package/dist/assets/{SandboxManager-Dv47pbC6.js → SandboxManager-CDjGbD5N.js} +1 -1
  13. package/dist/assets/{SettingsPage-C_2ftG-C.js → SettingsPage-D0v_BZTU.js} +1 -1
  14. package/dist/assets/{TailscalePage-DzMX_7XA.js → TailscalePage-B8SJQpMF.js} +1 -1
  15. package/dist/assets/index-BqOdTpct.css +1 -0
  16. package/dist/assets/index-DqIZ_Ol5.js +140 -0
  17. package/dist/assets/{sw-register-CarYQEeI.js → sw-register-CvyRJNug.js} +1 -1
  18. package/dist/index.html +2 -2
  19. package/dist/sw.js +1 -1
  20. package/package.json +1 -1
  21. package/server/linear-agent-bridge.ts +0 -1
  22. package/server/magic-ui-ops.test.ts +59 -0
  23. package/server/magic-ui-ops.ts +29 -0
  24. package/server/magic-ui-types.ts +26 -2
  25. package/server/magic-ui-watcher.test.ts +85 -0
  26. package/server/magic-ui-watcher.ts +10 -2
  27. package/server/routes/system-routes.test.ts +401 -0
  28. package/server/routes/system-routes.ts +10 -0
  29. package/server/system-disk.test.ts +48 -0
  30. package/server/system-disk.ts +55 -0
  31. package/server/system-memory.test.ts +32 -0
  32. package/server/system-memory.ts +32 -0
  33. package/dist/assets/index-DSCRqqpK.css +0 -1
  34. package/dist/assets/index-DspbwgSL.js +0 -140
@@ -30,6 +30,55 @@ vi.mock("../service.js", () => ({
30
30
  refreshServiceDefinition: vi.fn(),
31
31
  }));
32
32
 
33
+ // ─── Mock claude-compat checker ────────────────────────────────────────────
34
+ // The compat routes gate the post-2.1.121 --sdk-url lockdown workarounds.
35
+ // Everything here touches the real Claude binary on disk, so it is mocked
36
+ // wholesale — these tests exercise the route logic, never the filesystem.
37
+ vi.mock("../claude-compat-checker.js", () => ({
38
+ checkCompat: vi.fn(async () => {}),
39
+ getCompatState: vi.fn(() => ({
40
+ installedVersion: "2.1.130",
41
+ installedPath: "/usr/local/bin/claude",
42
+ isIncompatible: true,
43
+ isPatched: false,
44
+ availableKnownGood: ["2.1.119", "2.1.120"],
45
+ suggestedPinTarget: "2.1.120",
46
+ lastChecked: 0,
47
+ error: null,
48
+ })),
49
+ }));
50
+
51
+ // ─── Mock claude-patcher ───────────────────────────────────────────────────
52
+ vi.mock("../claude-patcher.js", () => ({
53
+ pinToVersion: vi.fn(async () => ({ ok: true })),
54
+ patchBinary: vi.fn(async () => ({
55
+ ok: true,
56
+ patchedPath: "/usr/local/bin/claude",
57
+ replacements: 3,
58
+ })),
59
+ unpatch: vi.fn(async () => ({ ok: true, target: "2.1.130" })),
60
+ }));
61
+
62
+ // ─── Mock settings-manager ─────────────────────────────────────────────────
63
+ vi.mock("../settings-manager.js", () => ({
64
+ getSettings: vi.fn(() => ({
65
+ claudeBridgeMode: "none",
66
+ claudeBridgeIngressUrl: "",
67
+ claudeCompatBannerDismissedVersion: "",
68
+ dockerAutoUpdate: false,
69
+ })),
70
+ updateSettings: vi.fn(),
71
+ }));
72
+
73
+ // ─── Mock cli-ingress-server ───────────────────────────────────────────────
74
+ // Starting the real one binds a TLS listener.
75
+ vi.mock("../cli-ingress-server.js", () => ({
76
+ startCliIngressServer: vi.fn(async () => ({
77
+ urlPrefix: "wss://[::1]:8443",
78
+ stop: vi.fn(),
79
+ })),
80
+ }));
81
+
33
82
  import { Hono } from "hono";
34
83
  import { getUsageLimits } from "../usage-limits.js";
35
84
  import {
@@ -39,6 +88,9 @@ import {
39
88
  setUpdateInProgress,
40
89
  } from "../update-checker.js";
41
90
  import { registerSystemRoutes } from "./system-routes.js";
91
+ import { checkCompat, getCompatState } from "../claude-compat-checker.js";
92
+ import { pinToVersion, patchBinary, unpatch } from "../claude-patcher.js";
93
+ import { getSettings, updateSettings } from "../settings-manager.js";
42
94
 
43
95
  // ─── Helpers ───────────────────────────────────────────────────────────────
44
96
 
@@ -75,8 +127,39 @@ let launcher: ReturnType<typeof createMockLauncher>;
75
127
  let wsBridge: ReturnType<typeof createMockWsBridge>;
76
128
  let terminalManager: ReturnType<typeof createMockTerminalManager>;
77
129
 
130
+ /** Default compat state: an incompatible CLI with a known-good pin target. */
131
+ function defaultCompatState() {
132
+ return {
133
+ installedVersion: "2.1.130",
134
+ installedPath: "/usr/local/bin/claude",
135
+ isIncompatible: true,
136
+ isPatched: false,
137
+ availableKnownGood: ["2.1.119", "2.1.120"],
138
+ suggestedPinTarget: "2.1.120",
139
+ lastChecked: 0,
140
+ error: null,
141
+ };
142
+ }
143
+
78
144
  beforeEach(() => {
145
+ // clearAllMocks() resets call history but NOT implementations, so a
146
+ // mockReturnValue set inside one test leaks into every later one. Restoring
147
+ // the defaults here keeps the suite order-independent.
79
148
  vi.clearAllMocks();
149
+ vi.mocked(getCompatState).mockReturnValue(defaultCompatState() as any);
150
+ vi.mocked(pinToVersion).mockResolvedValue({ ok: true } as any);
151
+ vi.mocked(patchBinary).mockResolvedValue({
152
+ ok: true,
153
+ patchedPath: "/usr/local/bin/claude",
154
+ replacements: 3,
155
+ } as any);
156
+ vi.mocked(unpatch).mockResolvedValue({ ok: true, target: "2.1.130" } as any);
157
+ vi.mocked(getSettings).mockReturnValue({
158
+ claudeBridgeMode: "none",
159
+ claudeBridgeIngressUrl: "",
160
+ claudeCompatBannerDismissedVersion: "",
161
+ dockerAutoUpdate: false,
162
+ } as any);
80
163
 
81
164
  launcher = createMockLauncher();
82
165
  wsBridge = createMockWsBridge();
@@ -635,3 +718,321 @@ describe("POST /api/sessions/:id/message", () => {
635
718
  expect(json2.error).toMatch(/content/i);
636
719
  });
637
720
  });
721
+
722
+ // ═══════════════════════════════════════════════════════════════════════════
723
+ // GET /api/system/memory and GET /api/system/disk
724
+ // ═══════════════════════════════════════════════════════════════════════════
725
+
726
+ describe("GET /api/system/memory", () => {
727
+ it("returns a memory snapshot including swap fields", async () => {
728
+ const res = await app.request("/api/system/memory");
729
+ expect(res.status).toBe(200);
730
+ const json = await res.json();
731
+
732
+ expect(json.total_bytes).toBeGreaterThan(0);
733
+ expect(json.used_bytes).toBeLessThanOrEqual(json.total_bytes);
734
+ // Swap must always be present, even on a host with none configured —
735
+ // the UI keys off swap_total_bytes === 0 to hide the meter.
736
+ expect(typeof json.swap_total_bytes).toBe("number");
737
+ expect(typeof json.swap_used_bytes).toBe("number");
738
+ expect(typeof json.swap_used_percent).toBe("number");
739
+ });
740
+ });
741
+
742
+ describe("GET /api/system/disk", () => {
743
+ it("returns a disk snapshot for the Companion data volume", async () => {
744
+ const res = await app.request("/api/system/disk");
745
+ expect(res.status).toBe(200);
746
+ const json = await res.json();
747
+
748
+ // getSystemDisk returns null when statfs is unavailable; the route passes
749
+ // that through as JSON null rather than an empty 204 body.
750
+ if (json === null) return;
751
+
752
+ expect(json.total_bytes).toBeGreaterThan(0);
753
+ expect(json.used_bytes).toBe(json.total_bytes - json.available_bytes);
754
+ expect(json.used_percent).toBeGreaterThanOrEqual(0);
755
+ expect(json.used_percent).toBeLessThanOrEqual(100);
756
+ expect(typeof json.path).toBe("string");
757
+ });
758
+
759
+ it("always responds 200 with a JSON body, never an empty 204", async () => {
760
+ // The browser client calls res.json() unconditionally — an empty body
761
+ // would throw and log a spurious API failure on every poll.
762
+ const res = await app.request("/api/system/disk");
763
+ expect(res.status).toBe(200);
764
+ await expect(res.json()).resolves.not.toThrow();
765
+ });
766
+ });
767
+
768
+ // ═══════════════════════════════════════════════════════════════════════════
769
+ // POST /api/update — guard branches only
770
+ // ═══════════════════════════════════════════════════════════════════════════
771
+
772
+ // The success path spawns `bun install -g` and calls process.exit(), so these
773
+ // tests deliberately cover only the three refusal branches.
774
+ describe("POST /api/update (guards)", () => {
775
+ it("returns 400 when not running as a service", async () => {
776
+ vi.mocked(getUpdateState).mockReturnValue({
777
+ currentVersion: "1.0.0", latestVersion: "1.1.0", lastChecked: 0,
778
+ isServiceMode: false, checking: false, updateInProgress: false, channel: "stable",
779
+ } as any);
780
+
781
+ const res = await app.request("/api/update", { method: "POST" });
782
+ expect(res.status).toBe(400);
783
+ expect((await res.json()).error).toMatch(/service mode/i);
784
+ expect(setUpdateInProgress).not.toHaveBeenCalled();
785
+ });
786
+
787
+ it("returns 400 when no update is available", async () => {
788
+ vi.mocked(getUpdateState).mockReturnValue({
789
+ currentVersion: "1.0.0", latestVersion: "1.0.0", lastChecked: 0,
790
+ isServiceMode: true, checking: false, updateInProgress: false, channel: "stable",
791
+ } as any);
792
+ vi.mocked(isUpdateAvailable).mockReturnValue(false);
793
+
794
+ const res = await app.request("/api/update", { method: "POST" });
795
+ expect(res.status).toBe(400);
796
+ expect((await res.json()).error).toMatch(/no update/i);
797
+ });
798
+
799
+ it("returns 409 when an update is already in progress", async () => {
800
+ vi.mocked(getUpdateState).mockReturnValue({
801
+ currentVersion: "1.0.0", latestVersion: "1.1.0", lastChecked: 0,
802
+ isServiceMode: true, checking: false, updateInProgress: true, channel: "stable",
803
+ } as any);
804
+ vi.mocked(isUpdateAvailable).mockReturnValue(true);
805
+
806
+ const res = await app.request("/api/update", { method: "POST" });
807
+ expect(res.status).toBe(409);
808
+ expect((await res.json()).error).toMatch(/already in progress/i);
809
+ });
810
+ });
811
+
812
+ // ═══════════════════════════════════════════════════════════════════════════
813
+ // Terminal routes
814
+ // ═══════════════════════════════════════════════════════════════════════════
815
+
816
+ describe("terminal routes", () => {
817
+ it("GET /api/terminal reports inactive when no terminal exists", async () => {
818
+ terminalManager.getInfo.mockReturnValue(null);
819
+ const res = await app.request("/api/terminal");
820
+ expect(res.status).toBe(200);
821
+ expect(await res.json()).toEqual({ active: false });
822
+ });
823
+
824
+ it("GET /api/terminal returns the terminal id and cwd when active", async () => {
825
+ terminalManager.getInfo.mockReturnValue({ id: "t-1", cwd: "/repo" });
826
+ const res = await app.request("/api/terminal?terminalId=t-1");
827
+ expect(await res.json()).toEqual({ active: true, terminalId: "t-1", cwd: "/repo" });
828
+ expect(terminalManager.getInfo).toHaveBeenCalledWith("t-1");
829
+ });
830
+
831
+ it("POST /api/terminal/spawn requires a cwd", async () => {
832
+ const res = await app.request("/api/terminal/spawn", {
833
+ method: "POST",
834
+ headers: { "Content-Type": "application/json" },
835
+ body: JSON.stringify({}),
836
+ });
837
+ expect(res.status).toBe(400);
838
+ expect((await res.json()).error).toMatch(/cwd/i);
839
+ expect(terminalManager.spawn).not.toHaveBeenCalled();
840
+ });
841
+
842
+ it("POST /api/terminal/spawn passes dimensions and container through", async () => {
843
+ const res = await app.request("/api/terminal/spawn", {
844
+ method: "POST",
845
+ headers: { "Content-Type": "application/json" },
846
+ body: JSON.stringify({ cwd: "/repo", cols: 120, rows: 40, containerId: "c-1" }),
847
+ });
848
+ expect(res.status).toBe(200);
849
+ expect(await res.json()).toEqual({ terminalId: "terminal-123" });
850
+ expect(terminalManager.spawn).toHaveBeenCalledWith("/repo", 120, 40, { containerId: "c-1" });
851
+ });
852
+
853
+ it("POST /api/terminal/kill requires a terminalId", async () => {
854
+ // Covers both a missing body and a whitespace-only id.
855
+ const res1 = await app.request("/api/terminal/kill", { method: "POST" });
856
+ expect(res1.status).toBe(400);
857
+
858
+ const res2 = await app.request("/api/terminal/kill", {
859
+ method: "POST",
860
+ headers: { "Content-Type": "application/json" },
861
+ body: JSON.stringify({ terminalId: " " }),
862
+ });
863
+ expect(res2.status).toBe(400);
864
+ expect(terminalManager.kill).not.toHaveBeenCalled();
865
+ });
866
+
867
+ it("POST /api/terminal/kill kills the named terminal", async () => {
868
+ const res = await app.request("/api/terminal/kill", {
869
+ method: "POST",
870
+ headers: { "Content-Type": "application/json" },
871
+ body: JSON.stringify({ terminalId: "t-1" }),
872
+ });
873
+ expect(res.status).toBe(200);
874
+ expect(terminalManager.kill).toHaveBeenCalledWith("t-1");
875
+ });
876
+ });
877
+
878
+ // ═══════════════════════════════════════════════════════════════════════════
879
+ // Claude compatibility routes (--sdk-url lockdown workarounds)
880
+ // ═══════════════════════════════════════════════════════════════════════════
881
+
882
+ describe("GET /api/claude-compat", () => {
883
+ it("refreshes when the cached state is stale, then returns the payload", async () => {
884
+ // lastChecked === 0 means "never checked" — must trigger a refresh.
885
+ const res = await app.request("/api/claude-compat");
886
+ expect(res.status).toBe(200);
887
+ expect(checkCompat).toHaveBeenCalled();
888
+
889
+ const json = await res.json();
890
+ expect(json.installedVersion).toBe("2.1.130");
891
+ expect(json.isIncompatible).toBe(true);
892
+ expect(json.suggestedPinTarget).toBe("2.1.120");
893
+ // Settings-derived fields are merged into the same payload.
894
+ expect(json.bridgeMode).toBe("none");
895
+ expect(json.bannerDismissedVersion).toBe("");
896
+ });
897
+
898
+ it("skips the refresh when the cached state is fresh", async () => {
899
+ vi.mocked(getCompatState).mockReturnValue({
900
+ installedVersion: "2.1.130", installedPath: "/usr/local/bin/claude",
901
+ isIncompatible: false, isPatched: false, availableKnownGood: [],
902
+ suggestedPinTarget: "", lastChecked: Date.now(), error: null,
903
+ } as any);
904
+
905
+ await app.request("/api/claude-compat");
906
+ expect(checkCompat).not.toHaveBeenCalled();
907
+ });
908
+ });
909
+
910
+ describe("POST /api/claude-compat/refresh", () => {
911
+ it("always re-checks and returns the fresh payload", async () => {
912
+ const res = await app.request("/api/claude-compat/refresh", { method: "POST" });
913
+ expect(res.status).toBe(200);
914
+ expect(checkCompat).toHaveBeenCalled();
915
+ expect((await res.json()).installedVersion).toBe("2.1.130");
916
+ });
917
+ });
918
+
919
+ describe("POST /api/claude-compat/pin", () => {
920
+ it("pins to the suggested target and disables patched bridge mode", async () => {
921
+ const res = await app.request("/api/claude-compat/pin", { method: "POST" });
922
+ expect(res.status).toBe(200);
923
+ expect(pinToVersion).toHaveBeenCalledWith("2.1.120");
924
+
925
+ // Pinning returns to a non-validator binary, so the bridge must be turned
926
+ // off — otherwise the CLI keeps being pointed at wss://[::1].
927
+ expect(updateSettings).toHaveBeenCalledWith({
928
+ claudeBridgeMode: "none",
929
+ claudeBridgeIngressUrl: "",
930
+ });
931
+ expect((await res.json()).pinnedTo).toBe("2.1.120");
932
+ });
933
+
934
+ it("honours an explicit version from the body", async () => {
935
+ await app.request("/api/claude-compat/pin", {
936
+ method: "POST",
937
+ headers: { "Content-Type": "application/json" },
938
+ body: JSON.stringify({ version: "2.1.119" }),
939
+ });
940
+ expect(pinToVersion).toHaveBeenCalledWith("2.1.119");
941
+ });
942
+
943
+ it("returns 400 when no known-good version is available to pin to", async () => {
944
+ vi.mocked(getCompatState).mockReturnValue({
945
+ installedVersion: "2.1.130", installedPath: "/usr/local/bin/claude",
946
+ isIncompatible: true, isPatched: false, availableKnownGood: [],
947
+ suggestedPinTarget: "", lastChecked: 0, error: null,
948
+ } as any);
949
+
950
+ const res = await app.request("/api/claude-compat/pin", { method: "POST" });
951
+ expect(res.status).toBe(400);
952
+ expect((await res.json()).error).toMatch(/known-good/i);
953
+ expect(pinToVersion).not.toHaveBeenCalled();
954
+ });
955
+
956
+ it("surfaces a pin failure as 400", async () => {
957
+ vi.mocked(pinToVersion).mockResolvedValue({ ok: false, error: "download failed" } as any);
958
+ const res = await app.request("/api/claude-compat/pin", { method: "POST" });
959
+ expect(res.status).toBe(400);
960
+ expect((await res.json()).error).toBe("download failed");
961
+ });
962
+ });
963
+
964
+ describe("POST /api/claude-compat/patch", () => {
965
+ it("patches the binary, starts the ingress listener and persists the URL", async () => {
966
+ const res = await app.request("/api/claude-compat/patch", { method: "POST" });
967
+ expect(res.status).toBe(200);
968
+ expect(patchBinary).toHaveBeenCalled();
969
+
970
+ const json = await res.json();
971
+ expect(json.replacements).toBe(3);
972
+ expect(updateSettings).toHaveBeenCalledWith({
973
+ claudeBridgeMode: "patched",
974
+ claudeBridgeIngressUrl: "wss://[::1]:8443",
975
+ });
976
+ });
977
+
978
+ it("returns 400 when the binary cannot be patched", async () => {
979
+ vi.mocked(patchBinary).mockResolvedValue({ ok: false, error: "unknown layout" } as any);
980
+ const res = await app.request("/api/claude-compat/patch", { method: "POST" });
981
+ expect(res.status).toBe(400);
982
+ expect((await res.json()).error).toBe("unknown layout");
983
+ });
984
+ });
985
+
986
+ describe("POST /api/claude-compat/unpatch", () => {
987
+ it("restores the binary and clears bridge settings", async () => {
988
+ const res = await app.request("/api/claude-compat/unpatch", { method: "POST" });
989
+ expect(res.status).toBe(200);
990
+ expect(unpatch).toHaveBeenCalled();
991
+ expect(updateSettings).toHaveBeenCalledWith({
992
+ claudeBridgeMode: "none",
993
+ claudeBridgeIngressUrl: "",
994
+ });
995
+ expect((await res.json()).target).toBe("2.1.130");
996
+ });
997
+
998
+ it("returns 400 when unpatching fails", async () => {
999
+ vi.mocked(unpatch).mockResolvedValue({ ok: false, error: "no backup" } as any);
1000
+ const res = await app.request("/api/claude-compat/unpatch", { method: "POST" });
1001
+ expect(res.status).toBe(400);
1002
+ expect((await res.json()).error).toBe("no backup");
1003
+ });
1004
+ });
1005
+
1006
+ describe("POST /api/claude-compat/dismiss-banner", () => {
1007
+ it("records the explicitly supplied version", async () => {
1008
+ const res = await app.request("/api/claude-compat/dismiss-banner", {
1009
+ method: "POST",
1010
+ headers: { "Content-Type": "application/json" },
1011
+ body: JSON.stringify({ version: "2.1.131" }),
1012
+ });
1013
+ expect(res.status).toBe(200);
1014
+ expect(updateSettings).toHaveBeenCalledWith({
1015
+ claudeCompatBannerDismissedVersion: "2.1.131",
1016
+ });
1017
+ });
1018
+
1019
+ it("falls back to the currently installed version", async () => {
1020
+ const res = await app.request("/api/claude-compat/dismiss-banner", { method: "POST" });
1021
+ expect(res.status).toBe(200);
1022
+ expect(updateSettings).toHaveBeenCalledWith({
1023
+ claudeCompatBannerDismissedVersion: "2.1.130",
1024
+ });
1025
+ });
1026
+
1027
+ it("returns 400 when there is no version to record", async () => {
1028
+ vi.mocked(getCompatState).mockReturnValue({
1029
+ installedVersion: null, installedPath: null, isIncompatible: false,
1030
+ isPatched: false, availableKnownGood: [], suggestedPinTarget: "",
1031
+ lastChecked: 0, error: null,
1032
+ } as any);
1033
+
1034
+ const res = await app.request("/api/claude-compat/dismiss-banner", { method: "POST" });
1035
+ expect(res.status).toBe(400);
1036
+ expect((await res.json()).error).toMatch(/version/i);
1037
+ });
1038
+ });
@@ -4,6 +4,7 @@ import type { WsBridge } from "../ws-bridge.js";
4
4
  import type { TerminalManager } from "../terminal-manager.js";
5
5
  import { getUsageLimits } from "../usage-limits.js";
6
6
  import { getSystemMemory } from "../system-memory.js";
7
+ import { getSystemDisk } from "../system-disk.js";
7
8
  import {
8
9
  getUpdateState,
9
10
  checkForUpdate,
@@ -49,6 +50,15 @@ export function registerSystemRoutes(
49
50
  return c.json(getSystemMemory());
50
51
  });
51
52
 
53
+ // Free space on the volume holding COMPANION_HOME. Backed by a single
54
+ // statfs(2) call, so it is cheap enough to poll without caching.
55
+ api.get("/system/disk", (c) => {
56
+ // JSON null rather than 204: the browser client calls res.json()
57
+ // unconditionally, and an empty body would throw and register a spurious
58
+ // API failure on every poll.
59
+ return c.json(getSystemDisk());
60
+ });
61
+
52
62
  api.get("/sessions/:id/usage-limits", async (c) => {
53
63
  const sessionId = c.req.param("id");
54
64
  const session = deps.wsBridge.getSession(sessionId);
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getSystemDisk } from "./system-disk.js";
3
+
4
+ // getSystemDisk reads the real filesystem via statfs(2), so exact byte counts
5
+ // are machine-dependent. The invariants below must hold on any host where
6
+ // statfs succeeds. On a host without statfs the function returns null, which
7
+ // every test tolerates — the meter is best-effort by design.
8
+ describe("getSystemDisk", () => {
9
+ it("returns a coherent snapshot with sane invariants", () => {
10
+ const d = getSystemDisk();
11
+ if (!d) return; // statfs unavailable — nothing to assert
12
+
13
+ // A mounted filesystem always has positive capacity.
14
+ expect(d.total_bytes).toBeGreaterThan(0);
15
+
16
+ expect(d.used_bytes).toBeGreaterThanOrEqual(0);
17
+ expect(d.available_bytes).toBeGreaterThanOrEqual(0);
18
+ expect(d.used_bytes).toBeLessThanOrEqual(d.total_bytes);
19
+ expect(d.available_bytes).toBeLessThanOrEqual(d.total_bytes);
20
+
21
+ // used is derived as total - available (never bfree), so the identity
22
+ // must hold exactly. This is the guard against a future refactor
23
+ // switching to bfree and silently under-reporting usage.
24
+ expect(d.used_bytes).toBe(Math.max(0, d.total_bytes - d.available_bytes));
25
+ });
26
+
27
+ it("reports used_percent in 0–100 rounded to one decimal", () => {
28
+ const d = getSystemDisk();
29
+ if (!d) return;
30
+ expect(d.used_percent).toBeGreaterThanOrEqual(0);
31
+ expect(d.used_percent).toBeLessThanOrEqual(100);
32
+ expect(Math.round(d.used_percent * 10)).toBe(d.used_percent * 10);
33
+ });
34
+
35
+ it("reports the Companion data dir as the measured path", () => {
36
+ const d = getSystemDisk();
37
+ if (!d) return;
38
+ // The meter must describe the volume that session/recording files grow
39
+ // into, which is not necessarily the one holding "/".
40
+ expect(d.path).toBeTruthy();
41
+ expect(typeof d.path).toBe("string");
42
+ });
43
+
44
+ it("never throws, even though the underlying syscall can fail", () => {
45
+ // The route calls this on every poll with no try/catch of its own.
46
+ expect(() => getSystemDisk()).not.toThrow();
47
+ });
48
+ });
@@ -0,0 +1,55 @@
1
+ import { statfsSync } from "node:fs";
2
+ import { COMPANION_HOME } from "./paths.js";
3
+
4
+ export interface SystemDiskInfo {
5
+ /** Total size of the filesystem holding COMPANION_HOME, in bytes. */
6
+ total_bytes: number;
7
+ /** Space in use (total - available), in bytes. */
8
+ used_bytes: number;
9
+ /** Space available to this (unprivileged) user, in bytes. */
10
+ available_bytes: number;
11
+ /** Used percentage of total, 0–100, rounded to one decimal. */
12
+ used_percent: number;
13
+ /** Path the figures were measured for. */
14
+ path: string;
15
+ }
16
+
17
+ /**
18
+ * Snapshot of free space on the filesystem that holds the Companion data
19
+ * directory — that's the volume sessions, recordings and logs actually grow
20
+ * into, which is not necessarily the one holding "/".
21
+ *
22
+ * Deliberately uses statfs(2) rather than shelling out to `df`, and never
23
+ * walks the tree the way `du` would: this is a single syscall against the
24
+ * already-in-memory superblock (~0.1ms, no disk I/O), so it is safe to call
25
+ * on a poll interval and needs no caching layer.
26
+ *
27
+ * We report `bavail` (blocks free to unprivileged users) rather than `bfree`,
28
+ * which excludes the root-reserved reserve. That mirrors how getSystemMemory()
29
+ * prefers MemAvailable over MemFree: both answer "how much can I actually
30
+ * still use" rather than "how much is technically unallocated". It is also why
31
+ * this can read a percent or two above `df` for the same filesystem.
32
+ */
33
+ export function getSystemDisk(): SystemDiskInfo | null {
34
+ try {
35
+ const stats = statfsSync(COMPANION_HOME);
36
+ // bsize is the preferred I/O block size that blocks/bavail are counted in.
37
+ const total = stats.blocks * stats.bsize;
38
+ const available = stats.bavail * stats.bsize;
39
+ const used = Math.max(0, total - available);
40
+ const used_percent =
41
+ total > 0 ? Math.round((used / total) * 1000) / 10 : 0;
42
+ return {
43
+ total_bytes: total,
44
+ used_bytes: used,
45
+ available_bytes: available,
46
+ used_percent,
47
+ path: COMPANION_HOME,
48
+ };
49
+ } catch {
50
+ // statfs is unavailable (very old runtime) or the path does not exist yet
51
+ // on a first run. The meter is best-effort — the route returns 204 and the
52
+ // UI renders nothing rather than showing a bogus zeroed bar.
53
+ return null;
54
+ }
55
+ }
@@ -28,4 +28,36 @@ describe("getSystemMemory", () => {
28
28
  // At most one decimal place.
29
29
  expect(Math.round(m.used_percent * 10)).toBe(m.used_percent * 10);
30
30
  });
31
+
32
+ // Swap is read from the same /proc/meminfo snapshot as RAM. A host may have
33
+ // no swap at all, in which case every swap field must be 0 — the UI keys off
34
+ // swap_total_bytes === 0 to hide the meter entirely.
35
+ it("returns coherent swap figures, or zeroes when swap is absent", () => {
36
+ const m = getSystemMemory();
37
+
38
+ expect(m.swap_total_bytes).toBeGreaterThanOrEqual(0);
39
+ expect(m.swap_used_bytes).toBeGreaterThanOrEqual(0);
40
+
41
+ if (m.swap_total_bytes === 0) {
42
+ // No swap configured (or a non-Linux host): must not report phantom use.
43
+ expect(m.swap_used_bytes).toBe(0);
44
+ expect(m.swap_used_percent).toBe(0);
45
+ return;
46
+ }
47
+
48
+ // Used swap can never exceed the configured total, even though SwapFree
49
+ // can briefly exceed SwapTotal during swapoff — that's what the clamp is for.
50
+ expect(m.swap_used_bytes).toBeLessThanOrEqual(m.swap_total_bytes);
51
+ expect(m.swap_used_percent).toBeGreaterThanOrEqual(0);
52
+ expect(m.swap_used_percent).toBeLessThanOrEqual(100);
53
+ expect(Math.round(m.swap_used_percent * 10)).toBe(m.swap_used_percent * 10);
54
+ });
55
+
56
+ it("keeps swap independent of the RAM figures", () => {
57
+ // MemAvailable deliberately excludes swap, so a swapping box shows healthy
58
+ // RAM headroom while thrashing. Guards against a refactor that folds swap
59
+ // into used_bytes and hides exactly the condition we want to surface.
60
+ const m = getSystemMemory();
61
+ expect(m.used_bytes).toBe(Math.max(0, m.total_bytes - m.available_bytes));
62
+ });
31
63
  });
@@ -10,6 +10,15 @@ export interface SystemMemoryInfo {
10
10
  available_bytes: number;
11
11
  /** Used percentage of total, 0–100, rounded to one decimal. */
12
12
  used_percent: number;
13
+ /**
14
+ * Total swap, in bytes. 0 when no swap is configured, or on platforms where
15
+ * we cannot read it — callers should treat 0 as "no swap meter to show".
16
+ */
17
+ swap_total_bytes: number;
18
+ /** Swap in use (total - free), in bytes. */
19
+ swap_used_bytes: number;
20
+ /** Used percentage of swap, 0–100, rounded to one decimal. 0 when no swap. */
21
+ swap_used_percent: number;
13
22
  }
14
23
 
15
24
  /**
@@ -25,10 +34,19 @@ function readMeminfoKb(meminfo: string, key: string): number | null {
25
34
  * MemAvailable, which accounts for reclaimable page cache and therefore
26
35
  * reflects true OOM headroom far better than os.freemem(). Everywhere else
27
36
  * (or if /proc is unreadable) we fall back to the os module.
37
+ *
38
+ * Swap comes from the same single /proc/meminfo read, so reporting it costs
39
+ * nothing extra. It is worth surfacing separately rather than folding into the
40
+ * RAM figure: a box that has begun swapping heavily is already thrashing, and
41
+ * because MemAvailable excludes swap the RAM meter alone cannot show it. Swap
42
+ * exhaustion on top of high RAM use is the state immediately preceding the OOM
43
+ * killer, which is exactly what these meters exist to warn about.
28
44
  */
29
45
  export function getSystemMemory(): SystemMemoryInfo {
30
46
  let total = os.totalmem();
31
47
  let available = os.freemem();
48
+ let swapTotal = 0;
49
+ let swapUsed = 0;
32
50
 
33
51
  try {
34
52
  const meminfo = readFileSync("/proc/meminfo", "utf8");
@@ -38,16 +56,30 @@ export function getSystemMemory(): SystemMemoryInfo {
38
56
  total = totalKb * 1024;
39
57
  available = availableKb * 1024;
40
58
  }
59
+ const swapTotalKb = readMeminfoKb(meminfo, "SwapTotal");
60
+ const swapFreeKb = readMeminfoKb(meminfo, "SwapFree");
61
+ if (swapTotalKb !== null && swapFreeKb !== null) {
62
+ swapTotal = swapTotalKb * 1024;
63
+ // Clamp: SwapFree can momentarily exceed SwapTotal mid-swapoff.
64
+ swapUsed = Math.max(0, swapTotal - swapFreeKb * 1024);
65
+ }
41
66
  } catch {
42
67
  // Not Linux, or /proc/meminfo unavailable — keep the os module values.
68
+ // The os module exposes no swap figures, so swap stays 0 (= "unknown",
69
+ // rendered as no meter) rather than being reported as 0-bytes-used.
43
70
  }
44
71
 
45
72
  const used = Math.max(0, total - available);
46
73
  const used_percent = total > 0 ? Math.round((used / total) * 1000) / 10 : 0;
74
+ const swap_used_percent =
75
+ swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 1000) / 10 : 0;
47
76
  return {
48
77
  total_bytes: total,
49
78
  used_bytes: used,
50
79
  available_bytes: available,
51
80
  used_percent,
81
+ swap_total_bytes: swapTotal,
82
+ swap_used_bytes: swapUsed,
83
+ swap_used_percent,
52
84
  };
53
85
  }