paseo-plugin-machine-status 0.1.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.
@@ -0,0 +1,24 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const ExplanationSchema = z.object({
5
+ headline: z.string(),
6
+ kind: z.enum(["apple-system", "agent-spawned", "container", "dev-runtime", "user-app", "unknown"]),
7
+ state: z.enum(["just-started", "sustained-load", "memory-heavy", "steady"]),
8
+ concern: z.enum(["none", "watch", "act"]),
9
+ findings: z.array(z.string()),
10
+ advice: z.array(z.string()),
11
+ });
12
+
13
+ export const explainProcess = defineRpc({
14
+ name: "process.explain",
15
+ input: z.object({ pid: z.number().int().min(0) }),
16
+ output: z.object({
17
+ found: z.boolean(),
18
+ name: z.string(),
19
+ pid: z.number(),
20
+ explanation: ExplanationSchema,
21
+ }),
22
+ });
23
+
24
+ export type Explanation = z.output<typeof ExplanationSchema>;
@@ -0,0 +1,39 @@
1
+ /** Presentation helpers shared by every surface. Pure functions, no runtime imports. */
2
+
3
+ const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const;
4
+
5
+ export function formatBytes(bytes: number, fractionDigits = 1): string {
6
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
7
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1);
8
+ const value = bytes / 1024 ** exponent;
9
+ const digits = exponent === 0 ? 0 : fractionDigits;
10
+ return `${value.toFixed(digits)} ${BYTE_UNITS[exponent]}`;
11
+ }
12
+
13
+ export function formatPercent(value: number | null, fractionDigits = 0): string {
14
+ if (value === null || !Number.isFinite(value)) return "—";
15
+ return `${value.toFixed(fractionDigits)}%`;
16
+ }
17
+
18
+ export function formatDuration(totalSeconds: number): string {
19
+ if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return "—";
20
+ const days = Math.floor(totalSeconds / 86_400);
21
+ const hours = Math.floor((totalSeconds % 86_400) / 3_600);
22
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
23
+ if (days > 0) return `${days}d ${hours}h`;
24
+ if (hours > 0) return `${hours}h ${minutes}m`;
25
+ return `${minutes}m`;
26
+ }
27
+
28
+ export function formatMinutes(minutes: number | null): string {
29
+ if (minutes === null || !Number.isFinite(minutes) || minutes <= 0) return "—";
30
+ const hours = Math.floor(minutes / 60);
31
+ const rest = Math.round(minutes % 60);
32
+ return hours > 0 ? `${hours}h ${rest}m` : `${rest}m`;
33
+ }
34
+
35
+ /** Clamps a raw percentage into the 0..100 range a progress bar can render. */
36
+ export function clampPercent(value: number | null): number {
37
+ if (value === null || !Number.isFinite(value)) return 0;
38
+ return Math.max(0, Math.min(100, value));
39
+ }
package/shared/pill.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { PortRow } from "./services";
2
+ import { severityOf, type Severity } from "./viz";
3
+
4
+ export interface PillReading {
5
+ /** What the pill shows: two percentages, nothing else fits. */
6
+ text: string;
7
+ /** The worse of the two readings, so one colour can speak for both. */
8
+ severity: Severity;
9
+ /** Spoken label, where there is room to say which number is which. */
10
+ label: string;
11
+ }
12
+
13
+ const RANK: Record<Severity, number> = { good: 0, warning: 1, serious: 2, critical: 3 };
14
+
15
+ const show = (value: number | null): string =>
16
+ value === null || !Number.isFinite(value) ? "—" : `${Math.round(value)}%`;
17
+
18
+ /**
19
+ * Reduces the machine to what fits beside a text field.
20
+ *
21
+ * CPU load and memory pressure are the two numbers that change while an agent
22
+ * works, and they fail in different ways: one saturates, the other swaps. Both
23
+ * are shown; the colour follows whichever is worse, because a pill has room for
24
+ * one colour and the user needs to know that something is wrong before knowing
25
+ * which one.
26
+ */
27
+ export function pillReading(cpuLoadPercent: number, pressurePercent: number | null): PillReading {
28
+ const cpu = cpuSeverity(cpuLoadPercent);
29
+ const memory = memorySeverity(pressurePercent);
30
+ return {
31
+ text: `${show(cpuLoadPercent)} · ${show(pressurePercent)}`,
32
+ severity: RANK[cpu] >= RANK[memory] ? cpu : memory,
33
+ label: `Machine load: CPU ${show(cpuLoadPercent)}, memory ${show(pressurePercent)}`,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * The thresholds the pill colours by, shared with its card so the two never
39
+ * disagree about the same number. CPU load reads as utilisation; memory pressure
40
+ * keeps the higher thresholds the pill has always used.
41
+ */
42
+ export function cpuSeverity(loadPercent: number | null): Severity {
43
+ return severityOf(loadPercent);
44
+ }
45
+
46
+ export function memorySeverity(pressurePercent: number | null): Severity {
47
+ return severityOf(pressurePercent, 80, 92);
48
+ }
49
+
50
+ /**
51
+ * Development ports held by an agent or by a process an agent started, once per
52
+ * port number: the side effects of agent work that outlive the turn that made
53
+ * them. IPv4 and IPv6 listeners on the same port collapse into one row.
54
+ */
55
+ export function agentPorts(ports: readonly PortRow[], limit = 3): PortRow[] {
56
+ const byPort = new Map<number, PortRow>();
57
+ for (const port of ports) {
58
+ const kind = port.attribution?.kind;
59
+ if (port.relevance !== "dev" || (kind !== "agent" && kind !== "agent-child")) continue;
60
+ if (!byPort.has(port.port)) byPort.set(port.port, port);
61
+ }
62
+ return [...byPort.values()].sort((a, b) => a.port - b.port).slice(0, limit);
63
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Runtime-neutral classification for ports and services.
3
+ *
4
+ * This module deliberately imports nothing from `@getpaseo/plugin`: the same
5
+ * rules are used by the Paseo plugin and by the standalone perf-agent, and the
6
+ * agent has no Paseo runtime to load `defineRpc` from.
7
+ */
8
+
9
+
10
+ const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
11
+ const WILDCARD = new Set(["*", "0.0.0.0", "::", "[::]"]);
12
+
13
+ /**
14
+ * A wildcard bind is reachable from the network; a loopback bind is not. The
15
+ * distinction is the whole point of the list, so it is computed from the address
16
+ * rather than guessed from the port number.
17
+ */
18
+ export function scopeOf(address: string): "public" | "lan" | "local" {
19
+ const value = address.replace(/^\[|\]$/g, "");
20
+ if (WILDCARD.has(value)) return "public";
21
+ if (LOOPBACK.has(value)) return "local";
22
+ return "lan";
23
+ }
24
+
25
+ /** netstat writes `127.0.0.1.49165`, `*.3620`, `::1.15432` — the port is after the last dot. */
26
+ export function parseNetstatAddress(field: string): { address: string; port: number } | null {
27
+ const index = field.lastIndexOf(".");
28
+ if (index <= 0) return null;
29
+ const port = Number(field.slice(index + 1));
30
+ if (!Number.isInteger(port) || port <= 0) return null;
31
+ return { address: field.slice(0, index), port };
32
+ }
33
+
34
+ /** lsof writes `*:49258`, `127.0.0.1:15432`, `[::1]:15432` — the port is after the last colon. */
35
+ export function parseLsofAddress(name: string): { address: string; port: number } | null {
36
+ if (name.includes("->")) return null; // an established connection, not a bound socket
37
+ const index = name.lastIndexOf(":");
38
+ if (index <= 0) return null;
39
+ const port = Number(name.slice(index + 1));
40
+ if (!Number.isInteger(port) || port <= 0) return null;
41
+ return { address: name.slice(0, index).replace(/^\[|\]$/g, ""), port };
42
+ }
43
+
44
+ export function portKey(protocol: string, address: string, port: number): string {
45
+ return `${protocol}:${address.replace(/^\[|\]$/g, "")}:${port}`;
46
+ }
47
+
48
+ /**
49
+ * `ps` reports commands as a full path, a bare name, or a name with a version
50
+ * suffix (`next-server (v16.3.1)`). All three have to reduce to the same token.
51
+ */
52
+ export function executableName(command: string): string {
53
+ const withoutVersion = command.replace(/\s*\(.*\)\s*$/, "").trim();
54
+ return withoutVersion.includes("/")
55
+ ? (withoutVersion.split("/").pop() ?? withoutVersion)
56
+ : withoutVersion;
57
+ }
58
+
59
+ /** Paths macOS reserves for itself. Nothing under these is development work. */
60
+ const SYSTEM_PREFIXES = [
61
+ "/System/",
62
+ "/usr/libexec/",
63
+ "/usr/sbin/",
64
+ "/Library/Apple/",
65
+ "/Library/PrivilegedHelperTools/",
66
+ ];
67
+
68
+ /** Runtimes and services a developer actually starts. */
69
+ const DEV_RUNTIMES = new Set([
70
+ "node", "bun", "deno", "npm", "npx", "pnpm", "yarn", "tsx", "ts-node", "vite",
71
+ "next-server", "next", "esbuild", "webpack", "turbo", "nodemon",
72
+ "python", "python3", "uvicorn", "gunicorn", "flask", "django-admin",
73
+ "ruby", "rails", "puma", "php", "php-fpm", "java", "gradle",
74
+ "go", "air", "cargo", "rustc", "dotnet", "elixir", "beam.smp",
75
+ "postgres", "postmaster", "mysqld", "mongod", "redis-server", "valkey-server",
76
+ "kubectl", "minikube", "ngrok", "cloudflared", "caddy", "nginx",
77
+ "opencode", "claude", "codex", "kimi", "gemini", "aider", "amp", "goose",
78
+ "Paseo Daemon",
79
+ ]);
80
+
81
+ export interface RelevanceInput {
82
+ kind: "agent" | "agent-child" | "docker" | "process" | null;
83
+ /** Full path when `ps` knows it; the bare command otherwise. */
84
+ command: string;
85
+ owner: "you" | "other";
86
+ home: string;
87
+ }
88
+
89
+ /**
90
+ * Development ports are identified by evidence, never by port number. Ranges do
91
+ * not separate them: 5000 and 7000 here are AirPlay, while 49165 is a coding
92
+ * agent.
93
+ */
94
+ export function classifyRelevance({ kind, command, owner, home }: RelevanceInput): "dev" | "system" {
95
+ if (kind === "agent" || kind === "agent-child" || kind === "docker") return "dev";
96
+ if (owner === "other") return "system";
97
+ if (SYSTEM_PREFIXES.some((prefix) => command.startsWith(prefix))) return "system";
98
+
99
+ const name = executableName(command);
100
+ if (DEV_RUNTIMES.has(name)) return "dev";
101
+ if (/^com\.docker/.test(name)) return "dev";
102
+
103
+ // Anything the user installed or built under their own home, except the
104
+ // Library tree, which is where macOS keeps per-user daemons.
105
+ if (home.length > 0 && command.startsWith(`${home}/`) && !command.startsWith(`${home}/Library/`)) {
106
+ return "dev";
107
+ }
108
+ return "system";
109
+ }
110
+
111
+ /**
112
+ * Known binaries get a readable name. Everything else keeps the command it
113
+ * actually runs — inventing a plausible service name would make the list read as
114
+ * knowledge it does not have.
115
+ */
116
+ const NAMES: readonly (readonly [RegExp, string])[] = [
117
+ [/^vite$|[/\s]vite$/i, "Vite dev server"],
118
+ [/next-server|^next$/i, "Next.js"],
119
+ [/^postgres$|^postmaster$/i, "PostgreSQL"],
120
+ [/^redis-server$/i, "Redis"],
121
+ [/^valkey/i, "Valkey"],
122
+ [/^mysqld$/i, "MySQL"],
123
+ [/^mongod$/i, "MongoDB"],
124
+ [/^sshd$/i, "OpenSSH"],
125
+ [/^tailscaled?$/i, "Tailscale"],
126
+ [/^com\.docke|^docker/i, "Docker"],
127
+ [/^esbuild$/i, "esbuild"],
128
+ [/^ControlCenter$/i, "Control Center"],
129
+ [/^rapportd$/i, "Handoff (rapportd)"],
130
+ [/^kubectl/i, "kubectl port-forward"],
131
+ ];
132
+
133
+ export function friendlyName(command: string): string {
134
+ const base = command.split("/").pop() ?? command;
135
+ for (const [pattern, name] of NAMES) {
136
+ if (pattern.test(base)) return name;
137
+ }
138
+ return base;
139
+ }
140
+
141
+ /** Only a locally reachable TCP port can be opened in a browser. */
142
+ export function httpUrlFor(row: {
143
+ protocol: string;
144
+ scope: string;
145
+ port: number;
146
+ }): string | null {
147
+ if (row.protocol !== "tcp") return null;
148
+ if (row.scope === "lan") return null;
149
+ return `http://127.0.0.1:${row.port}`;
150
+ }
151
+
152
+ /**
153
+ * The SDK's list shape is not pinned down by the docs, so the result is read
154
+ * defensively: an array, or the first array-valued key of an object. A shape
155
+ * change degrades this panel instead of breaking the whole RPC.
156
+ */
157
+ export function normalizeAgents(
158
+ raw: unknown,
159
+ /** Expands the `~/…` form the Paseo CLI prints; the SDK returns absolute paths. */
160
+ home = "",
161
+ ): {
162
+ id: string;
163
+ provider: string;
164
+ cwd: string;
165
+ title: string;
166
+ status: string;
167
+ }[] {
168
+ const list = Array.isArray(raw)
169
+ ? raw
170
+ : raw && typeof raw === "object"
171
+ ? (Object.values(raw as Record<string, unknown>).find(Array.isArray) as unknown[] | undefined) ?? []
172
+ : [];
173
+
174
+ return list
175
+ .filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === "object")
176
+ .map((entry) => {
177
+ const config = (entry.config ?? {}) as Record<string, unknown>;
178
+ const provider = String(entry.provider ?? config.provider ?? "");
179
+ const cwd = String(entry.cwd ?? "");
180
+ return {
181
+ id: String(entry.id ?? ""),
182
+ // "codex/gpt-5.5" identifies the binary by its first segment.
183
+ provider: provider.split("/")[0]?.toLowerCase() ?? "",
184
+ cwd: home && cwd.startsWith("~/") ? `${home}/${cwd.slice(2)}` : cwd,
185
+ title: String(entry.title ?? entry.name ?? ""),
186
+ status: String(entry.status ?? ""),
187
+ };
188
+ })
189
+ .filter((agent) => agent.provider.length > 0);
190
+ }
@@ -0,0 +1,52 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const PortSchema = z.object({
5
+ id: z.string(),
6
+ port: z.number(),
7
+ protocol: z.enum(["tcp", "udp"]),
8
+ address: z.string(),
9
+ scope: z.enum(["public", "lan", "local"]),
10
+ pid: z.number().nullable(),
11
+ command: z.string().nullable(),
12
+ /** "other" means the socket exists but belongs to a user we cannot inspect. */
13
+ owner: z.enum(["you", "other"]),
14
+ /** Whether this port belongs to development work or to the OS. */
15
+ relevance: z.enum(["dev", "system"]),
16
+ attribution: z
17
+ .object({
18
+ kind: z.enum(["agent", "agent-child", "docker", "process"]),
19
+ label: z.string(),
20
+ detail: z.string(),
21
+ })
22
+ .nullable(),
23
+ url: z.string().nullable(),
24
+ });
25
+
26
+ export const ServiceSchema = z.object({
27
+ id: z.string(),
28
+ name: z.string(),
29
+ subtitle: z.string(),
30
+ ports: z.array(z.number()),
31
+ status: z.enum(["running", "stopped"]),
32
+ source: z.enum(["agent", "agent-child", "docker", "listener"]),
33
+ relevance: z.enum(["dev", "system"]),
34
+ pid: z.number().nullable(),
35
+ });
36
+
37
+ export const readServices = defineRpc({
38
+ name: "services.read",
39
+ input: z.object({}),
40
+ output: z.object({
41
+ collectedAt: z.string(),
42
+ ports: z.array(PortSchema),
43
+ services: z.array(ServiceSchema),
44
+ /** Anything the collection could not see, stated rather than silently dropped. */
45
+ notes: z.array(z.string()),
46
+ }),
47
+ });
48
+
49
+ export type PortRow = z.output<typeof PortSchema>;
50
+ export type ServiceRow = z.output<typeof ServiceSchema>;
51
+
52
+ export * from "./services-classify";
package/shared/viz.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Chart palette and geometry. Pure values and pure functions only, so the colour
3
+ * decisions and the bar maths are both testable without a renderer.
4
+ *
5
+ * Paseo exposes six theme tokens and never says which theme is active, so the
6
+ * data palette is chosen by measuring the luminance of `surface0`. Chrome comes
7
+ * from the theme; series colour does not — a categorical hue has to stay the
8
+ * same hue across themes or it stops identifying anything.
9
+ *
10
+ * Both columns below are the reference categorical order and were validated with
11
+ * scripts/validate_palette.js against their own surface: every hard gate passes
12
+ * in both modes (worst adjacent CVD ΔE 8.4 dark / 9.1 light, normal-vision 19.8
13
+ * dark / 22.9 light). The light column's aqua and yellow sit under 3:1, which the
14
+ * relief rule permits because every series is directly labelled with its value.
15
+ */
16
+
17
+ export interface VizPalette {
18
+ /** Categorical slots in fixed order. Assigned by slot, never cycled. */
19
+ series: readonly [string, string, string, string];
20
+ /** For "free"/"unused" remainders, which are an absence rather than a category. */
21
+ neutral: string;
22
+ grid: string;
23
+ /**
24
+ * Hairline ring. Deliberately NOT a text token: `foregroundMuted` is tuned to be
25
+ * legible as type, which makes it far too loud for a 1px edge.
26
+ */
27
+ border: string;
28
+ borderStrong: string;
29
+ }
30
+
31
+ export const DARK_PALETTE: VizPalette = {
32
+ series: ["#3987e5", "#d95926", "#199e70", "#c98500"],
33
+ neutral: "#383835",
34
+ grid: "#2c2c2a",
35
+ border: "rgba(255,255,255,0.10)",
36
+ borderStrong: "rgba(255,255,255,0.18)",
37
+ };
38
+
39
+ export const LIGHT_PALETTE: VizPalette = {
40
+ series: ["#2a78d6", "#eb6834", "#1baf7a", "#eda100"],
41
+ neutral: "#c3c2b7",
42
+ grid: "#e1e0d9",
43
+ border: "rgba(11,11,11,0.10)",
44
+ borderStrong: "rgba(11,11,11,0.18)",
45
+ };
46
+
47
+ /** Fixed across themes: a status colour must never impersonate a series. */
48
+ export const STATUS = {
49
+ good: "#0ca30c",
50
+ warning: "#fab219",
51
+ serious: "#ec835a",
52
+ critical: "#d03b3b",
53
+ } as const;
54
+
55
+ export type Severity = keyof typeof STATUS;
56
+
57
+ const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
58
+ const RGB = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i;
59
+
60
+ function channel(value: number): number {
61
+ const c = value / 255;
62
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
63
+ }
64
+
65
+ /** WCAG relative luminance, or null when the colour is in a format we cannot read. */
66
+ export function relativeLuminance(color: string): number | null {
67
+ const trimmed = color.trim();
68
+
69
+ const hex = HEX.exec(trimmed);
70
+ if (hex) {
71
+ const digits = hex[1].length === 3 ? hex[1].replace(/./g, (d) => d + d) : hex[1];
72
+ const [r, g, b] = [0, 2, 4].map((offset) => parseInt(digits.slice(offset, offset + 2), 16));
73
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
74
+ }
75
+
76
+ const rgb = RGB.exec(trimmed);
77
+ if (rgb) {
78
+ const [r, g, b] = [1, 2, 3].map((index) => Number(rgb[index]));
79
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ /** Unreadable colour formats fall back to dark, which is Paseo's default. */
86
+ export function isDarkSurface(color: string): boolean {
87
+ const luminance = relativeLuminance(color);
88
+ return luminance === null ? true : luminance < 0.4;
89
+ }
90
+
91
+ export function paletteFor(surface: string): VizPalette {
92
+ return isDarkSurface(surface) ? DARK_PALETTE : LIGHT_PALETTE;
93
+ }
94
+
95
+ /**
96
+ * Severity for a utilisation percentage. Always paired with a text label in the
97
+ * UI: a status colour never carries the meaning on its own.
98
+ */
99
+ export function severityOf(percent: number | null, warnAt = 75, criticalAt = 90): Severity {
100
+ if (percent === null || !Number.isFinite(percent)) return "good";
101
+ if (percent >= criticalAt) return "critical";
102
+ if (percent >= warnAt) return "warning";
103
+ return "good";
104
+ }
105
+
106
+ export const SEVERITY_LABEL: Record<Severity, string> = {
107
+ good: "OK",
108
+ warning: "High",
109
+ serious: "Serious",
110
+ critical: "Critical",
111
+ };
112
+
113
+ /** Column heights as percentages, for a bar-per-sample chart. */
114
+ export function columnHeights(values: readonly number[], min: number, max: number): number[] {
115
+ const span = max - min;
116
+ if (span <= 0) return values.map(() => 0);
117
+ return values.map((value) => {
118
+ const clamped = Math.max(min, Math.min(max, value));
119
+ return ((clamped - min) / span) * 100;
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Splits one stacked column into segment percentages of `total`. The remainder is
125
+ * returned as the last entry so the column always sums to 100.
126
+ */
127
+ export function stackedColumn(parts: readonly number[], total: number): number[] {
128
+ if (total <= 0) return parts.map(() => 0).concat(100);
129
+ const segments = parts.map((part) => Math.max(0, (part / total) * 100));
130
+ const used = segments.reduce((sum, segment) => sum + segment, 0);
131
+ return segments.concat(Math.max(0, 100 - used));
132
+ }
133
+
134
+ /**
135
+ * Right-aligns a series into a fixed number of slots, padding the left with nulls.
136
+ *
137
+ * A live monitor's plot has a constant width whether or not history has filled it:
138
+ * the newest sample belongs at the right edge next to "now", and the empty left is
139
+ * the honest picture of how much history exists. Stretching a handful of samples
140
+ * across the full width would instead imply they span the whole window.
141
+ */
142
+ export function padSlots<T>(values: readonly T[], slots: number): (T | null)[] {
143
+ if (slots <= 0) return [];
144
+ if (values.length >= slots) return values.slice(values.length - slots) as T[];
145
+ return [...new Array<null>(slots - values.length).fill(null), ...values];
146
+ }
147
+
148
+ export function takeLast<T>(items: readonly T[], count: number): T[] {
149
+ return count >= items.length ? [...items] : items.slice(items.length - count);
150
+ }
151
+
152
+ /** Human span between the first and last sample, for an honest axis caption. */
153
+ export function spanLabel(first: string | undefined, last: string | undefined): string {
154
+ if (!first || !last) return "no history yet";
155
+ const seconds = Math.round((Date.parse(last) - Date.parse(first)) / 1000);
156
+ if (!Number.isFinite(seconds) || seconds <= 0) return "just started";
157
+ if (seconds < 90) return `last ${seconds}s`;
158
+ return `last ${Math.round(seconds / 60)} min`;
159
+ }