@xpufx/paseo-top 0.4.3 → 0.4.5

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.
Files changed (130) hide show
  1. package/OVERVIEW.md +17 -0
  2. package/client/host-ui.tsx +184 -0
  3. package/client/multi-host-view.tsx +57 -43
  4. package/client/multi-host.ts +36 -13
  5. package/client/pill-labels.ts +124 -7
  6. package/client/pill.tsx +724 -669
  7. package/client/resources-query.ts +1 -1
  8. package/client/settings-events.ts +33 -0
  9. package/client/settings-ui.tsx +86 -9
  10. package/client/surface.tsx +310 -274
  11. package/client/telemetry-copy.ts +8 -2
  12. package/client/telemetry.tsx +372 -321
  13. package/client/turn-panel.tsx +126 -104
  14. package/client/vendor/paseo-plugin-helper/{host.ts → core/host.ts} +21 -61
  15. package/client/vendor/paseo-plugin-helper/core/index.ts +76 -0
  16. package/client/vendor/paseo-plugin-helper/core/plugin-load-count.ts +89 -0
  17. package/client/vendor/paseo-plugin-helper/{query-refresh.ts → core/query-refresh.ts} +2 -2
  18. package/client/vendor/paseo-plugin-helper/{query.ts → core/query.ts} +2 -2
  19. package/client/vendor/paseo-plugin-helper/{settings.ts → core/settings.ts} +1 -1
  20. package/client/vendor/paseo-plugin-helper/{shared-settings.ts → core/shared-settings.ts} +2 -2
  21. package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/clipboard.ts +2 -2
  22. package/client/vendor/paseo-plugin-helper/lifecycle/flair.ts +26 -0
  23. package/client/vendor/paseo-plugin-helper/{theme/color-utils.ts → lifecycle/host-color.ts} +95 -38
  24. package/client/vendor/paseo-plugin-helper/lifecycle/host-theme.tsx +109 -0
  25. package/client/vendor/paseo-plugin-helper/lifecycle/index.ts +23 -0
  26. package/client/vendor/paseo-plugin-helper/lifecycle/multi-host.ts +66 -0
  27. package/client/vendor/paseo-plugin-helper/{panel.tsx → lifecycle/panel.tsx} +7 -7
  28. package/client/vendor/paseo-plugin-helper/{pill.tsx → lifecycle/pill.tsx} +118 -338
  29. package/client/vendor/paseo-plugin-helper/lifecycle/providers.tsx +46 -0
  30. package/client/vendor/paseo-plugin-helper/lifecycle/scroll-owner.ts +26 -0
  31. package/client/vendor/paseo-plugin-helper/lifecycle/surface.tsx +140 -0
  32. package/client/vendor/paseo-plugin-helper/ui/color.ts +1 -0
  33. package/client/vendor/paseo-plugin-helper/ui/content.tsx +1326 -0
  34. package/client/vendor/paseo-plugin-helper/ui/controls.tsx +1018 -0
  35. package/client/vendor/paseo-plugin-helper/{components/DataTable.tsx → ui/data.tsx} +55 -47
  36. package/client/vendor/paseo-plugin-helper/ui/index.ts +44 -0
  37. package/client/vendor/paseo-plugin-helper/ui/layout.tsx +263 -0
  38. package/client/vendor/paseo-plugin-helper/ui/modal.tsx +111 -0
  39. package/client/vendor/paseo-plugin-helper/ui/plugin-reload-banner.tsx +119 -0
  40. package/client/vendor/paseo-plugin-helper/{settings-screen.tsx → ui/settings-screen.tsx} +3 -3
  41. package/client/vendor/paseo-plugin-helper/ui/settings.ts +36 -0
  42. package/client/vendor/paseo-plugin-helper/ui/text.tsx +821 -0
  43. package/client/vendor/paseo-plugin-helper/ui/theme.tsx +1 -0
  44. package/client/vendor/permission-audit/client.tsx +706 -0
  45. package/client/vendor/permission-audit/index.tsx +1 -0
  46. package/index.client.tsx +58 -37
  47. package/index.server.ts +256 -227
  48. package/package.json +14 -2
  49. package/paseo-plugin.json +2 -0
  50. package/screenshots/1-paseo-top-modal-system.jpg +0 -0
  51. package/screenshots/2-paseo-top-modal-workspace.jpg +0 -0
  52. package/screenshots/3-paseo-top-modal-settings.jpg +0 -0
  53. package/screenshots/4-paseo-top-modal-about.jpg +0 -0
  54. package/screenshots/paseo-top-dashboard.jpg +0 -0
  55. package/screenshots/paseo-top-general-view.jpg +0 -0
  56. package/server/resources.ts +98 -0
  57. package/server/vendor/paseo-plugin-helper/custom-pills.ts +70 -0
  58. package/server/vendor/paseo-plugin-helper/index.ts +4 -0
  59. package/server/vendor/paseo-plugin-helper/logger.ts +5 -16
  60. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  61. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  62. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +2 -2
  63. package/server/vendor/paseo-plugin-helper/storage-guard.ts +294 -0
  64. package/server/vendor/paseo-plugin-helper/storage.ts +36 -2
  65. package/server/vendor/paseo-plugin-helper/telemetry.ts +486 -0
  66. package/server/vendor/paseo-plugin-helper/tickets.ts +1232 -0
  67. package/server/vendor/permission-audit/index.ts +1 -0
  68. package/server/vendor/permission-audit/server.ts +839 -0
  69. package/shared/helper-version.ts +20 -0
  70. package/shared/multi-host.ts +156 -155
  71. package/shared/resources.ts +146 -1
  72. package/shared/vendor/paseo-plugin-helper/async.ts +14 -7
  73. package/shared/vendor/paseo-plugin-helper/features.ts +244 -0
  74. package/shared/vendor/paseo-plugin-helper/forge.ts +27 -0
  75. package/shared/vendor/paseo-plugin-helper/index.ts +2 -0
  76. package/shared/vendor/paseo-plugin-helper/tickets.ts +1104 -0
  77. package/shared/vendor/permission-audit/filter.ts +61 -0
  78. package/shared/vendor/permission-audit/index.ts +2 -0
  79. package/shared/vendor/permission-audit/shared.ts +119 -0
  80. package/shared/version.ts +1 -1
  81. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +0 -493
  82. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +0 -250
  83. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +0 -156
  84. package/client/vendor/paseo-plugin-helper/components/Button.tsx +0 -184
  85. package/client/vendor/paseo-plugin-helper/components/Card.tsx +0 -225
  86. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +0 -196
  87. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +0 -277
  88. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +0 -172
  89. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +0 -180
  90. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +0 -97
  91. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +0 -70
  92. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +0 -75
  93. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +0 -446
  94. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +0 -247
  95. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +0 -117
  96. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +0 -53
  97. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +0 -118
  98. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +0 -80
  99. package/client/vendor/paseo-plugin-helper/components/Select.tsx +0 -215
  100. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +0 -80
  101. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +0 -319
  102. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +0 -150
  103. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +0 -163
  104. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +0 -157
  105. package/client/vendor/paseo-plugin-helper/components/index.ts +0 -25
  106. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +0 -224
  107. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +0 -79
  108. package/client/vendor/paseo-plugin-helper/icon.tsx +0 -39
  109. package/client/vendor/paseo-plugin-helper/index.ts +0 -29
  110. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +0 -49
  111. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +0 -103
  112. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +0 -65
  113. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +0 -378
  114. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +0 -51
  115. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +0 -39
  116. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +0 -39
  117. package/client/vendor/paseo-plugin-helper/layout/index.ts +0 -7
  118. package/client/vendor/paseo-plugin-helper/styles/index.ts +0 -1
  119. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +0 -550
  120. package/client/vendor/paseo-plugin-helper/surface.tsx +0 -80
  121. package/client/vendor/paseo-plugin-helper/theme/flair.ts +0 -76
  122. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +0 -121
  123. package/client/vendor/paseo-plugin-helper/theme/index.ts +0 -7
  124. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +0 -178
  125. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +0 -213
  126. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +0 -200
  127. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +0 -57
  128. /package/client/vendor/paseo-plugin-helper/{command-center.ts → core/command-center.ts} +0 -0
  129. /package/client/vendor/paseo-plugin-helper/{snapshot.ts → core/snapshot.ts} +0 -0
  130. /package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/haptics.ts +0 -0
@@ -0,0 +1,20 @@
1
+ // Declared paseo-plugin-helper expectation for top.
2
+ //
3
+ // This plugin carries a committed vendored helper tree, but its bare
4
+ // `paseo-plugin-helper/<tree>` imports still resolve through this checkout's
5
+ // tsconfig `paths` alias into ../../packages/paseo-plugin-helper/src, so part
6
+ // of what it runs comes from the checkout rather than from the copy it carries.
7
+ // Resolution is therefore "checkout", and this file records what that resolution is
8
+ // supposed to be, so scripts/helper-resolution.test.mjs can contradict it if the
9
+ // two ever disagree (#633, #649).
10
+ //
11
+ // HELPER_VERSION is the helper's own package.json `version` — the same value the
12
+ // vendored READMEs pin, not a scheme invented for this plugin.
13
+ // HELPER_REVISION is a content digest of packages/paseo-plugin-helper/src at
14
+ // stamp time. It is the authoritative check, and it is preferred over the
15
+ // checkout sha in shared/version.ts because a squash-merge deletes the very
16
+ // commit that sha names, orphaning it in every clean clone while it still
17
+ // resolves on any machine holding the branch worktree.
18
+ export const HELPER_VERSION = "0.4.0-beta.12";
19
+ export const HELPER_SERVED_FROM = "checkout";
20
+ export const HELPER_REVISION = "sha256:8ac23e85d016";
@@ -1,4 +1,4 @@
1
- import { TimeoutError, withTimeout } from "./vendor/paseo-plugin-helper/index";
1
+ import { isTimeoutError, withTimeout } from "./vendor/paseo-plugin-helper/index";
2
2
 
3
3
  /**
4
4
  * Bounded multi-host fleet polling state machine.
@@ -133,132 +133,74 @@ export function aggregateFleet(
133
133
  return aggregate;
134
134
  }
135
135
 
136
- export class MultiHostPoller {
137
- static readonly DEFAULT_INTERVAL_MS = 15_000;
138
- static readonly DEFAULT_TIMEOUT_MS = 4_000;
136
+ export const DEFAULT_MULTI_HOST_INTERVAL_MS = 15_000;
137
+ export const DEFAULT_MULTI_HOST_TIMEOUT_MS = 4_000;
139
138
 
140
- private readonly options: Required<
141
- Pick<MultiHostPollerOptions, "timeoutMs" | "intervalMs" | "now">
142
- > &
143
- Pick<MultiHostPollerOptions, "acquire" | "onUpdate">;
144
- private readonly snapshots = new Map<string, FleetHostSnapshot>();
145
- private readonly clients = new Map<string, FleetHostClient>();
146
- private readonly inFlight = new Set<string>();
147
- /** Bumped on any connection transition so in-flight results are discarded. */
148
- private readonly generation = new Map<string, number>();
149
- private timer: ReturnType<typeof setInterval> | null = null;
150
- private running = false;
151
- private tickInFlight = false;
152
- private disposed = false;
153
-
154
- constructor(options: MultiHostPollerOptions) {
155
- this.options = {
156
- timeoutMs: options.timeoutMs ?? MultiHostPoller.DEFAULT_TIMEOUT_MS,
157
- intervalMs: options.intervalMs ?? MultiHostPoller.DEFAULT_INTERVAL_MS,
158
- now: options.now ?? (() => Date.now()),
159
- acquire: options.acquire,
160
- onUpdate: options.onUpdate,
161
- };
162
- }
163
-
164
- /** Reconcile the live host list, releasing clients across transitions. */
165
- setHosts(hosts: readonly FleetHostInput[]): void {
166
- if (this.disposed) return;
167
- const next = new Map(hosts.map((host) => [host.serverId, host]));
168
-
169
- for (const serverId of [...this.snapshots.keys()]) {
170
- if (!next.has(serverId)) {
171
- this.bumpGeneration(serverId);
172
- this.disposeClient(serverId);
173
- this.snapshots.delete(serverId);
174
- this.inFlight.delete(serverId);
175
- }
176
- }
177
-
178
- for (const host of next.values()) {
179
- const existing = this.snapshots.get(host.serverId);
180
- if (!existing) {
181
- this.snapshots.set(host.serverId, {
182
- serverId: host.serverId,
183
- label: host.label,
184
- connectionStatus: host.status,
185
- status: fleetStatusFor(host.status),
186
- latencyMs: null,
187
- lastUpdatedAt: null,
188
- staleAt: null,
189
- error: null,
190
- counts: null,
191
- });
192
- continue;
193
- }
194
- const transitioned = existing.connectionStatus !== host.status;
195
- if (transitioned) {
196
- // A status change (including reconnect/failover) invalidates the
197
- // borrowed client; reacquire on the next probe.
198
- this.bumpGeneration(host.serverId);
199
- this.disposeClient(host.serverId);
200
- existing.connectionStatus = host.status;
201
- existing.status = fleetStatusFor(host.status);
202
- if (host.status !== "online") {
203
- // Disconnected hosts keep no counts; only a `stale` probe retains
204
- // its last-known snapshot as a deliberate "unresponsive" state.
205
- existing.staleAt = null;
206
- existing.counts = null;
207
- }
208
- }
209
- existing.label = host.label;
210
- }
139
+ export interface MultiHostPoller {
140
+ setHosts(hosts: readonly FleetHostInput[]): void;
141
+ start(): void;
142
+ stop(): void;
143
+ pollOnce(): Promise<void>;
144
+ getSnapshots(): FleetHostSnapshot[];
145
+ dispose(): void;
146
+ }
211
147
 
212
- this.emit();
148
+ /**
149
+ * Closure-based on purpose: plugin client bundles are evaluated by Hermes from
150
+ * source, where a `class` expression binds as `undefined`. A class here made the
151
+ * Fleet tab crash with "Cannot read property 'prototype' of undefined"
152
+ * (#1066, #510). Never use `class` in client-reachable code.
153
+ */
154
+ export function createMultiHostPoller(opts: MultiHostPollerOptions): MultiHostPoller {
155
+ const options = {
156
+ timeoutMs: opts.timeoutMs ?? DEFAULT_MULTI_HOST_TIMEOUT_MS,
157
+ intervalMs: opts.intervalMs ?? DEFAULT_MULTI_HOST_INTERVAL_MS,
158
+ now: opts.now ?? (() => Date.now()),
159
+ acquire: opts.acquire,
160
+ onUpdate: opts.onUpdate,
161
+ };
162
+ const snapshots = new Map<string, FleetHostSnapshot>();
163
+ const clients = new Map<string, FleetHostClient>();
164
+ const inFlight = new Set<string>();
165
+ /** Bumped on any connection transition so in-flight results are discarded. */
166
+ const generation = new Map<string, number>();
167
+ let timer: ReturnType<typeof setInterval> | null = null;
168
+ let running = false;
169
+ let tickInFlight = false;
170
+ let disposed = false;
171
+
172
+ function currentGeneration(serverId: string): number {
173
+ return generation.get(serverId) ?? 0;
213
174
  }
214
-
215
- /** Start the immediate poll plus the fixed-cadence loop. */
216
- start(): void {
217
- if (this.disposed || this.running) return;
218
- this.running = true;
219
- void this.pollOnce();
220
- this.timer = setInterval(() => void this.pollOnce(), this.options.intervalMs);
175
+ function bumpGeneration(serverId: string): void {
176
+ generation.set(serverId, currentGeneration(serverId) + 1);
221
177
  }
222
-
223
- stop(): void {
224
- this.running = false;
225
- if (this.timer !== null) {
226
- clearInterval(this.timer);
227
- this.timer = null;
228
- }
178
+ function getSnapshots(): FleetHostSnapshot[] {
179
+ return sortFleetSnapshots([...snapshots.values()]);
229
180
  }
230
-
231
- /** Run one bounded pass over all hosts. Safe to await in tests. */
232
- async pollOnce(): Promise<void> {
233
- if (this.disposed || this.tickInFlight) return;
234
- this.tickInFlight = true;
235
- try {
236
- await Promise.all(
237
- [...this.snapshots.keys()].map((serverId) => this.probeHost(serverId)),
238
- );
239
- } finally {
240
- this.tickInFlight = false;
241
- this.emit();
242
- }
181
+ function emit(): void {
182
+ options.onUpdate?.(getSnapshots());
243
183
  }
244
-
245
- getSnapshots(): FleetHostSnapshot[] {
246
- return sortFleetSnapshots([...this.snapshots.values()]);
184
+ function acquireClient(serverId: string): FleetHostClient {
185
+ const existing = clients.get(serverId);
186
+ if (existing) return existing;
187
+ const client = options.acquire(serverId);
188
+ clients.set(serverId, client);
189
+ return client;
247
190
  }
248
-
249
- dispose(): void {
250
- if (this.disposed) return;
251
- this.disposed = true;
252
- this.stop();
253
- for (const serverId of [...this.clients.keys()]) {
254
- this.disposeClient(serverId);
191
+ function disposeClient(serverId: string): void {
192
+ const client = clients.get(serverId);
193
+ if (!client) return;
194
+ clients.delete(serverId);
195
+ try {
196
+ void client.dispose();
197
+ } catch {
198
+ // A released borrowed client may already be disposed; ignore.
255
199
  }
256
- this.snapshots.clear();
257
- this.inFlight.clear();
258
200
  }
259
201
 
260
- private async probeHost(serverId: string): Promise<void> {
261
- const snapshot = this.snapshots.get(serverId);
202
+ async function probeHost(serverId: string): Promise<void> {
203
+ const snapshot = snapshots.get(serverId);
262
204
  if (!snapshot) return;
263
205
 
264
206
  if (snapshot.connectionStatus !== "online") {
@@ -267,75 +209,134 @@ export class MultiHostPoller {
267
209
  snapshot.connectionStatus === "error" ? "Host connection error" : null;
268
210
  return;
269
211
  }
270
- if (this.inFlight.has(serverId)) return;
212
+ if (inFlight.has(serverId)) return;
271
213
 
272
- this.inFlight.add(serverId);
273
- const generation = this.currentGeneration(serverId);
274
- const startedAt = this.options.now();
275
- let timedOut = false;
214
+ inFlight.add(serverId);
215
+ const startGeneration = currentGeneration(serverId);
216
+ const startedAt = options.now();
276
217
  try {
277
- const client = this.acquireClient(serverId);
218
+ const client = acquireClient(serverId);
278
219
  const counts = await withTimeout(
279
220
  client.readCounts(),
280
- this.options.timeoutMs,
221
+ options.timeoutMs,
281
222
  `fleet probe ${snapshot.label}`,
282
223
  );
283
- if (this.currentGeneration(serverId) !== generation) return;
224
+ if (currentGeneration(serverId) !== startGeneration) return;
284
225
  snapshot.status = "online";
285
226
  snapshot.counts = counts;
286
- snapshot.latencyMs = this.options.now() - startedAt;
287
- snapshot.lastUpdatedAt = this.options.now();
227
+ snapshot.latencyMs = options.now() - startedAt;
228
+ snapshot.lastUpdatedAt = options.now();
288
229
  snapshot.staleAt = null;
289
230
  snapshot.error = null;
290
231
  } catch (cause) {
291
- if (this.currentGeneration(serverId) !== generation) return;
292
- timedOut = cause instanceof TimeoutError;
293
- if (timedOut) {
232
+ if (currentGeneration(serverId) !== startGeneration) return;
233
+ if (isTimeoutError(cause)) {
294
234
  // Unresponsive: mark stale with a timestamp and keep the borrowed
295
235
  // client. The next 15s tick is the only retry.
296
236
  snapshot.status = "stale";
297
- snapshot.staleAt = this.options.now();
237
+ snapshot.staleAt = options.now();
298
238
  snapshot.error = "Timed out";
299
239
  } else {
300
240
  // A released/replaced connection surfaces here; drop the client so the
301
241
  // next probe reacquires a fresh one.
302
242
  snapshot.status = "error";
303
243
  snapshot.error = errorMessage(cause);
304
- this.disposeClient(serverId);
244
+ disposeClient(serverId);
305
245
  }
306
246
  } finally {
307
- this.inFlight.delete(serverId);
247
+ inFlight.delete(serverId);
308
248
  }
309
249
  }
310
250
 
311
- private acquireClient(serverId: string): FleetHostClient {
312
- const existing = this.clients.get(serverId);
313
- if (existing) return existing;
314
- const client = this.options.acquire(serverId);
315
- this.clients.set(serverId, client);
316
- return client;
317
- }
318
-
319
- private disposeClient(serverId: string): void {
320
- const client = this.clients.get(serverId);
321
- if (!client) return;
322
- this.clients.delete(serverId);
251
+ async function pollOnce(): Promise<void> {
252
+ if (disposed || tickInFlight) return;
253
+ tickInFlight = true;
323
254
  try {
324
- void client.dispose();
325
- } catch {
326
- // A released borrowed client may already be disposed; ignore.
255
+ await Promise.all([...snapshots.keys()].map((serverId) => probeHost(serverId)));
256
+ } finally {
257
+ tickInFlight = false;
258
+ emit();
327
259
  }
328
260
  }
329
261
 
330
- private currentGeneration(serverId: string): number {
331
- return this.generation.get(serverId) ?? 0;
332
- }
262
+ function setHosts(hosts: readonly FleetHostInput[]): void {
263
+ if (disposed) return;
264
+ const next = new Map(hosts.map((host) => [host.serverId, host]));
265
+
266
+ for (const serverId of [...snapshots.keys()]) {
267
+ if (!next.has(serverId)) {
268
+ bumpGeneration(serverId);
269
+ disposeClient(serverId);
270
+ snapshots.delete(serverId);
271
+ inFlight.delete(serverId);
272
+ }
273
+ }
274
+
275
+ for (const host of next.values()) {
276
+ const existing = snapshots.get(host.serverId);
277
+ if (!existing) {
278
+ snapshots.set(host.serverId, {
279
+ serverId: host.serverId,
280
+ label: host.label,
281
+ connectionStatus: host.status,
282
+ status: fleetStatusFor(host.status),
283
+ latencyMs: null,
284
+ lastUpdatedAt: null,
285
+ staleAt: null,
286
+ error: null,
287
+ counts: null,
288
+ });
289
+ continue;
290
+ }
291
+ const transitioned = existing.connectionStatus !== host.status;
292
+ if (transitioned) {
293
+ // A status change (including reconnect/failover) invalidates the
294
+ // borrowed client; reacquire on the next probe.
295
+ bumpGeneration(host.serverId);
296
+ disposeClient(host.serverId);
297
+ existing.connectionStatus = host.status;
298
+ existing.status = fleetStatusFor(host.status);
299
+ if (host.status !== "online") {
300
+ // Disconnected hosts keep no counts; only a `stale` probe retains
301
+ // its last-known snapshot as a deliberate "unresponsive" state.
302
+ existing.staleAt = null;
303
+ existing.counts = null;
304
+ }
305
+ }
306
+ existing.label = host.label;
307
+ }
333
308
 
334
- private bumpGeneration(serverId: string): void {
335
- this.generation.set(serverId, this.currentGeneration(serverId) + 1);
309
+ emit();
336
310
  }
337
311
 
338
- private emit(): void {
339
- this.options.onUpdate?.(this.getSnapshots());
312
+ function stop(): void {
313
+ running = false;
314
+ if (timer !== null) {
315
+ clearInterval(timer);
316
+ timer = null;
317
+ }
340
318
  }
319
+
320
+ return {
321
+ setHosts,
322
+ /** Start the immediate poll plus the fixed-cadence loop. */
323
+ start() {
324
+ if (disposed || running) return;
325
+ running = true;
326
+ void pollOnce();
327
+ timer = setInterval(() => void pollOnce(), options.intervalMs);
328
+ },
329
+ stop,
330
+ /** Run one bounded pass over all hosts. Safe to await in tests. */
331
+ pollOnce,
332
+ getSnapshots,
333
+ dispose() {
334
+ if (disposed) return;
335
+ disposed = true;
336
+ stop();
337
+ for (const serverId of [...clients.keys()]) disposeClient(serverId);
338
+ snapshots.clear();
339
+ inFlight.clear();
340
+ },
341
+ };
341
342
  }
@@ -3,6 +3,9 @@ import {
3
3
  defineSettingsContract,
4
4
  type RpcOutput,
5
5
  type CustomPillState,
6
+ type MetricThresholds,
7
+ formatBytes,
8
+ formatUptime,
6
9
  } from "./vendor/paseo-plugin-helper/index";
7
10
  import { z } from "zod";
8
11
 
@@ -142,6 +145,12 @@ export const topTimelineTelemetrySchema = z.object({
142
145
  contextUsedTokens: z.number().optional(),
143
146
  contextMaxTokens: z.number().optional(),
144
147
  costUsd: z.number().optional(),
148
+ /** Tokens per second of generation (output + reasoning / durationSec). Zero for zero-duration or interrupted turns. */
149
+ tokensPerSecond: z.number().optional(),
150
+ /** Context pressure level: "normal" (<60%), "warning" (60-74%), "high" (>=75%). */
151
+ contextPressureLevel: z.enum(["normal", "warning", "high"]).optional(),
152
+ /** Context utilization percentage (0-100). */
153
+ contextUtilizationPct: z.number().optional(),
145
154
  });
146
155
  export type TopTimelineTelemetryData = z.infer<typeof topTimelineTelemetrySchema>;
147
156
 
@@ -177,6 +186,12 @@ export const getSystemResourcesRpc = defineContract({
177
186
  customPills: z.array(CustomPillStateSchema).optional(),
178
187
  lastTurn: topTimelineTelemetrySchema.nullable().optional(),
179
188
  liveUsage: liveUsageSchema.nullable().optional(),
189
+ /** Tokens per second of generation from the most recent turn. */
190
+ tokensPerSecond: z.number().optional(),
191
+ /** Context pressure level from the most recent turn. */
192
+ contextPressureLevel: z.enum(["normal", "warning", "high"]).optional(),
193
+ /** Context utilization percentage from the most recent turn. */
194
+ contextUtilizationPct: z.number().optional(),
180
195
  }),
181
196
  });
182
197
 
@@ -204,6 +219,7 @@ export const MetricIdSchema = z.enum([
204
219
  "tokens",
205
220
  "tools",
206
221
  "turns",
222
+ "velocity",
207
223
  ]);
208
224
  export type MetricId = z.infer<typeof MetricIdSchema>;
209
225
 
@@ -223,6 +239,7 @@ export const METRIC_IDS: readonly MetricId[] = [
223
239
  "tokens",
224
240
  "tools",
225
241
  "turns",
242
+ "velocity",
226
243
  ];
227
244
 
228
245
  export const CORE_TIMELINE_METRICS: readonly MetricId[] = [
@@ -233,6 +250,7 @@ export const CORE_TIMELINE_METRICS: readonly MetricId[] = [
233
250
  "agent_id",
234
251
  "changes",
235
252
  "tokens",
253
+ "velocity",
236
254
  ];
237
255
 
238
256
  export const DEFAULT_METRIC_SURFACES: Record<MetricId, SurfaceTarget> = {
@@ -251,6 +269,7 @@ export const DEFAULT_METRIC_SURFACES: Record<MetricId, SurfaceTarget> = {
251
269
  tokens: "timeline",
252
270
  tools: "timeline",
253
271
  turns: "none",
272
+ velocity: "both",
254
273
  };
255
274
 
256
275
  export interface MetricDefinition {
@@ -263,6 +282,12 @@ export interface MetricDefinition {
263
282
  pillOnly?: boolean;
264
283
  }
265
284
 
285
+ /** Context pressure thresholds matching the velocity pill's bloat logic. */
286
+ export const CONTEXT_PRESSURE_THRESHOLDS: Record<string, number> = {
287
+ normalUpper: 60,
288
+ warningUpper: 75,
289
+ };
290
+
266
291
  export const METRIC_DEFINITIONS: MetricDefinition[] = [
267
292
  {
268
293
  id: "cpu_ram",
@@ -366,6 +391,14 @@ export const METRIC_DEFINITIONS: MetricDefinition[] = [
366
391
  icon: "Repeat",
367
392
  shortLabel: "turns",
368
393
  },
394
+ {
395
+ id: "velocity",
396
+ title: "Generation Velocity",
397
+ description: "Live tokens-per-second tachometer with cache hit rate and context pressure",
398
+ icon: "Zap",
399
+ shortLabel: "tps",
400
+ core: true,
401
+ },
369
402
  ];
370
403
 
371
404
  export function isPillEnabled(target?: SurfaceTarget): boolean {
@@ -481,6 +514,7 @@ export const TIMELINE_RENDERED_METRICS: readonly MetricId[] = [
481
514
  "tokens",
482
515
  "tools",
483
516
  "turns",
517
+ "velocity",
484
518
  ];
485
519
 
486
520
  export interface LegacyFlagView {
@@ -549,6 +583,116 @@ export function legacyFlagView(settings: {
549
583
  };
550
584
  }
551
585
 
586
+ // --- Canonical metric thresholds -------------------------------------------
587
+
588
+ /**
589
+ * Single source of truth for CPU/RAM warning/danger color thresholds. The
590
+ * pill, the dashboard surface, and the timeline card all resolve through
591
+ * these constants so a metric never changes color depending on where it is
592
+ * displayed (pill.tsx and telemetry.tsx used to carry divergent copies).
593
+ */
594
+ export const CPU_THRESHOLDS: MetricThresholds = { warning: 60, danger: 85 };
595
+ export const MEM_THRESHOLDS: MetricThresholds = { warning: 70, danger: 85 };
596
+
597
+ // --- Tabs -------------------------------------------------------------------
598
+
599
+ export interface TopTabItem {
600
+ id: string;
601
+ label: string;
602
+ shortLabel: string;
603
+ icon: string;
604
+ }
605
+
606
+ const SYSTEM_TAB: TopTabItem = {
607
+ id: "system",
608
+ label: "System",
609
+ shortLabel: "System",
610
+ icon: "Activity",
611
+ };
612
+ const PERMISSIONS_TAB: TopTabItem = {
613
+ id: "permissions",
614
+ label: "Permissions",
615
+ shortLabel: "Permissions",
616
+ icon: "ShieldCheck",
617
+ };
618
+ const SETTINGS_TAB: TopTabItem = {
619
+ id: "settings",
620
+ label: "Settings",
621
+ shortLabel: "Settings",
622
+ icon: "Sliders",
623
+ };
624
+ const ABOUT_TAB: TopTabItem = { id: "about", label: "About", shortLabel: "About", icon: "Info" };
625
+
626
+ /** Sidebar dashboard tabs (client/surface.tsx). */
627
+ export const SURFACE_TABS: readonly TopTabItem[] = [
628
+ { ...SYSTEM_TAB, label: "Activity", shortLabel: "Activity" },
629
+ { id: "fleet", label: "Fleet", shortLabel: "Fleet", icon: "Server" },
630
+ PERMISSIONS_TAB,
631
+ SETTINGS_TAB,
632
+ ABOUT_TAB,
633
+ ];
634
+
635
+ /** Pill modal tabs (client/pill.tsx). */
636
+ export const MODAL_TABS: readonly TopTabItem[] = [
637
+ SYSTEM_TAB,
638
+ { id: "context", label: "Workspace", shortLabel: "Workspace", icon: "GitBranch" },
639
+ PERMISSIONS_TAB,
640
+ SETTINGS_TAB,
641
+ ABOUT_TAB,
642
+ ];
643
+
644
+ // --- About section extras ---------------------------------------------------
645
+
646
+ /**
647
+ * Host facts for the About tab. Shared verbatim by the dashboard surface and
648
+ * the pill modal so the two About sections cannot drift.
649
+ */
650
+ export function aboutExtraItems(
651
+ data: SystemResources | undefined,
652
+ ): Array<{ label: string; value: string; copyable?: boolean }> {
653
+ return [
654
+ {
655
+ label: "Host Platform",
656
+ value: data?.platform ? `${data.platform} (${data.arch ?? "unknown"})` : "Linux",
657
+ copyable: true,
658
+ },
659
+ { label: "Host Name", value: data?.hostname ?? "localhost", copyable: true },
660
+ { label: "CPU Model", value: data?.cpuModel ?? "unknown", copyable: true },
661
+ { label: "CPU Cores", value: `${data?.cpuCores ?? 0} cores` },
662
+ {
663
+ label: "Total Memory",
664
+ value: data?.memoryTotalBytes ? formatBytes(data.memoryTotalBytes) : "unknown",
665
+ },
666
+ {
667
+ label: "Host Uptime",
668
+ value: data?.uptimeSeconds ? formatUptime(data.uptimeSeconds) : "unknown",
669
+ },
670
+ ];
671
+ }
672
+
673
+ // --- Enabled-flag OR chain ---------------------------------------------------
674
+
675
+ /**
676
+ * The legacy pill-flag OR chain, formerly copy-pasted at every call site that
677
+ * asks "is any pill item enabled" (pill view, modal settings, multiple-mode
678
+ * sync). MCP is presence-gated separately, so it is not part of the base
679
+ * chain — callers OR it in with their own MCP resolution.
680
+ */
681
+ export function hasAnyLegacyPillFlagEnabled(flags: LegacyFlagView): boolean {
682
+ return (
683
+ flags.showCpuRam ||
684
+ flags.showBranch ||
685
+ flags.showWorktree ||
686
+ flags.showAgentTitle ||
687
+ flags.showAgent ||
688
+ flags.showAgentProvider ||
689
+ flags.showAgentActivity ||
690
+ flags.showAgentId ||
691
+ flags.showLoad ||
692
+ flags.showUptime
693
+ );
694
+ }
695
+
552
696
  /**
553
697
  * Migrates legacy boolean toggles and single recordTurnTelemetry switch into the
554
698
  * per-metric SurfaceTarget model: Record<MetricId, "pill" | "timeline" | "both" | "none">.
@@ -603,6 +747,7 @@ export function migrateLegacyMetricSurfaces(raw: Record<string, unknown>): Recor
603
747
  tokens: false,
604
748
  tools: false,
605
749
  turns: false,
750
+ velocity: false,
606
751
  };
607
752
 
608
753
  const result: Record<MetricId, SurfaceTarget> = { ...DEFAULT_METRIC_SURFACES };
@@ -654,7 +799,7 @@ export const TopSettingsSchema = z.preprocess(
654
799
  recordTurnTelemetry: z.boolean().optional(),
655
800
  timelineCadence: z.number().min(0).max(10).default(1),
656
801
  intervalSeconds: z.number().min(1).max(60).default(3),
657
- defaultTab: z.enum(["system", "context", "settings", "about"]).default("system"),
802
+ defaultTab: z.enum(["system", "context", "permissions", "settings", "about"]).default("system"),
658
803
  })
659
804
  );
660
805
 
@@ -1,11 +1,18 @@
1
- export class TimeoutError extends Error {
1
+ // NOTE: no `class` here. Hermes eval of plugin client bundles binds class
2
+ // expressions as `undefined`, so errors are plain Error objects with a brand.
3
+ export interface TimeoutError extends Error {
2
4
  readonly timeoutMs: number;
5
+ }
3
6
 
4
- constructor(message: string, timeoutMs: number) {
5
- super(message);
6
- this.name = "TimeoutError";
7
- this.timeoutMs = timeoutMs;
8
- }
7
+ export function createTimeoutError(message: string, timeoutMs: number): TimeoutError {
8
+ const err = new Error(message) as Error & { timeoutMs: number };
9
+ err.name = "TimeoutError";
10
+ err.timeoutMs = timeoutMs;
11
+ return err;
12
+ }
13
+
14
+ export function isTimeoutError(e: unknown): e is TimeoutError {
15
+ return e instanceof Error && e.name === "TimeoutError";
9
16
  }
10
17
 
11
18
  /**
@@ -21,7 +28,7 @@ export async function withTimeout<T>(
21
28
  let timer: any;
22
29
  const timeoutPromise = new Promise<never>((_, reject) => {
23
30
  timer = setTimeout(() => {
24
- reject(new TimeoutError(`${label} timed out after ${timeoutMs}ms`, timeoutMs));
31
+ reject(createTimeoutError(`${label} timed out after ${timeoutMs}ms`, timeoutMs));
25
32
  }, timeoutMs);
26
33
  });
27
34