@omercnet/paseo-top 0.0.0-stage → 0.0.1-alpha

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Omer Cohen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/OVERVIEW.md ADDED
@@ -0,0 +1,15 @@
1
+ Top shows how busy a host is without leaving your work, and which agent is responsible. It adds a row to the sidebar footer with the host name and its CPU, memory, disk and disk I/O, and a **Top** screen with dials, a five minute history strip for each reading, and a list of load by agent session.
2
+
3
+ Usage always describes one host. With several hosts connected, the footer follows the host you are working in, and the Top screen has Paseo's host picker. Each daemon measures itself; nothing is sent between hosts.
4
+
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
+
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.
8
+
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
+
11
+ CPU is the share of non-idle time over the last couple of seconds. Memory excludes reclaimable cache on Linux. Disk is the filesystem that holds the daemon user's home directory. Disk I/O is bytes read plus written per second across the physical disks, on Linux. History is kept by the daemon in memory, so it survives a page refresh and is shared by every client, but starts over when the plugin or daemon restarts. A reading the host cannot give shows `--`; if the plugin cannot be reached, the footer says unavailable.
12
+
13
+ The plugin only reads usage counters and process tables. It has no settings and runs no commands.
14
+
15
+ Requires Paseo ^0.11.0.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # Top
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ ## Develop
6
+
7
+ ```sh
8
+ bun install
9
+ cd top
10
+ npm run check && npm run typecheck && npm test
11
+ ```
12
+
13
+ Verify against an isolated daemon, never the default one:
14
+
15
+ ```sh
16
+ .agents/skills/deliver/scripts/dev-daemon.sh up top
17
+ .agents/skills/deliver/scripts/dev-daemon.sh down top
18
+ ```
19
+
20
+ ## Layout
21
+
22
+ - `shared/usage.ts`: the `top.usage` RPC contract and history constants.
23
+ - `server/usage.ts`: CPU, memory, disk and I/O readers, and the sampler with its history ring.
24
+ - `server/processes.ts`: `/proc` scan and attribution of processes to agent sessions.
25
+ - `server/names.ts`: joins agent and workspace names onto sessions.
26
+ - `index.server.ts`: wires the sampler to the RPC.
27
+ - `client/`, `index.client.tsx`: footer row, Top screen, dials and session list.
28
+ - `tests/`: unit tests; process tests run against a fake `/proc` tree.
package/client/dial.ts ADDED
@@ -0,0 +1,29 @@
1
+ /** A dial sweeps 240 degrees, symmetric around straight up, with a ladder of ticks along it. */
2
+ export const SWEEP = 240;
3
+ export const TICKS = 51;
4
+ export const MAJOR_EVERY = 5;
5
+
6
+ /** Where tick `index` sits on the 0-100 scale. */
7
+ export function tickPercent(index: number): number {
8
+ return (index / (TICKS - 1)) * 100;
9
+ }
10
+
11
+ /** Degrees clockwise from straight up for a 0-100 value; an unknown value rests at the start. */
12
+ export function needleDegrees(percent: number | null): number {
13
+ const clamped = Math.min(100, Math.max(0, percent ?? 0));
14
+ return (clamped / 100) * SWEEP - SWEEP / 2;
15
+ }
16
+
17
+ /** Bar heights as 0-1 fractions of `scale`, oldest first; unknown samples are 0. */
18
+ export function sparkHeights(values: readonly (number | null)[], scale: number): number[] {
19
+ return values.map((value) => (value === null || scale <= 0 ? 0 : Math.min(1, value / scale)));
20
+ }
21
+
22
+ /** A screen reader gets no current value for an unknown reading, rather than a false zero. */
23
+ export function accessibilityValue(percent: number | null): {
24
+ min: number;
25
+ max: number;
26
+ now?: number;
27
+ } {
28
+ return percent === null ? { min: 0, max: 100 } : { min: 0, max: 100, now: Math.round(percent) };
29
+ }
@@ -0,0 +1,55 @@
1
+ import type { PluginSidebarItemProps } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { SidebarRow } from "@getpaseo/plugin/client/ui";
4
+ import { Text, View } from "react-native";
5
+ import { formatPercent, formatRate, levelColor } from "./format";
6
+ import { SCREEN_ID, useUsage } from "./usage";
7
+
8
+ export function TopFooter({ theme, host, currentScreen, openScreen }: PluginSidebarItemProps) {
9
+ const usage = useUsage(host.id);
10
+ const heavy = usage.data?.sessions?.filter((session) => session.heavy).length ?? 0;
11
+ const metrics =
12
+ usage.data &&
13
+ ([
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],
18
+ ] as const);
19
+
20
+ return (
21
+ <SidebarRow
22
+ icon="Activity"
23
+ label={host.label}
24
+ active={currentScreen?.screenId === SCREEN_ID}
25
+ onPress={() => openScreen({ screenId: SCREEN_ID })}
26
+ trailing={
27
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
28
+ {usage.isError && (
29
+ <Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>unavailable</Text>
30
+ )}
31
+ {heavy > 0 && (
32
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
33
+ <Icon name="Flame" size={11} color={theme.colors.statusDanger} />
34
+ <Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>{heavy}</Text>
35
+ </View>
36
+ )}
37
+ {metrics?.map(([icon, text, percent]) => {
38
+ const color = levelColor(theme, percent);
39
+ return (
40
+ <View key={icon} style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
41
+ <Icon name={icon} size={11} color={color} />
42
+ <Text
43
+ numberOfLines={1}
44
+ style={{ color, fontSize: 11, fontVariant: ["tabular-nums"] }}
45
+ >
46
+ {text}
47
+ </Text>
48
+ </View>
49
+ );
50
+ })}
51
+ </View>
52
+ }
53
+ />
54
+ );
55
+ }
@@ -0,0 +1,56 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+
3
+ export type Level = "ok" | "warn" | "hot";
4
+
5
+ export function loadLevel(percent: number | null): Level {
6
+ if (percent === null) return "ok";
7
+ if (percent >= 90) return "hot";
8
+ return percent >= 70 ? "warn" : "ok";
9
+ }
10
+
11
+ export function formatPercent(percent: number | null): string {
12
+ return percent === null ? "--" : `${Math.round(percent)}%`;
13
+ }
14
+
15
+ /**
16
+ * Scales to the largest unit that keeps the number short, with one decimal below ten. Rounding can
17
+ * carry a value over the next unit (1023.6K is 1.0M, not 1024K), so a unit is promoted on the
18
+ * rounded number.
19
+ */
20
+ function scaled(value: number, base: number, units: readonly string[]): string {
21
+ let unit = 0;
22
+ let result = value;
23
+ while (Math.round(result) >= base && unit < units.length - 1) {
24
+ result /= base;
25
+ unit++;
26
+ }
27
+ const decimal = unit > 0 && Math.round(result * 10) / 10 < 10;
28
+ return `${decimal ? result.toFixed(1) : Math.round(result)}${units[unit]}`;
29
+ }
30
+
31
+ /** Short throughput such as `0/s`, `850/s`, `12K/s`, `1.5G/s`. */
32
+ export function formatRate(bytesPerSecond: number | null): string {
33
+ return bytesPerSecond === null ? "--" : `${scaled(bytesPerSecond, 1000, ["", "K", "M", "G"])}/s`;
34
+ }
35
+
36
+ /** CPU in cores, such as `0.3 cores` or `12 cores`. */
37
+ export function formatCores(cores: number): string {
38
+ return `${Math.round(cores * 10) / 10 < 10 ? cores.toFixed(1) : Math.round(cores)} cores`;
39
+ }
40
+
41
+ /** Short size such as `0B`, `850B`, `12M`, `2.5G`. */
42
+ export function formatBytes(bytes: number): string {
43
+ return scaled(bytes, 1024, ["B", "K", "M", "G", "T"]);
44
+ }
45
+
46
+ export function levelColor(theme: PluginTheme, percent: number | null): string {
47
+ const level = loadLevel(percent);
48
+ if (level === "hot") return theme.colors.statusDanger;
49
+ return level === "warn" ? theme.colors.statusWarning : theme.colors.foregroundMuted;
50
+ }
51
+
52
+ /** Like `levelColor`, but a healthy value is green; for dials that should look alive. */
53
+ export function zoneColor(theme: PluginTheme, percent: number): string {
54
+ const color = levelColor(theme, percent);
55
+ return color === theme.colors.foregroundMuted ? theme.colors.statusSuccess : color;
56
+ }
@@ -0,0 +1,187 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useEffect, useRef } from "react";
4
+ import { Animated, Easing, Text, View } from "react-native";
5
+ import {
6
+ accessibilityValue,
7
+ MAJOR_EVERY,
8
+ needleDegrees,
9
+ SWEEP,
10
+ sparkHeights,
11
+ TICKS,
12
+ tickPercent,
13
+ } from "./dial";
14
+ import { levelColor, loadLevel, zoneColor } from "./format";
15
+
16
+ const TICK_ROWS = Array.from({ length: TICKS }, (_, index) => index);
17
+
18
+ interface DialProps {
19
+ theme: PluginTheme;
20
+ size: number;
21
+ icon: string;
22
+ label: string;
23
+ /** 0-100, or null when the host cannot report it. */
24
+ value: number | null;
25
+ text: string;
26
+ }
27
+
28
+ /** A speedometer: green, amber and red zones light up to the value, and the needle sweeps there. */
29
+ export function Dial({ theme, size, icon, label, value, text }: DialProps) {
30
+ const radius = size / 2;
31
+ const needleLength = radius * 0.72;
32
+ const degrees = useRef(new Animated.Value(needleDegrees(value))).current;
33
+ useEffect(() => {
34
+ Animated.timing(degrees, {
35
+ toValue: needleDegrees(value),
36
+ duration: 700,
37
+ easing: Easing.out(Easing.cubic),
38
+ useNativeDriver: false,
39
+ }).start();
40
+ }, [degrees, value]);
41
+ const rotate = degrees.interpolate({
42
+ inputRange: [-SWEEP / 2, SWEEP / 2],
43
+ outputRange: [`${-SWEEP / 2}deg`, `${SWEEP / 2}deg`],
44
+ });
45
+
46
+ return (
47
+ <View
48
+ accessible
49
+ accessibilityRole="progressbar"
50
+ accessibilityLabel={`${label} ${text}`}
51
+ accessibilityValue={accessibilityValue(value)}
52
+ style={{ width: size, height: size * 0.9 }}
53
+ >
54
+ {TICK_ROWS.map((index) => {
55
+ const major = index % MAJOR_EVERY === 0;
56
+ const length = major ? 15 : 9;
57
+ const width = major ? 3 : 2;
58
+ const percent = tickPercent(index);
59
+ const lit = value !== null && percent <= value;
60
+ return (
61
+ <View
62
+ key={index}
63
+ style={{
64
+ position: "absolute",
65
+ left: radius - width / 2,
66
+ top: radius - length / 2,
67
+ width,
68
+ height: length,
69
+ borderRadius: width / 2,
70
+ backgroundColor: lit ? zoneColor(theme, percent) : theme.colors.foregroundMuted,
71
+ opacity: lit ? 1 : 0.2,
72
+ transform: [
73
+ { rotate: `${(percent / 100) * SWEEP - SWEEP / 2}deg` },
74
+ { translateY: -(radius - 6 - length / 2) },
75
+ ],
76
+ }}
77
+ />
78
+ );
79
+ })}
80
+ <Animated.View
81
+ style={{
82
+ position: "absolute",
83
+ left: radius - 1.5,
84
+ top: radius - needleLength,
85
+ width: 3,
86
+ height: needleLength,
87
+ borderRadius: 2,
88
+ backgroundColor: value === null ? theme.colors.foregroundMuted : theme.colors.foreground,
89
+ transform: [
90
+ { translateY: needleLength / 2 },
91
+ { rotate },
92
+ { translateY: -needleLength / 2 },
93
+ ],
94
+ }}
95
+ />
96
+ <View
97
+ style={{
98
+ position: "absolute",
99
+ left: radius - 8,
100
+ top: radius - 8,
101
+ width: 16,
102
+ height: 16,
103
+ borderRadius: 8,
104
+ borderWidth: 3,
105
+ borderColor: theme.colors.foreground,
106
+ backgroundColor: theme.colors.surface1,
107
+ }}
108
+ />
109
+ <View
110
+ style={{ position: "absolute", left: 0, right: 0, top: radius + 18, alignItems: "center" }}
111
+ >
112
+ <Text
113
+ style={{
114
+ color:
115
+ value === null || loadLevel(value) === "ok"
116
+ ? theme.colors.foreground
117
+ : levelColor(theme, value),
118
+ fontSize: size * 0.19,
119
+ fontWeight: "600",
120
+ fontVariant: ["tabular-nums"],
121
+ }}
122
+ >
123
+ {text}
124
+ </Text>
125
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 4, marginTop: 2 }}>
126
+ <Icon name={icon} size={12} color={theme.colors.foregroundMuted} />
127
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, letterSpacing: 0.6 }}>
128
+ {label}
129
+ </Text>
130
+ </View>
131
+ </View>
132
+ </View>
133
+ );
134
+ }
135
+
136
+ interface SparklineProps {
137
+ theme: PluginTheme;
138
+ /** Oldest first; `scale` is the value that fills the bar. */
139
+ values: readonly (number | null)[];
140
+ scale: number;
141
+ width: number;
142
+ /** Bars always occupy `slots` positions, so a young history does not stretch. */
143
+ slots: number;
144
+ color: (value: number | null) => string;
145
+ }
146
+
147
+ /** A strip of bars for the last few minutes, so a spike is still visible after it passes. */
148
+ export function Sparkline({ theme, values, scale, width, slots, color }: SparklineProps) {
149
+ const heights = sparkHeights(values, scale);
150
+ const empty = Math.max(0, slots - values.length);
151
+ return (
152
+ <View
153
+ style={{
154
+ width,
155
+ height: 28,
156
+ flexDirection: "row",
157
+ alignItems: "flex-end",
158
+ gap: 2,
159
+ borderBottomWidth: 1,
160
+ borderBottomColor: theme.colors.border,
161
+ }}
162
+ >
163
+ {Array.from({ length: slots }, (_, slot) => {
164
+ const sample = slot - empty;
165
+ // Slots are positional: samples are only ever appended and have no id.
166
+ const key = slot;
167
+ if (sample < 0) {
168
+ return (
169
+ <View key={key} style={{ flex: 1, height: 2, backgroundColor: theme.colors.border }} />
170
+ );
171
+ }
172
+ return (
173
+ <View
174
+ key={key}
175
+ style={{
176
+ flex: 1,
177
+ height: Math.max(2, (heights[sample] ?? 0) * 28),
178
+ borderRadius: 1,
179
+ backgroundColor: color(values[sample] ?? null),
180
+ opacity: 0.35 + 0.65 * ((slot + 1) / slots),
181
+ }}
182
+ />
183
+ );
184
+ })}
185
+ </View>
186
+ );
187
+ }
@@ -0,0 +1,165 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+ import type { PluginScreenProps } from "@getpaseo/plugin/client";
3
+ import { Icon } from "@getpaseo/plugin/client/react-native";
4
+ import type { ReactNode } from "react";
5
+ import { ScrollView, Text, View } from "react-native";
6
+ import { HISTORY_EVERY_MS, HISTORY_LENGTH, type Sample } from "../shared/usage";
7
+ import { formatPercent, formatRate, zoneColor } from "./format";
8
+ import { Dial, Sparkline } from "./gauge";
9
+ import { Sessions } from "./sessions";
10
+ import { useUsage } from "./usage";
11
+
12
+ const DIALS = [
13
+ { label: "CPU", icon: "Cpu", pick: (usage: Sample) => usage.cpu },
14
+ { label: "MEMORY", icon: "MemoryStick", pick: (usage: Sample) => usage.memory },
15
+ { label: "DISK", icon: "HardDrive", pick: (usage: Sample) => usage.disk },
16
+ ] as const;
17
+
18
+ function Card({
19
+ theme,
20
+ width,
21
+ children,
22
+ }: {
23
+ theme: PluginTheme;
24
+ width: number;
25
+ children: ReactNode;
26
+ }) {
27
+ return (
28
+ <View
29
+ style={{
30
+ width,
31
+ alignItems: "center",
32
+ gap: 12,
33
+ padding: 16,
34
+ borderRadius: 18,
35
+ borderWidth: 1,
36
+ borderColor: theme.colors.border,
37
+ backgroundColor: theme.colors.surface1,
38
+ }}
39
+ >
40
+ {children}
41
+ </View>
42
+ );
43
+ }
44
+
45
+ /** The screen header carries Paseo's host picker; the footer follows the host picked here. */
46
+ export function TopScreen({ theme, layout, host, navigation }: PluginScreenProps) {
47
+ const usage = useUsage(host.id);
48
+ const history = usage.data?.history ?? [];
49
+ const dial = layout.compact ? 156 : 200;
50
+ const inner = dial;
51
+ const io = history.map((sample) => sample.io);
52
+ // The live reading is fresher than the newest history point, so it counts toward the peak too.
53
+ const peak = [...io, usage.data?.io ?? null].reduce<number | null>(
54
+ (max, value) => (value === null ? max : Math.max(max ?? 0, value)),
55
+ null,
56
+ );
57
+ // The floor only keeps a near-idle disk from filling the bars; the label shows the real peak.
58
+ const scale = Math.max(1_000_000, peak ?? 0);
59
+
60
+ return (
61
+ <ScrollView
62
+ style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
63
+ contentContainerStyle={{ padding: layout.compact ? 16 : 24, gap: 20 }}
64
+ >
65
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
66
+ <Icon name="Activity" size={22} color={theme.colors.foreground} />
67
+ <Text style={{ color: theme.colors.foreground, fontSize: layout.compact ? 20 : 26 }}>
68
+ {host.label}
69
+ </Text>
70
+ <View
71
+ style={{
72
+ width: 8,
73
+ height: 8,
74
+ borderRadius: 4,
75
+ backgroundColor: usage.isError ? theme.colors.statusDanger : theme.colors.statusSuccess,
76
+ }}
77
+ />
78
+ </View>
79
+ {usage.isError && (
80
+ <Text style={{ color: theme.colors.statusDanger }}>
81
+ Usage is unavailable. Check that the top plugin is running on this host.
82
+ </Text>
83
+ )}
84
+ <View
85
+ style={{
86
+ flexDirection: "row",
87
+ flexWrap: "wrap",
88
+ gap: 16,
89
+ justifyContent: layout.compact ? "center" : "flex-start",
90
+ }}
91
+ >
92
+ {DIALS.map(({ label, icon, pick }) => {
93
+ const value = usage.data ? pick(usage.data) : null;
94
+ return (
95
+ <Card key={label} theme={theme} width={dial + 32}>
96
+ <Dial
97
+ theme={theme}
98
+ size={dial}
99
+ icon={icon}
100
+ label={label}
101
+ value={value}
102
+ text={formatPercent(value)}
103
+ />
104
+ <Sparkline
105
+ theme={theme}
106
+ values={history.map(pick)}
107
+ scale={100}
108
+ width={inner}
109
+ slots={HISTORY_LENGTH}
110
+ color={(sample) =>
111
+ sample === null ? theme.colors.foregroundMuted : zoneColor(theme, sample)
112
+ }
113
+ />
114
+ </Card>
115
+ );
116
+ })}
117
+ <Card theme={theme} width={dial + 32}>
118
+ <View style={{ height: dial * 0.9, width: dial, justifyContent: "center", gap: 6 }}>
119
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
120
+ <Icon name="Gauge" size={14} color={theme.colors.foregroundMuted} />
121
+ <Text
122
+ style={{ color: theme.colors.foregroundMuted, fontSize: 12, letterSpacing: 0.6 }}
123
+ >
124
+ DISK I/O
125
+ </Text>
126
+ </View>
127
+ <Text
128
+ style={{
129
+ color: theme.colors.foreground,
130
+ fontSize: dial * 0.2,
131
+ fontWeight: "600",
132
+ fontVariant: ["tabular-nums"],
133
+ }}
134
+ >
135
+ {formatRate(usage.data?.io ?? null)}
136
+ </Text>
137
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
138
+ {`peak ${formatRate(peak)}`}
139
+ </Text>
140
+ </View>
141
+ <Sparkline
142
+ theme={theme}
143
+ values={io}
144
+ scale={scale}
145
+ width={inner}
146
+ slots={HISTORY_LENGTH}
147
+ color={() => theme.colors.accent}
148
+ />
149
+ </Card>
150
+ </View>
151
+ {usage.data && (
152
+ <Sessions
153
+ theme={theme}
154
+ sessions={usage.data.sessions}
155
+ other={usage.data.other}
156
+ onOpenAgent={navigation ? (agentId) => navigation.openAgent({ agentId }) : undefined}
157
+ />
158
+ )}
159
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
160
+ Updates every 5 seconds. Bars cover the last {(HISTORY_LENGTH * HISTORY_EVERY_MS) / 60_000}{" "}
161
+ minutes.
162
+ </Text>
163
+ </ScrollView>
164
+ );
165
+ }
@@ -0,0 +1,91 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { Pressable, Text, View } from "react-native";
4
+ import type { Session } from "../shared/usage";
5
+ import { formatBytes, formatCores } from "./format";
6
+
7
+ export function sessionLabel(session: Session): string {
8
+ return session.title || `Agent ${session.agentId.slice(0, 8)}`;
9
+ }
10
+
11
+ interface SessionsProps {
12
+ theme: PluginTheme;
13
+ /** `null` where the host cannot attribute processes to agents. */
14
+ sessions: Session[] | null;
15
+ other: { cores: number; rss: number };
16
+ onOpenAgent?: (agentId: string) => void;
17
+ }
18
+
19
+ /** Load by agent session, busiest first, with what is actually burning it. */
20
+ export function Sessions({ theme, sessions, other, onOpenAgent }: SessionsProps) {
21
+ const muted = { color: theme.colors.foregroundMuted, fontSize: 12 };
22
+ const rows = [
23
+ <Text key="heading" style={{ ...muted, letterSpacing: 0.6 }}>
24
+ LOAD BY SESSION
25
+ </Text>,
26
+ ];
27
+
28
+ if (sessions === null) {
29
+ rows.push(
30
+ <Text key="unsupported" style={muted}>
31
+ Per-session load needs a Linux host.
32
+ </Text>,
33
+ );
34
+ } else if (sessions.length === 0) {
35
+ rows.push(
36
+ <Text key="empty" style={muted}>
37
+ No agent is running anything right now.
38
+ </Text>,
39
+ );
40
+ }
41
+ for (const session of sessions ?? []) {
42
+ const detail = [session.workspace, session.command].filter(Boolean).join(" · ");
43
+ rows.push(
44
+ <Pressable
45
+ key={session.agentId}
46
+ accessibilityRole={onOpenAgent ? "button" : undefined}
47
+ disabled={!onOpenAgent}
48
+ onPress={() => onOpenAgent?.(session.agentId)}
49
+ style={{ flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 }}
50
+ >
51
+ <View style={{ width: 14 }}>
52
+ {session.heavy && <Icon name="Flame" size={14} color={theme.colors.statusDanger} />}
53
+ </View>
54
+ <View style={{ flex: 1 }}>
55
+ <Text numberOfLines={1} style={{ color: theme.colors.foreground, fontSize: 14 }}>
56
+ {sessionLabel(session)}
57
+ </Text>
58
+ <Text numberOfLines={1} style={muted}>
59
+ {detail}
60
+ </Text>
61
+ </View>
62
+ <View style={{ alignItems: "flex-end" }}>
63
+ <Text
64
+ style={{
65
+ color: session.heavy ? theme.colors.statusDanger : theme.colors.foreground,
66
+ fontSize: 14,
67
+ fontVariant: ["tabular-nums"],
68
+ }}
69
+ >
70
+ {formatCores(session.cores)}
71
+ </Text>
72
+ <Text style={{ ...muted, fontVariant: ["tabular-nums"] }}>
73
+ {formatBytes(session.rss)}
74
+ </Text>
75
+ </View>
76
+ </Pressable>,
77
+ );
78
+ }
79
+ if (sessions !== null && (other.cores > 0 || other.rss > 0)) {
80
+ rows.push(
81
+ <View key="other" style={{ flexDirection: "row", gap: 10, paddingTop: 6 }}>
82
+ <View style={{ width: 14 }} />
83
+ <Text style={{ ...muted, flex: 1 }}>Everything else</Text>
84
+ <Text style={{ ...muted, fontVariant: ["tabular-nums"] }}>
85
+ {`${formatCores(other.cores)} · ${formatBytes(other.rss)}`}
86
+ </Text>
87
+ </View>,
88
+ );
89
+ }
90
+ return <View style={{ gap: 2 }}>{rows}</View>;
91
+ }
@@ -0,0 +1,16 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { getUsage } from "../shared/usage";
4
+
5
+ export const SCREEN_ID = "usage";
6
+ export const POLL_MS = 5000;
7
+
8
+ export function useUsage(hostId: string) {
9
+ const loadUsage = useRpc(getUsage);
10
+ // The host id keys the cache so a host switch never shows another host's numbers.
11
+ return useQuery({
12
+ queryKey: ["top", hostId, "usage"],
13
+ queryFn: () => loadUsage({}),
14
+ refetchInterval: POLL_MS,
15
+ });
16
+ }