diff --git a/changelog.d/mac-memory-meter.md b/changelog.d/mac-memory-meter.md new file mode 100644 index 000000000..5aa53654e --- /dev/null +++ b/changelog.d/mac-memory-meter.md @@ -0,0 +1,3 @@ +### Fixed +- A Mac's PC load in the sidebar shows its memory meter: memory in use is read from `vm_stat` as + Activity Monitor's Memory Used (App Memory, wired and compressed), so the file cache is not counted. diff --git a/docs/guide.md b/docs/guide.md index ac4783f6b..2ab66b65a 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -345,7 +345,7 @@ across an Ovis restart, which forgets where it saw each `letta` before. Check th | **Search the conversation** | **Search conversation**, from the pane's menu (right-click on a desktop, the tab's ⋯ on a phone) or the command palette, finds a word in what you wrote and in the agent's answers, earlier pages included; tool output, thinking and progress notes are left out. Matches list newest first as snippets; picking one opens the chat at that message, loading the earlier messages up to it. Nothing in the chat is highlighted, and the phone's screen gains no button. A remote PC offers it once its bridge is updated to one that searches. | | **Find in terminal** | **More → Find in terminal** or Mod+Shift+F searches literal text in herdr's history, with a match count, previous/next navigation and visible match highlights. Search jumps and scrolling move the same viewport in every attached client, including the TUI. Older bridges retain search navigation without automatic highlights. | | **Jump across PCs** | The command palette (Mod+Shift+K) searches all connected PCs at once by PC, workspace, branch, tab, pane title or agent kind. PC names distinguish results, and Recent remembers each pane's PC. Picking a result opens that PC and pane directly. Disconnected PCs are omitted; app and plugin actions remain on the currently selected PC. It also finds Settings pages and actions by related words (try `theme` or `font`), and browses folders: type a path such as `~/`, or pick **Browse folders**, and the folder you reach opens in Files. | -| **See each PC's load** | On by default once two or more PCs are connected; turn it on or off for this device in **Settings → Remote PCs → PC load in the sidebar**. Under a PC's name in the sidebar: CPU, memory (without the file cache) and the fullest disk as three small meters, red from 85%. Tap them for memory used and total, load average, uptime and every disk. A PC is read only while its group is open, the sidebar is shown and the page is visible. A remote PC shows nothing until its bridge is updated to this version, and a herdr-core machine shows none. Linux reads `/proc`; elsewhere CPU, load and disks come from the OS, Windows adds memory, and macOS leaves memory out. | +| **See each PC's load** | On by default once two or more PCs are connected; turn it on or off for this device in **Settings → Remote PCs → PC load in the sidebar**. Under a PC's name in the sidebar: CPU, memory (without the file cache) and the fullest disk as three small meters, red from 85%. Tap them for memory used and total, load average, uptime and every disk. A PC is read only while its group is open, the sidebar is shown and the page is visible. A remote PC shows nothing until its bridge is updated to this version, and a herdr-core machine shows none. Linux reads `/proc`; elsewhere CPU, load and disks come from the OS, Windows adds memory, and macOS reads memory from `vm_stat` as Activity Monitor's Memory Used. | | **Answer prompts** | Approval, question and plan menus become cards. Tap an option, or type its number in the composer. The server checks that the menu is still current before answering. | | **Compose** | `/` commands and `@` file mentions, any file or image up to 32 MB attached by path, and a draft per pane. Send during work schedules a next-turn message; use its ↑ Send now action to send it now. A strip under the input card names the pane's working folder (the whole path on hover) and, for a workspace of a repository with a worktree open, its branch at the other end; a phone leaves it out. Pick or type a terminal-only command and send normally: after acceptance, the pane's terminal opens over the chat, in this browser only, with **Back to Chat** and **Keep Terminal**; the chat stays where you were reading. For Claude Code's panels (`/config`, `/permissions`, `/theme` and the like) the chat may come back by itself when you close the panel, once Claude records the close; for Codex, omp, pi, gjc, OmO, Hermes (classic CLI), Devin, agy and Grok it comes back after their recorded pickers and panels, once the agent's empty input row is back on the screen; otherwise use **Back to Chat**, the header's Chat, or Back on a phone. If you go back to the same pane's chat while the command's screen is still open, the composer says so (Claude Code and every agent above, not OpenCode) and holds Send, quick replies and a queued message's Send now until the screen closes (**Send anyway** sends the message once); **Show terminal** opens it again. Messages the PC sends by itself at the end of a turn are not held. Automatic queued delivery never changes your view. Chat keeps one quiet **Ran in terminal** row with **Open Terminal**, which shows the terminal over the chat again; ordinary commands keep their outcome chips, and Hermes keeps its informational result cards. `/resume` opens **Earlier sessions…**. [Agent commands in Chat](agent-commands.md) explains where each agent's commands come from and what counts as a result. | | **Follow every agent** | Live RUN / INPUT / DONE / READY status for all panes, and alerts when an agent needs input, finishes or its terminal ends. | diff --git a/server/system-stats.test.ts b/server/system-stats.test.ts index 01ad4c3b1..3302bffcc 100644 --- a/server/system-stats.test.ts +++ b/server/system-stats.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { cpuPercent, cpuTimesOf, createSystemSampler, DISK_STALE_MS, diskOf, diskReader, type ReadPath, handleSystemRequest, parseMeminfo, STALE_MS, type SystemIo } from "./system-stats.ts"; +import { cpuPercent, cpuTimesOf, createSystemSampler, DISK_STALE_MS, diskOf, diskReader, type ReadPath, handleSystemRequest, parseMeminfo, parseVmStat, realIo, STALE_MS, type SystemIo } from "./system-stats.ts"; const MEMINFO = `MemTotal: 16384000 kB MemFree: 1000000 kB @@ -7,6 +7,23 @@ MemAvailable: 8192000 kB Buffers: 500000 kB `; +// vm_stat on an Apple silicon Mac with 48 GiB (macOS 26.5), the lines parseVmStat reads among the others +const VM_STAT = `Mach Virtual Memory Statistics: (page size of 16384 bytes) +Pages free: 900855. +Pages active: 932085. +Pages inactive: 877074. +Pages speculative: 53133. +Pages wired down: 169660. +Pages purgeable: 2550. +"Translation faults": 3418188362. +File-backed pages: 691408. +Anonymous pages: 1170884. +Pages stored in compressor: 478253. +Pages occupied by compressor: 138348. +Swapouts: 12049051. +`; +const GIB = 1024 ** 3; + describe("system readings", () => { it("reads memory as total minus MemAvailable, so the file cache is not used memory", () => { expect(parseMeminfo(MEMINFO)).toEqual({ total: 16384000 * 1024, used: 8192000 * 1024 }); @@ -14,6 +31,31 @@ describe("system readings", () => { expect(parseMeminfo("")).toBeNull(); }); + it("reads macOS memory as Activity Monitor's Memory Used: App Memory, wired and compressed", () => { + // (anonymous 1170884 - purgeable 2550) + wired 169660 + compressor 138348, in 16 KiB pages; the file cache is left out + expect(parseVmStat(VM_STAT, 48 * GIB)).toEqual({ total: 48 * GIB, used: 1476342 * 16384 }); + // an Intel Mac counts 4 KiB pages + const intel = VM_STAT.replace("page size of 16384 bytes", "page size of 4096 bytes"); + expect(parseVmStat(intel, 16 * GIB)).toEqual({ total: 16 * GIB, used: 1476342 * 4096 }); + }); + + it("keeps a macOS reading inside 0 and the total, and says null for one it cannot read", () => { + const purged = VM_STAT.replace(/^Pages purgeable:\s+\d+\./m, "Pages purgeable: 9999999."); + expect(parseVmStat(purged, 48 * GIB)?.used).toBe((169660 + 138348) * 16384); + expect(parseVmStat(VM_STAT, GIB)).toEqual({ total: GIB, used: GIB }); + expect(parseVmStat(VM_STAT.replace("Pages occupied by compressor", "Pages held by compressor"), 48 * GIB)).toBeNull(); + expect(parseVmStat(VM_STAT.replace("page size of 16384 bytes", "page size unknown"), 48 * GIB)).toBeNull(); + expect(parseVmStat(VM_STAT, 0)).toBeNull(); + expect(parseVmStat("", 48 * GIB)).toBeNull(); + }); + + it.skipIf(process.platform !== "darwin")("reads this Mac's memory from vm_stat", async () => { + const memory = await realIo.memory(); + expect(memory).not.toBeNull(); + expect(memory!.used).toBeGreaterThan(0); + expect(memory!.used).toBeLessThanOrEqual(memory!.total); + }); + it("measures CPU as the change between two readings", () => { const core = (user: number, idle: number) => ({ times: { user, nice: 0, sys: 0, idle, irq: 0 } }); const before = cpuTimesOf([core(100, 900), core(100, 900)]); diff --git a/server/system-stats.ts b/server/system-stats.ts index d399207ea..07b1d594b 100644 --- a/server/system-stats.ts +++ b/server/system-stats.ts @@ -9,9 +9,9 @@ * FRESH_MS share one reading, so several browsers or tabs never shorten each other's window. * * Linux is read from /proc (memory is total minus MemAvailable, so the file cache never reads as - * "used"). Everywhere else gets what Node's `os` can say, and the fields it cannot say are null: a - * number that was not measured is never shown as zero. macOS memory is one of those: `os` counts - * the file cache as used there, so it is left out rather than shown high. + * "used"). macOS memory is read from `vm_stat` as Activity Monitor's "Memory Used" (`os` counts the + * file cache as used there). Everywhere else gets what Node's `os` can say, and the fields it cannot + * say are null: a number that was not measured is never shown as zero. * * Limits worth knowing. In a container without lxcfs, /proc and `os` describe the host, not the * container's cgroup limits. libuv leaves iowait, softirq and steal out of the CPU times, so a VM @@ -79,6 +79,26 @@ export function parseMeminfo(text: string): { total: number; used: number } | nu return { total, used: Math.max(0, total - Math.min(available, total)) }; } +/** + * macOS memory in use from `vm_stat`, as Activity Monitor's "Memory Used": App Memory (anonymous + * pages less the purgeable ones) plus wired pages plus the pages the compressor occupies, in bytes. + * The file cache and free pages are not used memory. Null when the page size or a count is missing. + */ +export function parseVmStat(text: string, total: number): { total: number; used: number } | null { + const size = /page size of (\d+) bytes/.exec(text); + const pages = (name: string): number | null => { + const match = new RegExp(`^${name}:\\s+(\\d+)\\.?\\s*$`, "m").exec(text); + return match ? Number(match[1]) : null; + }; + const anonymous = pages("Anonymous pages"); + const purgeable = pages("Pages purgeable"); + const wired = pages("Pages wired down"); + const compressor = pages("Pages occupied by compressor"); + if (size === null || anonymous === null || purgeable === null || wired === null || compressor === null || !(total > 0)) return null; + const used = (Math.max(0, anonymous - purgeable) + wired + compressor) * Number(size[1]); + return { total, used: Math.min(used, total) }; +} + const round = (value: number, places = 1): number => Math.round(value * 10 ** places) / 10 ** places; /** One reading from two sets of counters `seconds` apart. */ @@ -154,7 +174,14 @@ export const realIo: SystemIo = { if (osPlatform() === "linux") { try { return parseMeminfo(await readFile("/proc/meminfo", "utf8")); } catch { return null; } } - // `os` counts the file cache as used on macOS, which would read as a full PC: left out there + // `os` counts the file cache as used on macOS, which would read as a full PC: vm_stat says what is used + if (osPlatform() === "darwin") { + try { + const child = Bun.spawn(["/usr/bin/vm_stat"], { stdout: "pipe", stderr: "ignore", timeout: 2000 }); + const text = await new Response(child.stdout).text(); + return await child.exited === 0 ? parseVmStat(text, totalmem()) : null; + } catch { return null; } + } if (osPlatform() !== "win32") return null; const total = totalmem(); return total > 0 ? { total, used: Math.max(0, total - freemem()) } : null; diff --git a/shared/system.ts b/shared/system.ts index 1b54e237f..cd128ab9f 100644 --- a/shared/system.ts +++ b/shared/system.ts @@ -23,7 +23,7 @@ export interface SystemReport { cpu_count: number; /** the share of CPU time in use since the previous reading; null when there is nothing to compare yet */ cpu_percent: number | null; - /** memory in use without the file cache (Linux: total minus MemAvailable) */ + /** memory in use without the file cache (Linux: total minus MemAvailable; macOS: from vm_stat) */ memory: { used: number; total: number; percent: number } | null; /** 1, 5 and 15 minute load averages; null on Windows, which has none */ load: [number, number, number] | null;