paseo-plugin-machine-status 0.3.1 → 0.3.2

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
@@ -14,7 +14,7 @@ Machine Status shows what the daemon's Mac is doing while your agents work: CPU
14
14
 
15
15
  ## Setup
16
16
 
17
- - A macOS daemon on Paseo 0.11 to 0.14. On other platforms the screen says it cannot collect metrics.
17
+ - A macOS daemon on Paseo 0.11 or newer. On other platforms the screen says it cannot collect metrics.
18
18
  - Docker Desktop, OrbStack or Rancher Desktop for the Docker tab. Without one, the tab says which is missing.
19
19
 
20
20
  ## What it can change
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  <p align="center">
12
12
  <a href="https://github.com/dutchakdev/paseo-plugin-machine-status/actions/workflows/ci.yml"><img src="https://github.com/dutchakdev/paseo-plugin-machine-status/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
13
13
  <a href="https://www.npmjs.com/package/paseo-plugin-machine-status"><img src="https://img.shields.io/npm/v/paseo-plugin-machine-status" alt="npm"></a>
14
- <img src="https://img.shields.io/badge/Paseo-0.11.x%20%E2%80%93%200.14.x-3987e5" alt="Supports Paseo 0.11.x through 0.14.x, including betas">
14
+ <img src="https://img.shields.io/badge/Paseo-0.11%2B-3987e5" alt="Supports Paseo 0.11 and newer, including betas">
15
15
  <img src="https://img.shields.io/badge/macOS-only-9aa1a6" alt="macOS only">
16
16
  <img src="https://img.shields.io/badge/dependencies-none-c98500" alt="No runtime dependencies">
17
17
  <img src="https://img.shields.io/badge/license-MIT-9aa1a6" alt="MIT licensed">
@@ -44,12 +44,12 @@ source that matches your Paseo:
44
44
  Paseo 0.11 a bare `owner/name` names a directory entry, so a GitHub source there needs
45
45
  the `git:` prefix: `git:dutchakdev/paseo-plugin-machine-status`.
46
46
 
47
- The plugin supports **Paseo 0.11.x through 0.14.x, including betas**, on both the
47
+ The plugin supports **Paseo 0.11 and newer, including betas**, on both the
48
48
  daemon, which runs its collectors, and the app, which runs its screens.
49
49
 
50
50
  The manifest asks for `>=0.11.0` because it declares the icon shown in the plugin
51
51
  directory, and Paseo 0.8 to 0.10 refuse a manifest with a field they do not know.
52
- Paseo also checks a prerelease's stable version core, so `0.14.0-beta.1` satisfies
52
+ Paseo also checks a prerelease's stable version core, so the betas of later releases satisfy
53
53
  this range. The 0.11.0 betas pass that check but predate the `icon` field, so they
54
54
  refuse the manifest as well and need the release named in the install table; see
55
55
  [version requirements](https://paseo.sh/docs/plugins/reference#requirements).
@@ -364,8 +364,8 @@ including a refusal to terminate the test runner itself.
364
364
 
365
365
  ### Paseo versions
366
366
 
367
- The SDK stays pinned to 0.8.0 for typechecking and tests, so the compiler proves
368
- the plugin uses only the 0.8 API, which every supported release still provides.
367
+ The SDK stays pinned to 0.11.4 for typechecking and tests, so the compiler proves
368
+ the plugin uses only the 0.11 API, which every supported release still provides.
369
369
  Paseo supplies the runtime instances on every supported release line, so none of
370
370
  it ships with the plugin.
371
371
 
@@ -0,0 +1,139 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+
3
+ export interface DirectoryAgent {
4
+ id: string;
5
+ workspaceId?: string | null;
6
+ archivedAt?: string | null;
7
+ }
8
+
9
+ export interface DirectoryListener {
10
+ /** An agent exists, or changed. */
11
+ track(agent: DirectoryAgent): void;
12
+ /** An agent is gone. */
13
+ hide(agentId: string): void;
14
+ /** The agents that are known at the moment. */
15
+ known(): Iterable<string>;
16
+ }
17
+
18
+ /** The most Paseo returns in one page. */
19
+ const PAGE_LIMIT = 200;
20
+ /** How long after a failure the observation is asked for again; each failure in a row doubles it, up to the cap. */
21
+ export const RETRY_MS = 5_000;
22
+ export const RETRY_CAP_MS = 60_000;
23
+
24
+ /**
25
+ * Follows the agent directory through one owned observation.
26
+ *
27
+ * Paseo 0.15 keeps a plugin installed across a dropped connection and does not run
28
+ * its setup again, so a list read once at setup would miss everything that happened
29
+ * meanwhile. The observation hands over the existing agents and, after each
30
+ * reconnect, a fresh snapshot of the first page. The rest are read page by page, and
31
+ * only a directory read to its end removes the agents that went while the app was
32
+ * away; a page that fails starts the read again later. Paseo gives up an observation
33
+ * whose request fails after a reconnect, so a failure asks for a new one. Returns
34
+ * the cleanup.
35
+ */
36
+ export function followAgents(paseo: PluginClientContext["paseo"], listener: DirectoryListener): () => void {
37
+ const lifetime = new AbortController();
38
+ let retry: ReturnType<typeof setTimeout> | undefined;
39
+ let failures = 0;
40
+ // Each snapshot starts a new read, and a read still under way gives way to it.
41
+ let reading = 0;
42
+ // Agents an update spoke of during a read: a page read after that may be older than the update.
43
+ const updated = new Set<string>();
44
+
45
+ async function catchUp(first: Awaited<ReturnType<typeof paseo.agents.list>>) {
46
+ const read = ++reading;
47
+ updated.clear();
48
+ const listed = new Set<string>();
49
+ const take = (page: typeof first) => {
50
+ for (const { agent } of page.entries) {
51
+ listed.add(agent.id);
52
+ if (!updated.has(agent.id)) listener.track(agent);
53
+ }
54
+ };
55
+ take(first);
56
+ let { hasMore, nextCursor } = first.pageInfo;
57
+ while (hasMore) {
58
+ // A directory that cannot be read to its end says nothing about the agents it did not reach, so none is removed.
59
+ if (!nextCursor) return;
60
+ let page: typeof first;
61
+ try {
62
+ page = await paseo.agents.list({ page: { limit: PAGE_LIMIT, cursor: nextCursor } });
63
+ } catch {
64
+ if (read === reading) readAgain();
65
+ return;
66
+ }
67
+ if (read !== reading || lifetime.signal.aborted) return;
68
+ if (page.pageInfo.nextCursor === nextCursor) return;
69
+ take(page);
70
+ ({ hasMore, nextCursor } = page.pageInfo);
71
+ }
72
+ failures = 0;
73
+ for (const id of [...listener.known()]) {
74
+ if (!listed.has(id) && !updated.has(id)) listener.hide(id);
75
+ }
76
+ }
77
+
78
+ function later(action: () => void) {
79
+ if (lifetime.signal.aborted) return;
80
+ clearTimeout(retry);
81
+ retry = setTimeout(action, Math.min(RETRY_CAP_MS, RETRY_MS * 2 ** failures));
82
+ failures += 1;
83
+ }
84
+
85
+ function readAgain() {
86
+ later(() => {
87
+ const read = reading;
88
+ void paseo.agents
89
+ .list({ page: { limit: PAGE_LIMIT } })
90
+ .then((first) => {
91
+ if (read === reading && !lifetime.signal.aborted) void catchUp(first);
92
+ return undefined;
93
+ })
94
+ .catch(() => {
95
+ if (read === reading) readAgain();
96
+ });
97
+ });
98
+ }
99
+
100
+ function observeAgain() {
101
+ later(observe);
102
+ }
103
+
104
+ function observe() {
105
+ if (lifetime.signal.aborted) return;
106
+ void paseo.agents
107
+ .list({ subscribe: {}, page: { limit: PAGE_LIMIT }, signal: lifetime.signal })
108
+ .then(({ subscription }) => {
109
+ subscription.subscribe({
110
+ snapshot(result) {
111
+ if (lifetime.signal.aborted) return;
112
+ clearTimeout(retry);
113
+ failures = 0;
114
+ void catchUp(result);
115
+ },
116
+ update(message) {
117
+ if (lifetime.signal.aborted || message.type !== "agent_update") return;
118
+ const update = message.payload;
119
+ if (update.kind === "upsert") {
120
+ updated.add(update.agent.id);
121
+ listener.track(update.agent);
122
+ } else {
123
+ updated.add(update.agentId);
124
+ listener.hide(update.agentId);
125
+ }
126
+ },
127
+ error: observeAgain,
128
+ });
129
+ return undefined;
130
+ })
131
+ .catch(observeAgain);
132
+ }
133
+
134
+ observe();
135
+ return () => {
136
+ lifetime.abort();
137
+ clearTimeout(retry);
138
+ };
139
+ }
package/client/pill.tsx CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  import { Icon } from "@getpaseo/plugin/client/react-native";
8
8
  import React, { useSyncExternalStore } from "react";
9
9
  import { readMetrics } from "../shared/contracts";
10
+ import { followAgents, type DirectoryAgent } from "./agent-directory";
10
11
  import { openMachineScreen } from "./compat";
11
12
  import { createMachineCard } from "./pill-card";
12
13
  import { pillReading, type PillReading } from "../shared/pill";
@@ -56,19 +57,12 @@ function createIcon(store: ReadingStore) {
56
57
  };
57
58
  }
58
59
 
59
- interface TrackedAgent {
60
- id: string;
61
- workspaceId?: string | null;
62
- archivedAt?: string | null;
63
- }
64
-
65
60
  /**
66
61
  * CPU load and memory pressure beside every agent's composer, fed by one poll.
67
62
  *
68
63
  * Paseo scopes a pill to a single agent, so this follows the agent directory: a
69
64
  * pill appears for each agent that has a workspace and goes when the agent is
70
- * archived or removed. The directory is seeded from `list`, because a
71
- * subscription reports changes rather than the agents that already exist.
65
+ * archived or removed.
72
66
  */
73
67
  export function contributePills(client: PluginClientContext) {
74
68
  const store = createReadingStore();
@@ -124,32 +118,19 @@ export function contributePills(client: PluginClientContext) {
124
118
  pills.delete(agentId);
125
119
  }
126
120
 
127
- function track(agent: TrackedAgent) {
121
+ function track(agent: DirectoryAgent) {
128
122
  if (agent.workspaceId && !agent.archivedAt) show(agent.id, agent.workspaceId);
129
123
  else hide(agent.id);
130
124
  }
131
125
 
132
- const unsubscribe = client.paseo.agents.subscribe((update) => {
133
- if (disposed) return;
134
- if (update.kind === "upsert") track(update.agent);
135
- else hide(update.agentId);
136
- });
137
-
138
- client.paseo.agents
139
- .list()
140
- .then(({ entries }) => {
141
- if (!disposed) for (const entry of entries) track(entry.agent);
142
- })
143
- .catch(() => {
144
- // Without the seed, pills appear as each agent next reports a change.
145
- });
126
+ const stopFollowing = followAgents(client.paseo, { track, hide, known: () => pills.keys() });
146
127
 
147
128
  const timer = setInterval(() => void refresh(), REFRESH_MS);
148
129
 
149
130
  return () => {
150
131
  disposed = true;
151
132
  clearInterval(timer);
152
- unsubscribe();
133
+ stopFollowing();
153
134
  for (const { registration } of pills.values()) registration.remove();
154
135
  pills.clear();
155
136
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-plugin-machine-status",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Machine dashboard, open ports and Docker control inside Paseo",
5
5
  "license": "MIT",
6
6
  "author": "dutchakdev",
@@ -26,9 +26,9 @@
26
26
  "check": "npm run typecheck && npm test"
27
27
  },
28
28
  "devDependencies": {
29
- "@getpaseo/client": "0.8.0",
30
- "@getpaseo/plugin": "0.8.0",
31
- "@getpaseo/protocol": "0.8.0",
29
+ "@getpaseo/client": "0.11.4",
30
+ "@getpaseo/plugin": "0.11.4",
31
+ "@getpaseo/protocol": "0.11.4",
32
32
  "@tanstack/react-query": "^5.90.11",
33
33
  "@types/node": "22.20.5",
34
34
  "@types/react": "~19.2.0",
@@ -36,8 +36,9 @@
36
36
  "react-native": "0.81.5",
37
37
  "semver": "7.8.5",
38
38
  "tsx": "^4.23.13",
39
- "typescript": "^5.9.3",
40
- "vitest": "^4.1.11",
39
+ "typescript": "^7.0.2",
40
+ "vite": "^8.3.5",
41
+ "vitest": "^5.0.5",
41
42
  "zod": "^4.4.3"
42
43
  },
43
44
  "files": [