@omercnet/paseo-top 0.0.1-alpha → 0.0.1-next.250.1

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.
package/OVERVIEW.md CHANGED
@@ -4,7 +4,7 @@ Usage always describes one host. With several hosts connected, the footer follow
4
4
 
5
5
  **Load by session** adds up the CPU and memory of everything an agent started, not only the agent process. A build, test run or dev server launched by a tool counts toward the agent that launched it. Each row shows the agent and workspace names you gave them, the busiest command in its tree, cores and memory in use, and opens the agent when pressed. A session is flagged with a flame, and counted in the footer, when it holds one busy core for 30 seconds or a quarter of the host's memory. Everything not listed is shown on one "Everything else" line.
6
6
 
7
- How processes are matched to agents: Paseo gives every agent session an identifier in its environment, and everything the session starts inherits it. Top reads only that identifier from each process, never the rest of the environment. A process whose environment was cleared by a tool is charged to the nearest ancestor that has one. Only agents that belong to the host you are viewing are listed; processes of agents that belong to other daemons on the same machine are counted under "Everything else", together with processes of no agent and sessions beyond the first ten. Per-session load needs Linux and only sees processes owned by the daemon's user. Containers run outside the agent's process tree and show as unattributed.
7
+ How processes are matched to agents: Paseo gives every agent session an identifier in its environment, and everything the session starts inherits it. On every scan Top reads each process's environment into memory, keeps only that identifier and drops the rest at once; nothing else from it is stored, logged or sent anywhere. A process whose environment cannot be read, such as one owned by another user, cannot be tagged. A process with no identifier is charged to the nearest ancestor that has one, since tools often clear it. Only agents that belong to the host you are viewing are listed; processes of agents that belong to other daemons on the same machine are counted under "Everything else", together with untagged processes and sessions beyond the first ten. Per-session load needs Linux. The scan covers every process `/proc` lists, so other users' processes are counted too, under "Everything else". Containers run outside the agent's process tree and show as unattributed.
8
8
 
9
9
  What the numbers are and are not: load is sampled every ten seconds, so a process that starts and exits between two scans is never seen, and one that is still running is charged for the CPU it used since it started. A session is flagged only when it has held a full core across three scans in a row, so a single burst does not flag it. Memory is each process's resident memory added up, so memory shared between processes of one agent is counted more than once, and the quarter-of-memory rule compares against the machine's memory, not a container's limit. Sessions plus "Everything else" cover the processes that were scanned, not the whole host: kernel time, finished processes and the page cache are outside them.
10
10
 
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  A Paseo plugin that shows CPU, memory, disk and disk I/O of a host in the sidebar footer, and which agent sessions are responsible. See [OVERVIEW.md](OVERVIEW.md) for behavior.
4
4
 
5
+ ## Screenshots
6
+
7
+ ![Top screen with host resource gauges and per-session load](https://raw.githubusercontent.com/omercnet/paseo-plugins/pr-assets-top/top-screen.jpg)
8
+
5
9
  ## Develop
6
10
 
7
11
  ```sh
package/client/footer.tsx CHANGED
@@ -2,19 +2,26 @@ import type { PluginSidebarItemProps } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import { SidebarRow } from "@getpaseo/plugin/client/ui";
4
4
  import { Text, View } from "react-native";
5
- import { formatPercent, formatRate, levelColor } from "./format";
5
+ import { formatPercent, formatRate, type Level, levelColor, loadLevel } from "./format";
6
6
  import { SCREEN_ID, useUsage } from "./usage";
7
7
 
8
+ const LEVEL_NAME: Record<Level, string> = { ok: "", warn: ", high", hot: ", critical" };
9
+
10
+ /** What a screen reader says: the reading in words, with the level spelled out, not just coloured. */
11
+ function metricLabel(name: string, text: string, level: Level): string {
12
+ return text === "--" ? `${name} unavailable` : `${name} ${text}${LEVEL_NAME[level]}`;
13
+ }
14
+
8
15
  export function TopFooter({ theme, host, currentScreen, openScreen }: PluginSidebarItemProps) {
9
16
  const usage = useUsage(host.id);
10
17
  const heavy = usage.data?.sessions?.filter((session) => session.heavy).length ?? 0;
11
18
  const metrics =
12
19
  usage.data &&
13
20
  ([
14
- ["Cpu", formatPercent(usage.data.cpu), usage.data.cpu],
15
- ["MemoryStick", formatPercent(usage.data.memory), usage.data.memory],
16
- ["HardDrive", formatPercent(usage.data.disk), usage.data.disk],
17
- ["Gauge", formatRate(usage.data.io), null],
21
+ ["Cpu", "CPU", formatPercent(usage.data.cpu), usage.data.cpu],
22
+ ["MemoryStick", "Memory", formatPercent(usage.data.memory), usage.data.memory],
23
+ ["HardDrive", "Disk", formatPercent(usage.data.disk), usage.data.disk],
24
+ ["Gauge", "Disk I/O", formatRate(usage.data.io), null],
18
25
  ] as const);
19
26
 
20
27
  return (
@@ -29,15 +36,25 @@ export function TopFooter({ theme, host, currentScreen, openScreen }: PluginSide
29
36
  <Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>unavailable</Text>
30
37
  )}
31
38
  {heavy > 0 && (
32
- <View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
39
+ <View
40
+ accessible
41
+ accessibilityLabel={`${heavy} heavy ${heavy === 1 ? "session" : "sessions"}`}
42
+ style={{ flexDirection: "row", alignItems: "center", gap: 2 }}
43
+ >
33
44
  <Icon name="Flame" size={11} color={theme.colors.statusDanger} />
34
45
  <Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>{heavy}</Text>
35
46
  </View>
36
47
  )}
37
- {metrics?.map(([icon, text, percent]) => {
48
+ {metrics?.map(([icon, name, text, percent]) => {
38
49
  const color = levelColor(theme, percent);
50
+ const level = loadLevel(percent);
39
51
  return (
40
- <View key={icon} style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
52
+ <View
53
+ key={icon}
54
+ accessible
55
+ accessibilityLabel={metricLabel(name, text, level)}
56
+ style={{ flexDirection: "row", alignItems: "center", gap: 2 }}
57
+ >
41
58
  <Icon name={icon} size={11} color={color} />
42
59
  <Text
43
60
  numberOfLines={1}
package/client/gauge.tsx CHANGED
@@ -31,12 +31,14 @@ export function Dial({ theme, size, icon, label, value, text }: DialProps) {
31
31
  const needleLength = radius * 0.72;
32
32
  const degrees = useRef(new Animated.Value(needleDegrees(value))).current;
33
33
  useEffect(() => {
34
- Animated.timing(degrees, {
34
+ const animation = Animated.timing(degrees, {
35
35
  toValue: needleDegrees(value),
36
36
  duration: 700,
37
37
  easing: Easing.out(Easing.cubic),
38
38
  useNativeDriver: false,
39
- }).start();
39
+ });
40
+ animation.start();
41
+ return () => animation.stop();
40
42
  }, [degrees, value]);
41
43
  const rotate = degrees.interpolate({
42
44
  inputRange: [-SWEEP / 2, SWEEP / 2],
package/client/screen.tsx CHANGED
@@ -68,6 +68,10 @@ export function TopScreen({ theme, layout, host, navigation }: PluginScreenProps
68
68
  {host.label}
69
69
  </Text>
70
70
  <View
71
+ accessible
72
+ accessibilityLabel={
73
+ usage.isError ? "Usage unavailable" : usage.data ? "Usage live" : "Usage loading"
74
+ }
71
75
  style={{
72
76
  width: 8,
73
77
  height: 8,
package/index.server.ts CHANGED
@@ -11,5 +11,8 @@ export default function contribute(server: PluginServerContext) {
11
11
  if (!usage.sessions) return usage;
12
12
  return { ...usage, ...(await ownSessions(paseo, usage.sessions, usage.other)) };
13
13
  });
14
- return sampler.stop;
14
+ return () => {
15
+ sampler.stop();
16
+ ownSessions.stop();
17
+ };
15
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-top",
3
- "version": "0.0.1-alpha",
3
+ "version": "0.0.1-next.250.1",
4
4
  "type": "module",
5
5
  "description": "A Paseo plugin that shows host CPU, memory and disk usage in the sidebar footer.",
6
6
  "license": "MIT",
package/paseo-plugin.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "id": "top",
3
3
  "description": "CPU, memory and disk usage of the selected host in the sidebar footer.",
4
+ "media": [
5
+ "https://raw.githubusercontent.com/omercnet/paseo-plugins/pr-assets-top/top-screen.jpg"
6
+ ],
4
7
  "requirements": { "paseo": "^0.11.0" }
5
8
  }
package/server/names.ts CHANGED
@@ -7,6 +7,8 @@ const FULL_REFRESH_MS = 30_000;
7
7
  const EARLY_REFRESH_MS = 5000;
8
8
  /** Only the first ever fetch is waited for, and not forever. */
9
9
  const FIRST_FETCH_TIMEOUT_MS = 3000;
10
+ /** A read that has not settled by now is abandoned and cancelled, so a hung daemon cannot block later reads. */
11
+ const REFRESH_LIFETIME_MS = 20_000;
10
12
  const PAGE_SIZE = 200;
11
13
  export const MAX_PAGES = 25;
12
14
  export const MAX_SESSIONS = 10;
@@ -26,12 +28,23 @@ interface Page<T> {
26
28
  pageInfo: { hasMore: boolean; nextCursor: string | null };
27
29
  }
28
30
 
29
- /** The daemon pages its lists, and an agent left off the first page must still be recognised. */
30
- async function collect<T>(fetchPage: (cursor: string | undefined) => Promise<Page<T>>) {
31
+ /**
32
+ * The daemon pages its lists, and an agent left off the first page must still be recognised. Aborting
33
+ * ends the read even when the daemon never answers the page in flight, which is then ignored.
34
+ */
35
+ async function collect<T>(
36
+ signal: AbortSignal,
37
+ fetchPage: (cursor: string | undefined) => Promise<Page<T>>,
38
+ ) {
39
+ const aborted = new Promise<never>((_, reject) => {
40
+ const cancel = () => reject(new Error("Daemon read cancelled"));
41
+ if (signal.aborted) cancel();
42
+ else signal.addEventListener("abort", cancel, { once: true });
43
+ });
31
44
  const entries: T[] = [];
32
45
  let cursor: string | undefined;
33
46
  for (let page = 0; page < MAX_PAGES; page++) {
34
- const result = await fetchPage(cursor);
47
+ const result = await Promise.race([fetchPage(cursor), aborted]);
35
48
  entries.push(...result.entries);
36
49
  if (!result.pageInfo.hasMore || !result.pageInfo.nextCursor) break;
37
50
  cursor = result.pageInfo.nextCursor;
@@ -40,7 +53,7 @@ async function collect<T>(fetchPage: (cursor: string | undefined) => Promise<Pag
40
53
  }
41
54
 
42
55
  /**
43
- * The process scan sees every process the daemon's user owns, including agents that belong to other
56
+ * The process scan sees every readable process /proc lists, including agents that belong to other
44
57
  * daemons on the same machine. This keeps only the sessions of agents this daemon knows, names them
45
58
  * with the user's own agent and workspace names, and folds everything else into `other` so the
46
59
  * numbers still add up to the processes that were scanned.
@@ -48,34 +61,48 @@ async function collect<T>(fetchPage: (cursor: string | undefined) => Promise<Pag
48
61
  * The daemon's lists are read in the background and shared between callers, so a slow daemon never
49
62
  * holds up the readings. Only the very first read is waited for, for a few seconds at most. Until
50
63
  * then, and whenever the lists have never been available, no session can be shown as this host's and
51
- * all of its load is reported as other load. Once read, a failed refresh keeps the last lists.
64
+ * all of its load is reported as other load. Once read, a failed refresh keeps the last lists. A read
65
+ * that outlives `REFRESH_LIFETIME_MS` is cancelled and replaced by the next one, and whatever the
66
+ * abandoned read later returns is discarded. `stop` cancels the read in flight.
52
67
  */
53
68
  export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETCH_TIMEOUT_MS) {
54
69
  let known: Known | null = null;
55
70
  /** Agents confirmed absent from the latest read, so they do not trigger another one. */
56
71
  let foreign = new Set<string>();
57
72
  let fetchedAt = Number.NEGATIVE_INFINITY;
58
- let inflight: Promise<void> | null = null;
73
+ let inflight: { done: Promise<void>; startedAt: number; abort: AbortController } | null = null;
59
74
 
60
75
  const refresh = (paseo: PaseoApi, candidates: string[]) => {
61
- if (inflight) return inflight;
76
+ if (inflight && now() - inflight.startedAt >= REFRESH_LIFETIME_MS) {
77
+ inflight.abort.abort();
78
+ inflight = null;
79
+ }
80
+ if (inflight) return inflight.done;
62
81
  fetchedAt = now();
63
- inflight = (async () => {
82
+ const abort = new AbortController();
83
+ const { signal } = abort;
84
+ const run = { done: Promise.resolve(), startedAt: fetchedAt, abort };
85
+ inflight = run;
86
+ run.done = (async () => {
64
87
  try {
65
88
  const [agents, workspaces] = await Promise.all([
66
- collect((cursor) =>
89
+ collect(signal, (cursor) =>
67
90
  paseo.agents.list({
68
91
  filter: { includeArchived: true },
69
92
  // Oldest first: an agent's creation time never changes, so paging cannot skip one that
70
93
  // is busy and being updated while the pages are read.
71
94
  sort: [{ key: "created_at", direction: "asc" }],
72
95
  page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) },
96
+ signal,
73
97
  }),
74
98
  ),
75
- collect((cursor) =>
99
+ // The SDK's workspace list takes no signal; the abort stops the paging between pages.
100
+ collect(signal, (cursor) =>
76
101
  paseo.workspaces.list({ page: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) } }),
77
102
  ),
78
103
  ]);
104
+ // An abandoned read must not overwrite what a newer one stored.
105
+ if (inflight !== run) return;
79
106
  const next: Known = {
80
107
  agents: new Map(
81
108
  agents.map(({ agent }) => [
@@ -91,14 +118,14 @@ export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETC
91
118
  foreign = new Set(candidates.filter((id) => !next.agents.has(id)));
92
119
  } catch {
93
120
  // Keep the previous lists.
121
+ } finally {
122
+ if (inflight === run) inflight = null;
94
123
  }
95
- })().finally(() => {
96
- inflight = null;
97
- });
98
- return inflight;
124
+ })();
125
+ return run.done;
99
126
  };
100
127
 
101
- return async (paseo: PaseoApi, sessions: Session[], other: Load) => {
128
+ const ownership = async (paseo: PaseoApi, sessions: Session[], other: Load) => {
102
129
  if (sessions.length === 0) return { sessions, other };
103
130
 
104
131
  const ids = sessions.map((session) => session.agentId);
@@ -107,7 +134,7 @@ export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETC
107
134
  const pending =
108
135
  age >= FULL_REFRESH_MS || (unseen && age >= EARLY_REFRESH_MS)
109
136
  ? refresh(paseo, ids)
110
- : inflight;
137
+ : inflight?.done;
111
138
  if (known === null && pending) {
112
139
  await new Promise<void>((resolve) => {
113
140
  const timer = setTimeout(resolve, firstFetchTimeoutMs);
@@ -140,4 +167,11 @@ export function createOwnership(now = Date.now, firstFetchTimeoutMs = FIRST_FETC
140
167
  }
141
168
  return { sessions: own.slice(0, MAX_SESSIONS), other: rest };
142
169
  };
170
+
171
+ return Object.assign(ownership, {
172
+ stop: () => {
173
+ inflight?.abort.abort();
174
+ inflight = null;
175
+ },
176
+ });
143
177
  }
@@ -68,7 +68,7 @@ export function parseStat(text: string): Stat | null {
68
68
  return { comm: text.slice(open + 1, close), ...numbers, rss: Math.max(0, numbers.rss) };
69
69
  }
70
70
 
71
- /** Only the agent id is read out of the environment; the rest of it can hold secrets and is dropped. */
71
+ /** The whole environment is read, but only the agent id is kept; the rest can hold secrets and is dropped. */
72
72
  export function parseAgentId(environ: string): string | null {
73
73
  for (const entry of environ.split("\0")) {
74
74
  if (entry.startsWith("PASEO_AGENT_ID=")) return entry.slice("PASEO_AGENT_ID=".length) || null;
@@ -77,34 +77,29 @@ export function parseAgentId(environ: string): string | null {
77
77
  }
78
78
 
79
79
  /**
80
- * Every process the daemon's user can see. A process's environment never changes after it starts, so
81
- * it is read once and remembered in `agentIds`; entries for processes that are gone are dropped.
80
+ * Every process listed under /proc, which includes other users' that are readable. The environment
81
+ * is read whole but only `PASEO_AGENT_ID` is kept, and it is read again on every scan, because a
82
+ * process can replace its environment when it execs without changing its pid or start time. A
83
+ * process whose environment cannot be read (another user's) has no agent.
82
84
  */
83
- export function readProcs(agentIds: Map<string, string | null>, root = "/proc"): ProcInfo[] {
85
+ export function readProcs(root = "/proc"): ProcInfo[] {
84
86
  const procs: ProcInfo[] = [];
85
- const seen = new Set<string>();
86
87
  for (const name of readdirSync(root)) {
87
88
  if (!/^\d+$/.test(name)) continue;
88
89
  try {
89
90
  const stat = parseStat(readFileSync(`${root}/${name}/stat`, "utf8"));
90
91
  if (!stat) continue;
91
- const key = `${name}:${stat.start}`;
92
- seen.add(key);
93
- let agentId = agentIds.get(key);
94
- if (agentId === undefined) {
95
- try {
96
- agentId = parseAgentId(readFileSync(`${root}/${name}/environ`, "utf8"));
97
- } catch {
98
- agentId = null; // another user's process
99
- }
100
- agentIds.set(key, agentId);
92
+ let agentId: string | null;
93
+ try {
94
+ agentId = parseAgentId(readFileSync(`${root}/${name}/environ`, "utf8"));
95
+ } catch {
96
+ agentId = null;
101
97
  }
102
- procs.push({ key, pid: Number(name), agentId, ...stat });
98
+ procs.push({ key: `${name}:${stat.start}`, pid: Number(name), agentId, ...stat });
103
99
  } catch {
104
100
  // The process exited while it was being read.
105
101
  }
106
102
  }
107
- for (const key of agentIds.keys()) if (!seen.has(key)) agentIds.delete(key);
108
103
  return procs;
109
104
  }
110
105
 
package/server/usage.ts CHANGED
@@ -18,10 +18,11 @@ export function readCpuTimes(cpus: ReadonlyArray<Pick<os.CpuInfo, "times">> = os
18
18
  return { idle, total };
19
19
  }
20
20
 
21
- /** Busy share between two readings, or null when no time elapsed or the counters went backwards. */
21
+ /** Busy share between two readings, or null when no time elapsed, the counters went backwards or are not numbers. */
22
22
  export function cpuPercent(previous: CpuTimes, current: CpuTimes): number | null {
23
23
  const total = current.total - previous.total;
24
24
  const idle = current.idle - previous.idle;
25
+ if (!Number.isFinite(total) || !Number.isFinite(idle)) return null;
25
26
  if (total <= 0 || idle < 0 || idle > total) return null;
26
27
  return (1 - idle / total) * 100;
27
28
  }
@@ -35,13 +36,18 @@ export function diskPercent(
35
36
  const { blocks, bfree, bavail } = stat(path);
36
37
  const used = blocks - bfree;
37
38
  const usable = used + bavail;
38
- return usable > 0 ? (used / usable) * 100 : null;
39
+ const percent = (used / usable) * 100;
40
+ return usable > 0 && Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : null;
39
41
  } catch {
40
42
  return null;
41
43
  }
42
44
  }
43
45
 
44
- /** Whole-disk bytes read plus written since boot (Linux only); null where /proc is unavailable. */
46
+ /**
47
+ * Whole-disk bytes read plus written since boot (Linux only). Null where /proc is unavailable, and
48
+ * when any whole disk has a malformed counter: a partial total would show up as a throughput spike
49
+ * once the counter reads again.
50
+ */
45
51
  export function readDiskBytes(
46
52
  sysBlock = "/sys/block",
47
53
  diskstats = "/proc/diskstats",
@@ -52,31 +58,53 @@ export function readDiskBytes(
52
58
  readdirSync(sysBlock).filter((name) => !/^(loop|ram|dm-|zram|md)/.test(name)),
53
59
  );
54
60
  let sectors = 0;
61
+ let matched = false;
55
62
  for (const line of readFileSync(diskstats, "utf8").split("\n")) {
56
63
  const fields = line.trim().split(/\s+/);
57
- if (disks.has(fields[2] ?? "")) sectors += Number(fields[5]) + Number(fields[9]);
64
+ if (!disks.has(fields[2] ?? "")) continue;
65
+ const read = Number(fields[5]);
66
+ const written = Number(fields[9]);
67
+ if (!(Number.isFinite(read) && read >= 0 && Number.isFinite(written) && written >= 0)) {
68
+ return null;
69
+ }
70
+ sectors += read + written;
71
+ matched = true;
58
72
  }
59
- return sectors * 512;
73
+ return matched && Number.isFinite(sectors) ? sectors * 512 : null;
60
74
  } catch {
61
75
  return null;
62
76
  }
63
77
  }
64
78
 
65
- /** Bytes per second between two readings, or null when the counter is missing or went backwards. */
79
+ /** Bytes per second between two readings, or null when a counter is missing, not finite, or went backwards. */
66
80
  export function bytesPerSecond(
67
81
  previous: number | null,
68
82
  current: number | null,
69
83
  elapsedMs: number,
70
84
  ): number | null {
71
- if (previous === null || current === null || current < previous || elapsedMs <= 0) return null;
72
- return ((current - previous) * 1000) / elapsedMs;
85
+ if (previous === null || current === null) return null;
86
+ if (!(Number.isFinite(previous) && Number.isFinite(current) && Number.isFinite(elapsedMs))) {
87
+ return null;
88
+ }
89
+ if (current < previous || elapsedMs <= 0) return null;
90
+ const rate = ((current - previous) * 1000) / elapsedMs;
91
+ return Number.isFinite(rate) ? rate : null;
92
+ }
93
+
94
+ /**
95
+ * Used share of memory, clamped to 0-100; null when the host reports no usable total. Linux reports
96
+ * reclaimable cache as free, so this is memory programs cannot get back.
97
+ */
98
+ export function memoryPercent(total = os.totalmem(), free = os.freemem()): number | null {
99
+ if (!Number.isFinite(total) || !Number.isFinite(free) || total <= 0) return null;
100
+ return Math.min(100, Math.max(0, (1 - free / total) * 100));
73
101
  }
74
102
 
75
103
  interface SamplerOptions {
76
104
  intervalMs?: number;
77
105
  readCpu?: () => CpuTimes;
78
106
  readIo?: () => number | null;
79
- readMemory?: () => number;
107
+ readMemory?: () => number | null;
80
108
  readDisk?: () => number | null;
81
109
  /** Lists processes, or returns null where that is not possible. */
82
110
  scan?: () => ProcInfo[] | null;
@@ -92,8 +120,7 @@ export function startSampler({
92
120
  intervalMs = 2000,
93
121
  readCpu = readCpuTimes,
94
122
  readIo = readDiskBytes,
95
- // Linux reports reclaimable cache as free, so this is memory programs cannot get back.
96
- readMemory = () => (1 - os.freemem() / os.totalmem()) * 100,
123
+ readMemory = memoryPercent,
97
124
  readDisk = diskPercent,
98
125
  scan = defaultScan(),
99
126
  totalMemory = os.totalmem,
@@ -136,12 +163,11 @@ export function startSampler({
136
163
  };
137
164
  }
138
165
 
139
- /** Reads /proc; the environment cache lives as long as the sampler. */
166
+ /** Reads /proc; returns null for a scan where it cannot be listed. */
140
167
  function defaultScan(): () => ProcInfo[] | null {
141
- const agentIds = new Map<string, string | null>();
142
168
  return () => {
143
169
  try {
144
- return readProcs(agentIds);
170
+ return readProcs();
145
171
  } catch {
146
172
  return null;
147
173
  }
package/shared/usage.ts CHANGED
@@ -10,7 +10,7 @@ const percent = z.number().min(0).max(100);
10
10
  /** Usage of the daemon host. Percentages are 0-100; `null` means the host cannot report it yet. */
11
11
  const sample = z.object({
12
12
  cpu: percent.nullable(),
13
- memory: percent,
13
+ memory: percent.nullable(),
14
14
  disk: percent.nullable(),
15
15
  /** Disk read plus write throughput in bytes per second. */
16
16
  io: z.number().min(0).nullable(),