@xpufx/paseo-top 0.4.4 → 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 (129) 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 +94 -1
  6. package/client/pill.tsx +690 -614
  7. package/client/resources-query.ts +1 -1
  8. package/client/settings-events.ts +1 -1
  9. package/client/settings-ui.tsx +86 -9
  10. package/client/surface.tsx +308 -276
  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} +9 -3
  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} +51 -18
  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/{surface.tsx → lifecycle/surface.tsx} +6 -6
  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} +50 -58
  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 -234
  48. package/package.json +7 -4
  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 +2 -0
  59. package/server/vendor/paseo-plugin-helper/logger.ts +5 -16
  60. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +2 -2
  61. package/server/vendor/paseo-plugin-helper/storage-guard.ts +294 -0
  62. package/server/vendor/paseo-plugin-helper/storage.ts +36 -2
  63. package/server/vendor/paseo-plugin-helper/telemetry.ts +486 -0
  64. package/server/vendor/paseo-plugin-helper/tickets.ts +18 -67
  65. package/server/vendor/permission-audit/index.ts +1 -0
  66. package/server/vendor/permission-audit/server.ts +839 -0
  67. package/shared/helper-version.ts +1 -1
  68. package/shared/multi-host.ts +153 -159
  69. package/shared/resources.ts +145 -0
  70. package/shared/vendor/paseo-plugin-helper/async.ts +14 -7
  71. package/shared/vendor/paseo-plugin-helper/features.ts +244 -0
  72. package/shared/vendor/paseo-plugin-helper/forge.ts +27 -0
  73. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  74. package/shared/vendor/paseo-plugin-helper/tickets.ts +122 -28
  75. package/shared/vendor/permission-audit/filter.ts +61 -0
  76. package/shared/vendor/permission-audit/index.ts +2 -0
  77. package/shared/vendor/permission-audit/shared.ts +119 -0
  78. package/shared/version.ts +1 -1
  79. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +0 -493
  80. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +0 -250
  81. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +0 -168
  82. package/client/vendor/paseo-plugin-helper/components/Button.tsx +0 -204
  83. package/client/vendor/paseo-plugin-helper/components/Card.tsx +0 -257
  84. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +0 -196
  85. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +0 -277
  86. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +0 -172
  87. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +0 -180
  88. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +0 -97
  89. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +0 -70
  90. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +0 -88
  91. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +0 -142
  92. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +0 -446
  93. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +0 -247
  94. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +0 -117
  95. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +0 -53
  96. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +0 -132
  97. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +0 -87
  98. package/client/vendor/paseo-plugin-helper/components/Select.tsx +0 -283
  99. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +0 -80
  100. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +0 -319
  101. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +0 -150
  102. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +0 -163
  103. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +0 -157
  104. package/client/vendor/paseo-plugin-helper/components/index.ts +0 -26
  105. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +0 -225
  106. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +0 -79
  107. package/client/vendor/paseo-plugin-helper/icon.tsx +0 -39
  108. package/client/vendor/paseo-plugin-helper/index.ts +0 -30
  109. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +0 -49
  110. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +0 -103
  111. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +0 -65
  112. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +0 -368
  113. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +0 -51
  114. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +0 -39
  115. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +0 -39
  116. package/client/vendor/paseo-plugin-helper/layout/index.ts +0 -7
  117. package/client/vendor/paseo-plugin-helper/styles/index.ts +0 -1
  118. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +0 -550
  119. package/client/vendor/paseo-plugin-helper/theme/flair.ts +0 -76
  120. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +0 -121
  121. package/client/vendor/paseo-plugin-helper/theme/index.ts +0 -7
  122. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +0 -178
  123. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +0 -213
  124. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +0 -200
  125. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +0 -57
  126. package/client/vendor/paseo-plugin-helper/tickets.tsx +0 -766
  127. /package/client/vendor/paseo-plugin-helper/{command-center.ts → core/command-center.ts} +0 -0
  128. /package/client/vendor/paseo-plugin-helper/{snapshot.ts → core/snapshot.ts} +0 -0
  129. /package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/haptics.ts +0 -0
package/package.json CHANGED
@@ -2,16 +2,17 @@
2
2
  "name": "@xpufx/paseo-top",
3
3
  "private": false,
4
4
  "license": "MIT",
5
- "version": "0.4.4",
6
- "dependencies": {
7
- "permission-audit": "*"
8
- },
5
+ "version": "0.4.5",
9
6
  "scripts": {
10
7
  "stamp": "node -e \"import('./server/vendor/paseo-plugin-helper/version.ts').then(m => m.stampVersion({ targetFile: 'shared/version.ts' }))\"",
11
8
  "typecheck": "tsc --noEmit",
12
9
  "test": "npm run typecheck && vitest run --config client/vitest.config.ts"
13
10
  },
11
+ "dependencies": {
12
+ "@xpufx/paseo-plugin-ui": "^0.1.0"
13
+ },
14
14
  "devDependencies": {
15
+ "paseo-plugin-ui-testing": "*",
15
16
  "@getpaseo/plugin": "^0.9.0",
16
17
  "@tanstack/react-query": "^5.90.11",
17
18
  "@types/react": "^19.0.10",
@@ -26,7 +27,9 @@
26
27
  "files": [
27
28
  "paseo-plugin.json",
28
29
  "README.md",
30
+ "OVERVIEW.md",
29
31
  "LICENSE",
32
+ "screenshots",
30
33
  "index.client.tsx",
31
34
  "index.server.ts",
32
35
  "client",
package/paseo-plugin.json CHANGED
@@ -1,4 +1,6 @@
1
1
  {
2
2
  "id": "top",
3
+ "name": "Top",
4
+ "description": "Live host system and workspace monitor for the Paseo composer trackbar",
3
5
  "requirements": { "paseo": ">=0.9.0" }
4
6
  }
@@ -29,6 +29,91 @@ import {
29
29
  } from "../shared/resources";
30
30
  import { PLUGIN_VERSION } from "../shared/version";
31
31
 
32
+ // --- Velocity & Context Pressure helpers -------------------------------------
33
+
34
+ /** Thresholds for context pressure levels. */
35
+ const CONTEXT_NORMAL_UPPER = 60; // <60% → normal
36
+ const CONTEXT_WARNING_UPPER = 75; // 60-74% → warning, >=75% → high
37
+
38
+ /**
39
+ * Compute live generation velocity (tokens per second).
40
+ *
41
+ * Formula: (outputTokens + reasoningTokens) / (durationMs / 1000)
42
+ *
43
+ * Returns 0 for:
44
+ * - zero or missing duration (< 50 ms threshold to avoid skewed averages)
45
+ * - missing output tokens
46
+ * - interrupted/canceled turns (prevents skewed averages on partial generations)
47
+ */
48
+ export function computeTokensPerSecond(
49
+ outputTokens: number | undefined,
50
+ durationMs: number | undefined,
51
+ outcomeKind: string,
52
+ ): number | undefined {
53
+ if (outputTokens == null || !Number.isFinite(outputTokens)) return undefined;
54
+ if (outputTokens <= 0) return 0;
55
+
56
+ const dur = durationMs ?? undefined;
57
+ if (dur == null || !Number.isFinite(dur) || dur < 50) return 0;
58
+
59
+ // Interrupted/canceled turns with partial data yield 0 TPS.
60
+ if (outcomeKind === "canceled" || outcomeKind === "failed") return 0;
61
+
62
+ const durationSec = dur / 1000;
63
+ const rate = outputTokens / durationSec;
64
+ if (!Number.isFinite(rate) || rate <= 0) return 0;
65
+ return Math.round(rate * 100) / 100; // round to 2dp
66
+ }
67
+
68
+ /** Context pressure level based on utilization percentage. */
69
+ type ContextPressureLevel = "normal" | "warning" | "high";
70
+
71
+ /**
72
+ * Classify context pressure into discrete levels:
73
+ * - "normal": utilization < 60%
74
+ * - "warning": 60–74%
75
+ * - "high": >= 75% (amnesia risk)
76
+ *
77
+ * Returns undefined when context window info is absent.
78
+ */
79
+ export function classifyContextPressure(
80
+ contextUsedTokens: number | undefined,
81
+ contextMaxTokens: number | undefined,
82
+ ): { level: ContextPressureLevel; pct: number } | undefined {
83
+ if (
84
+ contextUsedTokens == null ||
85
+ contextMaxTokens == null ||
86
+ contextMaxTokens <= 0
87
+ ) {
88
+ return undefined;
89
+ }
90
+
91
+ const pct = Math.min(100, Math.round((contextUsedTokens / contextMaxTokens) * 100));
92
+
93
+ let level: ContextPressureLevel;
94
+ if (pct >= CONTEXT_WARNING_UPPER) {
95
+ level = "high";
96
+ } else if (pct >= CONTEXT_NORMAL_UPPER) {
97
+ level = "warning";
98
+ } else {
99
+ level = "normal";
100
+ }
101
+
102
+ return { level, pct };
103
+ }
104
+
105
+ /** Spread context pressure fields into the telemetry object. */
106
+ function computeContextPressure(
107
+ contextUsedTokens: number | undefined,
108
+ contextMaxTokens: number | undefined,
109
+ ): Pick<TopTimelineTelemetryData, "contextPressureLevel" | "contextUtilizationPct"> {
110
+ const result = classifyContextPressure(contextUsedTokens, contextMaxTokens);
111
+ return {
112
+ contextPressureLevel: result?.level,
113
+ contextUtilizationPct: result?.pct,
114
+ };
115
+ }
116
+
32
117
  export const log = createPluginLogger("top", { version: PLUGIN_VERSION });
33
118
 
34
119
  log.info("Initialized host system resources monitor", { version: PLUGIN_VERSION });
@@ -437,6 +522,10 @@ export async function handleGetSystemResources(input?: {
437
522
  .filter((state) => effectiveEnabled.has(state.id)),
438
523
  lastTurn: lastTurnTelemetry,
439
524
  liveUsage: lastLiveUsage,
525
+ // Velocity data from the most recent turn telemetry.
526
+ tokensPerSecond: lastTurnTelemetry?.tokensPerSecond,
527
+ contextPressureLevel: lastTurnTelemetry?.contextPressureLevel,
528
+ contextUtilizationPct: lastTurnTelemetry?.contextUtilizationPct,
440
529
  };
441
530
  }
442
531
 
@@ -778,6 +867,15 @@ export async function collectTurnTelemetry(
778
867
  contextUsedTokens,
779
868
  contextMaxTokens,
780
869
  costUsd,
870
+ // Live generation velocity: (output + reasoning) / durationSec.
871
+ // Guard against zero-duration, missing fields, and interrupted turns.
872
+ tokensPerSecond: computeTokensPerSecond(
873
+ outputTokens,
874
+ durationMs,
875
+ outcome.kind,
876
+ ),
877
+ // Context pressure from the most recent turn's utilization.
878
+ ...computeContextPressure(contextUsedTokens, contextMaxTokens),
781
879
  };
782
880
  lastTurnTelemetry = data;
783
881
  return data;
@@ -11,6 +11,9 @@ import {
11
11
  parseNumericPillValue,
12
12
  formatPillDisplay,
13
13
  } from "../../../shared/vendor/paseo-plugin-helper/custom-pills";
14
+ import type { FeatureModule, FeatureDisposer } from "../../../shared/vendor/paseo-plugin-helper/features";
15
+ import type { PluginRpcContract } from "../../../shared/vendor/paseo-plugin-helper/rpc";
16
+
14
17
 
15
18
 
16
19
  /**
@@ -342,3 +345,70 @@ export class CustomPillPoller {
342
345
  }
343
346
  }
344
347
  }
348
+
349
+ export interface CustomPillsFeatureOptions {
350
+ id?: string;
351
+ configDir?: string;
352
+ logger?: PluginLogger;
353
+ poller?: CustomPillPoller;
354
+ contracts?: {
355
+ get?: PluginRpcContract;
356
+ list?: PluginRpcContract;
357
+ run?: PluginRpcContract;
358
+ };
359
+ handlers?: {
360
+ get?: (input?: any) => Promise<any> | any;
361
+ list?: (input?: any) => Promise<any> | any;
362
+ run?: (input: any) => Promise<any> | any;
363
+ };
364
+ onMount?: (poller: CustomPillPoller) => void;
365
+ startPoller?: boolean;
366
+ }
367
+
368
+ /**
369
+ * Creates a reusable FeatureModule for declarative custom metric pills (#933).
370
+ */
371
+ export function createCustomPillsFeature<TServerContext = any, TClientContext = any>(
372
+ options: CustomPillsFeatureOptions = {},
373
+ ): FeatureModule<TServerContext, TClientContext> {
374
+ const poller = options.poller ?? new CustomPillPoller({
375
+ configDir: options.configDir,
376
+ logger: options.logger,
377
+ });
378
+
379
+ const contractsRecord: Record<string, PluginRpcContract> = {};
380
+ if (options.contracts?.get) contractsRecord.get = options.contracts.get;
381
+ if (options.contracts?.list) contractsRecord.list = options.contracts.list;
382
+ if (options.contracts?.run) contractsRecord.run = options.contracts.run;
383
+
384
+ return {
385
+ id: options.id || "custom-pills",
386
+ contracts: contractsRecord,
387
+ contributeServer(server: any) {
388
+ if (options.startPoller !== false) {
389
+ void poller.start();
390
+ }
391
+ options.onMount?.(poller);
392
+
393
+ if (typeof server?.handle === "function") {
394
+ if (options.contracts?.get && options.handlers?.get) {
395
+ server.handle(options.contracts.get, options.handlers.get);
396
+ }
397
+ if (options.contracts?.list && options.handlers?.list) {
398
+ server.handle(options.contracts.list, options.handlers.list);
399
+ }
400
+ if (options.contracts?.run && options.handlers?.run) {
401
+ server.handle(options.contracts.run, options.handlers.run);
402
+ }
403
+ }
404
+
405
+ let stopped = false;
406
+ return () => {
407
+ if (stopped) return;
408
+ stopped = true;
409
+ poller.stop();
410
+ };
411
+ },
412
+ };
413
+ }
414
+
@@ -17,4 +17,6 @@ export * from "./rpc-guard";
17
17
  export * from "./agent";
18
18
  export * from "./workspace-beacon";
19
19
  export * from "./tickets";
20
+ export * from "./storage-guard";
21
+ export * from "./telemetry";
20
22
 
@@ -46,28 +46,17 @@ export function isProductionEnv(env: NodeJS.ProcessEnv = process.env): boolean {
46
46
  return (env.NODE_ENV ?? "").trim().toLowerCase() === "production";
47
47
  }
48
48
 
49
- /**
50
- * True only when `NODE_ENV` is explicitly a development value
51
- * (`development`/`dev`). Unset, empty, `production`, and anything else
52
- * (e.g. `test`) all count as quiet, so a shipped plugin defaults to info
53
- * without any env var set.
54
- */
55
- export function isDevelopmentEnv(env: NodeJS.ProcessEnv = process.env): boolean {
56
- const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase();
57
- return nodeEnv === "development" || nodeEnv === "dev";
58
- }
59
-
60
49
  /**
61
50
  * Default rule (documented for operators):
62
51
  * explicit `PASEO_PLUGIN_LOG_LEVEL`/`PASEO_LOG_LEVEL`/`PASEO_DEBUG` always wins;
63
- * otherwise quiet (`info`) — including when no env var is set at all, so a
64
- * shipped plugin never emits debug logs by default. Debug only when `NODE_ENV`
65
- * is explicitly a development value (`development`/`dev`).
52
+ * otherwise `info` in every environment — including `NODE_ENV=development`/`dev`
53
+ * and when no env var is set at all. Debug requires an explicit level; the
54
+ * environment name alone never lifts the floor.
66
55
  */
67
56
  export function resolveDefaultMinLevel(
68
57
  env: NodeJS.ProcessEnv = process.env,
69
58
  ): LogLevel {
70
- return resolveMinLevelFromEnv(env) ?? (isDevelopmentEnv(env) ? "debug" : "info");
59
+ return resolveMinLevelFromEnv(env) ?? "info";
71
60
  }
72
61
 
73
62
  export interface PluginLoggerOptions {
@@ -90,7 +79,7 @@ export interface PluginLoggerOptions {
90
79
 
91
80
  /**
92
81
  * Minimum log level to print. Defaults to resolveDefaultMinLevel():
93
- * info unless NODE_ENV is explicitly development (or an explicit level is set).
82
+ * info unless an explicit level is set via the environment.
94
83
  */
95
84
  minLevel?: LogLevel;
96
85
 
@@ -1,4 +1,4 @@
1
- import { withTimeout, TimeoutError } from "../../../shared/vendor/paseo-plugin-helper/async";
1
+ import { withTimeout, isTimeoutError } from "../../../shared/vendor/paseo-plugin-helper/async";
2
2
 
3
3
  export interface RpcGuardOptions<T> {
4
4
  timeoutMs?: number;
@@ -40,7 +40,7 @@ export function guardRpcHandler<TInput, TOutput>(
40
40
  "RPC handler",
41
41
  );
42
42
  } catch (err) {
43
- if (err instanceof TimeoutError) {
43
+ if (isTimeoutError(err)) {
44
44
  options.onTimeout?.({ timeoutMs, inflight });
45
45
  }
46
46
  throw err;
@@ -0,0 +1,294 @@
1
+ import fs from "node:fs";
2
+ import type { PathLike } from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { resolvePaseoHome } from "./storage";
7
+
8
+ export class ScopedStorageViolationError extends Error {
9
+ readonly code = "ERR_SCOPED_STORAGE_VIOLATION";
10
+ readonly attemptedPath: string;
11
+ readonly allowedRoots: string[];
12
+
13
+ constructor(message: string, attemptedPath: string, allowedRoots: string[]) {
14
+ super(message);
15
+ this.name = "ScopedStorageViolationError";
16
+ this.attemptedPath = attemptedPath;
17
+ this.allowedRoots = allowedRoots;
18
+ Object.setPrototypeOf(this, new.target.prototype);
19
+ }
20
+ }
21
+
22
+ export interface InstallStorageGuardOptions {
23
+ pluginId: string;
24
+ namespace?: string; // defaults to "plugin-data/xpufx"
25
+ baseDir?: string;
26
+ allowTmp?: boolean; // defaults to true
27
+ mode?: "enforce" | "warn"; // defaults to "enforce"
28
+ onViolation?: (violation: { path: string; operation: string; allowedRoots: string[] }) => void;
29
+ }
30
+
31
+ function resolvePathString(target: PathLike | number): string | null {
32
+ if (typeof target === "string") {
33
+ return path.resolve(target);
34
+ }
35
+ if (Buffer.isBuffer(target)) {
36
+ return path.resolve(target.toString("utf8"));
37
+ }
38
+ if (target instanceof URL) {
39
+ if (target.protocol === "file:") {
40
+ const pathname = decodeURIComponent(target.pathname);
41
+ return path.resolve(pathname);
42
+ }
43
+ return null;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function isPathWithinRoot(targetPath: string, rootDir: string): boolean {
49
+ const resolvedTarget = path.resolve(targetPath);
50
+ const resolvedRoot = path.resolve(rootDir);
51
+
52
+ if (resolvedTarget === resolvedRoot) {
53
+ return true;
54
+ }
55
+
56
+ const relative = path.relative(resolvedRoot, resolvedTarget);
57
+ return !relative.startsWith("..") && !path.isAbsolute(relative);
58
+ }
59
+
60
+ export function installStorageGuard(options: InstallStorageGuardOptions): () => void {
61
+ const pluginId = options.pluginId;
62
+ const namespace = options.namespace ?? "plugin-data/xpufx";
63
+ const allowTmp = options.allowTmp ?? true;
64
+ const mode = options.mode ?? "enforce";
65
+
66
+ const pluginStorageDir = options.baseDir
67
+ ? path.resolve(options.baseDir, pluginId)
68
+ : path.resolve(resolvePaseoHome(), namespace, pluginId);
69
+ const allowedRoots: string[] = [pluginStorageDir];
70
+
71
+ if (allowTmp) {
72
+ allowedRoots.push(path.resolve(os.tmpdir()));
73
+ allowedRoots.push(path.resolve(process.cwd(), ".tmp"));
74
+ }
75
+
76
+ function validateTarget(target: PathLike | number, operation: string): void {
77
+ const resolved = resolvePathString(target);
78
+ if (!resolved) {
79
+ return;
80
+ }
81
+
82
+ const isAllowed = allowedRoots.some((root) => isPathWithinRoot(resolved, root));
83
+ if (!isAllowed) {
84
+ options.onViolation?.({
85
+ path: resolved,
86
+ operation,
87
+ allowedRoots: [...allowedRoots],
88
+ });
89
+
90
+ const message = `Scoped storage violation: write operation '${operation}' on '${resolved}' is outside permitted scopes: [${allowedRoots.join(", ")}]`;
91
+
92
+ if (mode === "enforce") {
93
+ throw new ScopedStorageViolationError(message, resolved, [...allowedRoots]);
94
+ } else {
95
+ console.warn(`[WARN] ${message}`);
96
+ }
97
+ }
98
+ }
99
+
100
+ // Backup originals
101
+ const origWriteFile = fs.writeFile;
102
+ const origWriteFileSync = fs.writeFileSync;
103
+ const origAppendFile = fs.appendFile;
104
+ const origAppendFileSync = fs.appendFileSync;
105
+ const origMkdir = fs.mkdir;
106
+ const origMkdirSync = fs.mkdirSync;
107
+ const origRmdir = fs.rmdir;
108
+ const origRmdirSync = fs.rmdirSync;
109
+ const origUnlink = fs.unlink;
110
+ const origUnlinkSync = fs.unlinkSync;
111
+ const origRm = fs.rm;
112
+ const origRmSync = fs.rmSync;
113
+ const origRename = fs.rename;
114
+ const origRenameSync = fs.renameSync;
115
+ const origCopyFile = fs.copyFile;
116
+ const origCopyFileSync = fs.copyFileSync;
117
+ const origCreateWriteStream = fs.createWriteStream;
118
+
119
+ const origPromisesWriteFile = fsp.writeFile;
120
+ const origPromisesAppendFile = fsp.appendFile;
121
+ const origPromisesMkdir = fsp.mkdir;
122
+ const origPromisesRmdir = fsp.rmdir;
123
+ const origPromisesUnlink = fsp.unlink;
124
+ const origPromisesRm = fsp.rm;
125
+ const origPromisesRename = fsp.rename;
126
+ const origPromisesCopyFile = fsp.copyFile;
127
+
128
+ // Intercept node:fs sync & async
129
+ const anyFs = fs as any;
130
+
131
+ anyFs.writeFile = function (file: PathLike | number, ...args: unknown[]) {
132
+ validateTarget(file, "writeFile");
133
+ return Reflect.apply(origWriteFile, fs, [file, ...args]);
134
+ };
135
+
136
+ anyFs.writeFileSync = function (file: PathLike | number, ...args: unknown[]) {
137
+ validateTarget(file, "writeFileSync");
138
+ return Reflect.apply(origWriteFileSync, fs, [file, ...args]);
139
+ };
140
+
141
+ anyFs.appendFile = function (file: PathLike | number, ...args: unknown[]) {
142
+ validateTarget(file, "appendFile");
143
+ return Reflect.apply(origAppendFile, fs, [file, ...args]);
144
+ };
145
+
146
+ anyFs.appendFileSync = function (file: PathLike | number, ...args: unknown[]) {
147
+ validateTarget(file, "appendFileSync");
148
+ return Reflect.apply(origAppendFileSync, fs, [file, ...args]);
149
+ };
150
+
151
+ anyFs.mkdir = function (pathArg: PathLike, ...args: unknown[]) {
152
+ validateTarget(pathArg, "mkdir");
153
+ return Reflect.apply(origMkdir, fs, [pathArg, ...args]);
154
+ };
155
+
156
+ anyFs.mkdirSync = function (pathArg: PathLike, ...args: unknown[]) {
157
+ validateTarget(pathArg, "mkdirSync");
158
+ return Reflect.apply(origMkdirSync, fs, [pathArg, ...args]);
159
+ };
160
+
161
+ anyFs.rmdir = function (pathArg: PathLike, ...args: unknown[]) {
162
+ validateTarget(pathArg, "rmdir");
163
+ return Reflect.apply(origRmdir, fs, [pathArg, ...args]);
164
+ };
165
+
166
+ anyFs.rmdirSync = function (pathArg: PathLike, ...args: unknown[]) {
167
+ validateTarget(pathArg, "rmdirSync");
168
+ return Reflect.apply(origRmdirSync, fs, [pathArg, ...args]);
169
+ };
170
+
171
+ anyFs.unlink = function (pathArg: PathLike, ...args: unknown[]) {
172
+ validateTarget(pathArg, "unlink");
173
+ return Reflect.apply(origUnlink, fs, [pathArg, ...args]);
174
+ };
175
+
176
+ anyFs.unlinkSync = function (pathArg: PathLike, ...args: unknown[]) {
177
+ validateTarget(pathArg, "unlinkSync");
178
+ return Reflect.apply(origUnlinkSync, fs, [pathArg, ...args]);
179
+ };
180
+
181
+ anyFs.rm = function (pathArg: PathLike, ...args: unknown[]) {
182
+ validateTarget(pathArg, "rm");
183
+ return Reflect.apply(origRm, fs, [pathArg, ...args]);
184
+ };
185
+
186
+ anyFs.rmSync = function (pathArg: PathLike, ...args: unknown[]) {
187
+ validateTarget(pathArg, "rmSync");
188
+ return Reflect.apply(origRmSync, fs, [pathArg, ...args]);
189
+ };
190
+
191
+ anyFs.rename = function (oldPath: PathLike, newPath: PathLike, ...args: unknown[]) {
192
+ validateTarget(oldPath, "rename:source");
193
+ validateTarget(newPath, "rename:destination");
194
+ return Reflect.apply(origRename, fs, [oldPath, newPath, ...args]);
195
+ };
196
+
197
+ anyFs.renameSync = function (oldPath: PathLike, newPath: PathLike, ...args: unknown[]) {
198
+ validateTarget(oldPath, "renameSync:source");
199
+ validateTarget(newPath, "renameSync:destination");
200
+ return Reflect.apply(origRenameSync, fs, [oldPath, newPath, ...args]);
201
+ };
202
+
203
+ anyFs.copyFile = function (src: PathLike, dest: PathLike, ...args: unknown[]) {
204
+ validateTarget(dest, "copyFile:destination");
205
+ return Reflect.apply(origCopyFile, fs, [src, dest, ...args]);
206
+ };
207
+
208
+ anyFs.copyFileSync = function (src: PathLike, dest: PathLike, ...args: unknown[]) {
209
+ validateTarget(dest, "copyFileSync:destination");
210
+ return Reflect.apply(origCopyFileSync, fs, [src, dest, ...args]);
211
+ };
212
+
213
+ anyFs.createWriteStream = function (pathArg: PathLike, ...args: unknown[]) {
214
+ validateTarget(pathArg, "createWriteStream");
215
+ return Reflect.apply(origCreateWriteStream, fs, [pathArg, ...args]);
216
+ };
217
+
218
+ // Intercept node:fs/promises
219
+ const anyFsp = fsp as any;
220
+
221
+ anyFsp.writeFile = async function (file: PathLike | number | fsp.FileHandle, ...args: unknown[]) {
222
+ if (typeof file === "string" || Buffer.isBuffer(file) || file instanceof URL) {
223
+ validateTarget(file as unknown as PathLike, "fsp.writeFile");
224
+ }
225
+ return Reflect.apply(origPromisesWriteFile, fsp, [file, ...args]);
226
+ };
227
+
228
+ anyFsp.appendFile = async function (file: PathLike | number | fsp.FileHandle, ...args: unknown[]) {
229
+ if (typeof file === "string" || Buffer.isBuffer(file) || file instanceof URL) {
230
+ validateTarget(file as unknown as PathLike, "fsp.appendFile");
231
+ }
232
+ return Reflect.apply(origPromisesAppendFile, fsp, [file, ...args]);
233
+ };
234
+
235
+ anyFsp.mkdir = async function (pathArg: PathLike, ...args: unknown[]) {
236
+ validateTarget(pathArg, "fsp.mkdir");
237
+ return Reflect.apply(origPromisesMkdir, fsp, [pathArg, ...args]);
238
+ };
239
+
240
+ anyFsp.rmdir = async function (pathArg: PathLike, ...args: unknown[]) {
241
+ validateTarget(pathArg, "fsp.rmdir");
242
+ return Reflect.apply(origPromisesRmdir, fsp, [pathArg, ...args]);
243
+ };
244
+
245
+ anyFsp.unlink = async function (pathArg: PathLike, ...args: unknown[]) {
246
+ validateTarget(pathArg, "fsp.unlink");
247
+ return Reflect.apply(origPromisesUnlink, fsp, [pathArg, ...args]);
248
+ };
249
+
250
+ anyFsp.rm = async function (pathArg: PathLike, ...args: unknown[]) {
251
+ validateTarget(pathArg, "fsp.rm");
252
+ return Reflect.apply(origPromisesRm, fsp, [pathArg, ...args]);
253
+ };
254
+
255
+ anyFsp.rename = async function (oldPath: PathLike, newPath: PathLike) {
256
+ validateTarget(oldPath, "fsp.rename:source");
257
+ validateTarget(newPath, "fsp.rename:destination");
258
+ return Reflect.apply(origPromisesRename, fsp, [oldPath, newPath]);
259
+ };
260
+
261
+ anyFsp.copyFile = async function (src: PathLike, dest: PathLike, ...args: unknown[]) {
262
+ validateTarget(dest, "fsp.copyFile:destination");
263
+ return Reflect.apply(origPromisesCopyFile, fsp, [src, dest, ...args]);
264
+ };
265
+
266
+ return function uninstallStorageGuard(): void {
267
+ fs.writeFile = origWriteFile;
268
+ fs.writeFileSync = origWriteFileSync;
269
+ fs.appendFile = origAppendFile;
270
+ fs.appendFileSync = origAppendFileSync;
271
+ fs.mkdir = origMkdir;
272
+ fs.mkdirSync = origMkdirSync;
273
+ fs.rmdir = origRmdir;
274
+ fs.rmdirSync = origRmdirSync;
275
+ fs.unlink = origUnlink;
276
+ fs.unlinkSync = origUnlinkSync;
277
+ fs.rm = origRm;
278
+ fs.rmSync = origRmSync;
279
+ fs.rename = origRename;
280
+ fs.renameSync = origRenameSync;
281
+ fs.copyFile = origCopyFile;
282
+ fs.copyFileSync = origCopyFileSync;
283
+ fs.createWriteStream = origCreateWriteStream;
284
+
285
+ fsp.writeFile = origPromisesWriteFile;
286
+ fsp.appendFile = origPromisesAppendFile;
287
+ fsp.mkdir = origPromisesMkdir;
288
+ fsp.rmdir = origPromisesRmdir;
289
+ fsp.unlink = origPromisesUnlink;
290
+ fsp.rm = origPromisesRm;
291
+ fsp.rename = origPromisesRename;
292
+ fsp.copyFile = origPromisesCopyFile;
293
+ };
294
+ }
@@ -7,7 +7,7 @@ export interface PluginStorageOptions<T> {
7
7
  defaultData?: T;
8
8
  /**
9
9
  * Base directory override. When provided, storage is located at path.join(baseDir, pluginId).
10
- * When omitted, defaults to path.join(os.homedir(), ".paseo", namespace ?? "plugin-data/xpufx", pluginId).
10
+ * When omitted, defaults to path.join(resolvePaseoHome(), namespace ?? "plugin-data/xpufx", pluginId).
11
11
  */
12
12
  baseDir?: string;
13
13
  /**
@@ -27,6 +27,40 @@ export interface StorageStats {
27
27
  lastModified: Date | null;
28
28
  }
29
29
 
30
+ const AGENT_MUX_PROFILE_HOME_RE = /(?:^|[\\/])\.agent-mux[\\/]profiles[\\/]/;
31
+
32
+ /**
33
+ * Resolve the real user home even when `HOME` points into an agent-mux profile.
34
+ * A profile session has `HOME=~/.agent-mux/profiles/<provider>/<profile>` while
35
+ * the live Paseo state stays on the host home. agent-mux exports `REAL_HOME` for
36
+ * every profile session, so prefer it; otherwise strip the profile prefix so
37
+ * paths under `~/.paseo` still resolve to the host (#1158, #973).
38
+ */
39
+ export function resolveHostHome(env: NodeJS.ProcessEnv = process.env): string {
40
+ const home = (env.HOME || os.homedir() || "").trim();
41
+ if (!home || !AGENT_MUX_PROFILE_HOME_RE.test(home)) {
42
+ return home;
43
+ }
44
+ const realHome = (env.REAL_HOME || "").trim();
45
+ if (realHome) return realHome;
46
+ const marker = home.match(AGENT_MUX_PROFILE_HOME_RE);
47
+ if (marker && typeof marker.index === "number" && marker.index > 0) {
48
+ return home.slice(0, marker.index);
49
+ }
50
+ return home;
51
+ }
52
+
53
+ /**
54
+ * Resolve the canonical `~/.paseo` directory. `PASEO_HOME` is the daemon's own
55
+ * declaration of that root, so prefer it; otherwise anchor to the host home
56
+ * rather than an agent-mux profile (#1158).
57
+ */
58
+ export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string {
59
+ const paseoHome = (env.PASEO_HOME || "").trim();
60
+ if (paseoHome) return paseoHome;
61
+ return path.join(resolveHostHome(env), ".paseo");
62
+ }
63
+
30
64
  export const DEFAULT_NAMESPACE_README = `# Paseo Plugins Storage (xpufx)
31
65
 
32
66
  This directory is managed by \`paseo-plugin-helper\` to store persistent settings, cached metrics, and state for plugins.
@@ -60,7 +94,7 @@ export class PluginStorage<T extends Record<string, any>> {
60
94
  this.namespaceDir = options.baseDir;
61
95
  this.pluginDir = path.join(options.baseDir, pluginId);
62
96
  } else {
63
- this.namespaceDir = path.join(os.homedir(), ".paseo", namespace);
97
+ this.namespaceDir = path.join(resolvePaseoHome(), namespace);
64
98
  this.pluginDir = path.join(this.namespaceDir, pluginId);
65
99
  }
66
100